diff --git a/.dockerignore b/.dockerignore index 7aaa533fe7..30d3d533bf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -36,6 +36,23 @@ 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/copilot-profile-v17-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/test-fixtures/pi-acp/profile-v20-identity.json +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v21-identity.json +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-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/.env.runner-e2e.example b/.env.runner-e2e.example index c1e2377a39..dcfbfc6df4 100644 --- a/.env.runner-e2e.example +++ b/.env.runner-e2e.example @@ -16,3 +16,8 @@ PAPERCLIP_E2E_DAYTONA_IMAGE=ghcr.io/paperclipai/paperclip-daytona-runner@sha256: # Optional, explicit subscription qualification only. Supply privately at runtime; # never commit an actual auth.json payload. API-key profiles do not use this value. # GROK_AUTH_JSON= + +# Native Cursor/Pi/Copilot Daytona fixtures additionally require exact executable +# digests from that immutable Linux image (not the local controller binaries). +# PAPERCLIP_E2E_DAYTONA_NODE_SHA256=sha256:REPLACE_ME +# PAPERCLIP_E2E_DAYTONA_RUNNERD_SHA256=sha256:REPLACE_ME 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..d6a2115efb 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 @@ -193,9 +194,11 @@ jobs: run: | set -euo pipefail 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 install --resolution-only --ignore-scripts --no-frozen-lockfile > remote-verification/npm-setup.log 2>&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/.gitignore b/.gitignore index d24d2da675..84396923f1 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,6 @@ test-results/ # Retained local lifecycle behavior measurements .lifecycle-baseline/ + +# Local native Runner test/session state +.paperclip-runner-prp/ diff --git a/Dockerfile b/Dockerfile index 1db10902f6..2f4e30ffdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,9 @@ COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin COPY patches/ patches/ COPY scripts/link-plugin-dev-sdk.mjs scripts/ -RUN pnpm install --frozen-lockfile +# CI owns repository lock updates. Resolve manifest changes in the image's +# private build tree, then install from that exact generated lock. +RUN pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile && pnpm install --frozen-lockfile FROM base AS rust-toolchain WORKDIR /app 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/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 6592bf7d6a..e4e57e09bf 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -1637,6 +1637,14 @@ describe("worktree helpers", () => { }); const { default: EmbeddedPostgres } = await import("embedded-postgres"); + let targetPgLogs = Buffer.alloc(0); + let targetPgStartupResolved = false; + const captureTargetPgLog = (message: unknown) => { + const text = message instanceof Error ? message.message : String(message); + const tail = Buffer.concat([targetPgLogs, Buffer.from(`${text}\n`)]).subarray(-8192); + targetPgLogs = Buffer.alloc(tail.length); + tail.copy(targetPgLogs); + }; const targetPg = new EmbeddedPostgres({ databaseDir: targetConfig.database.embeddedPostgresDataDir, user: "paperclip", @@ -1644,23 +1652,35 @@ describe("worktree helpers", () => { port: targetConfig.database.embeddedPostgresPort, persistent: true, initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, + onLog: captureTargetPgLog, + onError: captureTargetPgLog, }); - await targetPg.start(); - onTestFinished(() => targetPg.stop()); - const targetDb = createDb( - `postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`, - ); - const [seededLocalBoard] = await targetDb - .select({ id: authUsers.id }) - .from(authUsers) - .where(eq(authUsers.id, "local-board")); - const seededAccounts = await targetDb.select().from(authAccounts); - expect(seededLocalBoard?.id).toBe("local-board"); - expect(seededAccounts).toHaveLength(0); - await targetDb.$client.end({ timeout: 5 }); + try { + await targetPg.start(); + targetPgStartupResolved = true; + onTestFinished(() => targetPg.stop()); + const targetDb = createDb( + `postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`, + ); + const [seededLocalBoard] = await targetDb + .select({ id: authUsers.id }) + .from(authUsers) + .where(eq(authUsers.id, "local-board")); + const seededAccounts = await targetDb.select().from(authAccounts); + expect(seededLocalBoard?.id).toBe("local-board"); + expect(seededAccounts).toHaveLength(0); + await targetDb.$client.end({ timeout: 5 }); + } catch (error) { + // Preserve the actual failure while retaining the public startup logs + // that would otherwise be discarded before fixture cleanup. + console.error("Target PostgreSQL fixture failure", { + port: targetConfig.database.embeddedPostgresPort, + startupResolved: targetPgStartupResolved, + recentLogs: targetPgLogs.toString("utf8"), + }); + throw error; + } }, ); 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/doc/agent-files.md b/doc/agent-files.md index f037cb2551..d0fce516eb 100644 --- a/doc/agent-files.md +++ b/doc/agent-files.md @@ -95,6 +95,10 @@ file contents. Temporary checkpoint payloads are removed after application. The operator still provisions storage for the canonical folders, active working copies and changed-file payloads; these are not aggregate disk quotas. +Other providers use full-directory collection after verified retirement. A +successful warm ACP turn can retain its unchanged copy with the still-running +provider; restart recovery preserves files until retirement is proven. + ## Run lifecycle 1. Under the agent lock, restore current files into a private run copy and save @@ -136,6 +140,56 @@ Relative supporting files are read from `AGENT_HOME`, not the read-only prompt snapshot. External instruction bundles remain read-only and use their existing lifecycle. +For warm ACP providers, unchanged whole-directory retention uses the following +additional ownership and lease fences: + +A successful warm turn may retain its complete, unchanged materialized directory +and the exact `AGENT_HOME` root with the same provider process. A bounded read-only +probe must verify the full baseline and root identity without unsafe paths or +concurrent changes. Local hashing runs in an isolated child; remote observation +runs at the registered remote root. A failed or uncertain probe requires stopped +collection. The next authorized run claims that same materialization within the +company, agent, workspace, environment and configuration scope. Projectless runs +use the stable workspace descriptor (cwd, repository URL/ref and branch), rather +than the per-run workspace placeholder. A remote handoff verifies both DB leases +belong to the same company, environment and provider allocation, then binds the +successor run's active lease and collector without re-uploading the host mirror. +Heartbeat explicitly permits a collection-only successor handoff before checking +warm reuse. A changed or uncertain directory records durable `retirementRequired`; +that receipt cannot authorize reuse. The successor capability is installed only +after the ownership transaction commits. Configuration changes and abandoned +preparation then stop the provider and collect through that current capability, +never an expired prior lease. Ordinary adoption remains unchanged-only. Failed +authorization or lease validation retires the prior owner without borrowing an +unverified successor; if its lease is unusable, the bytes remain pending. +Before initial admission, the remote transfer's two empty scratch directories are +removed with exact-path, identity-checked `rmdir` operations. Any content, link or +uncertain identity fails preparation; the complete probe never excludes them. +An exact recorded lease that is missing or no longer matches the run/environment +blocks retrieval as well as deletion. Legacy receipts without a lease ID require +exactly one company/run/environment lease, including when a transport is still +cached. Missing or ambiguous ownership preserves pending files without remote +commands. No SSH exception bypasses this fence, and no-ID receipts grant no warm +adoption authority. +The original materialization root remains unchanged. The prior run +loses collection authority; stale cleanup cannot remove the current owner's root. + +Edits, additions, removals or changed configuration retire the owner before +collection and fresh preparation. A configuration or copy change found at the +final dispatch fence fails that run after retirement; it does not replay the +request automatically. Idle expiry, restart and abandoned preparation also retire +before collection. Immediate collection retries have a fixed call budget and must +advance; a deferred ownership state ends the callback without inventing attempts. +A failed close leaves an unstopped pending receipt through generic run cleanup. +Later owner retirement or independent current-run stop proof can still collect it; +a prior run's stop proof cannot authorize collection. The whole-directory contract +closes the provider process, +including child processes, to establish this safe collection boundary. It +preserves the provider's resumable conversation. Only the loaded instruction entry +participates in the new runtime instruction digest; adding or editing another file does not change +that digest. Relative supporting files are read from `AGENT_HOME`, not from the +read-only prompt snapshot. + The editor supplies the hash of the file it read. A stale browser save returns 409 and retains the user's unsaved draft. Run synchronization itself uses per-file last-sync-wins: a later run can overwrite a saved browser edit to the @@ -199,12 +253,29 @@ save receipt. An interrupted apply can replay its changed files with the same last-sync-wins rule. Cleanup resumes for terminal runs; no copy is retained as an archive after cleanup succeeds. +An unchanged-turn observation is neither a save nor proof that a provider stopped. +If retirement fails, the controller records unresolved ownership and preserves +the materialized root. A crash after ownership transfer can leave the new run +without independently recorded stop proof; terminal run status or the old run's +alias is not enough to collect that root. Recovery leaves it preserved until the +required proof is available. Exact destruction of the current owner's remote +allocation permits an explicit unavailable/no-save outcome and owned local +cleanup. A stopped-but-retained allocation instead stays pending with its files +preserved. The current remote execute API may restart a stopped sandbox even +when it bypasses a persistent session, so recovery cannot use it for retrieval +or deletion. Safe retrieval from a stopped allocation remains a transport gap; +live owner retirement still collects before the environment stops. No save is +claimed for the pending case, and stale prior-run leases cannot authorize cleanup. + ## Verification `agent-directory-working-copies.test.ts` exercises nested/binary files, directory isolation, last-sync-wins edits and deletions, terminal cleanup, link rejection, old-head -adoption, and stable prompt digests. The legacy working-copy and native-tool -suites exercise compatibility. Workspace merge tests exercise preflight and +adoption, stable prompt digests, warm ownership transfer, concurrent stale claims, +and stop-proved recovery. `agent-directory-probe.test.ts` covers stable snapshots, +unsafe paths, mutation races and bounded child failures; composed native executor +tests cover retained roots and retirement before collection. The legacy +working-copy and native-tool suites exercise compatibility. Workspace merge tests exercise preflight and interrupted replay. The explicit Product E2E `instruction-persistence` suite creates a file through diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md new file mode 100644 index 0000000000..ceddfb1b7d --- /dev/null +++ b/doc/architecture/runner-copilot-capabilities.md @@ -0,0 +1,956 @@ +# Copilot 1.0.88 rich ACP capability audit + +Current integration (2026-10-08): **Copilot profile v17 remains pending** with +digest `sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f`. +It binds master's shared protocol-validation sources without changing the +executable or supplying new paid qualification. Historical checkpoints below +retain their original identities and outcomes. + +Historical source candidate (2026-10-01): **Copilot profile v12 is unqualified**. +Receipt v2 preserves the original `inputSha256` and separately captures +`normalizedInputSha256` from the same invocation's validated outgoing body. +For the native sidecar, only a successful, correlated `tool.resolve` commits +that capture. A pipe write alone cannot attest delivery: Rust can reject an +otherwise valid frame at its smaller payload admission limit. Explicit errors, +cancellation, timeout, missing responses and stale or foreign responses leave +the capture null. A returned `accepted:false` witnesses delivery but still +rejects completion. The direct driver commits only after its proposal emission. + +Delivery does not prove canonical equality or completion acceptance. The grader +must independently match the normalized digest with the unique proposed and +accepted result bodies; receiver sanitization does not relax that comparison. +The sidecar also rejects oversized frames or bodies its Unicode encoder would +change. Dropped frames consume no stream sequence; writable backpressure is +handled as an accepted buffer write, without claiming receiver admission. + +The server preserves only the fixed schema literal inside a validated receipt +notice; adjacent credentials and JWT-shaped values remain redacted. The paid +v10 Daytona failure remains failed. The v11 build and offline CI evidence is +historical and does not qualify this corrected source. Retained v10 and v11 +warm sessions are incompatible. Native executable bytes are unchanged; fresh +runtime packs and local/Daytona qualification are required. Tests exercise the +actual parsed sidecar command handler and the Rust subprocess transport, +including rejection of a 300 KiB payload before pending-tool admission. + +Historical source candidate (2026-10-01): **Copilot profile v10 was unqualified**. +Admitted Paperclip MCP calls append a bounded `paperclip.semantic_tool_receipt.v1` +text block after the original result blocks. It records the operation, call-ID +hash, canonical argument hash, result hash and transport outcome. An invocation +callback bound to the active turn emits matching durable evidence. Native tool +output alone is not authority: the projector requires that callback, exact +arguments and result, and one native lifecycle. It rejects foreign, late, +duplicate and conflicting receipts. Tool names and titles cannot grant a match. +`returned` does not mean accepted; a returned `accepted:false` remains a rejection. + +The native reader accepts at most 256 KiB of the complete `rawOutput`, including +its repeated text fields. Large and chunked bridge results still carry a receipt, +but output above that bound has no native correlation evidence. Error results +retain `isError`; deterministic tests cover errors, while the actual pinned +ARM64 executable has only been checked with a small two-block success response +and a local mock model. The captured pending/completed ACP frames are retained +in `src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.json`, with +executable/capture hashes and sanitization recorded alongside. A regression +replays those frames through the production projector with the separately +owned receipt, plus missing-authority, altered-input/result, missing-content +and duplicate-terminal negatives. This is not paid or cross-platform qualification. +Native executable and distribution bytes are unchanged. The profile binds the +receipt, bridge and projector sources plus the complete permission-policy import +closure; retained v9 sessions are incompatible. Fresh packs and local/Daytona +qualification remain required. Completion feedback also preserves an explicitly +requested final-response format instead of overriding it with a summary request. + +Historical source candidate (2026-09-30): **Copilot profile v9 is unqualified**. + +Historical source candidate (2026-09-30): **Copilot profile v9 is unqualified**. +Typed edit permission callbacks now put a single bounded workspace-relative target +in the first canonical prompt (`Change file: path`). The same pure projector is +used by both runner paths and the bounded diagnostic notice. It checks `path`, +`fileName`, and every supplied location for agreement; it never displays file +contents, diffs, commands, or arbitrary raw input. This is display context, not a +filesystem authorization or symlink-containment guarantee. + +If the target is missing, outside the workspace, conflicting, malformed, too +large, contains control/direction-format characters, or would be redacted, the +request offers only the provider's one-time denial (when present) and cancellation. +Forged once/session grants are rejected. Raw `kind: edit` is required; titles do +not infer operation type. Non-edit permissions retain their existing behavior. +Default/full-auto selection is unchanged: callbacks handled by Paperclip receive +this check before any permission-mode fallback; no new automatic grant is added. +The existing transport policy still applies before permission handling. + +The declaration binds the context projector, permission adapter, workspace path +validator, semantic redaction policy, and generated tool-operation classifier. +A recursive value-import test requires every non-builtin policy dependency to +remain in this source hash set; type-only imports do not affect execution. +The candidate v9 digest was refreshed before live admission to close this +transitive policy binding. No retained v8 evidence is regraded. Copilot's +native executable, patched inner distribution, and shared ACPX patch are unchanged; +the sidecar assets and runner's embedded admission digest change. Older profile +identities cannot be admitted as v9 or reused as matching warm sessions. Fresh +runtime packs/images and exact-revision local/Daytona qualification are pending. +Earlier denial cases establish their own no-write behavior; their generic +`Create file` card did not prove complete decision context. Deterministic adapter, +sidecar and direct-driver tests cover safe and denied context; no new live claim +or native USD/accounting capability is implied. + +Historical v8 source candidate (2026-09-30): **Copilot profile v8 is unqualified**. +Its native executable, owned mapper and native distribution closures are unchanged +from v7, but its declaration binds the full shared ACPX patch. The patch's additive +Cursor metadata persistence changes those bytes, so v8 has a distinct command +digest and rejects v7 sessions. Copilot gains no usage or cost capability from this +change. Retained v7 paid cases below remain exact historical evidence, not v8 +qualification. Fresh pack/image admission and the required exact-runtime local +and Daytona qualification remain outstanding. + + +If the target is missing, outside the workspace, conflicting, malformed, too +large, contains control/direction-format characters, or would be redacted, the +request offers only the provider's one-time denial (when present) and cancellation. +Forged once/session grants are rejected. Raw `kind: edit` is required; titles do +not infer operation type. Non-edit permissions retain their existing behavior. +Default/full-auto selection is unchanged: callbacks handled by Paperclip receive +this check before any permission-mode fallback; no new automatic grant is added. +The existing transport policy still applies before permission handling. + +The declaration binds the context projector, permission adapter, workspace path +validator, semantic redaction policy, and generated tool-operation classifier. +A recursive value-import test requires every non-builtin policy dependency to +remain in this source hash set; type-only imports do not affect execution. +The candidate v9 digest was refreshed before live admission to close this +transitive policy binding. No retained v8 evidence is regraded. Copilot's +native executable, patched inner distribution, and shared ACPX patch are unchanged; +the sidecar assets and runner's embedded admission digest change. Older profile +identities cannot be admitted as v9 or reused as matching warm sessions. Fresh +runtime packs/images and exact-revision local/Daytona qualification are pending. +Earlier denial cases establish their own no-write behavior; their generic +`Create file` card did not prove complete decision context. Deterministic adapter, +sidecar and direct-driver tests cover safe and denied context; no new live claim +or native USD/accounting capability is implied. + +Historical v8 qualification checkpoint (2026-09-30): **Copilot profile v8 remains unqualified.** The a8df warm attempt passed all nine Product matchers and daemon invariants, but the supervisor failed final-pack cleanup on an extra executable; the exact orphan was later retired safely and the original failure remains. The earlier 5c69 warm-PID failure is a separate unchanged historical grade. Sidecar rotation per `attach_run` is ACPX authority rotation, separate from daemon continuity. The Daytona denial observed no write but failed its original cancellation fixture after receiving normal provider completion; later checks were not graded. Its database timestamp does not establish that completion was durably observable before Stop, so the earlier ordering inference is withdrawn. + +Shutdown ownership [#14730](https://github.com/paperclipai/paperclip/pull/14730), head `d09ed62b6`, has Apex 5/5 and all 52 checks green. Denial settlement [#14733](https://github.com/paperclipai/paperclip/pull/14733), head `1b47b30be`, passes 220 focused tests and E2E typecheck; fresh review and CI are pending. Suite v4 requires a retained pre-dispatch API observation for the normal-completion branch. Neither accepted branch proves Stop reached active work. Neither change has a live qualification pass; final-source verification remains pending. V8 binds the shared ACPX patch under a new digest, rejects v7 sessions, and adds no usage/cost capability. Native per-run USD is unknown. + +Exact attempt references: warm result `effadec7b6b134e3b31148cf7eea3c2b24d37ee0fc6ce36dbc90ec420b171560`, reconciliation `1ad1788161e34abf7ddcda15e08134a4c80d1f0af176d7b5397ea6dd810dace0`; Daytona denial result `050a239b6d86754d0f0045979b7c343c007729528546b49f069e7396cd167b38`, reconciliation `2ef9834cd53345c03c04bbfec1ad0bba24a2de1d7096d9225acdfa185241b82a`. + +Historical v7 checkpoint (2026-09-30): **Copilot profile v7 remains unqualified**. Paid controller/Product source is `40064d28522de25fea85c1297f35b41bb8a8897a`; native runtime is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`. Local attached async passed eight checks and local native denial passed ten, with exact command/permission correlation, expected task states, owned cleanup and full end integrity. Daytona hello passed six checks on the current Linux image; one owned sandbox was removed and absence observed through the cleanup window. The local three-turn warm case failed its stable-process invariant despite nine passing behavioral checks. The new controller repair retains the exact agent-files directory with the warm native owner and protects collection authority; it has deterministic and database coverage, but no new paid warm proof yet. The first Daytona denial launch failed before Product dispatch because two executable-digest fields were missing; it created no sandbox and remains a failed attempt. The source `3d21d375d` PR head has green CI and Greptile 5/5. Native per-run USD is unknown; included credits reached 20 with additional usage disabled at $0. The cloud hello infrastructure bound is $0.086931, with provisional analytics. These passes qualify only the named cases. See the [comparative capability report](runner-rich-acp-capabilities.md) for exact evidence, original failures and remaining gates. + +Message identity implementation (2026-09-30, **deterministic evidence and one paid async case; full qualification pending**): the pinned 1.0.88 +ACP mapper discards `assistant.message_start` and removes native `messageId` +from text deltas. The separate candidate keeps the original executable and +every embedded distribution asset, patches the hash-pinned JavaScript mapper, +and supplies its complete verified distribution through a per-spawn owned +entry shim. The shim installs the module guard before importing Copilot; +ambient distribution/version/module paths do not select executable content. +The original executable, inner archive, upstream app, patched app and complete +platform closure have independent hashes. Unknown upstream bytes are refused. + +The mapping carries real message IDs on the ordered standard ACP stream, +including empty starts; completed native messages do not echo their full text +again. Distinct native messages remain distinct transcript items. Earlier +messages close as commentary, and only the last message supplies final output. +An empty final message clears preceding text. Reasoning and unidentified native +session notices are not promoted into the final answer. This does not deduplicate +equal text or infer a message boundary from a tool call. + +A credential-free loopback probe through the actual pinned ARM64 process +observed three equal-text messages with three native IDs, three empty starts, +and attached-shell settlement. A separate actual ACPX transport fixture preserved +empty boundaries across three warm turns. All three platform distributions were +materialized; this is not native x64/Linux execution proof or paid qualification. +The [complete candidate inventories](../../packages/paperclip-runner/test/fixtures/copilot-message-identity-distributions-1.0.88.json) +retain platform closure hashes. Private bounded probe receipts are under +`runtime-6-6-9-eof-preparation/copilot-message-identity-candidate/` in the existing +qualification artifact collection. Copilot v7 binds this candidate to a new profile and warm identity; v6 paid +failures and missing settlement observations remain unchanged. The 1.0.89 comparison moves ACP transport into its native runtime and +does not establish that upgrading alone fixes message identity. + +Initially audited 2026-09-28 against repository base `c65fc9e3c81c41aafe421aa90a00514b84343285`; +updated 2026-09-29. Status remains **candidate, not qualified**. On frozen source +`bd4cc29c3017ed5e1484423e842fd48e3b2f49f3`, profile v4 local hello, question/answer, +semantic plan, controller restart and file-edit cases passed. Daytona hello passed +one native `gpt-5.6-luna` run with six matchers on immutable image +`sha256:bff4c3f291087a0eeae37e4c20dd51857b92833eaf73ba3aca4157f37de1e109`. +Earlier question-marker, recovery, startup-timeout and PostgreSQL failures remain +retained; later success does not establish causes for unresolved earlier failures. +The earlier 2026-09-29 admission identity was profile **v5**, binding shared ACPX patch +`79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b`. +The native Copilot binary and permission/instruction policy are unchanged. The +new digest rejects old warm sessions; v4 paid receipts remain historical evidence, +not a claim of v5 qualification. The new native-protection Product cases below +have not run on the new source. + +Daytona sandbox-specific analytics stabilized across three reads at a provisional +$0.0039682144 for the closed attempt interval; invoice finality and native per-run +Copilot USD remain unknown. The Product list-price estimate was $0.0040321867. +One exact owned sandbox was absent after the full 360-second cleanup observation; +45 local PIDs retired and semaphore counts returned 255→266→255. The conditional +720-second infrastructure bound was $0.053496. These are distinct accounting claims, +not a zero-cost assertion. The allocated provider budget remains $25 within the +shared $100 ceiling; no new paid attempt follows automatically from registration. +Mac x64, the remaining Daytona workflows, broader permissions/background variants +and complete rich-field projection remain unqualified. + +## Connection policy audit, 2026-09-29 + +The pinned server accepts permission-changing `session/set_config_option`, +`session/set_mode`, and leading-slash CLI commands. Entering autopilot enables +allow-all; loading an autopilot conversation enables it again. A clean launch +configuration alone therefore cannot establish safe restored-session policy. + +`copilot-policy.ts` and the optional ACPX `protocolGuardFactory` enforce policy +on each actual stdio connection, before bytes are delivered to the SDK or written +to the provider. Copilot admission requires the full new/load/resume result to +report the exact agent-mode URI and `allow_all: off`. Missing, duplicated, +unknown-authority, or custom-agent configuration fails closed. A connection may +select only its explicitly admitted model; a prompt requires proof of that +model. The guard rejects leading-slash commands, native mode/config controls, +and unsafe mode/config drift. Its authority is fresh for every connection and +remains invalid after a violation. It does not restore trust from cached ACPX +status. The stream errors, aborts outstanding response delivery, and terminates +the provider on violation; the existing runner owns bounded process cleanup. +Other providers do not install this guard. + +The Copilot SDK has native `ask_user` and `exit_plan_mode` capabilities, while +inspection of the pinned deterministic ACP tool catalogs confirmed that these +tools are absent in both agent and plan mode. Forced model tool calls in the +inspected catalogs return explicit tool-unavailable results rather than +producing a blocking request. This distinguishes SDK capability from ACP +exposure. Agent mode is the only admitted production mode; no production +qualification claim is made for plan mode. No `--no-ask-user` or tool-exclusion +flag was used. If any native input-request event nevertheless arrives, the +guard terminates the connection; it never presents a form whose answer cannot +be delivered. Unexpected blocking input remains a guarded safety condition, +not a qualified responder. + +The [offline conformance record](../../packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json) +retains every attempt and its evidence digest, including the isolated comparison release. A protected file outside the +working directory was denied before its contents reached the fixture model. +A durable conversation was closed and loaded, then requested permission for a +shell write; `reject_once` prevented the file. The initial attempt to load an +empty conversation failed with resource-not-found and remains retained. The +second attempt seeds one text turn before close/load; it is not a retry of a +measured behavior failure. A separate +process-death attempt first performs one allowed seed append, then SIGKILLs and +replaces the exact provider using the same private session state. Load succeeds, +the seed append still occurs exactly once, and a fresh denied write remains +absent. Both native processes use permission request ID 0, proving why response +authority must belong to the current connection. These are local native +close/load and process-death probes, not final packaged Product qualification. + +Production-pin probes use the verified 1.0.88 ARM64 binary; an explicitly selected +1.0.89 comparison has separately verified archive integrity. All use an explicitly constructed +credential-free environment, `COPILOT_OFFLINE=true`, and a synthetic OpenAI-style +model at an ephemeral loopback HTTP server. The synthetic `gpt-4.1` ID does not +name an authenticated GitHub model selection. Counts include the setup turn. +There are zero paid provider calls and zero model spend. Raw evidence, including +the failed empty-session attempt, stays in the private +`copilot-policy-20260929` artifact directory. + +The prior Product question failure remains a model-behavior failure: the retained +snapshot contains the exact requested marker in the task instructions, the +answered Cobalt choice, and warm-session continuation. Copilot instead supplied +`[terminal marker]` to `paperclip_finish`. Neither the grader nor the terminal +marker requirement changed. A final-runtime rerun remains required. + +### Final v3 Product question attempt + +The [final v3 question receipt](../../packages/paperclip-runner/test/fixtures/copilot-product-v3-question-2026-09-29.json) +retains the single reserved attempt at source `3d0c45920`, with the exact frozen +controller distribution, native assets, pack, Node, daemon and sidecar hashes. +The first turn produced the structured question and the board answered Cobalt. +Continuation failed during native `session.open` recovery, before continuation +usage was reported, with an unclassified sidecar rejection. The 72.086-second +case failed; cleanup passed and no retry occurred. This is a recovery/admission +failure, distinct from the earlier literal-marker behavior failure. Neither is +removed from qualification evidence. + +One run reports GitHub/unpriced usage: 28,545 input, 13,818 cached input and 525 +output tokens. Provider USD and upstream model-request count remain unknown. +The refreshed account counter moved from 5 to 6 of 1,500 included credits; +additional usage remained disabled with a $0 budget and $0 account cash charges. +This account-level delta is not a per-turn USD allocation. The Product aggregate's +zero reported cost with `unpriced`/incomplete coverage is not an authoritative +zero-cost receipt. No additional live attempt is authorized by this result. + +A [credential-free native recovery reproduction](../../packages/paperclip-runner/test/fixtures/copilot-runtime-context-recovery-2026-09-29.json) +closes the provider, deletes the prior registered instruction copy, and reproduces +`ENOENT` at `bindAcpxAgentFiles`. Reopening with the current registered copy +succeeds with the same native session. A second explicit fixture turn proves +actual native `session/load` and reaches `end_turn`. The fixture uses exactly two +loopback responses, a synthetic credential and test-only model metadata; +it makes no paid calls and does not qualify authenticated model selection. This +supports the stale durable runtime-context diagnosis; the failed Product run did +not retain the underlying wire error. Cold rotated restoration now takes the +current authenticated runtime context. Live reconnect and pending warm-transition +receipts keep their existing context; replacing that context in an active provider +is a separate lifecycle operation. + +### Native instruction delivery + +The initial candidate was profile v4. Its declaration binds native personal-file +instruction delivery, including replacement under the provider lifetime lease before native launch. The isolated +`COPILOT_HOME/copilot-instructions.md` is written atomically with mode 0600 under +the protected provider home, and empty instructions replace any stale content. +Concurrent contenders rejected by the lease and unauthenticated admissions do +not mutate the instruction file. The host awaits each write before releasing its +lease, including cancellation, so a late write cannot overwrite a successor. +Profile v3 identities are rejected before native launch; historical evidence +below remains labeled with its original profile and source. + +The [native model-request probe](../../packages/paperclip-runner/test/fixtures/copilot-native-instruction-delivery-2026-09-29.json) +retains a separate instruction-delivery failure: Copilot 1.0.88 ignores generic +ACP `_meta.systemPrompt` on new sessions, and ACPX does not include it on load. +Actual loopback model requests contained neither the Paperclip prompt nor the +registered directory guidance. Passing a refreshed descriptor alone is insufficient. + +A credential-free follow-up wrote the composed text to the isolated provider +home's `copilot-instructions.md`, using Copilot's native personal-instruction +mechanism. The first request's system message contained the old directory and +original entry. After closing the provider, deleting the old directory and +reopening with fresh instructions, actual `session/load` and a second explicit +turn completed. The second model request contained the current directory and +updated entry, with neither old value in its system message. Both attempts used +two synthetic loopback responses and no paid calls. This probe is not a final +built-production or authenticated qualification result. A follow-up +[source-level v4 probe](../../packages/paperclip-runner/test/fixtures/copilot-v4-native-instruction-delivery-2026-09-29.json) +uses the actual production sandbox writer and confirms the same new/load model +request behavior. The [post-lease source probe](../../packages/paperclip-runner/test/fixtures/copilot-v4-owned-instruction-delivery-2026-09-29.json) +reconfirms this after moving refresh under lifetime ownership, and retains an +earlier zero-call lease-admission failure. The final frozen pack still requires +independent verification. + +### Native detached work is explicitly unsupported + +The broader 2026-09-29 deterministic probe reproduces early completion for +`bash` with `mode: async` and `detach: true`. After an allowed finite two-second +command, Copilot sends a completed tool update naming a detached shell ID, then +`session.idle` and `end_turn` before the marker file exists. A separate diagnostic +keeps observing for three seconds after terminal and proves the marker appears +late. Both attempts retain their failed settlement assertion. + +The preceding standard tool-call notification includes `rawInput.detach: true`, +but the observed permission request carries only the command. The Copilot guard +rejects that update before the SDK can deliver any permission answer, terminates +the provider, and reports `COPILOT_DETACHED_WORK_UNSUPPORTED` with instructions to +run attached or use a managed runtime service. It also rejects detach carried +only in a permission request. Attached asynchronous work retains the normal +permission path. This is policy denial, not completed background settlement. + +A native offline probe invokes the exact production TypeScript tool-update +policy, kills the provider before permission dispatch, and observes no marker +through the command's bounded delay. It makes one loopback fixture request and +sends zero permission responses. Separate actual patched-ACPX stream tests prove +the same rejection before SDK delivery. The native probe intentionally tests +only tool admission: its synthetic model does not satisfy full production model +admission. Neither layer's result is misrepresented as final-pack live evidence. + +The bounded schema audit finds `detach` on `bash`, absent on `read_bash`, +`stop_bash`, and `task`; `write_bash` and PowerShell variants are not advertised +in this ARM64 catalog. Embedded JavaScript references `write_bash` but delegates +schemas to native Rust. The guard is independent of tool name, but this audit +cannot prove every platform/feature-flag variant or shell-created daemon +lifetime. Comprehensive background qualification remains blocked. Empty native +background-task notices and fixed delays cannot establish settlement. + +An isolated exact-integrity 1.0.89 comparison passes the same attached async +scenario and reproduces the detached late marker. Production remains pinned to +1.0.88. As checked on 2026-09-29, upstream #4743 is closed and explicitly concerns +attached async commands, distinguishing deliberate detached services. These +results do not establish that issue remains unfixed. #4537 remains open; the +narrow denied-write/read/recovery probes did not reproduce its permission bypass. +The original detached failures remain recorded as failures of the attempted +runner settlement contract; they are not relabeled as successful settlement. + +## Evidence and scope + +The audit inspected the exact `@github/copilot@1.0.88` platform archives, the +native executable's embedded `app.js`, `schemas/session-events.schema.json`, CLI +help/config/environment output, and real ACP wire traffic. The embedded schema +SHA-256 is `d8cb713c05d5278a68dde5c5d4482574f836e922ae13aa06f82474c209a7c6e9`. +The [complete event/field inventory](../../packages/paperclip-runner/test/fixtures/copilot-event-inventory-1.0.88.json) +contains all 150 native event types and their data field names, including every +event we do not subscribe to. Fields in that file describe the native schema; +they are not a claim that every event was observed on the wire. + +Primary external references: [ACP server documentation](https://docs.github.com/en/copilot/reference/copilot-cli-reference/acp-server), +[CLI command reference](https://docs.github.com/en/copilot/reference/cli-command-reference), +[denial bypass report #4537](https://github.com/github/copilot-cli/issues/4537), +and [background completion report #4743](https://github.com/github/copilot-cli/issues/4743). +The exact pinned implementation takes precedence over moving documentation. +The [harness priorities report](https://pages.paperclip.ing/2026-09-25-harness-priorities/) +defines the requested product outcome. Existing Codex app-server contracts and +conformance tests are the comparison baseline, especially `turn/steer`, +`turn/interrupt`, `thread/read`, file changes, user input, and scoped approvals. + +## Distribution and authority + +Launch the verified platform `copilot` executable directly with `--acp --stdio`. +Do not execute the mutable `npm-loader.js`, install hooks, or an ambient PATH +binary. `materialize-copilot-binary.mjs` validates exact package/version, executable +mode and bytes, then writes a native-closure manifest. Runtime descriptor leases +must independently verify the trusted closure digest before every launch. + +| Platform | Executable SHA-256 | Bytes | Closure SHA-256 | +| --- | --- | ---: | --- | +| macOS ARM64 | `a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3` | 152595280 | `fb3b367a45cd76122fe931521fa2a18adf234ba944fc302db9e10e005e57037e` | +| macOS x64 | `85eb919f6b9b9dd833ce5e326cbf974b3ee2d4a9ac525c59d4ec9c9ec085715b` | 165041200 | `05f3497b336b3efdec347beb2e3b80b02cfa95f811fafddc25d0b029ab95d711` | +| Linux x64 | `0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748` | 169544512 | `1a675c5b54ae4d94f08718a318451e0499708ded388b4cfd98acec6b4311ccbd` | + +All three archives were verified against the npm SHA-512 integrity value before +hashing the executable. Archive pins are retained in the materializer. The macOS +ARM64 executable has live evidence; Linux x64 now has the initialize-only image +proof described below. macOS x64 remains execution-unverified. The binary contains its JavaScript/native runtime and +extracts it into `COPILOT_PKG_CACHE_HOME`; this must be a fresh per-spawn private +lease directory, never a writable cache shared across executions. The native +distribution verifier supplied by the foundation owns that isolation boundary. + +`copilot-profile.ts` supplies private HOME/XDG directories, COPILOT_HOME, +COPILOT_CACHE_HOME and an extraction-cache binding; update disabling; no built-in +MCP servers; no remote/remote-export or shell startup environment; and secret +environment stripping for child shells/MCP. Only explicitly bound +`COPILOT_GITHUB_TOKEN` may authenticate production use. The offline fixture uses +an intentionally separate, credential-free loopback provider, not this production +credential path. Private configuration disables hooks, memory and automatic IDE +attachment and has no trusted folders. + +Never set `COPILOT_ALLOW_ALL=true`: this exact string also trusts workspace +hooks, plugins and MCP configuration. Paperclip's full-auto policy answers the +individual permission callback. It must not grant ambient configuration trust. +Mode/config changes, including autopilot and the provider's `allow_all` option, +must not bypass the admitted Paperclip policy. Slash-command discovery includes +commands capable of changing permissions, cwd, remote/export, MCP and schedules; +these are not authority to offer an unrestricted command UI. Adversarial +workspace/config qualification remains required before release. + +The pinned ACP handler maps `allow_always` to native `approve-for-session` for +commands, writes, reads, MCP and other supported tools. Path approval is also +session-scoped; URL approval is session-scoped to an origin pattern. Factory +permissions omit that option and reject fabricated permanent approval. This +scope is confirmed in source, not inferred from the option label. Read/write +session grants are broader than one file and must be described accurately. + +## Negotiation and event contract + +The observed initialize result advertises protocol 1; `loadSession: true`; +HTTP/SSE MCP; image and embedded-context input; no audio input; and session list +and close. It does not advertise steering, forking, goals, or a question/plan +extension responder. The source adapter supports model/reasoning/config changes, +but the offline session only returns `mode` and `allow_all` options. There is no +verified GitHub model ID from the historical offline probe. Require an explicitly selected model +and exact effective-model verification; never silently use the fixture's +`gpt-4.1` or substitute another model. Authenticated discovery on 2026-09-28 subsequently advertised and accepted +`gpt-5.6-luna`; subsequent canonical and Product cases verified inference on that exact model. + +The native event extension is real and is negotiated with: + +```json +{"clientCapabilities":{"_meta":{"github.com/copilot":{"events":["subagent.started","session.workspace_file_changed","assistant.usage"]}}}} +``` + +The notifications are: + +```json +{"method":"github.com/copilot/sessionEvent","params":{"sessionId":"...","type":"subagent.started","timestamp":"...","data":{},"agentId":"..."}} +``` + +The source caps subscription names at 128, payloads at 32 KiB, and pending +notification sends at 256. Oversized/unserializable payloads carry `dataOmitted`; +backpressure can drop events. There is no event ID or reliable replay contract. +`skill.context_delivered` and `skill.context_delivered_ref` are explicitly blocked +even when subscribed. These are provider restrictions, not missing runner parsing. + +`copilot-events.ts` requests 22 event types and projects only bounded declared +fields after matching the admitted session and an active receiver. The pinned +notification has no originating turn ID; arrival during a later turn cannot +establish attribution. It records source method/type, +provider timestamp and subagent identity. Payloads cannot authorize filesystem +reads, workspace rebinding, permission changes, native-input replies or terminal +settlement. Inline binary assets are content-address verified; only metadata is +forwarded until a provider-session artifact resolver can upload them safely. + +`copilot-extension-adapter.ts` retains every safe projected field in bounded, +session-scoped provider notices with method/event/session provenance and an +explicit unknown originating-turn attribution. Typed delegation, compaction and +artifact projections are withheld because they would falsely attribute delayed +session notifications to the receiving turn. Native correlated ACP turn events +remain separate. Follow-up: a versioned provider correlation contract is required +before promoting these notices into typed turn activity. Notices have readable +summaries; secret-shaped string values +are scrubbed without erasing numeric token counters. These display events never +create a usage charge, input-resolution acknowledgment, registered artifact, or +turn terminal event. The provider registry installs this factory and initialize +capability metadata in the provider branch. + +## Comparison against Codex app-server + +“Source” means verified in the pinned implementation; “wire” means observed in +the real ARM64 executable against the deterministic offline model. Product UI and +Daytona claims require the separate live product qualification. + +| Capability / Codex benchmark | Copilot native and ACP exposure | Runner and user-visible surface | Evidence / remaining gap | +| --- | --- | --- | --- | +| Authentication | Initialize advertises `copilot-login`, including terminal-auth command, args and label. | Explicit company-bound `COPILOT_GITHUB_TOKEN`; missing binding fails before executable admission. Terminal login is not launched. | Packaged initialize and host cleanup/retry test; terminal-auth remains intentionally unused because it would introduce ambient interactive identity. | +| Prompt attachments | Initialize advertises images and embedded context, and explicitly denies audio input. | Current runner prompt contract sends text. Image/context input blocks are not forwarded. | Observed initialize; P1 add validated attachment inputs. Audio is confirmed unsupported in this ACP advertisement. | +| Active turn steering (`turn/steer`) | Native SDK steering exists. ACP `session/prompt` unconditionally aborts the active session before sending a new prompt. | Unsupported active steering; do not impersonate it with concurrent prompts. | Source; P1 add a versioned upstream ACP steering method. | +| Ordered follow-ups | Native pending-message controls and `pending_messages.modified`; notification has no queue body. | Lifecycle activity only. Scheduler can start a subsequent completed-turn prompt, but that is not native queue delivery. | Source; P1 require queue acknowledgment and ordering contract. | +| Interruption (`turn/interrupt`) | Standard `session/cancel`, active prompt abort and process shutdown. | Shared cancellation and bounded cleanup. | Source; authenticated cancellation/process-tree test pending. | +| Session recovery/history | `session/load`, list and close; native history and rewind richer. | Exact identity/warm continuation through shared ACPX host; never replay approvals or mutations. | Live Product controller restart preserved the pending interaction and reused the same provider session. Provider-death restoration and history loading remain unqualified. | +| Session list / explicit close | Initialize advertises both methods. | Runner owns its selected-session registry and process cleanup; it does not call Copilot's list or explicit close methods. | Observed initialize; P2 company-scoped history/session management before consuming these interfaces. | +| Fork / history paging | Native CLI/SDK capabilities exist; no ACP fork advertised. | Unsupported. | Confirmed absent from initialize advertisement, not proof native harness lacks it; P2 upstream extension. | +| Tools / correlation | Standard `tool_call`/`tool_call_update`; parent identity in `_meta["github.com/copilot"].agentId`. HTTP/SSE MCP supported. | Shared tool activity, authenticated runner-owned MCP bridge. | Real create/bash/read_bash traffic and canonical authenticated get-task-context pass; Product semantic question and plan calls observed. Full adversarial company-boundary coverage remains pending. | +| MCP transport selection | Both HTTP and SSE are advertised. | The assigned Paperclip gateway uses the controlled HTTP bridge. Arbitrary SSE endpoint configuration is not exposed. | Observed initialize; SSE remains unused, P2 only if a governed connection requires it. | +| Scoped approvals | `session/request_permission`, actual options allow_once/allow_always/reject_once. | Shared durable permissions; only received decisions offered, policy enforced. | Real-service wire ID 0 denied before file creation, with no side effect through cleanup. Durable Product restrictive-mode recovery and wider tool denial remain unqualified. | +| Structured questions | Native `ask_user` callback and `user_input.requested`; current ACP adapter does not wire the responder. | Emits capability-gap notice if native notification arrives; cannot claim answer delivery. | Actual agent-mode tool list omits `ask_user` without a suppression flag. Paperclip semantic questions are available: restart case passes, while the separate question case failed its exact marker. Pinned offline agent/plan catalogs now prove both tools unavailable; the production guard admits agent mode and terminates any unexpected native blocking request. | +| Plan approval | Native `exit_plan_mode` callback; notification contains plan content/actions but lacks qualified ACP responder. | Capability-gap notice only; never synthesize plan acceptance. | Agent-mode tool list omits exit_plan_mode. Paperclip semantic plan/revision approval passes through the UI; Native plan-mode input is confirmed unavailable offline; production mode controls remain disabled. | +| Plan progress | Standard plan from todos SQL; native `session.plan_changed` has operation only. | Existing ACP plan/activity; native operation preserved, `planContentAvailable:false`. | Source; plan document reads require native interface. P1. | +| Models / reasoning / config | Source `session/set_model`, config options for model, reasoning, mode, custom agents, allow_all. | Explicit model admission. Mode/governance changes must remain policy-gated. | Authenticated catalog, exact set_model/config echo and real inference verified for gpt-5.6-luna. Other models and config-mode changes remain unqualified. | +| Native notification attribution | Copilot passthrough identifies the session, optional subagent and timestamp, but no originating turn. | Bounded redacted session notices retain all admitted details; typed delegation/artifact/compaction projection is withheld. | Delayed same-session regression; P1 require a provider-origin turn correlation contract. Arrival time and optional payload IDs cannot establish it. | +| Usage | Standard prompt usage and context usage; native assistant usage, AI-unit checkpoint. | Token/counter metadata with source; multiplier and nano-AI-units distinct from USD. | Real token counters retain GitHub provenance; authoritative per-turn USD is unavailable. External included-credit snapshots are separate, with additional cash billing disabled. CLI requested-cost coverage fails closed when unknown. Never double-count passthrough. | +| Subagent activity | Native started/configured/completed/failed, model and tool IDs, token/call/duration stats. | Bounded structured activity retaining attribution and model-selection details. | Source and unit fixtures; live UI attribution pending. | +| Task file changes / diffs | Standard tools carry locations/diff content for create/edit/str_replace/apply_patch. | Shared ACP tool activity retains bounded `rawOutput`, `inputUpdated` and the first validated relative location. Structured tool `content` diffs/images, `rawInput` and secondary locations are dropped; no complete diff presentation is claimed. | Real denied-create wire contains a diff, but wire presence is not runner/UI preservation. P1 add typed, bounded diff/image content and all validated locations with tool/session provenance; Product file-edit/validation and downloadable-file presentation pass; rich diff rendering remains unqualified. | +| Provider workspace files | `session.workspace_file_changed.path` is relative to provider session workspace files, not task cwd. | Validated reference tagged `provider_session_workspace`, resolution required. | Source + traversal tests; P1 safe file retrieval/upload. | +| Images / binary artifacts | Prompt image input; native content-addressed binary_asset base64. | Hash/length-validated metadata references; bytes not blindly read from disk or emitted in activity. | Source + unit digest tests; P1 durable artifact storage; >32 KiB payload provider omission remains. | +| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. A deterministic detach:true case now reproduces end_turn before the finite command completes; see the 2026-09-29 blocker. Native detach is now rejected before permission delivery; comprehensive background qualification remains blocked pending unproven tool/platform variants. | +| Compaction/context | Native compaction lifecycle/token counts/context git metadata. | Safe bounded counters/status, immutable workspace binding. | Source + projection tests; raw summary/private custom instructions omitted. | +| Goals / remote/schedules | Native autopilot/objectives/remote/schedule facilities; no qualified ACP goal protocol. | Unsupported through this profile; remote disabled. | Source; P2 separate governance review before control exposure. | + +## Unused event and field accounting + +The inventory is exhaustive for the pinned native event schema: 22 selectively +subscribed events, 15 standard-ACP projection events, 111 native passthrough events +not subscribed, and 2 provider-blocked events. Its `fields` array names every +native data field; `fieldCoverage` records each field's disposition and follow-up. +Preserving one standard ACP projection does not imply all native fields survive. + +Reasons and priorities are explicit: + +| Group | Reason and follow-up | +| --- | --- | +| Standard text/reasoning/tool/plan/config events | Standard ACP already transports the user-facing content. Additional native metadata is not assumed preserved. P2 compare native field inventory against normalization before adding fields; avoid duplicate output and raw reasoning. | +| Standard ACP parent tool content and locations | Confirmed shared normalization gap: structured `content` diff/image blocks, `rawInput`, and locations after the first safe relative path are not carried into canonical tool events. Only bounded `rawOutput`, `inputUpdated`, tool lifecycle/identity, and that first path survive. P1 introduce validated diff/image artifact schemas and preserve every safe location with attribution; retain secret redaction and workspace containment rather than forwarding raw provider objects. The retained offline diff proves harness exposure only. | +| Native user/plan/elicitation requests and completions | No qualified ACP responder/correlation acknowledgment. The 2026-09-29 agent/plan probes prove these two native tools unavailable on the pinned offline surface; the connection guard fails closed if any native request appears. Add a versioned upstream responder before displaying an answerable UI. | +| Native permission authorization internals, sandbox decisions, recovery | Standard permission callback is the policy decision boundary. P1 collect sanitized denial diagnostics; never let native carried-forward/assent events authorize actions. | +| Native usage diagnostics omitted from subscribed assistant.usage | Quota snapshots, reasoning summaries, fusion/RTE payloads and upstream service/cache diagnostics are not normalized. P2 type/redact useful performance details; quotas and model multipliers cannot substitute for verifiable dollar spend. | +| Native compaction summaries/checkpoint paths/custom instructions | Avoid copying private instruction/summary bodies or treating provider paths as task paths. P2 add explicit safe metadata schema/artifact retrieval where useful. | +| Native model-cache checkpoint data / premium request total | Complex cache state not normalized; premium request totals are not USD. P1 document billing provenance before integration. | +| Native artifact metadata/bytes | Base64 is verified then omitted; arbitrary nested metadata has no current UI contract. P1 safe artifact upload with content type/size/path policy, not a guessed cwd join. | +| Native hook/extension/skill events | Ambient hooks/extensions are disabled and runner-owned skills have their own attribution. P2 typed activity if a verified owned extension needs it. Two context-delivered types are blocked upstream. | +| Native MCP auth, headers, dynamic lists, reconnect and tool events | Only runner-owned bridge is admitted. P1 sanitized bridge availability diagnostics; do not surface OAuth/headers as unrestricted interactions. | +| Native remote/handoff, schedule, canvas, fusion/factory, memory/indexed-search, UI-ephemeral events | No corresponding admitted ACP control/resource or typed product surface. P2 investigate useful artifact/subagent projections separately; disabled remote authority stays disabled. | +| Native raw user/system messages, assistant lifecycle/retries, streaming internals, tool progress | Standard ACP provides primary conversation/tool lifecycle; duplicate/private bodies intentionally not subscribed. P1 audit lost meaningful progress/retry metadata with sanitized bounded samples. | +| Native external-tool/sampling/limits callbacks | No qualified ACP responder. P0 prove no unresolved request on admitted model/tools; otherwise keep release unqualified. | +| Native capability/model/session lifecycle/config notices | Initial handshake and normalized session/config are admission authority. P1 detect capability/model drift and fail closed rather than treat a notice as authorization. | + +Subagent subscribed fields are preserved within the declared text bounds; +truncated display strings carry an explicit truncation marker. Empty native +`pending_messages.modified` and `session.background_tasks_changed` events have no +queue/task list to preserve; their projection explicitly says refresh unavailable. +Native context repository/git-root strings, completion receipt finalTool, +compaction summary internals, artifact metadata, nested cache state, and native +question/plan contents are individually marked in the inventory. No exposed +native field should be read as silently supported merely because its event name +appears in the subscription. + +## Deterministic verification and retained wire + +The reusable `scripts/probe-copilot-acp.py` verifies the pinned executable before +running it with a fresh environment and a deterministic loopback OpenAI-compatible +fixture. `COPILOT_OFFLINE=true`; no credential is inherited. It bounds fake model +calls and process lifetime and removes its owned workspace. The fixture model ID +does not verify any GitHub model's availability. + +```sh +python3 packages/paperclip-runner/scripts/probe-copilot-acp.py --package-root /path/to/copilot-darwin-arm64/package --scenario deny-write +python3 packages/paperclip-runner/scripts/probe-copilot-acp.py --package-root /path/to/copilot-darwin-arm64/package --scenario attached-shell +node --test packages/paperclip-runner/scripts/materialize-copilot-binary.test.mjs packages/paperclip-runner/scripts/build-copilot-distribution.test.mjs +pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-events.test.ts src/drivers/acpx/copilot-profile.test.ts src/drivers/acpx/copilot-evidence.test.ts +pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-extension-adapter.test.ts src/drivers/acpx/copilot-registry.test.ts +``` + +Retained real-binary evidence: + +- [Denial wire](../../packages/paperclip-runner/test/fixtures/copilot-acp-denial-1.0.88.json): request ID 0; reject_once; failed tool update; target file absent after end_turn. This narrow case did not reproduce #4537. +- [Attached-shell settlement wire](../../packages/paperclip-runner/test/fixtures/copilot-acp-settlement-1.0.88.json): two-second async attached command; marker written; output consumed through read_bash; idle followed by end_turn. This narrow case did not reproduce #4743. +- All three materialized platform executables match pinned digests. Only ARM64 wire behavior was exercised. + +These are local offline conformance probes, not product E2E or live GitHub +qualification. No screenshots were produced because no product UI was exercised. +The later live sections record explicit authentication, exact-model inference, +file validation, semantic tools, plan approval and controller-restart evidence. +Remaining qualification blockers include the failed standalone question marker, +every native blocking question/plan mode, restrictive permissions across native +tools and configuration, provider-death recovery, active cancellation, +multi-company isolation, rich artifact/diff UI, macOS x64 execution and Linux x64 +Daytona E2E. Authoritative per-turn USD remains unavailable; external included-credit +reconciliation is distinct from cost-limit coverage. Retry with +another pinned release if a blocking interaction or settlement/denial case fails; +do not suppress the interaction to obtain a pass. + +## Build-owned native distribution + +`buildPinnedCopilotDistribution({ outputRoot })` in +`scripts/build-copilot-distribution.mjs` downloads the exact platform npm archive +from `registry.npmjs.org`, verifies its pinned SHA-512 integrity, and admits only +the four expected regular tar members. Traversal, links, PAX overrides, duplicate +entries, bad checksums, hidden trailers, and oversized input fail admission. The +binary is independently checked against the pinned SHA-256 and exact size before +it is materialized; no install script, npm launcher, or downloaded executable runs +during this build. `outputRoot` is the selected pack's exact +`provider-assets/copilot/-` directory. + +The factory resolves those assets from the runner's verified package authority, +including the descriptor-loaded sidecar path, then the native verifier makes a +private executable lease and fresh extraction cache. Callers cannot choose a +runtime binary or distribution root. + +On 2026-09-28 the strict archive reader verified the actual pinned archives for +all three platforms. A fresh macOS ARM64 registry download completed the full +builder, returning the profile digest above and closure +`sha256:fb3b367a45cd76122fe931521fa2a18adf234ba944fc302db9e10e005e57037e`. +The temporary output was removed after verification. This packaging proof used +no model credentials, executed no provider turn, and incurred $0 model spend; +it does not qualify either local product behavior or Daytona execution. + +The Copilot branch connects all three closed registries: profile installation +selects the pinned native verifier, profile extensions advertise only the 22 +selected native event types and create the Copilot adapter, and candidate packs +select the verified archive builder. Registry conformance checks the complete +subagent field projection through the shared turn binder, attribution, canonical +schema validation, meaningful display details, and stale/cross-session rejection. +Admission error classification distinguishes missing authentication, account or +organization denial, and unavailable explicit models using fixed safe messages; +unrelated runner integrity errors keep their original classification. + +## Complete provider-pack proof + +The [retained packaged-launch evidence](../../packages/paperclip-runner/test/fixtures/copilot-provider-pack-darwin-arm64-1.0.88.json) +records clean source revision `5272fc6398d42344d1888a3f97ca6909684eefbf`, +provider-pack digest `sha256:4ba17b2455b0ab92cfe6ee223f77708379fe219792ccb66dbdef70060e6e22e1`, +the profile and native closure digests, and the exact protocol-1 initialize response. +The complete pack was built with standalone Node 24.19.0. Its packaged +`verifyAcpxProfileInstallation` registry acquired a private native command lease, +launched Copilot 1.0.88, preserved numeric request ID 0, and observed clean EOF +settlement. The native terminal-login command path is sanitized in the fixture. + +The smoke uses `COPILOT_OFFLINE=true` and an explicitly configured loopback +metadata-only provider; that server received zero requests, and no prompt was +sent. This low-level packaging test deliberately does not claim production +authentication or model availability. The real host separately rejects absent, +blank, or NUL-containing explicit credentials before opening a command lease; +ambient `GH_TOKEN` and `GITHUB_TOKEN` cannot satisfy admission. A host regression +verifies that this failure releases ownership and permits a subsequent explicitly +bound retry without spawning a provider during the test. The controller now mints +a provider/session binding from explicit credential names; the sidecar rejects +unbound ambient credentials and removes the binding before native launch. +Caller-supplied binding markers cannot override the controller-generated value. +The final runner spawn allowlist now preserves the selected credential and +binding through local and remote launch specifications; regression tests cover +the complete controller-to-launcher-to-sidecar boundary. Pending direct product +backends reject before driver construction. Qualification remains available +through the existing host-controlled runnerd CLI. + +Probe attempts are accounted for: an initial smoke client closed stdin before +initialize completed and was corrected; a bare unauthenticated initialize then +hit the 20-second deadline; a metadata-fixture initialize passed; the final pack +was rebuilt with the authentication preflight and passed again. After rebasing +onto foundation `5aeebb20c`, the complete pack was rebuilt and the fifth initialize +probe passed, with numeric ID 0, clean EOF, and zero fixture HTTP requests. All +five attempts were local with no credentials or inference. After the final +foundation `f80c312cd` and Copilot review fixes, the complete pack was rebuilt +and a sixth initialize probe passed with the same results. After foundation +`7721662f2` fixed the final spawn boundary, the pack was rebuilt from the source +above and a seventh initialize probe passed. All seven probes used $0 model and +infrastructure spend. There was no Daytona +deployment. The candidate remains unqualified. + +Final focused checks at the source revision above passed: 200 Copilot/provider-host, +environment, backend-admission and durable-control-plane tests (including four +retained-evidence cases), 12 strict +builder/materializer, candidate-registry and probe-cleanup tests, all six Daytona +image-content tests, and the runner +TypeScript build including generated schema checks and verified sidecar bundles. +The evidence update itself passed the four evidence cases again. The complete +pack remains inspectable at `/tmp/paperclip-copilot-launch-boundary-pack-20260928` +on the build host; the sanitized tracked fixture provides the portable proof. +The exact Docker resolution command, seeded from the tracked lockfile, produced +`650e23d20e967bcfbfced888e131199b9a06e66a1ba4f64cfb68383b59def4a8`, matching +the reviewed image pin; the subsequent frozen runner install passed. Generated +lock changes remain uncommitted. These checks do not substitute for live GitHub +or product/Daytona qualification. + +```sh +/path/to/standalone/node packages/paperclip-runner/scripts/build-provider-pack.mjs /absolute/provider-pack --candidate-providers=copilot +/absolute/provider-pack/node_modules/node/bin/node packages/paperclip-runner/scripts/copilot-provider-pack-smoke.mjs /absolute/provider-pack +``` + +Both Copilot builder scripts are explicit Daytona image hash inputs. The selected +`copilot` candidate also changes image identity, while manifest qualification stays +`pending`. Linux x64 binaries are pinned and buildable; live Linux/Daytona behavior +still requires the qualification cases above. + + +## Authenticated qualification preparation (2026-09-28) + +The [sanitized authenticated discovery](../../packages/paperclip-runner/test/fixtures/copilot-authenticated-discovery-1.0.88.json) +uses the previously verified native pack and a dedicated, short-lived personal +Copilot Requests token, explicitly bound as `COPILOT_GITHUB_TOKEN`. The token has +no repository write authority. No credential, account identifier, provider +session ID, or private path is retained in that fixture. + +Three metadata-only sessions were created and closed: catalog discovery, +`session/set_model`, then `session/set_model` plus `session/set_config_option` +with exact `gpt-5.6-luna` echo. The native catalog advertises 21 model IDs and +marks Luna enabled. No `session/prompt` request was sent. This verifies auth and +model selection only. The reproducible metadata probe is +`scripts/discover-copilot-acp.mjs`; it refuses any outbound method outside its +closed initialize/new/select/close list and denies unexpected inbound requests. + +The initial paid qualification reservation is $2 within the provider's $25 +allocation and shared $100 budget. The account dashboard baseline is 0 of 1,500 +included AI credits; additional paid usage is disabled with a $0 cash budget. +Included credit consumption must still be reconciled and reported separately +from cash charges. The [sanitized first live proof](../../packages/paperclip-runner/test/fixtures/copilot-canonical-live-proof-2026-09-28.json) +records one `get-task-context` turn completed in 33.383 seconds: one authenticated +semantic call and successful result, completed terminal, complete transcript and +mock-only boundary all pass the unchanged canonical scorer. The first attempt +failed before prompting because a stale Rust binary omitted credential binding; +the fresh source-built release binary fixed admission. The live attempt's +post-turn package-provenance lookup failed, so the same retained artifact was +scored offline after regenerating the exact source tarball. Both original +failure records remain inspectable; recovery sent no additional model prompt. +The sibling launcher now validates that tarball before paid work. + +The receipt reports 24,258 input tokens, 11,781 cached input tokens and 441 output +tokens. Its $0.00326022 catalog estimate is not a GitHub charge. The runner's +`providerRequests: 1` is a terminal receipt count, not an observed count of +upstream HTTP requests. GitHub still displayed 0/1,500 credits after this small +turn; lag or rounding prevents exact credit attribution. Additional paid usage remains disabled with a $0 budget; that billing boundary +does not imply zero consumption of included credits. + +The pinned CLI documents `--max-ai-credits` as a soft cap, minimum 30 credits. +Its ACP startup/session implementation does not propagate that option into +`sessionLimits`, unlike the native interactive/server paths; ACP exposes no +budget configuration option. Treat provider-enforced per-session credit limits +as unavailable through this pinned integration (P1 follow-up: upstream ACP limit +support). The 60-second deadline, $0.50 declared envelope and $2 reservation do +not become a per-response hard cap. All profiles remain pending until the full +local and Daytona qualification succeeds. + + +## First local Product result and accounting defect (2026-09-28) + +The [retained local Product proof](../../packages/paperclip-runner/test/fixtures/copilot-product-live-proof-2026-09-28.json) +records `extended-harnesses.runner-acpx-copilot.local.hello-complete` from committed +source `bcc9c638a25b91b84065f12633f083bd4f7a689f`. The first attempt passed all six +behavior matchers in 38.436 seconds, with one provider turn, no automatic retry, +and successful cleanup. Browser evidence shows the exact completion marker once, +the task marked Done, the Copilot agent, and expandable tool activity. The active +turn deadline was 120 seconds within the existing $2 reservation. This is one +basic Product case, not full local or Daytona qualification. + +The original result is retained unchanged with digest +`sha256:92c8aff3960e443be0c009c891d49d285476c3d6b67999fe97bb323076c4dc8b`. +It exposed a shared accounting defect: model-family inference labeled the biller +`openai`, while missing provider cost became `costUsd: 0`, `costStatus: reported` +and `billing.complete: true`. Those fields are invalid accounting evidence. The +correct biller is GitHub, provider USD cost is unknown, and the retained behavioral +pass must not be interpreted as accounting qualification. The shared server fix +was incorporated before the following file and question cases. The original +result also lacks source SHA fields; the independent launcher manifest records +the exact committed source above. + +After this case, GitHub displayed 1 of 1,500 included AI credits consumed across +the successful protocol and Product turns together. Exact per-run credit use +remains unknown. Additional usage is disabled, with $0 of the $0 cash budget +spent; included-credit consumption is reported separately. The Product receipt +contains 31,332 input, 15,451 cached input and 337 output tokens, without a verified +USD receipt. + +The shared eval CLI now preserves completed provider outcomes while returning a +separate nonzero accounting failure when cost coverage is unknown or its bound +is exceeded. The sibling scorer retains semantic assertions under +`accounting_failure`; roster and campaign orchestration stop subsequent queued +cases. External dashboard reconciliation does not override the CLI budget +result. Profiles remain pending until the outstanding qualification matrix passes. + +## Merged-source local Product coverage (2026-09-28) + +The [retained merged-source proof](../../packages/paperclip-runner/test/fixtures/copilot-product-merged-live-proof-2026-09-28.json) +records both successful behavior and failures without rewriting original results. +The file and question cases used source `ee9536001fbe733b2386dd3379730a4e0be59488`, +an immutable runnerd whose Rust tree matches that source, and verified native +assets. A separate complete compiled pack has digest +`sha256:809ea6bef2fc1122ef214840d119f37854598007ff7449189027294b0802a681`. +Targeted validation passed 67 provider/contract tests, 11 packaging/script tests, +93 Product harness tests, and the TypeScript/verified-sidecar build. The packaged +offline registry launch also passed without a prompt or fixture HTTP request. + +| Local case | Retained result | Accounting and remaining limits | +| --- | --- | --- | +| File edit and validation | 7/7 matchers passed in 59.696s. Native shell output proves the file was edited and compared successfully; the downloadable file, exact marker and Done status were visually checked. | Correct GitHub biller, `unpriced`, absent USD field, incomplete billing. Native raw output contains exit code 0, but normalized typed `exitCode` is absent; follow-up P2 is preserving this structured result. | +| Question and continuation | 4/6 matchers passed in 102.939s. Cobalt was selected through the real question card and continuation reused the same provider session. The final output was literally `[terminal marker]`, so the exact-marker checks failed. | Original run-log events and the browser screenshot confirm provider behavior, not public redaction. No retry or grader relaxation. Continuation is GitHub/unpriced. The paused first run incorrectly said `reported` with no cost and zero counters; separate fix `c276496e4` keeps absent cost unpriced and passed 13 accounting tests. | +| Plan approval | Failed after 9.531s during embedded PostgreSQL bootstrap, before any provider call. | Host semaphore exhaustion was independently confirmed. The failed attempt is retained and does not qualify plan behavior. | +| Controller restart | Not executed. | Held before launch because the same host resource exhaustion affected other Product and DB checks. | + +The two inference cases each had a 120-second active deadline, a 300-second outer +deadline, a $2 reservation and zero automatic retries. GitHub's displayed included +credits moved from 1 to 2 after the file case and from 2 to 3 after the question +case. Additional paid usage stayed disabled with a $0 budget and $0 cash charge. +Display deltas are not exact per-request credit receipts; provider USD remains +unknown. At that historical checkpoint, five provider turns were observed; the underlying +HTTP model-request count is unavailable. No additional paid attempt is running. +Later plan, restart and native risk-probe results follow below. This checkpoint +remains unchanged as historical evidence; its failed question result is not erased. + +## Final shared-source packaging checkpoint + +The [final pack proof](../../packages/paperclip-runner/test/fixtures/copilot-provider-pack-final-2026-09-28.json) +uses committed source `8aa867b64d5fc2fd62cff110bd000addf5dc54de` on foundation +`f063fbf2b`, including the paused-cost and bounded native-copy fixes. Pack digest +is `sha256:627992ea80e8be7154d07cbc9925b781519199e25690b02d4a044f44342e6bd8`. +Two clean resolutions from the tracked manifest graph and lock produced identical +dependency bytes, SHA256 `aa97f89ba8a7c63573114dda54895523d316df67956c65eeccbc89d3166bb1b4`; +the frozen filtered install passed and no generated lock is committed. The +packaged registry probe again initialized numeric request 0 and closed cleanly, +with no credential, prompt or fixture HTTP request. TypeScript and sidecar build, +68 provider/contract tests, and 11 builder/script tests passed at this checkpoint. +The refreshed immutable runnerd has the matching Rust tree and digest +`sha256:e6a9fb5170b76a49b8411834b3706e8edf8f1a1ae85ad13b55368158aa7f67a0`. +This packaging proof does not rerun or supersede the live results above. Additional +Product starts are held while host PostgreSQL semaphore capacity is restored. + +## Real-service permission and detached-command probes + +Two subsequent single-turn probes used exact `gpt-5.6-luna` through the final +pack's verified native command lease, with isolated configuration, no ambient +credentials or MCP servers, and a dedicated explicitly bound token. Probe source +`47eed960b380e8c8054eb19985aaefeabc6336c3` is retained in +`scripts/qualify-copilot-acp.mjs`. Five credential-free probe tests include actual +JSON-RPC framing, numeric request ID 0, native option identity and process reaping. +Each live probe had a $2 reservation, 120-second prompt and 180-second outer +deadline, an awake supervisor, and zero retries. + +- [Denied write](../../packages/paperclip-runner/test/fixtures/copilot-live-denial-2026-09-28.json): Copilot requested a native file edit at 7.177s. The client returned its exact `reject_once` option for request ID 0. The turn ended at 7.187s and all 79 filesystem observations through 12.837s remained absent. Native exit and owned process-group cleanup passed. +- [Detached command](../../packages/paperclip-runner/test/fixtures/copilot-live-detached-2026-09-28.json): the native tool call explicitly requested `mode: async`, `detach: true`. Only the exact finite three-second marker command received `allow_once`. Native output confirmed the detached shell exited 0 at 10.083s; the marker was present before the 10.675s terminal response and remained correct through cleanup. + +These are actual GitHub-service results, distinct from the earlier loopback +fixtures. They qualify these two narrow file/command oracles only. They do not +prove all tools, an arbitrarily long detached process, governed Product approval +surfaces or Daytona execution. Authoritative USD remains absent. GitHub's display +was still 3/1,500 included credits after denial; display granularity or delay +prevents a zero-use claim. The post-command dashboard also remained at 3/1,500 included credits, +with additional usage disabled and $0 cash charges. Exact per-probe credit use +remains unknown. The overall candidate remains pending. + +## Semantic plan after host capacity recovered + +A separately authorized [Product plan attempt](../../packages/paperclip-runner/test/fixtures/copilot-product-plan-live-proof-2026-09-28.json) +passed all six matchers in 45.625 seconds at source +`c06fc5fccc88f5816450434493451b9d2d339125`, using the final provider pack and updated +immutable daemon. The original PostgreSQL startup failure is retained separately; +this was one explicit new attempt with no automatic retry. Browser review shows +the complete Plan revision 1, a confirmation targeting that exact revision, +the approval message and the exact terminal marker once. + +This is Paperclip semantic planning. It does not enable Copilot's unwired native +`exit_plan_mode` responder. The approved continuation deliberately opened a fresh +session after the adapter configuration changed and `forceFreshSession` was +requested, so this case does not prove warm-session reuse. Both paused and +completed runs now correctly report GitHub and `unpriced` with no USD field, +including the paused run's zero normalized token counters. Cleanup passed and +the retained fixture process audit found no remaining owned processes. + +GitHub subsequently displayed 5/1,500 included credits, an aggregate increase of +2 from the snapshot before the denied-write, detached-command and two-turn plan +batch. Display delay and granularity prevent allocation among those calls. +Additional usage stayed disabled with a $0 budget and $0 cash charge. Provider +USD remains unknown; this external reconciliation is not an authoritative +per-turn cost receipt. + + +## Controller restart and pending question recovery + +The [retained restart proof](../../packages/paperclip-runner/test/fixtures/copilot-product-restart-live-proof-2026-09-28.json) +passed all six matchers in 50.875 seconds at source +`19ca0f558d3aebddedc6ff14836ff3e71498e68e`, using the same final pack and immutable +daemon. The exact pending Cobalt/Amber interaction remained visible after server +restart. The board selected Cobalt; the continuation reused the same persisted +provider session and emitted the exact terminal marker once. Screenshots show +the recovered question, selected answer and Done task. This proves controller +reconnect with a preserved session, not reconstruction after provider death. + +Both paused and completed receipts are GitHub/unpriced with no USD field. The +continuation reports 49,436 input, 44,075 cached input and 418 output tokens. +Cleanup passed, the bounded supervisor exited successfully and an exact owned-root +process audit found no retained processes. This was one explicit attempt with +two provider turns, no automatic retry, a 120-second active deadline, a 300-second +outer deadline and a $2 reservation. The earlier standalone question's literal +`[terminal marker]` failure remains unchanged and continues to block its cell. + +Eleven provider turns have now been observed across the canonical, Product and +native risk probes; the number of upstream HTTP model requests is unavailable. +GitHub displayed 5/1,500 included credits both before and after restart, with +additional usage disabled and $0 cash charge. Display delay and precision mean +that the unchanged counter cannot prove zero included-credit consumption. This +external reconciliation remains separate from unknown provider USD. No further paid prompt is +authorized or running from this branch. The profile remains pending. + +Final provider checks after these evidence updates pass 74 focused TypeScript +tests and 24 packaging/discovery/risk-probe tests. The discovery regressions cover +rejecting the mutable `auto` model selector, releasing leases on early setup +failures, and rejecting pending RPC calls immediately after native exit or +malformed output. These probe-script changes do not alter the final runtime pack. + +Review tightened the denial probe to require the native `rawInput.fileName` to +resolve to the exact marker target. An unrelated edit or a command merely +mentioning the marker cannot satisfy the oracle. The original paid denial names +that exact target; offline replay of its retained wire and marker observations +passes the corrected oracle. The fixture records the original evidence and oracle +script digests. No additional provider prompt was sent. + + +## Linux image initialize-only proof + +The [Linux x64 pack proof](../../packages/paperclip-runner/test/fixtures/copilot-provider-pack-linux-x64-2026-09-28.json) +uses image `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:5457769683fd310223d3b0d4f1ed9a6cf341bdb16514746b3aaeabca2e888fee` +from the same source `8aa867b64d5fc2fd62cff110bd000addf5dc54de`. Its platform-specific +pack digest is `sha256:b11984fe02bd5d5a36b01ed559d2f4c765663df09a46117d3092b1183be08ba4`. +The verified executable matches the Linux pin, initializes Copilot 1.0.88/ACP 1 +with request ID 0, and exits 0 after stdin EOF. The maintained smoke script ran +under network-none, a read-only root and private tmpfs, with no provider +credentials and zero fixture requests or inference. Missing-token production +preflight still rejects. Two earlier operator invocations used the wrong image +repository or Node path and failed before provider launch; both are retained. +This is Linux packaging evidence, not an authenticated Daytona Product run or +model qualification. The profile remains pending. + +### Bounded native tool observations and protection evaluations + +The sidecar and direct TypeScript driver share one projector under `drivers/acpx`, +which projects an allowlist of active-turn ACP tool/permission fields into +existing `provider.notice.recorded` details and provenance: validated relative target, +request/tool identities, command SHA-256, explicit mode/detach, and linked shell +start/completion with provider-reported exit code. Session passthrough notifications +remain uncorrelated and cannot supply this evidence. Native ACP exposes an execution +kind, not a trustworthy `bash` name in the title. Strings use semantic redaction; +ambiguous identities, exhausted bounds or failed projection make evidence incomplete. +Projection cannot change permission delivery or terminal authority. This is an additive +observation change: Copilot profile v4 is unchanged, while source/pack provenance +changes. Older sessions lacking these notices cannot pass the new evidence oracles. + +The manual local `copilot-protection` Product suite registers an exact browser-denial +case with explicit cancellation and a finite attached-command settlement case. Both +remain unqualified until separately built and run. The denial case expects an unfinished +task/cancelled run; full restrictive workflow completion is still a gap because later +MCP permissions must not be auto-approved from provider-controlled titles. + +A separate credential-free real-binary fixture on 2026-09-29 exercised Copilot1.0.88 +(executable SHA-256 `a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3`) +against a loopback synthetic model. It started an eight-second attached async command; +the model immediately returned terminal text while the process was still live. The +binary itself issued a read-shell update, and process absence plus the marker were +observed before the ACP prompt result. The model never called `read_bash`. The private +`copilot-v4-protection-preparation/adversarial-attached-v2` receipt retains monotonic +ordering and process identity. This verifies one pinned finite attached scenario and +is distinct from detached-policy rejection or comprehensive background qualification. diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md new file mode 100644 index 0000000000..7876c5cba4 --- /dev/null +++ b/doc/architecture/runner-pi-capabilities.md @@ -0,0 +1,987 @@ +# Pi rich ACP runtime + +## Current integration — 2026-10-08 + +The experimental Runner admits **Pi profile 22** (`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`) and +**Cursor profile 15**. Copilot remains pending at **profile 17**. Legacy +`pi_local` is unchanged. Pi accepts any explicit caller-selected provider/model +ID and requires native acknowledgement; qualification models are examples, not +an allowlist or a fallback. + +The requested task execution and human-control paths have seven passing cloud +cases on profile 19 with the accepted Sonnet 4.6/low fixture. Profile 22 retains +that wrapper, helper and extension, incorporates master's environment changes, +patches brace-expansion to the official 5.0.12 payload, and excludes general AWS +IAM credentials from Pi's environment. Bedrock's provider-scoped bearer key is +supported. Those paid results retain their original profile-19 identity; +current integration is verified separately by source, package and CI checks. +Accounting and the wider platform/provider matrix remain deferred. See the +[readiness plan](../plans/2026-10-02-pi-production-readiness.md#current-delivery-scope--2026-10-08) +for the exact source/image evidence and merge status. + +All dated checkpoints below describe historical qualification attempts. Their +failures and profile numbers are retained; they are not current release gates. + +## Historical Pi 1.0 candidate (2026-10-02, profile v12) + +The candidate now pins **`@earendil-works/pi-coding-agent@1.0.0`** with +`pi-acp@0.0.33`, ACPX `0.13.1`, and portable Node `24.21.0`. Pi v11 and older +sessions must reopen. Cursor v10, Copilot v12, and legacy adapters are unchanged. +This is a new runtime candidate: historical Pi 0.84.2 passes do not qualify it. +Local Product, Runner protocol, and Daytona paid qualification remain pending. + +The [official release](https://github.com/earendil-works/pi/releases/tag/v1.0.0) +points to commit `a13d35a742c6ef8462812a28fbe1d8c8b7431c32`. The npm archive +is verified against published SHA-512 integrity +`/FtbxoSQU/mEv1QnichJjRjqteqaIaMWxmhB4G367+MwZfX7/DI5B9YAg5lqbN7nztFskBEtUSZ+FlmMBECtMw==` +and has SHA-256 `638ed3abbe54ef70cbf8673ae4bc531e791613756aac04644cfcdcc4af0fafaf`. +The isolated npm lock preserves the complete upstream shrinkwrap, supplementing +seven Pi-family entries' omitted integrities from their exact npm 1.0.0 records. +Upstream now pins Undici 8.10.2 itself; the previous 8.9.0 replacement is removed. + +Profile v11's first local hello failed on its first streamed update: Pi 1 RPC +removes `message` and `partial` from deltas, while the wrapper required the old +shape. The run stopped and the task became blocked; this was a runtime +compatibility failure, not a successful hello. V12 binds deltas to the original +message occurrence, tracks indexed tool fragments, and checks completed text, +thinking, tool identity, and arguments against native final receipts. Real Pi 1 +RPC/owned-extension tests reproduce the failure and pass the repaired local-only +get-context → finish path, interleaved tools, and a sanitized provider error. +These synthetic loopback responses establish compatibility, not paid qualification. +Pi may also send a native final message without streamed blocks. That receipt +is preserved without inventing deltas; only observed completed stream blocks +are checked against its indexed content. Error or aborted receipts may retire +incomplete blocks, but cannot contradict blocks already completed. + +Runtime failures now emit bounded known reasons before prompt rejection. Raw +exception bodies, credentials, paths, and unknown text stay withheld. Actual +provider token usage still comes from native completed receipts; fixture usage +is not live billing evidence. New wrapper/helper closure pins and Rust admission +constants require fresh platform packs and daemons. Historical v11 artifacts +must not be relabeled as v12. + +Compatibility changes preserve the existing rich runtime surface: + +- Native steering and follow-up acknowledgements now carry `disposition`. + Only `queued` is accepted as delivery; `handled`, missing, or malformed + dispositions fail visibly. Settlement still waits for `agent_settled`. +- Pi defaults streaming cache warming to enabled. The owned extension overrides + every `cache_warming_decision` with `stop`; no background model refresh is + authorized by an active or completed Runner turn. +- Pi's new built-in extensions are disabled by the existing `--no-extensions`. + The explicitly loaded Paperclip extension still supplies assigned MCP tools, + native questions, permission decisions, instructions, and registered agent files. +- Structural `system` message boundaries carry prompt/tool declaration changes. + They pass through without assistant IDs or final-answer authority; malformed + structural frames and overlap with an active assistant still fail closed. +- Pi's transcript-context API replaces the old provider `context.tools` view. + Provider-free SDK tests read current declarations through Pi's canonical helper; + production RPC parsing keeps message/tool boundaries and usage intact. + +The upstream [changelog](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/CHANGELOG.md) +and [RPC interface](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/rpc.md) +add capabilities which must not be confused with wrapper support: + +| Priority | Native Pi 1 capability | Runner status and reason | +| --- | --- | --- | +| P1 | Codemode, nested calls (`parentToolCallId`, `nestedCalls`), deferred tool search, exposure/annotations/output schema | Available upstream; built-in extensions remain disabled until nested provenance, permission checks, and output projection are qualified. Assigned semantic MCP tools retain direct exposure. | +| P1 | Native MCP OAuth, provider-auth HTTP MCP, OAuth issuer/scope hardening | Available upstream; arbitrary MCP/auth discovery is outside the closed assigned bridge. Paperclip owns credential and company scope. | +| P1 | Image generation and classifier calls through `ModelRuntime`/codemode | Available upstream; not exposed because separate model calls need budget attribution and artifact admission. Text/image tool-result transport remains supported. | +| P1 | `clear_queue`, per-input queued/handled disposition | Disposition is checked now. Queue clearing still needs a durable Runner cancellation contract; Stop continues to abort the owned active process/turn. | +| P2 | Append-only context edits, actionable `turn_end`/`agent_before_settle`, `context_with_system` | Available upstream; only existing observation hooks are installed. Host-directed edits need durable recovery semantics before exposure. | +| P2 | Provider stream hooks, virtual models, per-model compaction/image sizing | Available upstream; no arbitrary provider extensions or virtual routing are admitted. Native compaction and existing model configuration remain supported. | +| P2 | UI prompt lifecycle events and richer startup/fullscreen TUI | Native UI exists; Runner uses RPC and its existing question/wait states. No terminal TUI is advertised. | +| P2 | Fork/clone/history export | Native RPC capabilities remain available upstream; durable branch lineage and contained exported-artifact handling remain required before Runner controls expose them. | + +Pi's published CLI mode union is `text | json | rpc`; no native ACP mode is +present in this release. The reviewed `pi-acp` wrapper remains necessary. + + +Historical v10 qualification checkpoint (2026-09-30): **Pi profile v10 remains unqualified.** The capped-key/login prerequisite remains blocked. Native USD is unknown. The optional private budget helper is not integrated; Cursor's account-cycle cap does not establish Pi's provider spending bound. V10 keeps the native wrapper and closures unchanged, binds the shared ACPX patch under a new digest, and rejects v9 sessions. Fresh exact-runtime admission and final controller verification remain pending. + +Historical v9 checkpoint (2026-09-30): **Pi profile v9 remains unqualified**. Current runtime source is `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`; controller/Product harness source is `40064d28522de25fea85c1297f35b41bb8a8897a`. Runtime builds for macOS ARM64/x64 and Linux x64 are complete. No paid profile-v9 pass is claimed. A credential with a verifiable spend limit is still needed for the remaining paid qualification. The optional transport-budget candidate is frozen on a separate branch and is not integrated or a live spending guarantee. See the [comparative capability report](runner-rich-acp-capabilities.md) for current qualification gates and the field audit. The dated observations below retain their original profile identities. + +Historical implementation candidate, 2026-09-29: profile version 8 repairs +native assistant-message attribution. Real SDK `message_start` and `message_end` +boundaries, including empty messages, carry occurrence IDs through ACPX, the +sidecar and both terminal reducers. Tool-use, error and aborted messages remain +progress but cannot become a final answer. Missing or conflicting boundaries +fail closed. Loaded history has a separate display-only identity. Retry, +compaction, extension and command notices retain bounded, redacted severity and +metadata on `paperclip/pi_notice`; they are never assistant final content. +The [v8 declaration](../../packages/paperclip-runner/test-fixtures/pi-acp/profile-v8-identity.json) +also pins the shared ACPX parser and Pi host projections. Previous sessions must +be reopened. No paid profile-v8 qualification has run. + +The retained profile-v7 campaign passed native questions (15/15), then failed +question continuation (5/6): the model emitted the exact final marker, but the +old terminal reducer combined it with pre-tool narration. That failed Product +result remains a failure; the remaining five cells were not launched. The v8 +credential-free regressions reproduce this boundary through the actual pinned +SDK (fresh, warm and loaded sessions), patched wrapper, ACPX transport, sidecar, +TypeScript driver and Rust reducer. This is deterministic repair evidence, not +a replacement for the failed paid journey. + +Historical profile version 7 repairs +MCP tool-name admission and native question labels. MCP names containing colons +or periods, or exceeding model name limits, receive deterministic collision-checked +aliases of at most 64 characters; authenticated calls retain the exact original +name. Titles and select labels admit at most 1,000 UTF-16 code units, matching +the canonical question contract. Invalid owned arguments fail before a UI promise; +unsupported external UI requests emit an explicit error notice and stop the session. +The editor draft requires the shared `initialText` normalization and editable UI +roundtrip. Blank initial drafts are valid, but accepted empty or whitespace-only +input/editor answers are not currently representable: the canonical required-text +form prevents submission with a required-answer message, and its response +validator rejects blank text. Making +Pi fields optional alone would still drop those answers; no adapter fallback +invents an empty response. The transport distinguishes empty accept from cancel, +but that direct bridge property is not full Product support. A future explicit +canonical empty-text opt-in would need matching persisted-response and UI checks. +These repairs change the native closure and require fresh qualification. Profile +v7 has paid Product hello passes on local source `807feaecf` and Daytona source +`bd4cc29c3`; the latter passed all six matchers with complete owned cleanup. +The [qualification checkpoint](runner-rich-acp-qualification-2026-09-29.json) +retains exact profile, runtime, image and billing identities. Remaining Product +journeys, native interactions and platform coverage are still unqualified; the +`bd4cc29c3` local campaign passed hello and stopped at native questions: all +15 behavioral checks passed, but the required API evidence snapshot was absent. +The failed result is retained and the six later cells did not launch. A committed +harness-only fix captures the complete final API evidence; its paid retry passed +all 15 native-question checks. The following question-continuation case reached +Done but failed the exact final-message assertion: earlier pre-tool narration +was aggregated into the final item. Three runs settled across these two cells, +all owned processes exited, and five later cells did not launch. The native +message attribution defect is repaired in v8; authenticated v8 qualification +remains pending. The canonical +[v7 declaration](../../packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json) +binds the exact wrapper, helper, extension and three target closures. + +Credential-free regressions run the pinned SDK with synthetic model streams to +verify all four question methods and original MCP call names behind aliases. The +actual patched wrapper proves visible unsupported-dialog failure; the real form +normalizer verifies the 1,000-unit ASCII, CJK and surrogate-pair boundaries. The +closed snapshot admission test exercises the candidate assets without inference. + +Profile version 6 added the native +question tool, the authenticated agent-files root, and matching SDK path +normalization. Its authenticated Product and Runner qualification is pending. +The later v6 Product hello on source `97217c531` passed with one observed attempt +and an exclusive-key cost delta of $0.00065884. Its launcher omitted the explicit +zero-retry CLI flag, so it is retained as `passed_with_retry_policy_gap`, never +as v7 qualification. +The first frozen v6 Product hello attempt fails before provider startup because +the shared native-execution parser still admits only profile versions 1–5. +Both assignment and automatic recovery runs reject the same input; no usage +receipt exists. The early exclusive-key billing delta is $0 and cleanup passes. +The [retained failure](../../packages/paperclip-runner/test-fixtures/pi-acp/product-hello-profile-rejection.v6.darwin-arm64.json) +includes the actual local sidecar, Node, daemon, closure and pack identities. +The parser repair is included in frozen source `edf14a067`. Its second hello +attempt passes that boundary but fails at the 30-second `session.open` deadline. +No terminal usage exists; both immediate and delayed exclusive-key deltas are $0, +and all 40 owned process identities are gone. The +[v6 startup proof](../../packages/paperclip-runner/test-fixtures/pi-acp/startup-diagnostic.v6.darwin-arm64.json) +retains that failure and the credential-free diagnosis. Bounded 32-worker file +hashing and snapshot copying preserve all integrity checks, canonical ordering, +the 32 MiB snapshot batch ceiling, and fully drained failure cleanup. A diagnostic +build completes the full no-key Runner rejection in 9.8 seconds; its repeatable +protocol regression settles in 10.7 seconds and closes cleanly. A separate actual +ACP test asserts the precise missing-credential error. Timings vary with filesystem +cache and load; these are admission diagnostics, not authenticated qualification. +No timeout or provider-closure identity changes in this repair. + +The opt-in regression is `node --test test/pi-closed-startup.test.mjs` from the +Runner package, with `PAPERCLIP_TEST_PI_STARTUP_PACKAGE_ROOT` pointing at a built +Runner package containing Pi assets and `PAPERCLIP_TEST_PI_STARTUP_RUNNER_BINARY` +pointing at its compatible daemon. It spawns a clean environment, submits no +prompt, asserts terminal admission rejection within the unchanged 30-second +deadline, and verifies the daemon closes. It does not inherit provider credentials. + +Historical profile version 5 repairs the +Undici dependency and bundled Node runtime affected by GHSA-3wwx-pv8p-q78v. +Full authenticated v5 qualification is pending; its bounded native controls probe passes. Historical version 4 repairs native +tool ID reuse across model iterations and warm prompts; its native steering, +queued follow-up and denied-write probe passes. Historical version 3 passes cover local Product file +delivery, typed question continuation and native steering, queued follow-up and +denied-write controls. Its semantic plan journey exposed two defects: the shared +MCP display projection, now repaired, followed by a resumed-turn ID collision, +addressed by v4. Version 2 hello completion and every failure remain retained. +The wider local and Linux x64 Daytona matrix remains pending; this document does +not promote the candidate to a qualified production runtime. + +The runner pins `pi-acp@0.0.33` and +`@earendil-works/pi-coding-agent@0.84.2`. The candidate model is +`openrouter/deepseek/deepseek-v4-flash-0731`; only an explicitly bound OpenRouter +credential may reach this profile. `patches/pi-acp@0.0.33.patch` repairs the ACP +wrapper. `pi-runtime-extension.ts` supplies the runner-owned semantic bridge and +pre-execution native tool policy. This keeps the upstream session, model, +streaming, diff, retry, and compaction implementation while making its missing +boundaries explicit. + +## Capabilities and differences from Codex app-server + +| Surface | Pi implementation and boundary | +| --- | --- | +| Sessions | Native Pi JSONL create/load; resume is confined to the execution's private session home and original workspace. Cold `prompt` cannot implicitly load an arbitrary session. | +| Text and reasoning | Native start/end provenance and occurrence IDs separate pre-tool narration from the last completed assistant message; empty or tool-use terminal messages cannot promote stale text. Loaded history is display-only. The common runner's private reasoning policy still applies. | +| Native tools | `read`, `grep`, `find`, `ls`, `write`, `edit`, and `bash` pass the immutable extension gate before execution. File roots and read-only mode are checked before and after a permission wait. The host sandbox remains authoritative for shell commands and races. | +| Permissions | Native tool approval uses ACP `session/request_permission`, with allow once, allow for the session, and deny. Only offered options are accepted. Session grants cover an identical operation and still revalidate paths. Questions never become approvals. | +| Questions | The owned `paperclip_native_question` tool exposes Pi `select`, `confirm`, `input`, and `editor` through ACP form elicitation. Select is single-choice with stable option IDs. Pi returns `false` for both No and dismissal of confirm, so the tool reports `negative_or_cancelled`; it never invents a confirmed answer. Other methods return explicit cancellation. Permissions and semantic Plan approval remain separate. | +| Agent files | Only the authenticated execution's registered `agent_files` working copy can become `AGENT_HOME`. The Pi wrapper takes `PAPERCLIP_PI_AGENT_HOME` from the runner's bound launch, ignores ambient `AGENT_HOME`, and admits that canonical directory alongside the task workspace. Protected overlap, directory replacement and symlink escape fail closed. Read-only policy still forbids writes. `PI_CODING_AGENT_DIR` remains private provider state. | +| Semantic tools | Runner-bound HTTP MCP catalogs (numeric loopback HTTP or assigned HTTPS gateways) register under exact `mcp____` names. Calls use an occurrence-scoped delivery ID and preserve the bounded native ID as private ACP-wire provenance, together with the cancellation signal. Common normalized events currently drop that metadata. Authenticated PRP tool handling owns semantic authorization and durable interactions. Only the exact session-assigned gateway URL and credential are used, with redirects disabled. Ambient and unassigned MCP servers are not admitted. | +| Plans and artifacts | Pi has no native structured plan or artifact channel. Paperclip plan and artifact semantic tools remain available through the MCP bridge; native file edits retain bounded, workspace-confined ACP diff projection. Tool text/image results are preserved, and resource blocks are recorded without following URLs. | +| Steering | Capability-negotiated `pi/steer` issues native RPC `steer` during an active turn. `pi/follow_up` explicitly queues native RPC `follow_up`. Neither is inferred from a second ACP prompt. Each takes `{sessionId, message}` and returns `{accepted: true, sessionId, kind}` with the matching control kind. | +| Usage | Prompt results sum actual assistant message usage receipts across continuations. Input, output, cache reads/writes, total tokens and Pi-reported pricing estimates have provenance. Context-window occupancy is not billed usage. No receipt means no usage assertion; absent cache or cost fields remain unknown. Pi calculates cost from its model catalog rates, so this is not an authoritative provider bill. | +| Retry and compaction | The pinned session notice extension preserves bounded, redacted summaries, severity and known native counters/flags separately from assistant text. `agent_settled`, rather than a transient `agent_end`, settles a prompt. A final provider error remains a failed prompt. | +| Images | The upstream ACP wrapper accepts image blocks, but `AcpxRuntimeTurnInput` and the common adapter currently forward text only. P1: implement typed image content through the common converter and then qualify the exact model; live testing alone cannot close this implementation gap. | +| Cancellation and death | Cancellation expires live UI waits and calls native abort. Pi process exit rejects pending RPC requests and all active/queued turns. Partial RPC frames, oversized frames, and malformed JSON fail closed. | +| Fork and clone | Native Pi RPC exposes `fork(entryId)`, `clone`, and `get_fork_messages`; the ACP wrapper and Paperclip controls do not map them. P2: add explicit admitted session operations and verified lineage before exposing controls. | +| HTML export | Native `export_html` is available but unused by this integration. P2: add a bounded workspace artifact export with publication policy before exposing it. | +| Durable goal and native plan | No mapped native surface. Paperclip semantic tools remain the plan path; no Codex parity is claimed. | + +`initialize` advertises `_meta.paperclipPi.version = 1`, `steering`, +`queuedFollowUp`, the four question methods, `nativePermissions`, `promptUsage`, +`nativePlan: false`, and `pendingRequestRecovery: "live-process-only"`. The common +host must inspect this advertisement before sending provider extension requests. + +## Native model instruction delivery (profile version 6) + +The [fresh/load model-request proof](../../packages/paperclip-runner/test-fixtures/pi-acp/prompt-delivery.v6.darwin-arm64.json) +uses the actual pinned wrapper launch helper, owned extension, and Pi SDK +`AgentSession.prompt` with a synthetic model stream. Captured model requests +contain the exact composed Paperclip execution prompt, custom instruction-entry +content, and current `AGENT_HOME` guidance. A disk-loaded session retains its +prior reply while receiving changed entry content and a new agent-files root in +its current system prompt and latest task constraints. The old root is absent +from the current system prompt; historical user messages still retain prior +root text, which the current guidance explicitly supersedes. + +Pi uses `PAPERCLIP_PI_SYSTEM_INSTRUCTIONS` in its runner-owned launch +configuration and appends it through `before_agent_start`. Generic ACP +`_meta.systemPrompt` is ignored, so that metadata alone is insufficient. This +proof captures the native model-request boundary, beyond host options or wire +metadata. It uses no real credentials or network inference and does not exercise +a complete ACPX/PRP process or qualify an authenticated Product journey. + +## Occurrence identity (profile version 4) + +Pi can reuse a native ID such as `call_0` in a later model iteration, including +within one ACP prompt. The wrapper and immutable extension now derive one live +ID from a private per-child launch namespace, a monotonic model-iteration ordinal, +and the native ID. The native `turn_start` event advances the ordinal; the SDK's +relative `turnIndex` is not authority because it resets on each warm prompt. +Permission requests, streamed tools, lifecycle updates and semantic MCP requests +share that ID. Bounded private ACP-wire provenance retains the original native +ID and iteration. Common ACPX/sidecar normalization drops this `_meta`; these raw +IDs and iteration fields are not currently UI-visible. The normalized delivery ID +itself survives the common tool and permission paths. P2: add an allowlisted +diagnostic provenance projection and UI coverage without granting the raw ID +execution authority. Historical message-index/scope metadata has the same +private-wire-only disposition. + +An exact HTTP retry reuses the delivery ID and shared cached result. Reusing a +native ID for a second invocation in the same iteration, changing its arguments, +or receiving ambiguous iteration boundaries poisons identity state and fails +closed. Shared Runner bridge deduplication and Rust call tombstones are unchanged. +This avoids both replaying a mutation and mistaking a new corrected invocation for +an old cached error. Historical session replay uses a separate stable +`pi-history-` identity from session/message occurrence, explicitly marked +`history-display-only`; it never grants execution authority. + +The maintained `test/pi-native-package-contract.test.mjs` regression exercises +actual pinned Pi Agent/AgentSession dispatch with an in-memory model stream, +empty authentication storage and model networking disabled. Across two warm +prompts it verifies six native iterations, relative indices `[0,1,2,0,1,2]`, +four reused `call_0` executions, extension-before-wrapper ordering, and four +matching distinct delivery/display IDs. The authenticated loopback test in +`pi-runtime-extension.test.ts` uses the real Runner semantic bridge to prove +cached validation errors, concurrent exact retries, changed-payload rejection, +and a corrected invocation in the next iteration. Installed-wrapper tests verify +lifecycle correlation and stable, disjoint historical replay identities. + +The historical v5 command digest is +`sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803`. +Versions 1–4 cannot reopen under this identity. Paid and image proofs below remain +evidence of their recorded versions, not v7 qualification. The historical v4 +digest is `sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991`. + +## Lifetime and recovery + +A provider UI request is a live Pi RPC promise. Paperclip's durable interaction +can survive a control-plane connection loss while the runner, wrapper, and Pi +process remain alive. A response is forwarded once to that exact pending request. +If Pi or the wrapper dies, the interaction must expire; neither a new process nor +a loaded JSONL session has the original promise. Do not replay an approval into a +replacement process. Normal conversation continuation can load the private JSONL +session after a separately admitted restart. + +The wrapper attaches a per-turn generation to settlement work, fails queued +turns on provider death, and bounds RPC request waits. This does not constitute a +claim of generic provider-process handoff or exactly-once external side effects. + +## Version 6 native boundaries + +The native file gate now applies Pi 0.84.2's path interpretation before checking +roots: `@` prefix removal, home expansion, file URLs and Unicode spaces. Native +read also tries macOS screenshot spacing, decomposed Unicode and curly-quote +filenames. Every possible fallback is checked, including its physical symlink +target, both before and after approval. The prior raw-path check could authorize +a workspace-relative spelling that the SDK subsequently interpreted outside the +workspace. The actual pinned SDK regression verifies the interpreted path and +rejects aliases to private state. + +The native question tool accepts `{method,title}` plus the method's own fields: +`options: [{id,label}]` for select, `message` for confirm, `placeholder` for input, +and `prefill` for editor. There are at most 128 unique IDs and unique labels; +titles, input fields and answers have UTF-8 bounds. Unsupported fields, unoffered +answers and the reserved permission-title prefix are rejected. Exact retries of +one native occurrence reuse its pending or completed result. This tool cannot +approve a native operation or a Paperclip Plan. Durable semantic questions and +revision-bound Plan approval continue to use assigned Paperclip tools. + +Credential-free tests exercise all four methods through the actual pinned Pi +AgentSession tool dispatcher and separately through the installed ACP wrapper's +form transport. They do not establish browser persistence, reconnect delivery, +provider-death expiry, or live model behavior. Those remain distinct Product +qualification requirements for version 7. + +## Verified launch and packaging contract + +The wrapper rejects ambient launch unless `PAPERCLIP_ACPX_ISOLATED_CONTEXT=1`. +It executes an absolute bound Node executable with an absolute bound Pi CLI path, +without a shell or PATH lookup. The common command lease must supply: + +- `PAPERCLIP_PI_NODE_EXECUTABLE`, `PAPERCLIP_PI_ENTRYPOINT`, and + `PAPERCLIP_PI_EXTENSION_PATH`: files in the immutable private provider snapshot. +- `PI_CODING_AGENT_DIR`: isolated persisted session/auth/settings home. +- `PAPERCLIP_PI_READ_ONLY`: exactly `0` or `1`. +- `PAPERCLIP_PI_READ_ROOTS` and `PAPERCLIP_PI_PROTECTED_ROOTS`: JSON arrays of + absolute allowed skill/read roots and protected runtime/config roots. Assigned + skills must use a separate immutable lease outside the protected runtime and + executable roots; overlapping roots fail admission. Skill roots are always + immutable, even when nested inside a writable workspace. +- `PAPERCLIP_PI_SYSTEM_INSTRUCTIONS`: admitted instructions, bounded to 32 KiB. + +The wrapper synthesizes `PAPERCLIP_PI_RUNTIME_CONFIGURATION` for its child, +including only session-bound MCP servers. The owned extension captures and deletes +this configuration environment variable before model shell execution. Credentials +remain accessible only through the deliberately admitted runtime mechanisms. + +Pi starts with `--no-extensions --no-skills --no-prompt-templates --no-themes +--no-approve --offline`, followed by the explicit immutable extension and admitted +skill paths. Project trust is denied; user-bash RPC and terminal login are disabled. +Only the controlled `/compact`, `/session`, and `/autocompact` slash commands are +accepted. Startup package update checks are disabled. + +The wrapper must not infer complete extension registration from process startup. +It therefore probes +`get_commands` and requires the exact readiness sentinel +`paperclip-runtime-ready-v1` / `Paperclip runtime gate v1`. The extension registers +this command only after gates and every MCP catalog finish initialization. A +missing sentinel kills the subprocess before the first model prompt. + +`pi-verified-runtime.ts` inventories and checks the complete graph, including +Node, wrapper helper, extension, package metadata, native libraries, WASM and +resources. The manifest schema is `paperclip.pi-runtime-files.v1`, with relative +`node`, `piEntrypoint`, `extension`, `wrapperEntrypoint` fields and a complete +`files` array of SHA-256-bound regular files or contained relative symlinks. +Hardlinked files, escaping links, changed files, and undeclared files fail +admission. Its verifier returns the three launch environment bindings and a +manifest digest. Admission verification alone is not a command lease: the shared +host must retain its immutable snapshot and process guardian through termination. + +The published Pi package has a shrinkwrap and a nested dependency graph. Provider +pack creation must copy the complete installed graph and use the same pinned Pi +family dependencies, rather than reconstructing the graph from `cli.js` imports. +The emitted owned extension must be included in every macOS arm64/x64 and Linux +x64 pack; the candidate materializer emits it at `runtime/extensions/paperclip.js`. The wrapper's `dist/paperclip-runtime.js` is +part of its verified package and must never be omitted from a copy or hash. + +## Verification and maintenance + +The [v5 offline pack proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.v5.darwin-arm64.json) +binds runtime source `aec26ad83f1d082f0d0a5eaffbd615a9e2e26155`, combined macOS +pack `sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500`, +and immutable daemon `sha256:fe03a5f5e7b7d51aafb398aa435e4a200ca6e0206c46b84ba0baa15aa0be5f31`. +Recursive workspace typecheck and full build pass. Focused checks pass 43 +TypeScript tests including actual installation launch, 19 installed-wrapper/native +SDK tests, eight materializer tests and 13 Rust backend tests. Independent review +recomputed the v5 digest and verified all 13,827 installed files. The +[security closure proof](../../packages/paperclip-runner/test-fixtures/pi-acp/security-closure-proof.v5.json) +retains npm's vulnerable pre-replacement result, the fixed installed version, +unchanged upstream metadata, target pins and exact runtime file changes. + +Both Pi's private interpreter and the separately manifest-bound outer macOS pack +interpreter are official Node 24.21.0 with bundled Undici 7.29.1. Sibling provider +closure/profile declarations are unchanged. The actual Pi registry initializes +ACP1 under a network-denying sandbox, exits EOF0 and releases its process group. +Full Runner startup with no credential rejects in 8.558 seconds, below the +unchanged 30-second deadline; this is a typed admission failure, not authenticated +success. Linux packaging uses the later Docker-only source `94ef513f8`; its +runtime source trees remain identical to `aec26ad83`. Linux execution is recorded +separately. The two clean lock resolutions match `9eea6018…`; tracked lock bytes +remain unchanged. + +The [v4 offline proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.v4.darwin-arm64.json) +binds frozen source `f556110d588a9de9fefe676a9a62bf09e98afb85`, combined pack +`sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855`, +and release Runner SHA-256 +`373848d2d7287b2c47b2cee422cb7bd25073a594a620a9ad574f2d3d51cd1670`. +Recursive workspace typecheck and full build pass at that source. Full pack and +all three closure inventories verify; the actual Pi registry launch initializes +under a network-denying sandbox and exits cleanly on EOF. A separate full Runner +startup rejects missing credentials in 11.083 seconds without a prompt. That is +a timely admission rejection, not an authenticated success. The proof includes +execution-tree identities and two matching clean dependency resolutions. + +The v4 focused verification passes 49 TypeScript tests plus the separately enabled +actual-distribution installation test (all five installation cases), 24 installed +wrapper/native-SDK/materializer cases, and 13 Rust native-provider backend tests. +The new macOS arm64 closure was independently reproduced by a fresh locked +public-registry installation. The helper tests cover poisoned malformed inputs, +iteration overflow and the 4,096-entry limit; installed tests cover cancellation +followed by warm native-ID reuse. These checks make no provider inference calls. + + +The four colocated Vitest suites exercise questions, permissions, timeouts, +framing, usage, file policies, MCP behavior, and graph verification. The Node test +`test/pi-acp-package-contract.test.mjs` launches the actual patched npm wrapper +against a deterministic RPC fixture. It proves initialization, streaming, +terminal usage, question versus permission routing, explicit steering, provider +exit, required sentinel, and typed failure settlement. It requires the exact +installed pinned package; an absent or unpatched dependency is a failure. +`PAPERCLIP_TEST_PI_ACP_PACKAGE` can select a separately installed pinned fixture. +These fixtures invoke no model or paid API. + +At source `f58cfa1cbf4503b93a0be4480f51b492d8203b7c`, all 30 tests in those +four Vitest files passed, including the optional actual-distribution installation +test. The patched-wrapper, real-Pi, and distribution-builder Node suites passed +all 17 tests using the fresh provider-pack packages. TypeScript and the full +candidate pack build passed. Retry cases cover successful, failed, and missing +outcomes; the failed and missing cases were observed failing against the prior +wrapper before the repair. These focused checks do not establish a full +repository check or current-head CI pass. + +`test/pi-native-package-contract.test.mjs` runs the real Pi 0.84.2 CLI without +credentials. It loads the compiled owned extension, initializes a loopback MCP +catalog, and returns the exact readiness command. An invalid catalog exits Pi +before RPC admission. This checks the extension ABI and explicit loading path. It does not prove authenticated inference, native tool +execution, MCP model invocation, credential renewal, or remote sandbox behavior. +Those remain required local and Daytona qualification evidence under the shared +live-spend cap. No live inference spend was incurred by these deterministic tests. + +The helper in `patches/pi-acp@0.0.33.patch` is generated from +`src/drivers/acpx/pi-acp-runtime.ts` with Node `stripTypeScriptTypes`, trimming +trailing whitespace. The package contract checks that these bytes match. When +updating the upstream version, rebase the wrapper patch and rerun the actual +package tests; do not preserve an old qualified command hash after changing bytes. + +Primary references reviewed: + +- [Harness priorities report](https://pages.paperclip.ing/2026-09-25-harness-priorities/report.md) +- [pi-acp source](https://github.com/svkozak/pi-acp) +- [Pi RPC protocol](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md) +- [Pi extensions](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md) +- [ACP protocol and SDK](https://github.com/agentclientprotocol/typescript-sdk) + +## Candidate distribution build + +Run `node packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +/absolute/new-output` on the target build host. Supported targets are macOS arm64, +macOS x64 and Linux x64; no cross-platform qualification is inferred. The builder +uses exact official Node 24.21.0, pinned archive SHA-256 and executable SHA-256 +for each target. `--node=/absolute/portable-node` reuses a matching binary; without +it the builder downloads the pinned official archive. It rejects non-system +dynamic-library dependencies and executes the copied interpreter after relocation. +The initial Homebrew Node discovery failed this portability check and is not an +admitted pack interpreter. + +The isolated `scripts/pi-distribution/package-lock.json` pins all 143 dependency +packages and registry integrity, without changing the workspace pnpm graph. It +preserves Pi's upstream nested shrinkwrap, and adds npm registry SHA-512 integrity +for the six exact 0.84.2 Pi family packages whose published shrinkwrap omitted it. +Installation uses `npm ci --ignore-scripts`, public registry access, private npm +configuration and an environment without npm or provider credentials. The builder +checks every locked installed version and upstream shrinkwrap entry (with the +exact security exception below), applies the +owned ACP patch, verifies the helper matches its TypeScript source, and compiles +the owned extension to `runtime/extensions/paperclip.js`. + +Profile v5 replaces only the nested `undici@8.9.0` with `8.10.2`. Both an +existing-lock resolution and a fresh resolution ignore npm's scoped override +inside Pi's published shrinkwrap; `npm ci` also installs 8.9.0 despite the fixed +outer lock. The materializer therefore checks the exact original nested path, +version, registry URL and SHA-512, plus the installed vulnerable package identity, +before fetching the exact fixed tarball. It verifies the pinned SHA-512 before +extraction, bounds both gzip and inflated tar bytes to 4 MiB, rejects traversal, +links and unexpected entry types, and requires the 214 regular-file payload. +The published Pi package metadata and shrinkwrap remain unchanged; only this exact +old-to-new tuple is an exception. Every other package retains its previous lock +entry. Installed version checks and the complete source-pinned closure then make +it impossible for npm's old copy to survive admission. + +Node 24.21.0 also replaces bundled Undici 7.29.0 with 7.29.1, covering the global +WebSocket implementation as well as the nested npm copy. The materializer checks +`process.versions.undici` explicitly. Sources: [official advisory](https://github.com/advisories/GHSA-3wwx-pv8p-q78v), +[fixed Undici release](https://github.com/nodejs/undici/releases/tag/v8.10.2), +[Node 24.21.0 release](https://nodejs.org/en/blog/release/v24.21.0) and +[official archive checksums](https://nodejs.org/dist/v24.21.0/SHASUMS256.txt). +Only Pi's declarations and three closure pins change; sibling provider pins +remain unchanged. The containing provider pack must be rebuilt with the patched +portable interpreter and retains its own manifest identity. + +The result contains `runtime/` and a sibling `pi-distribution.json`. Keeping the +manifest outside its inventoried root avoids a self-referential file digest. The +returned environment bindings point into `runtime/`; callers must retain a verified +immutable lease on that entire root. Additional native libraries, package files, +WASM, data, and templates are all included in the inventory. Only npm-generated +`.bin` symlinks and its hidden installation lock are omitted; launch always uses +verified explicit files. The regular-file `native-closure.json` is independently +bound to a trusted per-target source constant. Its `pi-entry.cjs` bootstrap derives +snapshot-relative Node, Pi, extension and module-guard bindings; the Pi subprocess +loads the same verified module guard. + +The common provider-pack integration uses an explicit `--candidate-providers=pi` +flag. It invokes exported `materializePiDistribution` into +`provider-assets/pi/`, records the returned manifest digest and metadata path +in the outer pack payload, and includes the entire distribution in the outer +pack's integrity proof. The flag prepares an inspectable candidate; it must not +change its qualification status or enable admission without required live proof. +The default provider pack remains independent of this isolated graph. + +On 2026-09-28 the builder completed a real public-registry installation on macOS +arm64 with official Node 24.19.0. The resulting complete distribution passed all twelve patched +wrapper and real Pi admission tests; those tests never submit a model prompt to a +provider. Five builder tests cover the full dependency lock, missing/changed +packages, missing shrinkwrap entries, resource mutation, escaping links and unsafe +output paths. Linux x64/Daytona and macOS x64 execution remain pending. + +Official Node archive and executable pins are recorded in `pi-node-pins.ts`; +archive hashes were checked against the [official Node release checksums](https://nodejs.org/dist/v24.19.0/SHASUMS256.txt). +The x64 closure pins combine the identical locked package/resource graph with each +verified official x64 interpreter. They are candidate artifact identity, not proof +that those targets have executed successfully. + +### Installation authority and token semantics + +`verifyPiInstallation(profile)` admits only the current declared profile version and the source-owned +Pi identity. It resolves `provider-assets/pi/-` inside the verified +Runner package, checks the complete runtime against source-pinned closure hashes, +and opens a guarded immutable native snapshot. The snapshot bootstrap binds Node, +Pi, and the extension relative to itself, overriding inherited launch paths. +Unconfigured installations return an explicit bound-credential error and expose +no terminal login option. The materializer returns the runtime `version`, +`profileDigest`, and complete `closureDigest` for the provider-pack manifest. + +For the exact OpenRouter qualification model, Pi AI 0.84.2's +`dist/providers/data/openrouter.json` selects `openai-completions`. Its +`dist/api/openai-completions.js` assigns `usage.output` from `completion_tokens`, +which already includes reasoning tokens; `usage.reasoning` is a subset. PRP must +therefore use zero additional thought tokens for this pinned profile, rather than +counting that subset twice. Missing cache categories remain unknown at the wrapper +receipt boundary. `usage.cost` is a Pi catalog pricing estimate, never an invoice. + +The closed snapshot has been exercised against actual pinned Pi through ACP +initialization and the missing-credential admission path without a model call. +The first authenticated local Runner snapshot verified the exact configured +model ID. It did not settle or produce a terminal usage receipt; complete local +and Linux/Daytona receipt qualification remains outstanding. + +Historical profile version 3 declaration digest: `sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472`. Each profile digest hashes its versioned +profile domain, patched wrapper source and platform closure pins. Every native +closure remains independently checked at launch. Version 1 through 5 warm sessions cannot +be reused with this integration. + +Pi 0.84.2 emits `compaction_start`/`compaction_end`; the wrapper maps those +events, includes terminal `result.usage` receipts once, and retains bounded +summarization retry progress. Manual compaction requires an idle session and has +a 120-second RPC deadline; deadline expiry terminates Pi so a timed-out operation +cannot continue invisibly. Successful compaction reports +`assistant_message_and_compaction_receipts` provenance. Pi's summarization retry +helper discards failed-attempt usage, so any retry or missing compaction receipt +invalidates complete-turn token/cost totals instead of inventing complete coverage. + +The retained [macOS ARM64 provider-pack admission proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.json) +records source `7710736ca3924c655c5b0efd172cfd3c0173766a`, with version 3 +runtime source `06cf356a8bc94f709fcd15606fe05e17933fe3b2`. The latter differs only +by the capability report and Pi evidence JSON; all execution tree hashes match. +Its manifest digest is +`sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a`. +Two independent clean dependency resolutions match +`2c46a68811ba1d504a41b6f4d3f642bc93f4a1c615097754c9c6486881dba8e6`. +The generic installation registry and immutable snapshot passed initialization +and rejected the exact missing-bound-credential error before any model prompt. +Full Runner session.open terminated with typed rejection in 12.111 seconds, +below the unchanged 30-second deadline; this is not authenticated success. + +Version 3 verification passes TypeScript and release Runner builds, 95 focused +Vitest cases in seven files including actual-distribution launch, 25 installed +package/receipt/materializer checks, and 16 native-provider Rust tests. These +checks do not constitute full repository verification or paid qualification. +The [version 2 dd78 proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.dd78df1e.json) +retains the earlier pack used for hello, model admission and restrictive denial. +Its 107 Vitest checks and 22 installed checks remain tied to that source. + +The [9f2d0420e proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.9f2d0420e.json) +retains the earlier source and pack that reproduced the startup timeout. Its 97 +Vitest checks and 22 package checks, including the corrected explicit package-path +invocation, remain historical evidence. + +The [a6167ce3a proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.a6167ce3a.json) +and [58511d79d proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.58511d79d.json) +remain historical observations. The 128 targeted checks, 31 installation checks +and 17 package checks remain pinned to `58511d79d`; the full Runner suite +(2,202 passed, 11 skipped) and 13 native-provider Rust tests remain pinned to +`2e65b64d5`. They are not relabeled as checks of the latest source. + +The [pre-launch-boundary proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.2e65b64d5.darwin-arm64.json) +retains source `2e65b64d5a2148583109b8157d6e72f54f5ad29b` and manifest +`sha256:7300b9c449c02b61d2f93ef765f371c277e39a58d04739c6c24dcf7939500701`, +including the full runner suite and native-provider Rust results. It predates the +final runner environment allowlist and direct candidate-admission fixes. + +The [pre-policy proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.d039e1b7b.darwin-arm64.json) +retains source `d039e1b7b072862b4c326ba7194dc42617fa5984` and manifest +`sha256:5a47fc67b886c1e05d0f630ed89c1b9f5324610b649d1db020a3e036e36fc85d`, +including its cleanup-timeout retry history. It predates the Node engine metadata +and corresponding manifest/closure identities. + +The [previous integrated proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.f58cfa1cb.darwin-arm64.json) +retains source `f58cfa1cbf4503b93a0be4480f51b492d8203b7c` and manifest +`sha256:3de76fb7d3902d29285e3da51e36d2ba4654c29bab0c867a6209fadea77c2181`. +It covers the retry-status repair before the HTTPS and shared runtime fixes. +The [earlier integrated proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.1e0d11c48.darwin-arm64.json) +retains source `1e0d11c482f8ef50b6afc1430cb736579114b266` and manifest +`sha256:f859e30e51326ff875d616e2bb3fd4c0246ce1f58a5988968b91bae176a68281` +with its narrower capability assertions. These files are historical evidence, +not proof of the current executable bytes. Later runtime or foundation changes +require a new pack build and source-pinned admission record. + +## Remaining event and qualification work + +The candidate preserves the core request/response paths. It does not preserve +every field in Pi's native event stream. These are explicit follow-ups: + +- **P1 — retry/compaction field completeness and end-to-end evidence.** At source + `41503eb38f03c436b1569f9f0204625bb4d58782`, the wrapper emits bounded + `paperclip/pi_notice` activity instead of assistant text. The adapter preserves + source provenance, `attempt`, `maxAttempts`, `delayMs`, `success`, `aborted`, + `willRetry`, `enabled`, and bounded `reason`/`errorMessage` fields when supplied. + The UI renders the notice details. `finalError` is not forwarded, and + summarization retry currently has summary-only data. Package regressions cover + retry success/failure/unknown and compaction usage; adapter tests cover bounded + metadata and redaction. Extend field-by-field wrapper/canonical/browser parity + without giving notices terminal authority or treating deterministic tests as + paid qualification. See the [wrapper patch](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/patches/pi-acp%400.0.33.patch#L381), + [notice adapter](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts#L22), + [package regressions](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs#L225), + [adapter regressions](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.test.ts#L5), + and [UI details](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx#L283). +- **P1 — native queue state.** `queue_update` contains steering and follow-up queues. Extension calls + acknowledge RPC acceptance, but the wrapper does not project that event into + durable queued/delivered state. Do not treat `{accepted: true}` as proof that + a later model turn consumed a message. Queue contents are also user content, + so any added projection needs explicit retention rules. Next: emit bounded, + occurrence-bound queued/delivered notices and distinguish acceptance from actual + consumption; preserve queue content only under the existing content policy. +- **P2 — extension UI and configuration.** `setStatus`, `setWidget`, `setTitle`, + and `set_editor_text` messages + have no Paperclip UI projection. The admitted extension does not use them. + `notify` retains a bounded message and severity, while interactive `select`, + `confirm`, `input`, and `editor` have the explicit response bridge. Native + session-name and thinking-level change events also lack a separate event + projection. The wrapper advertises ACP thinking-level configuration, but the + current Runner host does not expose `set_mode` or `set_config_option`; no + reasoning-level override was applied during qualification. Next: define bounded + status/widget/title dispositions and explicit typed configuration controls; + require a provider acknowledgement before claiming a setting changed. +- **P2 — files and artifacts; P1 — image input.** File diffs are bounded text snapshots, not complete binary changes or durable + artifact publication. Semantic artifact tools remain the supported publication + path. Image prompting, model-triggered tools, approvals, typed questions, + explicit steering, warm recovery, and live usage need authenticated local and + Linux x64 Daytona proof for the exact declared model. The reported model ID + must be checked, rather than inferred from the configuration. Next: implement + image content through the shared text-only converter, qualify actual model + content handling, and separately test diff/artifact publication boundaries. + +## Authenticated local qualification progress + +The [v5 native controls proof](../../packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.v5.darwin-arm64.json) +passes the unchanged probe on frozen `aec26ad83` / pack `cf7d0998…` in 19.199 seconds. +The original write invocation `pi-849b369266c920633abffa988509e4ba61fcdf464f62c73900fd5ddd6d477402` +correlates with the denied failed-tool update. The decision is fsynced before either control request; both control +acknowledgements precede sending the original denial response. Visible output is exactly +`STEERED_CURRENTQUEUED_NEXT`, stale steering rejects, the forbidden file remains +absent, and the turn settles `end_turn`. Child, verified lease and temporary +workspace cleanup complete; an independent process check finds no remaining +probe or Pi process. No database is started. Actual usage is 3,727 input, 140 +output and 1,792 cached-read tokens. Delayed exclusive-key billing settles at +$0.000151739; the separate Pi catalog estimate is $0.000611156. Initial unchanged +reads are retained, not interpreted as free inference. This proves direct native +controls and denial on the patched profile; it does not qualify Product recovery +or remote execution. + + +The [v4 native controls proof](../../packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.v4.darwin-arm64.json) +passes on frozen `f556110d5` / pack `4df7e9fa…`: one native write request uses the +same normalized invocation ID in its persisted decision and failed tool update; +the original deny option is written only after both control acknowledgements. +Visible output is exactly `STEERED_CURRENTQUEUED_NEXT`, stale steering is rejected, +the forbidden file remains absent, and the prompt settles `end_turn`. Cleanup +completes without a database. The terminal receipt contains 2,030 input, 1,293 +output and 5,632 cached-read tokens. A later billing observation settles this +attempt at $0.000546502; Pi's separate catalog estimate is $0.000803936. Initial +unchanged billing reads are retained and were not treated as free inference. + +A newly indexed [Undici advisory](https://github.com/advisories/GHSA-3wwx-pv8p-q78v) +affects the historical v4 isolated npm Undici 8.9.0 and Node 24.19.0's bundled +Undici 7.29.0. Profile v5 fixes both copies as described above. The separately authorized v5 +control proof uses that repaired closure; no further paid case is authorized. +This paid v4 proof remains bound to its original bytes. + +The first [v4 plan attempt](../../packages/paperclip-runner/test-fixtures/pi-acp/product-plan-infrastructure-failure.v4.darwin-arm64.json) +failed before provider startup: embedded PostgreSQL could not initialize the +Product fixture. Canonical classification is `transient_infrastructure`, with +zero provider prompts and two unchanged post-attempt key-billing observations. +The fixture directory is gone and no matching process remains. A later read-only +host snapshot shows 87,263 of 87,381 SysV semaphores in use, supporting a resource +constraint. A separate disposable diagnostic then reproduces the cause with the +same pinned PostgreSQL binary: `semget(..., 17, 03600)` fails with `No space left +on device` when only 16 semaphores are free. It exits in 0.557 seconds, removes +its temporary directory, leaves no child process, and leaves IPC totals unchanged. +The original Product bootstrap stderr remains unavailable. No unrelated database +or IPC object was changed during that diagnosis. The later user-approved +fixed-snapshot host cleanup removed 4,892 stale semaphore sets and skipped 11 +whose recorded creator PID was present. Startup and graceful shutdown now pass; +the current checkpoint above supersedes this historical qualification hold. + + +The [sanitized attempt ledger](../../packages/paperclip-runner/test-fixtures/pi-acp/paid-qualification-progress.darwin-arm64.json) +retains all twenty attempts, including eight failures before a model prompt. One +Runner protocol turn reached the exact model and successfully called `get_task_context`, `get_task_history`, +`list_documents`, and `read_document`. The canonical case permits those extra +orientation reads, but also requires a completed turn; it timed out after 120s +without terminal usage. The later Product hello journey passes; the canonical +Runner protocol case has not yet passed. + +Exclusive OpenRouter key billing increased by **$0.028858372**, including +$0.000351509 from the sleep-interrupted Product attempt and $0.000654767 +from the successful hello journey, plus $0.010995043 across the restrictive-denial +and file-edit batch. A delayed charge crossed the latter attempt baselines, so +that batch is accounted as an aggregate without per-generation attribution. +Version 3 file, question and failed plan cases add $0.007828938. The version 3 +native controls probe adds $0.000140985 after a later key reading resolves its +initially unchanged observations; those early reads did not prove free inference. +The seven pre-prompt failures had two post-attempt +observations with zero delta. This is measured provider spend, separate from +Pi's stale catalog pricing estimate. A missing terminal receipt remains unknown, not free execution. Each +attempt had no automatic retries, a $2 reservation and a $0.50 billing watchdog; +the Product supervisor also imposed a 300s outer deadline. + +The paid trace exposed a shared projection defect: canonical `item.delta` events +with `kind: reasoning` were relabeled as assistant messages by the TypeScript +facade. Shared commit `a978d1bc5` preserves summary/detail reasoning channels, +including mixed coalesced batches, so the existing private-reasoning redactor +applies. Three regressions failed before the fix; 20 focused checks, 8 additional +Codex event checks and source typecheck passed afterward. Hidden reasoning is +excluded from this report and the published ledger. The provider timeout remains +a separate unresolved result. + +Product hello first failed before startup because the local PostgreSQL package's +symlink hydration had not run. Its bundled setup script repaired the installation +and a fresh database initialization passed. The deliberate repeat reached the +real browser/server, then candidate agent creation returned 422 because the +provider-profile validator still rejected candidates before the host's explicit +qualification admission boundary. Shared commit `26dde3cfe` corrected that gate. The next attempt created the agent +and task, then found a dormant Pi-only rejection in the verified runnerd backend +factory (`transportDriverIdentity`); it again submitted no model prompt and +incurred zero measured billing delta. The shared identity correction is included +in foundation `c3b7e9ecd`. The next attempt passed those guards but stopped at +`PRP command session.open timed out` after 36.5 seconds of run startup; cleanup +passed and the measured key billing delta remained zero. It produced no terminal +usage receipt or matcher success. + +The canonical live-eval completion contract had a separate defect: fresh sessions +omitted native `paperclip_finish`/`paperclip_block` schemas, their callback rejected +otherwise valid native reports, and ACP model instructions lacked the literal +contract revision. Shared commits `dc58afb28` and `aa4b88b16` expose and validate +those advisory reports and send the exact contract on fresh/resumed sessions. +They cannot mutate mock tasks, grant semantic permissions or settle the provider +turn early. Two declaration/acceptance regressions and one instruction-delivery +regression failed before repair; 45 live-session tests and three focused follow-up +checks passed, with source typecheck. This is a confirmed protocol defect, but +its causal contribution to the earlier model timeout is not proven. + +The [startup diagnostic](../../packages/paperclip-runner/test-fixtures/pi-acp/startup-diagnostic.darwin-arm64.json) +reproduces that failure without credentials through the deployed Runner transport. +Its `run.prepare` command completes, while `session.open` remains pending at the +unchanged 30-second deadline. Separately timing the same 13,827-file, 234 MB +closure measured 5.168 seconds of verification and 37.423 seconds to construct +its immutable snapshot, before any Pi process starts. Shared fix `2a30219f1` +copies at most eight files and 32 MiB per batch, with larger admitted files alone. +It retains every file identity, digest and confinement check and drains all copy +promises before cleanup. The same tree then copied in 5.851 seconds; ten security +and concurrency tests and both Runner TypeScript checks passed. The rebuilt +`dd78df1e` pack then settled the full credential-free Runner +`session.open` request in 10.087 seconds (10.198 including close), within the +unchanged deadline. It returned terminal `session_ensure_failed`; the outer PRP +response omits the underlying credential error. The independent immutable ACP +probe from the same pack asserted the missing-credential error directly. This +is timely rejection, not authenticated session success. The earlier timeout +attempt retains its original source identity. + +The next Product attempt (`hello05`, source `dd78df1e`) reached an authenticated +session in 11.631 seconds, submitted a turn and received `turn.accepted`. It then +crossed a confirmed macOS Maintenance Sleep of 888 seconds. The retained browser +trace ends without a semantic-tool or terminal event; completion and usage are +unknown. The canonical result remains failed/`secret_leak`: the evidence scanner +could not inspect an incomplete ZIP (`unzip` exit 9), rather than detecting a +credential-value match. Browser cleanup also failed after wake; a later process +check found no remaining process from this exact attempt. Both the canonical +921.632-second wall duration and supervisor 72.168-second monotonic duration are +retained. This result proves startup admission, not a completed Product journey. +The explicit retry adds an idle-sleep assertion and a wall-clock supervisor bound +without changing the provider timeout, canonical oracle or evidence scanner. + +The explicit [hello06 Product proof](../../packages/paperclip-runner/test-fixtures/pi-acp/product-hello-proof.darwin-arm64.json) +passes all six canonical matchers, including the exact response once, task `done`, +run `succeeded`, native runtime and local environment. Pi executes +`mcp__paperclip__paperclip_finish`, then reports a completed turn and succeeded run. +The case took 32.668 seconds; cleanup and evidence inspection passed. Its receipt +contains 26,867 input tokens, 283 output tokens and zero cached input tokens. +The catalog notice estimates $0.00384062; measured key billing is $0.000654767. +Product correctly marks dollar coverage `unpriced` and incomplete. These values +are not interchangeable. + +The separate [authenticated model-selection proof](../../packages/paperclip-runner/test-fixtures/pi-acp/model-admission-proof.darwin-arm64.json) +sends no prompt and measures zero key billing delta. It reads the actual ACP +model response, selects the declared model, and observes +`openrouter/deepseek/deepseek-v4-flash-0731` after Pi's native RPC state refresh. +The Product path enforces this comparison before prompting, but its retained +`session.started` model metadata says `unknown`; final usage carries the configured +identity. The separate proof closes model-selection evidence without pretending +it came from that Product transcript. + +The historical version 2 pack, model and hello proof JSON files retain Git tree hashes for +Runner source, scripts, Rust, protocol, patches, server source and Product E2E. +Those version 2 observations stay pinned to `dd78df1e` when later commits change only these +reports and fixtures; an execution-source change requires a fresh build. + +| Execution source path | Git tree at `dd78df1e` | +| --- | --- | +| `packages/paperclip-runner/src` | `a8123cb4396c77ce0127d5ccf49d3584b63a9a58` | +| `packages/paperclip-runner/scripts` | `9ede97769bb3f2bbfa8e02be220b5ce06e438a99` | +| `packages/paperclip-runner/runner` | `2cc84e150aecbd5faf2c8bcecc4b6352c24dbca8` | +| `packages/paperclip-runner/protocol` | `1d50458fb572d0c75f9a398f419209c52c23ba6f` | +| `patches` | `1e2f846b8b2dda202b79ee83baeb0860a3b9ab64` | +| `server/src` | `b267adc1e0ad36955b68314829cfd4256ab88d37` | +| `tests/runner-e2e` | `5ff4b8a85a14e3e005e3e240c145899c0bc742b5` | + + +Production admission remains qualification-pending until the live matrix and +spend receipt checks pass. + + +## Version 3: retain failed tool validation and Bash results + +The version 2 file-edit Product attempt exposed an owned bridge defect: five +`paperclip_finish` calls returned failed MCP results, but the extension replaced +all authenticated validation text with a generic rejection. Version 3 keeps +valid text and structured error details within an 8 KiB UTF-8 bound, redacts +bound credentials and labelled secrets, and still raises a failed tool result. +Malformed or oversized errors remain explicit failures. The original five call +arguments were not retained across the privacy boundary, and the fixture's +workspace was removed during cleanup, so this report does not reconstruct their +contents or assert that the file oracle passed. + +A separate Bash projection gap discarded native result fields because upstream +sent them only as private terminal metadata. The wrapper now also emits standard +ACP `rawInput` and `rawOutput`, preserving each structured value whole up to +64 KiB and marking larger values omitted. Installed-process fixtures cover +success, failure, partial output and the size boundary. Canonical `exitCode` +remains unknown: the shared projection does not yet map the native structured +field, and this patch does not infer it from text or success status. + +These changes alter the immutable wrapper and owned extension. All three target +closure pins and the versioned profile digest change; version 2 warm snapshots +are rejected. Earlier local hello, restrictive-denial and Linux initialization +proofs remain tied to their version 2 execution source. Version 3 must complete +fresh paid qualification before those behaviors can be claimed for its bytes; +the new immutable-pack checks above pass. + + +The [restrictive-denial proof](../../packages/paperclip-runner/test-fixtures/pi-acp/native-denial-proof.darwin-arm64.json) +retains the version 2 real native write rejection: the original offered decision +was durably recorded before responding, the matching tool failed, the prompt +settled, and an independent filesystem check found no forbidden marker. This is +direct ACP delivery proof; it does not exercise PRP restart replay. + +The [file-edit failure](../../packages/paperclip-runner/test-fixtures/pi-acp/product-file-failure.darwin-arm64.json) +retains the candidate timeout, five generic finish errors, missing file oracle, +unknown terminal usage and successful process cleanup. These failures are kept +alongside the version 3 corrections rather than overwritten by a future retry. +## Version 3 live evidence + +The [file delivery proof](../../packages/paperclip-runner/test-fixtures/pi-acp/product-file-proof.darwin-arm64.json) +passes all seven canonical matchers. The independent oracle reads the exact +24-byte workspace file before cleanup. The first finish request fails because a +workspace-only file is not downloadable; the retained validation message tells +Pi to register the deliverable. Pi does so and then finishes successfully. The +browser shows the download card and Done status. All five Bash results are +non-null; canonical exit code remains unknown. The case measured $0.003324737. +No separate raw copy of the workspace file was retained. + +The [typed question proof](../../packages/paperclip-runner/test-fixtures/pi-acp/product-question-proof.darwin-arm64.json) +passes all six matchers. The browser submits option ID `cobalt` for question ID +`verification-word`; the same durable interaction becomes answered and a distinct +warm continuation completes. The pending form and final answer were visually +inspected. This is continuation without server restart. The waiting run has no +usage receipt, while the continuation has one; total token coverage remains +partial. The measured key delta is $0.001413697. + +The [plan failure](../../packages/paperclip-runner/test-fixtures/pi-acp/product-plan-failure.darwin-arm64.json) +retains a complete two-step Plan at revision 1 and an unanswered confirmation +bound to that exact revision. The provider run succeeds and the task remains in +review. The real browser trace shows `task-chat-plan-preview-fallback` inside a +settled turn. Rust had retained `mcp__paperclip__write_document` as a builtin tool +with no namespace, so the UI could not embed the Plan at its `write_document` +boundary. The meaningful canonical matcher remains unchanged. No approval was +submitted, terminal usage is unavailable, and the measured key delta is +$0.003090504. Shared repair `5aa02662b` restores MCP name/namespace/transport while preserving +operation authority. Its negative fixture retains the observed fallback, and the +positive settled-turn UI regression passes. A fresh daemon and new source-pinned +live run are still required. + +The [native controls proof](../../packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.darwin-arm64.json) +submits one ACP prompt to the actual selected model. While its native write waits +for permission, both explicit controls acknowledge the same active session. The +original offered denial ID is persisted and fsynced before the pipe response. +Visible output is exactly `STEERED_CURRENT` followed by `QUEUED_NEXT`; a matching +failed tool update and independent absent-file check prove the denied write. +The prompt settles and a later steering request is rejected as stale. The native +receipt reports 1,941 input, 134 output and 3,584 cache-read tokens. Its +$0.000409612 catalog estimate differs from the later measured $0.000140985 key +delta. This proves consumed +controls on a live process, not durable queue replay after process replacement. + +All four observations use immutable pack `eb31cada…` at source `7710736ca…`, +with runtime source `06cf356a8…`; they predate the combined provider integration +`7ab463697…`. Execution tree hashes are retained in the Product proofs. The +[Linux x64 proof](../../packages/paperclip-runner/test-fixtures/pi-acp/offline-native-proof.linux-x64.v3.json) +uses profile version 3 at source `06cf356a8…`, image +`sha256:c5fa7976bba92a186a2f70dc8b8ddc58ab86606b80a819c89bf550d2d057c832`. +It launches the verified native wrapper, initializes ACP protocol 1 and exits +cleanly on EOF with network disabled and no credentials or model prompts. This +proves Linux executable admission; authenticated Daytona behavior remains pending. + +The explicit [plan02 follow-up](../../packages/paperclip-runner/test-fixtures/pi-acp/product-plan-resume-failure.darwin-arm64.json) +uses combined pack `0800f101…` from source `7ab463697…` and fresh daemon +`955d0714…` from `e35b11db2…`. It passes the repaired Plan presentation boundary +and accepts the exact revision. The resumed run then fails after 120 seconds. +Its first finish request receives a precise completion-criterion correction, but +the next finish and context read fail with duplicate call identity conflicts. +All three distinct operations reuse native ID `call_0`; the extension forwards +that ID unchanged into the shared MCP dedupe boundary. This is a further +integration defect. The shared dedupe guard and canonical grader remain intact. +Both runs lack terminal usage; measured billing is $0.002440082 and cleanup +passes. Restart and refreshed hello are held until this defect is repaired. diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index 54467eaa03..c1334de585 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -1,26 +1,637 @@ # Rich ACP integration and qualification report -Updated: 2026-09-29. Base: `c65fc9e3c81c41aafe421aa90a00514b84343285`. -Mainline integration: `3ca196b0a642aa21b8feba1fcd89edb53d1c622e`. -Status: implementation is split into a shared foundation and three provider PRs. -Provider review and qualification remain separate from foundation acceptance. -All three new profiles remain **pending qualification**. Cursor passed all five -local semantic Product E2E cases. Copilot passed completion, file validation, -plan approval and controller restart; its question case failed the required final -marker. Pi passed completion, file validation and semantic question continuation, -plus real native denial, active steering and queued follow-up probes on v2/v3/v4. -Pi completion/file/question passes retain their v2/v3 identities. The latest -Pi candidate is profile v5 with a patched dependency closure; its paid native -control probe passed, but its Product qualification remains blocked. Copilot -passed real native denial and detached-command settlement probes. Failed attempts remain in the evidence; -no Daytona resources have been started. This report does not certify a provider -from its ACP listing or a partial run. +## Current integration — 2026-10-08 + +The experimental Runner admits **Pi profile 22** (`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`) and +**Cursor profile 15**. Copilot remains pending at **profile 17**. Legacy +`pi_local` is unchanged. Pi accepts any explicit caller-selected provider/model +ID and requires native acknowledgement; qualification models are examples, not +an allowlist or a fallback. + +The requested task execution and human-control paths have seven passing cloud +cases on profile 19 with the accepted Sonnet 4.6/low fixture. Profile 22 retains +that wrapper, helper and extension, incorporates master's environment changes, +patches brace-expansion to the official 5.0.12 payload, and excludes general AWS +IAM credentials from Pi's environment. Bedrock's provider-scoped bearer key is +supported. Those paid results retain their original profile-19 identity; +current integration is verified separately by source, package and CI checks. +Accounting and the wider platform/provider matrix remain deferred. See the +[readiness plan](../plans/2026-10-02-pi-production-readiness.md#current-delivery-scope--2026-10-08) +for the exact source/image evidence and merge status. + +All dated checkpoints below describe historical qualification attempts. Their +failures and profile numbers are retained; they are not current release gates. + +Historical source checkpoint (2026-10-01): **Cursor v10, Copilot v12 and Pi v10 +remain unqualified**. Copilot receipt v2 distinguishes original provider +arguments from the validated outgoing completion input. The sidecar commits +the captured normalized digest only after its exact pending call receives a +successful, turn-bound `tool.resolve`. Pipe-write success alone is insufficient: +Rust may reject a frame at its smaller payload admission limit. Errors, +cancellation, timeout and stale responses cannot attest delivery. + +Delivery remains separate from canonical equality and acceptance. The grader +still requires the normalized digest to match the unique proposed and accepted +result bodies. Oversized frames and Unicode repair fail before commitment; +dropped frames do not consume stream sequence numbers. The server preserves +only the fixed schema literal inside a validated semantic-receipt notice. +The failed v10 Daytona attempt and held v11 builds remain historical. Fresh +v12 runtime packaging and local/Daytona evidence are required. The +[Copilot inventory](runner-copilot-capabilities.md) records these boundaries. + +Historical source checkpoint (2026-10-01): **Cursor v10, Copilot v10 and Pi v10 +remain unqualified**. Cursor now shares the bounded native tool-ID mapping +across sidecar activity and permissions, then deterministically joins that key +to the canonical opaque execution ID. Copilot correlates native tool results with +authenticated, turn-bound semantic receipts instead of display names. Both new +profile identities reject v9 warm sessions. Their native distribution bytes +are unchanged; new sidecar assets, runner admission digests and fresh local/Daytona +qualification are required. The [Cursor](runner-cursor-capabilities.md) and +[Copilot](runner-copilot-capabilities.md) inventories describe the new contracts, +the Copilot 256 KiB output limit and the credential-free native proof scope. +Pi remains blocked on a verifiable OpenRouter spending cap. Prior paid failures +and case-specific passes remain historical; none is regraded by these fixes. +The controller now dispatches bridged requests in durable notification order. +It consumes earlier activity before presenting a request, while a pending human +answer does not prevent later activity from streaming. A genuine permission-first +provider sequence remains permission-first. + +Historical source checkpoint (2026-09-30): **Cursor v9, Copilot v8 and Pi v10 +remain unqualified**. All three declarations bind shared ACPX patch SHA-256 +`bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e`. +Cursor additionally binds newly materialized `paperclip-cursor-usage-v4` closures; +Copilot and Pi native distribution bytes are unchanged. The new identities reject +retained 7/7/9 sessions, plus Cursor v8 sessions. Claude, Codex and Grok profile declarations stay unchanged. +The metadata is observation-only: native counters always remain partial, absent +fields stay absent, and no token totals or dollar costs are inferred. A supported +schema does not prove native counter aggregation semantics. No new paid pass, +build, publication or production qualification is claimed for these identities. +External eval definitions for 8/8/10 merged in [#35](https://github.com/paperclipai/paperclip-evals/pull/35) +as `25303cf84953985b961b95f89aff0bdb864b2d65`, from head +`b4ef1aa1296f8897714325d189e5a6f51dad9e01`: 136 deterministic tests, +21 validated cells, passing CI and Apex 5/5. Models, pricing and graders are +unchanged; this adds no paid proof. Those definitions remain the historical +Cursor v8 checkpoint and require a separate reviewed Cursor v9 update. + +Current checkpoint (2026-09-30): **Cursor v9, Copilot v8 and Pi v10 remain unqualified.** The frozen runtime is `5c69b69ef2aeab8d8a367b25a8d894cc5308befa`; controller candidate is `a8df2064d68f40fbf4dec670c4b8478c4b1b1b3f`. All profiles bind shared ACPX patch SHA-256 `bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e`. No profile is promoted and no prior grade is changed. + +Current-profile protocol eval definitions merged in [paperclip-evals #36](https://github.com/paperclipai/paperclip-evals/pull/36) as `d987357461933baca0d4c10cc081f40e7eae5c1b`: 136 deterministic tests and 21 validated cells. These definitions do not supply paid qualification evidence. + +| Profile | Latest status | +| --- | --- | +| Cursor v9 | Draft #14724 at `3adee6f3fc652d5f5ee40b2061a16cea0c7341f5`; Apex 5/5 and 51 checks plus one context are green. Eval-notice preservation is fixed for future runs. The strict accounting failure remains; native USD is unknown. Local/Daytona qualification remains pending. | +| Copilot v8 | Current warm attempt passed all 9 Product matchers and daemon invariants; overall supervisor failed final-pack cleanup on an extra executable. That exact orphan was later retired safely; the original grade stays failed. Daytona denial observed no write, but normal provider completion failed the original cancellation fixture; later checks were not graded. Database timestamps do not establish visibility before Stop; the earlier ordering inference is withdrawn. The 5c69 warm-PID failure is a separate historical grade. | +| Pi v10 | Capped-key/login access remains blocked. Native USD is unknown; fresh exact-runtime qualification remains pending. | + +Shutdown ownership [#14730](https://github.com/paperclipai/paperclip/pull/14730), head `d09ed62b6`, has Apex 5/5 and all 52 checks green. Denial settlement [#14733](https://github.com/paperclipai/paperclip/pull/14733), head `1b47b30be`, passes 220 focused tests and E2E typecheck; fresh review and CI are pending. Suite v4 retains exact API evidence before dispatching Stop; database timestamps are descriptive only, and neither accepted settlement branch proves active-turn cancellation. Final-source verification and fresh paid qualification remain pending. Sidecars rotate by `attach_run`, a separate ACPX authority behavior from daemon continuity. + +Cursor's verified fixed $25 account-cycle cap resets October 28 and applies to account on-demand fees, not per-cell costs. Its separate 15-test helper proposal is unintegrated and grants no launch authority. Pi's capped-key prerequisite remains unresolved; the optional helper is not integrated. Account-control review SHA-256: `0b661e38043759b5569a586dea57160926a361367c779bfe0fb0080c122f3bdd`. + +Latest Copilot attempt references: warm result `effadec7b6b134e3b31148cf7eea3c2b24d37ee0fc6ce36dbc90ec420b171560`, reconciliation `1ad1788161e34abf7ddcda15e08134a4c80d1f0af176d7b5397ea6dd810dace0`; Daytona denial result `050a239b6d86754d0f0045979b7c343c007729528546b49f069e7396cd167b38`, reconciliation `2ef9834cd53345c03c04bbfec1ad0bba24a2de1d7096d9225acdfa185241b82a`. + +Earlier current-profile case evidence (grades retained) is tied to Paperclip source `5c69b69ef2aeab8d8a367b25a8d894cc5308befa` and eval definitions `e4989bae1cedc36835da199826bf207d86b75761`. Copilot v8 protocol coverage totals seven cells: six passed and one behavior failure. The earlier `get_task_context` cell passed (result SHA-256 `7a30ad3de932f7e1bc3c3c3c766d8aa128c7003b35447ac0c73ef5f1f36914c0`); the remaining-batch reconciliation records five passes and one failure (SHA-256 `0e746a85e529a2fd45da4e553062753eabc1d8cb37d701529c5bc030a200cb81`). The `context-before-action` failure called `report_progress` before `get_task_context` and generated two comments. No native per-run USD was available; estimated model cost is separate. In local warm continuity, all nine matchers across three turns matched, but the candidate failed the stable-process requirement after observing three runner PIDs (result SHA-256 `e05e1af41a0c43396e8297ca0ac670065ff1f20bf3ce343930d5f45139277ed6`). For the Daytona permission-denial attempt, the recorded sandbox state was `building_snapshot` when the fixture lease wait expired, before the native permission operation or lease. That state can include queueing or image pulling; the evidence does not prove a snapshot was actually being built (result SHA-256 `fb928929a578403943707544e770ddc6ababe358c344a33c4af9028406804407`; state receipt SHA-256 `e32e5bf9b6ee18e8c2a4cb97436ceac7974b0fe1ed7cce02dae4ad2d3d58d875`). The later a8df attempt is recorded in the current checkpoint above; these earlier artifact grades remain unchanged and do not establish complete provider qualification. + +Cursor v9's current `get_task_context` cell passed four behavior checks, only one of which asserts a semantic operation. Root reconciliation classifies the attempt as `accounting_failure_account_bounded`, but the capture did not retain the bounded v4 usage envelope: the usage ledger was empty, the retained artifact records `provider_did_not_report_usage`, and native counters were not inspected. There is no usable provider aggregate or estimated cost, so this capture cannot live-qualify partial v4 diagnostics. The case and provider remain unqualified, and no native per-run USD is inferred. Result SHA-256 `7968dc62496feca03a7def6ee20b95ba825d42e9a98f1b6b763ccb8a263b35f4`; root reconciliation SHA-256 `3558c09a839a8aaf9a34970fb793e9c84ff8ca3c125f9543da9c208d4323c54f`. +A later live-eval recorder review found that generic mapping and evidence fallback drop `paperclip/canonicalProviderEvent`. This identifies a recorder loss path; the prior artifact does not prove that the provider emitted the diagnostic, so the existing `accounting_failure_account_bounded` result and its accounting failure remain unchanged. Draft Paperclip PR [#14724](https://github.com/paperclipai/paperclip/pull/14724), targeting #14699, is at head `3adee6f3fc652d5f5ee40b2061a16cea0c7341f5`. Its non-native suite passed 73 tests with one explicit native exclusion. The initial Apex review identified two P1 issues, subsequently repaired and re-reviewed at the current head above. Evidence `cursor-eval-notice-fix/APEX-REPAIR-01.json` records `nativeExecuted: false` and `oldPaidFailuresRegraded: false` (SHA-256 `a792c3979e4a33801d44974b32fa9078294ec650641ca4e23b1b28212a62659e`). This TS-only repair does not establish deploy-pack or native-runtime reusability: the whole provider deploy pack includes live/eval files and its digest would change; native equalities remain unproven. + +Startup preparation on controller `a8df2064d68f40fbf4dec670c4b8478c4b1b1b3f` with frozen runtime `5c69b69ef2aeab8d8a367b25a8d894cc5308befa` first failed because the frozen offline install recreated `node_modules` without the excluded Daytona workspace plugin-SDK link. The narrow `link-plugin-dev-sdk` export restored one symlink and its owned parent. The subsequent preparation digest is `b824a5dde48c94a7c64f2be273b1b03508bdbbd55dbc7cfe63a45a19371b0fd7`; readiness receipt `product-readiness.pending.json` SHA-256 `60fa994d929851670ce29d2dfb2cb773a06ee9e0c037142ccda44fb6e522a9d9` passed health, recovery, UI and plugin checks, retired 32 owned PIDs and observed clear IPC. Root review digest is `4be4f8ce295457b50c69683417bd3883b101089a994bae59be518fe781bf97ce`. This is setup evidence only: zero provider or cloud calls, not provider qualification. + + +The preceding PR workflow on candidate `a027895ea66ac74578ff677d4c555c263773c0a7` ended with nine jobs reporting the same runner shutdown signal at approximately 13:50 UTC, followed by cancellation. Bounded job logs show no explicit failed-test summary, so this is recorded as runner interruption evidence, not a product qualification result or source-test failure. Final PR review snapshot (2026-09-30): paperclip/paperclip #14696 at exact head `9dbf9ae0d030d837a9a4d9241967ff5d3d5c974e` has 52/52 checks green. Greptile Apex reviewed that exact head at 5/5 with no new findings; all eight existing Greptile review threads are resolved. This confirms the PR review state only; provider production qualification remains pending. The preparation review itself records that no paid authority was granted (receipt `protocol-preparation/root-preparation-review.json`, SHA-256 `a716c6bb1d31a7c5bd6547d9295ac8b133f4ad86ab8fccf53683c3b92cab5dac`). + +Cursor's native-question report has a [public upstream review finding](https://forum.cursor.com/t/agent-acp-never-offers-the-askquestion-tool-so-cursor-ask-question-is-never-sent/172976): the reported missing AskQuestion behavior was attributed to server-side ACP session identification, with no missing initialize capability identified and no verified fix release in the visible thread. This is diagnostic evidence, not confirmation that the pinned Cursor distribution supports the callback in every mode or proof that it is universally absent. Evidence identity: `root-findings.json`, SHA-256 `2fc34c1942e85ab58577b7cfa8ccdeafe520cc7c539aa67a7eef5a474848ad73`. + +The existing provider inventories remain the source for methods, events, projections and explicit gaps. Optional native artifact, diff and every-mode surfaces remain listed as gaps; they are not blanket qualification blockers for unrelated declared scope. No support declaration changes here. + +The following checkpoint and chronology retain their original source/profile +identities; their builds and paid outcomes do not qualify the new candidates. + +## Historical qualification checkpoint — September 30, 2026, after 64 attempts + +All three providers remain unqualified. The retained paid baseline is Cursor v7, +Copilot v7 and Pi v9, with native runtime source `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`. +Controller and Product harness revisions are recorded independently below. +New usage-projection source is under development and has no paid qualification. + +| New retained evidence | Outcome and boundary | +| --- | --- | +| Cursor v7 Daytona hello, controller3d21 | Six Product checks passed. One owned sandbox was deleted and remained absent through the late-create observation; local processes and temporary roots retired. Post-run authority passed. This qualifies only this case. | +| Copilot v7 Daytona denial, controller40064, attempt02 | Failed remote observer startup before permission proof. Cleanup confirmed removal of the owned sandbox and local processes. Original end source-checkout audit failed because the checkout moved during its final audit; an additive audit after exact restoration passed, without regrading the failed attempt. | +| Cursor v7 protocol get-task-context | All four semantic checks passed, but the unchanged eval rejected missing usage/cost coverage. No usage receipt or priced estimate was available. Preserve accounting_failure. | +| Copilot v7 seven-case protocol suite | Six cases passed: task context, document creation, task completion, human confirmation, context/document/progress workflow and governed waiting. Context-before-action failed: progress was committed before the context read. Both calls succeeded and six of seven checks in that case passed. State history confirms ordering; no grader or projection defect was established. No automatic retries. | + +Protocol cases use the seeded mock control plane; they do not qualify browser, +Daytona or production mutations. Copilot estimates for all seven measured cases +have complete token-price coverage, while native billed dollars remain unknown. +The failed orientation case is a model-behavior result, not an infrastructure +retry candidate. The five final independent cases passed 35 checks in total. + +The remote observer fix in [#14696](https://github.com/paperclipai/paperclip/pull/14696) +sends a small nonce-bound snapshot request instead of forwarding its source code +over an 8 KiB control socket. The exact latest source `a1145db4d8dbde17e624f8a14ef2726b33d09083` +passes Apex 5/5 and CI. Its 41 deterministic tests include generated install, +snapshot and close traffic; negative tests require actual directory creation and +socket listening. It still needs live proof in the combined controller candidate. + +The warm-directory review identified stable projectless scope, Darwin path +aliases, current lease ownership, stopped-versus-destroyed recovery, and composed +remote coverage gaps. New composed tests also exposed transfer scratch inside +AGENT_HOME, stale first-lease callbacks during handoff and compact receipts losing +the original materialization root. The corrected source passed 541 pure tests, direct server typecheck and all 63 +selected database-backed cases (49 directory-service and 14 composed remote +scenarios), with owned process/IPC/temporary-root cleanup verified. These repairs +are awaiting a fresh commit review in [#14695](https://github.com/paperclipai/paperclip/pull/14695). Earlier failing DB and +paid attempts remain retained; neither source tests nor session-ID reuse alone +prove paid warm process continuity. + +Review follow-up `84a50124a997f4256932eab15fe79dd9ce875940` bounds immediate +collection retries and preserves pending ownership after a failed stop through +generic run cleanup. A later confirmed close or independent current-run stop +proof can collect the retained files. Its 547 pure tests, 65 database-backed +cases and direct server typecheck pass; owned process, IPC and temporary-root +cleanup completed. Fresh Apex and CI are pending. This adds no paid warm proof. + +Cursor's pinned native `TurnEndedUpdate` has optional input, output, cache-read, +cache-write and reasoning counters. Its vendor ACP prompt response does not +project them. No evidence shows Paperclip dropped a supplied ACP usage receipt in +the failed attempt. The new candidate records bounded per-invocation observations +as explicitly partial diagnostics. It must not sum parent/child totals, invent +missing zeros or claim billing until the backend's aggregation/cache/reasoning +semantics and exact selected model pricing are verified. JSON-RPC errors do not +return this diagnostic envelope; reused child runs lack unambiguous callback +attribution. Those are explicit gaps, not complete accounting. The shared patch +changes identity declarations, requiring new Cursor/Copilot/Pi candidate identities +and new qualification; prior receipts remain bound to their original runtime. + +The private ledger has 64 closed records and no active paid invocation. Latest +Copilot account observation: Pro active, 23/1,500 included credits used, additional +usage disabled at $0. Latest Cursor observation: Pro+, fixed account-wide $25 cap, +$0 on-demand observed. These account totals may lag and are not per-run invoices. +Known Pi API cost is $0.040507624; the conservative retained infrastructure upper +bound is $0.635265. Cursor's entire $25 cap remains reserved globally. Unknown +native or infrastructure invoice totals remain unknown; no combined actual-spend +total is inferred. The $100 campaign ceiling remains in force. Pi v9 still needs +an enforceable provider spending bound before further paid calls. + +Evidence references (private attempts remain inspectable without publishing raw +provider transcripts): Cursor Daytona hello result `3ec06ed1aee8a5523262d4855dca3483de24139480da76f37d75a38570ecb843`; +Copilot denial02 result `048ec74bedf508080a099ea6be39f5d4ec159f31985dbe4c52f286a8f8813021`; +Cursor accounting reconciliation `4999b3c0abec441a4de2c6f45c6cb683cbfe4d94413c30f0333da3b6e53dbc03`; +Copilot ordering reconciliation `cf14df413fc9a98f6047842be21585e6c4d3fa72d4199ea9331885665193c152`; +five-case account reconciliation `0063361b04c33d724c9649be3790857a640bbe04357ffafd8c078e8cea40eb22`. + +## Historical checkpoint before the latest 11 attempts + +The following source and cost observations are retained historical checkpoints; +the dated qualification checkpoint above states the current result. + +Updated: 2026-09-30 UTC. **Cursor v7, Copilot v7 and Pi v9 remain unqualified.** Paid controller and Product harness source is recorded per case: `40064d28522de25fea85c1297f35b41bb8a8897a` or `3d21d375de2b6249d9f5322bf001ce0ce0e052aa`. Native runtime source remains `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c`. Runtime builds for macOS ARM64, macOS x64 and Linux x64 are complete. The corrected 3d21 controller uses unchanged compiled UI/package outputs built on 40064; it executes the controller TypeScript from 3d21. These distinct source roles do not relabel the runtime or older failures. + +Exact-head CI and Greptile now pass for Cursor `c58a8881f2cb771b35e19e1bf56e08cb89397e83` (53 successful checks, two skips, Greptile 5/5) and Copilot `3d21d375de2b6249d9f5322bf001ce0ce0e052aa` (54 successes, four skips, Greptile 5/5). Copilot's first server-shard run timed out in an unchanged legacy Cursor test; an isolated reproduction and one failed-job rerun passed. Superseded cancelled checks are not failures. The new warm-directory fix needs its own full checks and live proof. Foundation [#14430](https://github.com/paperclipai/paperclip/pull/14430) and Runner Eval definitions [#33](https://github.com/paperclipai/paperclip-evals/pull/33) are merged. The v7/v7/v9 definition update [#34](https://github.com/paperclipai/paperclip-evals/pull/34) merged as `52f6e897c08d236776a21723257e12c909d71137`, with 136 deterministic tests and 21 validated cells. These definitions are not paid proof. Provider PRs remain drafts. + +| Current-profile paid case | Exact outcome and evidence limit | +| --- | --- | +| Cursor local native-plan reject/revise/accept, controller40064 | Failed passive settlement despite 21 passing bridge checks. Workspace snapshots stayed empty; cleanup and end integrity passed. Original failure is retained. | +| Cursor local native-plan reject/revise/accept, controller3d21 | All 26 checks passed after the canonical contract-hash repair. Rejection feedback reached the revised plan; the browser accepted that exact revision. The single planning run succeeded and the task stayed In Progress with an explicit wait for the next user message. No implementation or task completion is claimed. Workspace bytes remained unchanged through cleanup. | +| Copilot local attached async, controller40064 | Eight checks passed: one exact command, trusted child exit, native-client and independent-marker observations before terminal settlement, one run and one final marker. Done was visible. | +| Copilot local native denial, controller40064 | Ten checks passed: exact browser denial, no target side effect through process retirement, no alternate native operation, and correlated cancellation. The task remained unfinished as required. | +| Copilot local three-turn warm continuity, controller40064 | Nine behavioral checks passed, but the case failed because all three turns used different runner processes. Session-ID reuse alone is not warm process continuity. | +| Copilot Daytona hello, controller40064 | Six checks passed on profile7 and the current immutable Linux image. One owned sandbox was removed, the full late-create observation passed, and all owned local processes retired. This qualifies only this case. | +| Copilot Daytona denial, controller40064, first attempt | Failed before Product dispatch because the private launcher omitted the required image Node and runnerd digest environment fields. Account/analytics reads occurred, but the launch path never reached a provider or sandbox creation. The original failure remains failed; a corrected helper needs a new attempt. | + +All passing cases above completed their owned process/semaphore cleanup, temporary-root cleanup and full end integrity audit. Cursor plan result SHA-256: `685609f01291db84f40d890ef562473b95065e86907bd964b656ad732f6dd97a`; reconciliation: `09a17d5aabc9d33bd7cfe083ad4875ed4da2c3d7b72438e00584237fc71190bc`. Copilot Daytona hello result: `960c9de5a78f4073918daed8f6f6ddfe6f90d1164bbb4cf8f2b4e3d20d5234a5`; reconciliation: `fd043e08b853e0a49901070593940a1aca68f8bd488c194578e6a3f65963d69e`. Copilot warm failure result: `1583d442c5bf70ea520fd63b3a6dcc03ba045a8af2af5e2f68dcf220bb4e8815`. Local async and denial result digests remain `d1d074bc9df4644794dfa8b020fdeba8a9b0de4618e1f55d1845f7726a2ece33` and `c1fd910ecc4bf9d6df7645f3eb90b45d9cc895cfea9e2f93b4cfdf8a2caf8625`. + +The warm-directory repair retains the exact registered `AGENT_HOME` with the native owner, claims it before composing the next turn, and transfers collection authority with database compare-and-swap guards. Changes or a final configuration mismatch require verified retirement before collection. Missing stop proof preserves unresolved ownership. Stop-proved remote recovery records an explicit unavailable/no-save outcome when bytes cannot be recovered. Independent review found and fixed that recovery transition. Validation passed 527 native/probe tests, 63 working-copy database tests, four final composed lifecycle cases, five follow-up recovery cases, and direct server typecheck. The paid warm failure remains failed until a new run proves this change. See [agent-file lifecycle rules](../agent-files.md). + +Native Cursor/Copilot per-run USD remains unknown. Cursor's fixed account-wide on-demand cap is $25, with $0 observed after the corrected plan case. Copilot additional usage remains disabled at $0; the included-credit counter reached 20 after Daytona hello and stayed 20 after the failed denial launch. Counters may lag. The hello sandbox's conservative cost bound is $0.086931; early analytics are provisional. The first 53 reservations are closed, retaining failed attempts. Pi v9 still needs a verifiable OpenRouter spending cap; its optional transport-budget candidate remains frozen and unintegrated. The $100 combined campaign ceiling remains in force. + +The [prior evidence checkpoint](runner-rich-acp-validation-2026-09-30-current.json) retains controller/harness `ca7026182c2b861e3badbcb7e5b733445a6ee403` and runtime `e822b614fc368043f4b3d5e34a8d9bbda644e055`; despite its filename, it is historical for the current source. The [earlier September 30 checkpoint](runner-rich-acp-validation-2026-09-30.json) is also historical. Original failures, unknown costs and exact source/profile identities remain unchanged. + +| Gate recorded at the historical checkpoint | Required evidence or decision | +| --- | --- | +| Cursor v7 | The exact corrected local native-plan case passed; complete the remaining required cases. Native AskQuestion availability remains unverified; semantic questions cannot substitute for its callback. Complete the required local/Daytona native input, permission, recovery, isolation and warm-continuity cases. | +| Copilot v7 | Local attached-async and native-denial plus Daytona hello passed. Repair and requalify failed warm process continuity; broader provider qualification remains pending. Complete the remaining local/Daytona denial, recovery, isolation and warm-continuity cases. Explicit detached work remains unsupported. Native external-tool/sampling/limits callbacks require proof that admitted tools cannot leave an unresolved request, or a qualified responder. | +| Pi v9 | Obtain an enforced provider spending bound, then run the unchanged required local/Daytona cases against the exact model and final runtime. Historical controls, question and hello passes do not qualify v9. | +| All providers | Bind every required case to exact controller/runtime/profile and platform evidence, retain failures, complete source/dependency/pack end audits and owned cleanup, and reconcile actual spend within the $100 ceiling. CI and packaging do not substitute for this paid evidence; native Cursor/Copilot per-run USD remains unknown. | + +The field inventories and [prioritized follow-ups](#historical-gaps-and-follow-ups--october-2-2026) distinguish native SDK/RPC capability, ACP exposure and Paperclip projection. Image inputs, structured tool diffs/media/raw arguments, secondary locations and typed exit codes remain partial or unused in the common path. Cursor's richer child details and discovery/configuration fields, Copilot's session-scoped files/assets and uncorrelated native notices, and Pi's queue-delivery/configuration fields remain explicit follow-ups. These optional surfaces are not silently claimed as supported or treated as new blockers for unrelated declared cases. Copilot native steering/fork/ask-user APIs do not imply ACP responders; Pi native fork/clone/export likewise have no mapped runner controls. Pi confirm reports `negative_or_cancelled` because No and dismissal are indistinguishable; accepted empty or whitespace-only input/editor responses are not representable by the current canonical form. + +| Retained paid observation | Outcome and limit | +| --- | --- | +| Cursor v6 Daytona hello, runtime/controller e822 | Six Product matchers passed. Root reconciled the original supervisor cleanup-tail failure using the retained absent sandbox, retired processes and passing integrity audit. Infrastructure upper bound: $0.086931; analytics are provisional. This proves one cell. | +| Copilot v6 local native denial, controller415/runtimee822 | Ten Product checks passed: delivered denial, correlated native failure, zero target mutations, explicit Stop, cancellation and owned cleanup. An additive screenshot-reader correction used the original evidence without a model retry. This historical v6 case passed; it does not qualify v7. | +| Cursor v6 local native plan, controller415/runtimee822 | All 21 native bridge checks passed, including reject, feedback, revision, exact acceptance and reconnect. The case failed because a normal planning stop had no semantic task-completion result. [Draft #14669](https://github.com/paperclipai/paperclip/pull/14669) adds a passive in-progress wait for the next user message. Its later paid attempt is recorded below. | +| Cursor v6 local native plan, controllerca702/runtimee822 | All 21 native bridge checks passed again. The case failed because the plan's own tool started after its input callback, while the runner had dropped the tool identity needed to distinguish it from unrelated work. Workspace snapshots stayed empty; no second run was observed. Native completion succeeded, but controller settlement failed. All owned processes retired and the full integrity audit passed. Cursor v7 binds the exact tool lifecycle; its later paid settlement failure is recorded above. This failed case is not regraded. | +| Copilot v6 attached-command settlement, controller415/runtimee822 | Failed supervision: incomplete IPC observation stopped an accepted turn. A later scanner match was a static UI demo string. The owned processes retired; an exact temporary executable copy was separately removed and the full pack audit passed. The original failed observation remains failed. | +| Copilot v6 attached-command settlement, controller81/runtimee822 | A new authenticated attempt reached task Done, but one leading ASCII space changed the native command digest. A fixture-only correction now permits a bounded leading SPACE/TAB prefix and retains both digests. The original case remains failed: independent settlement observations were not captured before that assertion, and two provider text bursts produced a doubled final marker. The source investigation confirmed that the pinned ACP mapper drops native message IDs. [Draft #14676](https://github.com/paperclipai/paperclip/pull/14676) preserves them in a separately versioned Copilot v7 candidate; no equal-text deduplication is applied. Cleanup, complete IPC observation and the full end integrity audit passed. Included-credit observations changed 15→16; extra usage stayed disabled at $0. A visible Done status alone does not qualify command settlement. | + +Fresh source81 controller startup, recovery, static UI/plugin admission and local/remote credential-free preflights passed. Native builds retain e822 provenance on macOS ARM64, macOS x64 (Rosetta), and Linux x64. Exact restoration of seven missing empty Cursor directories recovered the original ARM64 and Linux pack digests without changing files or expected inventories. Analogous omissions in the retained Linux build stage and macOS-x64 pack/stage still need restoration and verification before their next use; the actor is unknown. No additional platform qualification is inferred. + +Exact source415 CI completed with 53 successful checks/statuses and two skips; Greptile reviewed that head at 5/5. Local recursive typecheck, build and token gates passed. The original broad test command failed. Its four server failures and two CLI timeouts passed unchanged in isolated follow-ups. Resumed workspace checks exposed stale installed ACPX patch metadata: a private qualification-install overlay corrects seven patch-hash references. Repository policy assigns the source lockfile to the refresh bot, so commit `788e3b88ffe0e66e632fc2811087036dbb756f49` restores the exact tracked bytes from before the attempted metadata-only edit. A fresh frozen install, exact patched vendor-byte verification, four long-diagnostic cases and 25 actual package contracts pass. Some manually resumed commands also collected generated `dist` test duplicates; those invocation failures remain recorded separately. The source Codex auth timeout passed unchanged when isolated. The serialized command stopped on two import tests; both passed unchanged in isolation. All 110 unrun suites finished separately: 109 files and 2,046 tests passed, while one cache-bound test failed after its five-second TTL elapsed. That case passed unchanged in one narrow rerun. The final source415 checkout receipt is clean. No passing full-local gate is claimed. + +At parent source `6b1e96af0`, CI reported 49 successes, two skips and four failures, including the E2E aggregate. Commit `f401b831f` moves cold route imports into fixture setup and dismisses the actual announcement UI in the attachment browser test. All 26 route tests passed. The attachment test also passed with a forced real announcement and a successful dismissal response. Parent source `aec34c693` then completed CI with 53 successes and two skips. The separate preview-runtime CI stall passed unchanged under local instrumentation; its cause is unconfirmed, and no speculative runtime fix is included. + +Cursor v7 source `770dc88a1` preserves the native plan's parent tool identity through TypeScript, the sidecar and Rust. The controller requires one successful lifecycle for that exact tool in the same session and turn, and includes its events in the committed proof. It rejects unrelated work and altered proof. Historical Cursor v6 committed waits remain valid. All 228 focused tests, the Runner TypeScript build and server typecheck pass. Native distribution bytes are unchanged; current runtime builds are complete, and the corrected controller3d21 paid native-plan case above passed. + +Copilot v7 source `70b186b3c` preserves real native message identity through the pinned mapper and separates intermediate messages from the final answer. The native executable is unchanged; an owned, verified inner distribution carries the patch. Review fixes add owned-output rollback after failed materialization and actual ACPX history replay coverage before fresh warm turns. All 49 focused JavaScript/TypeScript checks, Runner typecheck and the exact Rust admission check pass. The earlier source passed 212 focused tests and verified complete closures for all three platforms; only ARM64 executed its native loopback proof. That asset evidence remains separate from the current source checks. Current runtime builds and exact-head CI are recorded above; fresh remote image/admission evidence and the remaining paid qualification are separate gates. The earlier v6 async failure stays failed. + +Cursor native AskQuestion remains unverified for the inspected exact models/modes. Pi v9 needs a verifiable OpenRouter spending bound before further paid runs. Its optional transport-budget candidate remains frozen: production allocation, launch scope, warm rotation, packaging and Daytona transport are incomplete. The provider-capped key remains the immediate path. Native Cursor/Copilot per-run USD remains unknown. Included usage and a disabled overage budget are billing coverage, not a native price receipt. The first 53 attempt records are closed. The table above separates corrected Cursor plan and Copilot hello passes from retained failed plan, warm and launcher attempts. The $100 campaign authorization remains in force, including failed attempts and infrastructure. + +Current profile identities bind shared runtime contract `paperclip.acpx-runtime-contract.v1`. All ACPX warm owners include that revision in their configuration fingerprint; incompatible owners retire without approval replay. Other transports retain their previous configuration digest. Legacy Cursor and Pi adapters remain unchanged. + +## Historical v5/v5/v8 evidence + +On source `5605c350b`, Cursor v5 passed all five basic paid local journeys: completion, semantic questions, semantic plan approval, controller restart, and file edit/validation. Copilot v5 passed paid Daytona completion. Pi v8 introduced message-boundary and bounded retry/compaction-notice repairs, but has no paid pass on that profile. None of these older receipts qualifies the current profile or its remaining local/Daytona roster. + +On integrated source `65b19549e`, 389 focused Runner tests passed (one optional +native installation case skipped), 30 Rust ACPX unit tests passed, 19 selected +server mode/recovery tests passed, and Runner build/server typecheck passed. +The final ACPX patch also passed 22 actual package contracts against the locked +SDK 1.4.0 dependency graph. These are deterministic checks, not paid qualification +or a passing repository-wide gate. The first frozen install exposed a preexisting +legacy ACPX 0.12 patch-hash mismatch; a metadata-only correction passes frozen +lock validation with every dependency version unchanged. + +Credential-free tests cover the new policy boundaries, real pinned native +processes, clean runtime builds and Product E2E assertions. On integrated source +`bd4cc29c3`, Cursor v4 has passed local hello, and Copilot v4 has passed all five +basic local journeys: hello, semantic questions, semantic plans, controller +restart and file edit/validation. Pi v7 has passed local hello on `807feaecf` and +Daytona hello on `bd4cc29c3`. Copilot also passed Daytona hello. Pi’s repaired +native-question evidence case passed 15/15; the following continuation case +exposed final-message aggregation of earlier narration. +These passes retain earlier failures and do not qualify native interactions, +all required platforms or the remaining remote journeys. The [harness priorities report](https://pages.paperclip.ing/2026-09-25-harness-priorities/) recommends Cursor and Copilot, followed by Pi, using the existing qualified ACPX path. Codex app-server is the richness benchmark. The legacy Cursor and Pi adapters are outside this change. +## Retained paid profile evidence (2026-09-29, collected through September 30 UTC) + +This historical paid checkpoint used runtime source `5605c350b2a4dae75be3779f164d7e1a0e4a5a87`. +It includes the passive native-read evidence fix from `794e90a258`: correlation +uses a fixture-owned path digest and the runner's actual opaque tool identity, +without weakening native permission checks. The 155 focused projector/Product +tests passed. The later Cursor identity fix and its regression coverage are +described below. Source, executable, dependency and profile identities are bound +to each retained run; documentation and unrelated test-only follow-ups do not +relabel the executable source. + +| Paid case and runtime source | Result | Cost coverage | +| --- | --- | --- | +| Cursor v5 local `hello-complete`, `5605c350b` | One authenticated run; all six matchers and the saved-mode qualification check passed; 76.202 seconds including supervision | Matching 39K-token account row is Included; on-demand remains $0 with the approved fixed $25 cap | +| Cursor v5 local `question-resume-complete`, `5605c350b` | Two authenticated runs on the same session; six matchers passed; answered question and Done visible; 99.186 seconds including supervision | Matching 58.4K and 24.6K-token account rows are Included; on-demand remains $0 | +| Cursor v5 local `plan-approve-complete`, `5605c350b` | Two authenticated runs; six matchers passed; displayed plan revision 1, approval and completion visible; 98.064 seconds including supervision | Matching 61.8K and 51.4K-token account rows are Included; on-demand remains $0 | +| Cursor v5 local `structured-question-restart-resume`, `5605c350b` | Two authenticated runs; six matchers passed; pending question survives controller restart and resumes the same session after the answer; 72.360 seconds including supervision | Matching 60.9K and 25.9K-token account rows are Included; on-demand remains $0 | +| Cursor v5 local `file-edit-validate`, `5605c350b` | One authenticated run; seven matchers passed, validated file and downloadable artifact visible; 66.805 seconds including supervision | Matching 166.2K-token account row is Included; on-demand remains $0 | +| Copilot v5 Daytona `hello-complete`, `5605c350b` | Browser/public-API journey passed six matchers in 82.859 seconds; authenticated semantic finish produced the exact completion marker and Done; exact sandbox destruction and the 360-second late-create observation passed | Additional usage is disabled with a $0 budget. Compute/storage upper bound is $0.086931 for this cell; individual sandbox analytics remain provisional, not zero cost | +| Cursor v5 local `question-resume-complete`, earlier `794e90a258` | Two authenticated runs on the same session; six matchers passed; 62.006 seconds | Both matching account rows are Included; retained as earlier-source evidence | +| Copilot v5 local `question-resume-complete`, earlier `794e90a258` | Two authenticated runs on the same session; six matchers passed; 51.328 seconds | Included credits increased from 12 to 14; additional usage disabled with a $0 budget | +| Pi v8 | No paid run on this current runtime yet | The available OpenRouter key has no enforced limit or management access. A capped qualification key is pending account sign-in | + +The five corrected-build Cursor cases retired every observed owned process, +left no owned semaphores and passed post-run authority checks. Screenshots, +durable events and original receipts are retained. All cases used zero automatic +retries. Native priced USD remains unavailable for Cursor and Copilot; included +usage is account billing coverage, not a fabricated native token price. +Paperclip semantic questions and plans do not qualify native provider callbacks. +The campaign remains provisional until its full end audit and required cases +pass. + +The next Cursor native-question case failed: no `cursor/ask_question` callback +was observed before the turn ended. The model reported that `AskQuestion` was +unavailable, but the retained events contain no actual tool-catalog discovery; +this is unverified availability for the exact model/mode, not confirmed harness +absence. The pinned native extension handler passes an offline single-/multi- +select exercise and requires no extra question capability in ACP initialization. +No semantic question is substituted for this missing native evidence. Two +subsequent native-only probes admitted the exact Luna and Composer +`composer-2.5[fast=true]` models in Plan mode, verified trusted-instruction +acknowledgements, and exposed no MCP servers. Each submitted one prompt and +ended without a native question callback. Their matching account rows are +Included (14.4K and 14.6K tokens), with complete process/lease cleanup and no +on-demand charge. These diagnostics leave native-question availability +unverified; they do not qualify the Product interaction or prove harness-wide +absence. An earlier diagnostic failed before inference because its test launch +omitted the required instruction binding; that failed attempt is retained. +An offline reversal of all 14 vendor-patch replacements recovered the exact +pinned upstream chunk and reproduced the installed bytes. Native question +handlers, presentation and dispatch are unchanged. The local request-context +tool list contains MCP descriptors; native model tool selection crosses the +remote AgentService boundary. The effective remote tool catalog remains +unobserved, so no local patch removal or additional model sweep is justified. + +The campaign's full end audit also failed. Earlier Daytona setup had installed +14,335 additional entries under the local plugin's `node_modules` using its +checked-in frozen lock. All 111,348 original dependency entries still match; +one SDK CLI file changed mode from 0600 to 0755 with unchanged contents when pnpm +linked its executable. The plugin also generated 60 build files outside the +original workspace inventory. These changes occurred after the original +inventory. The failed audit remains retained, and its behavioral passes do not +establish complete runtime qualification. + +A separate source-identical preparation now materializes the complete frozen +plugin dependency/build closure and compiled static UI before inventory. It +disables automatic plugin builds and development middleware through supported +server settings. A credential-free admission reached healthy server/plugin +readiness, verified served UI bytes, and left all 131,613 dependency/workspace +entries unchanged. All 30 owned processes retired without owned semaphores. +The new full start audit passed; new paid cases use a separate binding and do +not retroactively qualify the failed campaign. Every case remains provisional +until its own required behavior and the new full end audit pass. + +The first native plan case on this new preparation received and displayed the +complete `cursor/create_plan` request, including its revision and decision +options. The test did not answer it: its matcher required `acpx-runtime`, while +the legitimate sidecar emits `acpx-runtime-sidecar`. A second fixture assertion +compares JSON key order instead of structural equality. The unanswered session +timed out; the 149.418-second failed attempt, complete cleanup and passing full +end audit remain retained. Its 26.7K-token account row is Included. Fixture +commit `375cdf6e` accepts the two exact production ACPX origins and uses +structural equality; 880 fixture tests, fixture typecheck and replay against +the failed snapshot pass. Another explicitly bound attempt is required to +prove actual reject/revise/accept delivery. + +Copilot's first supplemented native-denial case delivered the browser's decline +as `reject_once`, recorded failure of the same native tool, and retained absent +target samples. It still failed qualification: cancellation was acknowledged +without a terminal turn event or provider retirement, then the 120-second +timeout changed the run to failed. Its watcher also reported incomplete +coverage while monitoring a workspace parent that startup can modify. The +144.581-second attempt retired every owned process, left no owned semaphores, +and passed the full end audit; additional billing remained disabled at $0. +Shared cancellation settlement and explicit permission-provider provenance are +being repaired. Fixture commit `98a29fcf7` creates an isolated denied-target +parent before dispatch for local Copilot, Cursor and Pi. Its watcher retains +coverage failure reasons, parent identities and a bounded event journal; exact +native path correlation, complete observation and zero target mutation remain +required. All 80 focused fixture tests and fixture typecheck pass. Remote sealed +observers are unchanged. Paid runs are held until the corrected runtime is +identified and verified. + +The Copilot cloud cell also retired all 46 observed local processes, restored +the host semaphore count from 321 to its baseline 310, removed its owned +temporary directory, and passed post-run authority checks. Its separate root +reconciliation records unknown native priced USD and a conservative cloud +ceiling; aggregate account analytics are not attributed to the test. + +The subsequent Cursor v5 hello completed all six Product matchers, but the +stricter qualification reader correctly failed: the shared app-server facade +reconstructed the native provider identity without `cursorMode`. Native +admission retained the acknowledged mode; the server checkpoint lost it. The +campaign stopped after this one cell, all owned processes and semaphores were +retired, and the full end audit passed. Its 39.5K-token account row is Included. +The parser now validates and preserves Agent/Plan/Ask mode. Nine regressions +exercise actual driver checkpoint/recovery, changed or missing mode, and invalid +mode values; all 56 recovery/lifecycle tests and the Runner TypeScript build +pass. The corrected-build Cursor cases above now pass the same strict validator. +The original failed receipt remains a failure. + +The most recent paid macOS ARM64 pack is +`sha256:3940fbe1c7b197b964b8f8182c1f1ae548cff822a95ecb4539e0ca851b4f2032`. +The rebuilt Linux x64 pack is +`sha256:83702fae11105f5b9ee8f9731b86dc06becb12075f68bdc98dcdfe9f9c842473`, +published and independently verified in +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:16c8be9c512c28cd9b16fe9ad5331f3fd7b58c42c382d3ebbe5897f658de0bea`. +The first Daytona setup attempt failed before browser fixture initialization, +provider inference or sandbox creation because the isolated HOME lacked a +Playwright browser-cache binding. Its failure remains retained. The explicit +verified cache binding passed a real private-HOME browser smoke before the +Copilot retry above. macOS x64 also has a corrected-source pack built under +Rosetta, reusing the previously verified daemon with identical Rust source; +this does not establish paid native Intel qualification. +Cursor's initialize probes needed forced cleanup after EOF, retained as a +settlement limitation rather than reported as graceful exit. + +Current profile pins and prelaunch rejection checks are merged in +[paperclip-evals #32](https://github.com/paperclipai/paperclip-evals/pull/32), +with 142 deterministic tests and 21 selected cells across all three explicit +lanes. These definitions remain separate from authenticated qualification. + +On prerequisite source `11b2842d`, recursive typecheck, token gates and build +pass. The initial server stage retained two database setup failures with 14,180 +passing tests; both affected suites passed a focused retry after host recovery. +Segmented follow-up exercised the workspace projects and all 149 serialized +server suites. Initial stale-socket CLI failures, route-file failures, two legacy +ACPX 0.12 diagnostics and a Codex process-monitor timeout passed focused +rechecks with retries disabled and unchanged timeouts. The legacy installed +package needed corrected patch-hash metadata; the frozen offline install passed +and the checked-in lockfile was restored byte-for-byte. This is passing segmented +coverage, not a clean aggregate `pnpm test:run`. Local browser suites were not +rerun in this verification cycle; earlier Runner browser and CI evidence are +separate. + +The Telegram recovery test's synthetic credential-lease fault affected another +company's Slack fixture. Its scope guard now has a failing negative control and +a passing Linux chat shard (356 passed, 710 skipped). At the September 30 UTC +checkpoint, prerequisite PR #14631 at `1cf7125b` has 52 successful and two +skipped checks; integration PR #14633 at `e154e853` has 51 successful checks. +Neither has failing or pending checks. A prior external-object mock assertion +and a runner-shutdown cancellation remain recorded; their exact-job reruns +passed. No provider is exposed as qualified on this evidence. + +## Historical production checkpoint (2026-09-29) + +The basic eval definitions and then-current profile pin follow-up merged in +`paperclip-evals` PRs [#29](https://github.com/paperclipai/paperclip-evals/pull/29) +and [#30](https://github.com/paperclipai/paperclip-evals/pull/30). The latter passed +134 unit tests, CI and Greptile 5/5 at `4a15550d16ced37c6edbafa91a11783a64e4e649`; +its merge commit is `0ad9c5a4275fbfbed3b32f261d3e603d9761a07c`. The Cursor/Copilot v4 and Pi v7 pin +update, including prelaunch profile admission, merged in +[PR #31](https://github.com/paperclipai/paperclip-evals/pull/31) at +`0b4b3932e95fe1685df5d523edff18c824fd3d54`. It passed 135 deterministic tests, +all three explicit lanes, current-head CI and Apex 5/5. These definitions +are not paid qualification results. Versioned config IDs separate current +candidate scorecards from historical runtime policies. + +| Historical paid profile | Latest observed result | Remaining work | +| --- | --- | --- | +| Cursor v4, source `2e0b0fec0`; paid runtime `bd4cc29c3` | All three closures verify; native instructions and bounded todos are implemented. Exact-head Apex reports 5/5. Current-profile local hello passed six matchers with one authenticated run, complete process cleanup and unchanged semaphore count | The question-resume case failed reopening its provider session; the following three cases were not launched. Rebuilt `627451ecf` passed question, plan and controller restart. Its file test validated the correct bytes but failed its exact response marker (5/7 matchers). Native callbacks, restrictive permissions, other platforms and Daytona remain unqualified. The approved account on-demand cap is $25 | +| Copilot v4, source `2042ca14c`; paid runtime `bd4cc29c3` | Native instruction delivery and lifetime ownership have deterministic proof. Exact-head Apex reports 5/5. All five basic local journeys passed; file edit/validation passed seven matchers in one run with complete cleanup | Daytona hello also passed six matchers with complete cleanup. Native permissions, attached background-command settlement, remaining remote workflows and other platforms remain open. The original session startup timeout is retained without a proven root cause | +| Pi v7, sources `807feaecf` and `bd4cc29c3` | Local hello on `807feaecf` and Daytona hello on `bd4cc29c3` passed six matchers each with zero retries and complete owned cleanup. Remote model plus sandbox analytics stabilized at $0.0053185282. Apex reports 5/5 on the reviewed provider head | The `bd4cc29c3` campaign passed hello, then failed packaging native-question evidence despite 15 passing behavioral checks. The harness-only repair subsequently passed all 15 native checks. The next continuation case failed final-message aggregation; five later cells did not launch. Remote/platform evidence remains incomplete | +| All three, Linux/Daytona | The immutable `bd4cc29c3` image and actual server artifact admission pass. Pi hello attempt 02 passed; failed attempt 01 remains retained | The host database resource blocker is repaired. Copilot’s retry passed six matchers and its exact sandbox remained absent for the full 360-second observation. Remaining remote cases are unqualified | + +Cursor and Copilot ignore generic ACP `_meta.systemPrompt`; setting `AGENT_HOME` +in the process environment does not make it part of model instructions. The v4 +profiles bind provider-specific delivery. Pi already uses its dedicated launch +configuration and owned `before_agent_start` extension. Offline source/SDK +checks use dependency doubles or synthetic model responses. They do not prove +final packaged, authenticated delivery. Cold restoration refreshes trusted context and instructions before reopening. +The shared continuation fix also allows a settled, authenticated new run to +refresh registered instruction/skill roots and its MCP binding. It preserves +profile, model, session, aggregate/context digests and all unknown policy fields; +same-run mutations and active-turn attachment remain rejected. + +The repository-wide test attempt recorded 14,057 passed tests, eight failed +tests and two failed setup hooks in the first general-server group. All affected +cases and setup groups passed isolated retries. One retry selected no tests +because Vitest shortened a parameterized name; the corrected selector ran and +passed the case. A confirmed short-prefix collision in the chat test fixture is +repaired with bounded, constraint-specific allocation and three actual-database +regressions. The broad failed attempt remains retained; it is not converted into +a passing full run. On combined source `97217c531`, token gates, repository-wide +typecheck and build pass; its broad test run completed with 14,052 passing tests, +six failed tests and nine failed files (including collection failures). That +archive lacked Git metadata. A filtered private install also resolved Vitest +against unsupported Vite 6; four unchanged suites (116 tests) pass with its +intended Vite 8 dependency. Other broad-run failures remain retained rather +than being credited as passing. +On `807feaecf`, typecheck/build and CI reproduced a test-fixture typing error; +shared prerequisite commit `bb56af7fb` fixes it. These sources predate the latest +review and continuation fixes. +On integrated `bd4cc29c3`, full build, recursive typecheck and token gates pass. +The completed full test attempt records 11,598 passing tests, six failed tests, +2,640 skipped tests and 43 failed files. Four cases fail before assertions during +database bootstrap; other files fail setup or cleanup. Two separate cases time +out at 15 seconds and 300 seconds. After host recovery both passed unchanged +in isolation (documentation read and streamed Git snapshot); the original +full run remains failed and the exact timeout causes remain unproven. This is not a green +repository test run. All Runner verification stages pass across retained, +targeted retries, including 2,533 base TypeScript tests, Rust, 22 SDK tests, +537 scenario tests, 25 main browser tests, six SDK browser tests, 43 scenario +browser tests, 112 issue-thread browser tests, import/package gates and an actual +clean-consumer pack/install. The aggregate `verify` command was not rerun from +the beginning after those repairs. The +[validation checkpoint](runner-rich-acp-validation-2026-09-29.json) retains source +revisions, log hashes, failed attempts and the exact scope of each pass. +The shared PR includes test setup/settlement repairs, corrected baseline browser +expectations, the standalone devtool loader repair and verification-script fixes. +The private embedded Postgres package required its declared postinstall to +restore shipped dylib symlinks before live startup; a credential-free server +health check and subsequent paid question passed with the repair hashed in +their evidence. A green full-suite run and final-head CI remain required. + +The Copilot attempt increased the visible included-credit counter from 5 to 6; +additional usage remained disabled at a $0 budget. Per-run USD is unknown. +The first two Pi attempts failed before inference and have zero exclusive-key +billing delta in delayed observations. Pi hello attempt 03 passed on the combined +`97217c531` runtime: 15,340 input, 332 output and 15,360 cached tokens, with a +delayed exclusive-key delta of $0.00065884. The native USD receipt remains +unpriced. All 48 observed owned process identities were retired. Its retained +invocation policy allowed one automatic retry even though the supervisor declared +zero; exactly one Product attempt ran. This configuration gap is retained in +evidence, and subsequent supervisors explicitly pass `--max-automatic-retries 0`. +The current-profile Copilot v4 attempt on `807feaecf` increased included usage +from 6 to 7 credits, with additional usage still disabled; it failed after answer +submission because run attachment rejected the refreshed registered file root. +All 59 owned processes retired. Pi v7 hello on the same source passed with the +$0.000646464 delayed delta above and explicit zero retries. Its informational +pricing-estimate notice appears with a generic warning icon; that presentation +needs refinement and is not a provider error. Failed attempts, missing cost +coverage and cleanup remain recorded. The $100 +shared budget and provider allocations remain in force; one successful hello +does not establish production qualification. + +The corrected Copilot question on `bd4cc29c3` passed all six matchers with two +runs on the same provider session, no invariant failures and all 59 observed +owned processes retired. Its included-credit counter moved from 7 to 8; the +subsequent four-case campaign moved from 8 to 11. That campaign passed hello, +plan approval and question continuation after controller restart, then stopped +at file-edit startup timeout. No automatic retries occurred. All campaign +processes retired and post-run runtime/dependency checks passed. Additional +GitHub usage remained disabled at $0/$0; these account counters do not supply a +native per-run dollar receipt. The plan and question journeys use authenticated +Paperclip semantic tools; they do not establish a native Copilot ask-user +responder. + +Pi Daytona attempt 01 used the previous `807feaecf` image and never reached +provider inference. Exclusive OpenRouter usage stayed unchanged. The owned +sandbox’s closed usage interval cost $0.000407164 in delayed sandbox-specific +analytics, and the resource was observed absent throughout the cleanup window. +The failed attempt and launcher teardown diagnostic are retained. The next +image is built from `bd4cc29c3`, with digest +`sha256:bff4c3f291087a0eeae37e4c20dd51857b92833eaf73ba3aca4157f37de1e109`. +Its installed Linux provider-pack digest is +`sha256:08ad9f6a6fb9c14c87e3bc5b20d01876986178c768433d709a45540e8d40a4be`. +An image build and manifest check are not paid remote qualification. + +The [sanitized qualification checkpoint](runner-rich-acp-qualification-2026-09-29.json) +retains current attempts and their original source/profile identities. Pi's +corrected `bd4cc29c3` Daytona hello passed six matchers with one run; the sandbox +was absent throughout the full cleanup window and all owned processes retired. +Three delayed reads stabilized at $0.000630644 OpenRouter usage plus $0.0046878842 +sandbox analytics ($0.0053185282 combined, provisional provider accounting). + +Host semaphore exhaustion was confirmed by a credential-free PostgreSQL +bootstrap reproduction (`semget` returned `ENOSPC`). With the user's approval, +a fixed-snapshot cleanup removed 4,892 stale sets (83,164 semaphores) and skipped +11 whose recorded creator PID was present. Startup and graceful shutdown now +pass. Private qualification supervisors were also repaired to let the owned +server shut down PostgreSQL before terminating launcher wrappers. Paid Cursor +hello and Copilot file runs each returned to the 255-set baseline without a +forced PostgreSQL kill; no provider runtime bytes changed. + +Cursor hello attempt 01 stopped in the private supervisor before provider +launch. A process-inspection race was reproduced and repaired; attempt 02 passed +six matchers with one run. Its matching account usage row is labeled Free (24K +tokens), while native token and dollar receipts remain unavailable. Copilot's +new file diagnostic passed seven matchers, exact file bytes and validation in +one run; its included-credit counter moved from 11 to 12 with additional usage +disabled. An initial workspace-only citation was rejected, then the model +registered its deliverable and completed in the same run. This is not a retry. + +Earlier Copilot local and first Daytona failures remain preserved. The remote +invocation never reached fixture creation or a cloud-write path; its machine +cleanup flag remains `unresolved_scope_not_captured`, separately from that +source-order assessment. Host exhaustion does not explain the earlier provider +session timeout. The Pi local/native campaign stopped at its second cell: hello passed, and all +15 native-question behavioral checks passed, but a required API snapshot was +missing. The failed Product result remains retained; the six later cells did +not launch. A minimal harness-only snapshot repair and seven focused regressions +are committed. The two calls cost $0.004005496 by delayed exclusive-key delta. +Cursor’s subsequent question-resume test also stopped its batch on a provider +reopen failure; all 56 owned processes exited and semaphore counts returned to +baseline. Its matching account row is Free (24.7K tokens). A credential-free reproduction shows that a fresh ACPX manager can return a +saved Cursor record without loading the native provider. The committed repair +forces an exact-model control to load that session and acknowledge current +instructions within the existing admission deadline, then renews the consumed +launch lease. Missing or incorrect acknowledgements still block prompting. +The original private sidecar stack is unavailable; this is corroborating +reproduction evidence. The fix passed 93 focused tests and package typecheck; +the rebuilt `627451ecf` runtime subsequently passed question continuation, +semantic plan approval and question continuation after controller restart. Its +file test created and validated exact bytes and reached Done, but the model added +`VALIDATION` to the explicitly requested final marker, failing two of seven +matchers. The batch retains that failure: four cells, seven runs, 23/25 matchers. +All owned processes retired. Seven matching account rows were explicitly Free +and on-demand usage remained zero; native per-run dollars are still unavailable. +Later cases from the original failed batch were not launched. + +Copilot Daytona hello attempt 02 passed six matchers with one run. All 45 owned +processes retired and the exact owned sandbox remained absent throughout the +360-second cleanup observation. Three delayed sandbox analytics reads stabilized +at $0.0039682144; this remains provisional infrastructure accounting, separate +from unavailable native per-run dollars and the account’s unchanged included-credit +counter. Additional Copilot usage remains disabled. + +Pi’s native-question retry on unchanged `bd4cc29c3` runtime and separate +`43c99f044` eval source passed 15/15 with complete required evidence. The next +question continuation reached Done with two succeeded runs, but failed one of +six matchers because the final item included earlier pre-tool narration. Retained +post-tool text deltas alone match the expected final marker. Both cells fully +settled, all 96 owned process identities retired across the two cells, and delayed +exclusive-key charges totaled $0.005707808. Five later cells did not launch; +the final-message defect remains under investigation. + +GitHub billing coverage was also audited: public standard-runner and self-hosted +GitHub minute charges do not add a per-minute charge, but external fleet costs +and private evals CI incremental charges remain unmetered. The $100 live-test +ceiling is not evidence of complete infrastructure cost attribution. + +## Registered persistent agent files + +Candidate ACP processes receive `AGENT_HOME` only from the authenticated +runtime context's server-registered `agent_files` working copy. Ambient +environment values cannot grant a directory. Admission rejects symlink roots, +filesystem roots and overlap with provider runtime state; directory identity is +rechecked at launch and before each turn. Each provider reopen receives the +current run's registered copy. The native executor requires provider shutdown +before collecting and synchronizing the copy. Pi additionally receives a +runner-owned native-tool root binding; its task read-only policy still applies. +These checks do not by themselves qualify provider-specific native file tools. + ## Branches and evidence ownership | Branch | Deliverable | @@ -28,14 +639,16 @@ adapters are outside this change. | [`codex/runner-rich-acp` / #14430](https://github.com/paperclipai/paperclip/pull/14430) | Shared ACPX extension boundary, durable permissions, canonical display events, provider pack infrastructure, configuration and UI | | [`codex/runner-cursor-acp` / #14435](https://github.com/paperclipai/paperclip/pull/14435) | Cursor native distribution, questions/plans, child activity, policy admission, wire fixtures | | [`codex/runner-copilot-acp` / #14434](https://github.com/paperclipai/paperclip/pull/14434) | Copilot native distribution, event inventory/projections, permission and settlement probes | +| [`codex/runner-acp-inputs` / #14591](https://github.com/paperclipai/paperclip/pull/14591) | Shared editable drafts, native question compatibility, bounded image builds and authenticated continuation fixes | | [`codex/runner-pi-acp` / #14436](https://github.com/paperclipai/paperclip/pull/14436) | Patched wrapper, owned extension, MCP/tools, permissions/input, portable dependency closure | | [`codex/rich-acp-extended-harness-evals` / evals #29](https://github.com/paperclipai/paperclip-evals/pull/29) | Explicit 21-cell Runner Eval campaign, semantic assertions, provenance and fail-closed budget accounting | The provider branches were implemented in parallel from the foundation. Final shared registration and packaging conflicts are resolved in dependency order: -foundation → Cursor → Copilot → Pi. They remain four separate worktrees and PR -review units; the later PR bases include their prerequisite providers. Foundation -acceptance requires Apex review and CI to pass. Provider PRs remain unmerged +foundation → Cursor → Copilot → shared input/continuation prerequisite → Pi. +They remain separate managed worktrees and PR review units; the later PR bases include their prerequisite providers. Foundation +acceptance completed with Apex 5/5 and passing CI; that result does not cover +subsequent provider changes. Provider PRs remain unmerged pending qualification. Source reports on the provider branches are `doc/architecture/runner-cursor-capabilities.md`, `doc/architecture/runner-copilot-capabilities.md`, and @@ -61,7 +674,9 @@ mean the required paid local and Daytona product cases have passed. “Not expos means the pinned interface was inspected; “unverified” is a separate finding. Codex's row is the existing app-server integration, not the Codex ACP bridge. -The provider reports above are the method/event and field inventories. In the +The provider reports above are the method/event and field inventories. Paid +observations in the matrix refer to retained historical versions unless an +explicit profile is named; the current checkpoint above controls qualification. In the table below, a method that is absent from a pinned implementation is distinct from an exposed method with no Paperclip control. Shared surfaces and their deterministic evidence are mapped separately after the comparison. @@ -72,18 +687,18 @@ deterministic evidence are mapped separately after the comparison. | Text and tools | Typed thread/turn/item events | Standard ACP updates; child activity kept separate | Standard ACP plus opt-in native session events | Wrapper text/tool updates and owned tool gates | | Active steering | Dedicated `turn/steer` | Concurrent prompt replaces/cancels, so it is not steering | Concurrent prompt replaces/cancels, so it is not steering | Owned `pi/steer` requires handshake, exact active turn and acknowledgment; real active-turn probe passed | | Queued follow-up | Product continuation controls | Controller can schedule a later prompt; native queue not established | Native pending-message activity exists; no qualified ACP queue responder | Owned `pi/follow_up`, separately named and ordered; real queued marker followed the steered current response | -| Cancellation | Typed interrupt and process lifecycle | ACP cancel; paid command cleanup pending | ACP cancel; bounded live detached-command settlement and cleanup passed | Native abort; wrapper waits for `agent_settled` and treats provider errors as failure | +| Cancellation | Typed interrupt and process lifecycle | ACP cancel; paid command cleanup pending | ACP cancel; attached async native settlement passes offline. Explicit detached work is rejected before side effects; it is not supported background settlement | Native abort; wrapper waits for `agent_settled` and treats provider errors as failure | | Session continuity | Read/load/history/fork and durable identity | Session load/list observed; paid semantic warm continuation passed; native history replay unverified and fork absent in tested methods | Session load plus native history events; semantic pending-question controller recovery passed | Private Pi JSONL mapping/load; native RPC `fork`, `clone`, `get_fork_messages` are not mapped through this ACP wrapper; unresolved UI promises cannot survive provider death | | Questions | Typed input requests and response correlation | `cursor/ask_question`, option identity and multiple selection preserved | Native ask-user capability exists, but pinned ACP does not wire its responder; do not display a false answerable form | `select`, `confirm`, `input`, `editor` through typed form elicitation | | Permissions | Durable typed approvals | Standard ACP permission options; denied shell write had no observed side effects; separately labeled exact-correlation assessment | Standard ACP; real native reject_once prevented marker creation; session decision scope inspected | Native pre-tool gate; allow once, exact-operation session grant, deny; paths rechecked after wait | | Plans | Typed plan and collaboration mode | `cursor/create_plan` includes full plan and revision-bound accept/reject/cancel; todo activity separate | Native plan events displayed; native plan-decision callback not exposed in ACP | No native structured plan event; authenticated Paperclip planning tools available | | Authenticated tools | Runner bridge and governed operations | ACP HTTP MCP binding; paid context/history reads passed | ACP HTTP MCP binding; paid context read passed | Owned extension registers exact bound MCP tools; four authenticated semantic reads succeeded in a paid partial run; no ambient servers | -| Delegation | Typed agent roles and lifecycle | Opt-in subagent lifecycle, nested ownership and bounded child activity; never parent transcript flattening | Native delegation/session events projected with role/model/agent provenance | No built-in ACP delegation protocol; arbitrary extensions are excluded | -| Files/diffs | Typed file changes and artifact references | Standard tool changes plus validated image references | File/workspace events retained; provider session files are not silently treated as task files | Wrapper-retained read/write/edit diffs; common typed/UI projection remains partial; semantic artifact tools | -| Images/artifacts | Typed references and registered work products | Existing contained files only, provenance, `registered:false` | Contained task references only; external/session-store paths become descriptive notices | Image/resource tool blocks preserved by wrapper; dedicated artifact channel absent | +| Delegation | Typed agent roles and lifecycle | Opt-in subagent lifecycle, nested ownership and bounded child activity; never parent transcript flattening | Native session details retain agent/model fields with unknown originating turn; typed turn-owned delegation is not inferred | No built-in ACP delegation protocol; arbitrary extensions are excluded | +| Files/diffs | Typed file changes and artifact references | Standard tool changes plus validated image references | Correlated ACP tool events remain available; native session file/workspace details are session notices, not attributed task file changes | Wrapper-retained read/write/edit diffs; common typed/UI projection remains partial; semantic artifact tools | +| Images/artifacts | Typed references and registered work products | Existing contained files only, provenance, `registered:false` | Native session artifact details are bounded notices with no inferred turn ownership or automatic registration | Image/resource tool blocks preserved by wrapper; dedicated artifact channel absent | | Image/attachment input | Typed input conversion | ACP advertises images, but the runner turn converter currently forwards text only | ACP advertises images/embedded context, but the runner turn converter currently forwards text only | Native Pi/ACP image input exists, but the runner turn converter currently forwards text only | | Usage | Per-request receipt and model context | Pinned ACP omitted receipts on denied and successful turns; account UI confirms included usage separately | ACP/native tokens retained; account UI confirms included credits separately, without a per-run USD receipt | Assistant-message and compaction token receipts; dollar cost is a catalog pricing estimate, never authoritative billing | -| Config/model changes | Typed configurable controls | Known modes/model interfaces researched; runtime policy cannot be changed by a display event | Model/reasoning/mode options exist; runtime policy remains authoritative | Exact pinned candidate model; arbitrary slash commands/config/extensions disabled | +| Config/model changes | Typed configurable controls | Agent/Plan/Ask setup is bound to native configuration acknowledgements and recovery; unsolicited mode drift fails admission. Rich parameterized model picker remains unused | Model/reasoning/mode options exist; runtime policy remains authoritative | Exact pinned candidate model; arbitrary slash commands/config/extensions disabled | | Reconnect/restart | Durable controller replay and qualified provider restoration | Semantic pending question survived a real controller restart; exact native callback restoration remains unverified | Semantic pending question survived controller restart with the same interaction and provider session; native callback restoration remains unverified | Wrapper explicitly advertises live-process-only pending-input recovery | | Shared capability | User-visible surface | Inspectable implementation / deterministic evidence | @@ -148,7 +763,7 @@ configuration defaults to full auto; it never overrides read-only task policy. ## Distribution and isolation Cursor pins `2026.09.26-dd393fe`; Copilot pins `1.0.88`; Pi pins wrapper `0.0.33`, -runtime `0.84.2`, portable Node and its full npm lock. Native distribution hashes +runtime `1.0.0` (Pi profile v12), portable Node and its full npm lock. Native distribution hashes cover macOS ARM64, macOS x64 and Linux x64. Source-owned closure pins remain separate from profile declaration digests. Native admission reads held files, verifies every admitted byte, creates a private immutable snapshot and retains @@ -166,44 +781,69 @@ provider's branch. Local and remote pack verification includes the complete candidate asset tree. The corresponding Daytona build argument is documented in `docker/daytona-runner/README.md`. Candidate packaging never promotes a profile. -ACP is not an OS sandbox. Cursor still reads project MCP/hooks beyond its -`--disable-project-configs` flag; the adapter rejects known ambient execution -configuration. Mutations during a run and remote team hooks still require -qualification. Pi's tool policy supplements the execution boundary; arbitrary +ACP is not an OS sandbox. Cursor v3 patches the verified native ACP +distribution to disable project and ambient MCP discovery and local/remote +hooks. Offline poison-configuration tests exercise the actual transformed +vendor functions; paid and remote qualification remain required. Pi's tool policy supplements the execution boundary; arbitrary shell commands and filesystem races require the host boundary. These are qualification gates, not claims that a JavaScript path check confines a shell. -## Explicit gaps and follow-ups +## Historical gaps and follow-ups — October 2, 2026 | Priority | Exposed but unused, partial, or unverified | Reason and next proof | | --- | --- | --- | -| P0 | Remaining paid product cases on local and Daytona | Credentials are explicitly bound. Cursor's five local semantic cases passed. Copilot completion/file/plan/restart cases and Pi completion/file/question passed. Copilot question continuation reached the correct UI and warm session but failed its final marker. Copilot question qualification, remaining Pi cases and all remote cases remain open. The 30-cell Product E2E and private 21-cell Runner Eval extended suites are explicit-only. Daytona billing access still requires user email verification. | -| P0 | Pi fresh-profile Product verification blocked by host resources | The next paid plan case failed during embedded PostgreSQL initialization before any provider prompt. A disposable reproduction found 16 free SysV semaphores when Postgres required a set of 17. Resume the unchanged canonical case only after task-owned cleanup or a supported isolated test host restores capacity; do not remove unrelated IPC objects or substitute an unqualified database fixture. | +| P0 | Remaining paid product cases on local and Daytona | Current Cursor7/Copilot7/Pi9 require authenticated local and Daytona evidence on the exact current runtime. Historical Cursor4 passed hello/question/plan/restart but its latest file marker failed despite correct bytes. Copilot4 passed all five basic local journeys and Daytona hello. Pi7 passed native questions but continuation exposed the final-message bug repaired in8. The semaphore blocker is resolved and Cursor's approved on-demand cap is $25. Native interaction, denial, settlement, platform and complete cost/cleanup gates remain open; no old profile pass qualifies new bytes. | +| P0 | Pi fresh-profile Product verification | Historical v4 plan startup failed with unavailable Postgres semaphores. Historical v6 attempts exposed profile admission and verified-runtime startup faults, repaired without raising deadlines. Preserve every failed attempt and run unchanged canonical cases against the final build; do not remove unrelated IPC objects. | | P0 | Copilot native ask-user and plan-decision callbacks | Pinned ACP does not install native responders. Prove no blocking request is exposed, or add a qualified responder/wrapper; never swallow the request. | -| P0 | Broader Copilot denial and background settlement qualification | Two bounded real-service probes passed on pinned 1.0.88: native reject_once prevented a requested file write, and an observed detach:true command completed before end_turn. These establish the selected cases only; retain broader local/Daytona qualification before promotion. | -| P0 | Cursor native question availability | A real default-mode prompt with no semantic MCP tools reported that native AskQuestion was unavailable. No native request arrived. The pinned client implements the RPC and mode controls, but inspected local guards do not explain the negotiated tool availability. Inspect the actual catalog/flags before another live probe; do not infer permanent harness absence or native success from semantic question tests. | -| P0 | Typed Cursor entitlement failure | Exact models and successful inference are observed on the selected paid accounts. Cursor's first-account entitlement denial became ordinary text and normal completion. Preserve this failure and qualify typed failure handling; never infer a successful task from terminal status alone. | -| P0 | Cursor project and remote hooks; native shell boundaries | Configuration flags do not cover every native source. Demonstrate policy cannot be bypassed before production qualification. | +| P0 | Broader Copilot denial and background settlement qualification | Pinned 1.0.88 and isolated 1.0.89 settle attached async commands, but deliberately detached work can finish after end_turn. The earlier paid detached success depended on that model waiting and is not general settlement evidence. Profiles v3/v4 reject explicit detached admission before effects; governed long-lived background work remains unsupported. Denial and process-death recovery pass offline; paid local/Daytona proof remains required. | +| P0 | Cursor native question availability | Explicit Luna and Composer native-only Plan-mode probes admitted the selected model and trusted instructions but emitted no question callback. The vendor-patch reversal and actual MCP-context constructor proof identify no local native-tool removal. Effective native tool selection is beyond the remote AgentService boundary; seek upstream catalog/availability evidence. No further blind model grid or semantic-question substitution qualifies this feature. | +| P0 | Typed Cursor entitlement failure | The v4 native patch preserves typed entitlement/authentication errors. Offline transformed-vendor tests and clean native authentication failure probes pass. The historical first-account failure remains retained; authenticated Product proof on the revised runtime is still required. | +| P0 | Cursor project and remote hooks; native shell boundaries | The verified v4 patch disables native ambient MCP/hooks at their actual initialization points on all three platforms. Retain real paid/remote isolation and command cleanup qualification before promotion. | | P1 | Native Pi queue selection in the product UI | The runner API exposes negotiated `follow_up` separately from active steering. The current composer has no native queue selector; add one without confusing it with controller-scheduled later turns. | | P1 | Pi `queue_update` contents and delivery state | RPC acknowledgment proves acceptance, not model consumption. Add bounded queued/delivered events and durable message correlation with explicit retention rules for user content; preserve this distinction on reconnect. The selected live marker-order probe proves its own consumed messages only. | -| P1 | Pi structured retry and compaction progress | Current text projection drops some `attempt`, `maxAttempts`, `delayMs`, `errorMessage`, `success`, `finalError`, `reason` and `willRetry` fields. Add bounded redacted provider notices with source-event provenance and outcome regressions; keep terminal failure authority separate. | +| P1 | Pi retry/compaction field completeness and end-to-end evidence | The wrapper now emits bounded `paperclip/pi_notice` activity instead of assistant text. The adapter preserves source provenance, retry counters/delay, outcome flags and bounded reason/error text; package tests cover retry success/failure/unknown and compaction usage. `finalError` is not forwarded, and summarization retry currently has summary-only data. Extend field-by-field wrapper/canonical/UI coverage; these deterministic checks do not qualify live behavior or give notices terminal authority. | | P1 | Native Cursor/Copilot active steering and queues | ACP prompt replacement is not steering; native SDK capabilities may be richer. Require a dedicated bound method plus acknowledgment before advertising. | | P1 | Child tool media/diff/raw payloads | Bounded delegation summaries preserve lifecycle and identity. Large nested payloads need a child-owned canonical item model; current omission is a visible notice and provider report entry. | | P1 | Standard ACP parent tool `content` diffs/images, secondary locations and raw input bodies | The existing common normalizer projects bounded output text, input-presence, and the first safe relative location. Raw argument bodies can contain secrets; richer content needs bounded typed blocks and a separately validated workspace binding for each file. Provider-specific image/file notices do not close this standard-tool gap. | | P1 | User attachments and image prompting | `AcpxRuntimeTurnInput` and the common runtime adapter currently forward text only, despite underlying image-input support. Implement validated attachment-to-ACP content conversion and model-specific capability admission, then qualify real local/Daytona image prompts. This is an implementation gap as well as a live-verification gap. | +| P1 | Copilot native session event attribution | `_session_event` omits an originating turn. Preserve bounded event fields as session-scoped notices with `turnAttribution: unknown`; typed delegation, artifacts and compaction need an explicit native correlation contract before turn-owned projection. Standard correlated ACP events remain separate. | | P1 | Copilot session-store files and export/artifact URIs | Provider paths are not task-workspace paths. Add a separately authorized export flow with validated bytes and provenance; do not resolve arbitrary URLs or auto-register. | | P1 | Pi native fork/history/export interfaces | Pinned Pi 0.84.2 native RPC exposes `fork(entryId)`, `clone`, `get_fork_messages` and `export_html`. The wrapper does not map them to runner controls. Add durable branch lineage for fork/clone and an authorized, contained artifact flow for HTML export before exposing them; do not label these native capabilities absent. | | P1 | Complete usage/billing provenance | Missing cache fields remain unknown. Pi price estimates are displayed separately. Budget qualification requires actual spend coverage, not an estimate presented as a bill. | -| P1 | Fork/history/model/mode controls not exposed by Paperclip | Research documents the native and ACP methods separately. Add governance-aware controls and durable lineage before enabling them. | +| P1 | Fork/history and richer configuration controls | Cursor session mode now has explicit admission-bound setup. Arbitrary session discovery/forking, mid-turn configuration changes and the parameterized model picker still need company-scoped controls and durable lineage. | | P1 | Exact pending-request restoration after process death | Session transcript restoration does not restore callbacks. Expire unresolved requests unless a provider proves exact restoration. | -| P1 | Persistent agent-directory access through ACPX | Mainline `3ca196b0a` supplies `AGENT_HOME`, but its existing ACPX environment allowlist does not forward it. Pi also confines native writes to the task workspace, while the local persistent agent directory lives outside it. The disabled candidates do not claim this new capability. Bind and validate the company/agent/run-owned directory explicitly through launch and native-tool policy, including warm-run rebinding and cleanup-before-collection tests; do not widen ambient environment or filesystem access. | +| P1 | Paid qualification of persistent agent-directory access | Candidate launch paths now bind only the registered `agent_files` working copy, and Pi's native-tool policy admits that root while preserving read-only restrictions. Cold restoration refreshes both the trusted context and composed instructions. Environment delivery alone does not prove model awareness: Cursor and Copilot ignore generic ACP `_meta.systemPrompt`. Provider-specific instruction delivery must pass fresh/load model-boundary checks, followed by paid local/Daytona persistence and cleanup-before-collection cases. Ambient roots remain excluded. | | P2 | Remaining Copilot native diagnostic/config/account events | The provider inventory records every event and field, its projection or reason for omission. Preserve bounded useful context; avoid credentials, raw environment or unbounded blobs. | +| P2 | Pi empty or whitespace-only submitted input/editor text | Canonical required text rejects blank responses; optional blank responses are omitted. Defaults can be empty and nonblank text preserves boundary whitespace. Add explicit empty-text semantics across TypeScript, Rust and UI before claiming complete native empty-answer parity. | | P2 | Pi native extension surfaces and unsupported slash commands | Arbitrary extensions/templates/themes may execute ambient code. Only reviewed runner-owned capabilities are admitted; structured native plan/goals are not fabricated. Native fork/clone/export are separate unmapped capabilities above. | | P2 | Pi status/widget/title/editor and session/configuration notifications | `setStatus`, `setWidget`, `setTitle` and `set_editor_text` have no UI projection and are unused by the owned extension. Native session-name and thinking-level events also lack a dedicated projection. Add reviewed bounded notice schemas and governed configuration controls before exposing these fields; `notify` and interactive input already have separate bridges. | | P2 | Pi invocation and history provenance metadata | The v4 wrapper retains `nativeToolCallId`, `modelIteration`, `historyMessageIndex` and `identityScope` in private ACP-wire metadata. Closed common tool and permission projections omit them from the UI. Normalized IDs still correlate live tool, MCP and permission events. Add bounded, redacted display-only provenance fields and parity tests before surfacing the native metadata. | | P2 | Conditional native-plan follow-up fields | Cursor's optional rejection-reason field also appears for accept/cancel. The current question renderer has no conditional fields; add conditional presentation without changing the revision-bound decision receipt. | | P2 | Cursor command exit code projection | The paid file case preserved native `exitCode: 0` inside output text, while the canonical command field remained null. Normalize a typed, correlated exit code without parsing arbitrary prose; current independent file assertions do not prove this field. | +### Capability-gap classification at source `41503eb38` + +This source review separates future implementation work from qualification of +already implemented behavior. It adds no paid proof and does not replace the +provider inventories or historical receipts. References below bind exact source +`41503eb38f03c436b1569f9f0204625bb4d58782`; a native advertisement or retained wire field +does not establish Runner projection or a usable product control. + +| Priority / category | Meaningful gap and reason | Follow-up and source boundary | +| --- | --- | --- | +| P0 / qualification | Native decisions, denial, attached-command settlement and callback recovery still need the required current-profile cases. Semantic question/plan/restart evidence is a different path. | Complete the existing local/Daytona cases and retain failed attempts; no new feature is implied by this row. The [capability matrix](#capability-matrix) and versioned qualification receipts govern claims. | +| P1 / Runner input | Native image input is available on the inspected surfaces, but Runner turns forward text only. | Add bounded, validated attachment conversion and exact-model admission, then qualify actual image prompts. [`AcpxRuntimeTurnInput`](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts#L111), [text forwarding](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts#L1404). | +| P1 / Runner output and UI | Structured tool diffs/media, raw arguments and secondary locations are not carried into canonical tool details; exit code remains null, including where Pi retains a structured native result. | Add typed, redacted fields and workspace validation with local/sidecar/UI parity tests. File-byte or artifact-card checks do not prove rich diff or exit-code presentation. [Common mapper](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/provider-events.ts#L1131), [sidecar boundary](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts#L917), [Pi result fixture](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs#L279). | +| P1 / native queue and UI | Pi exposes distinct `follow_up`, but native queue contents/consumption are not durably projected and the product queue has no native follow-up selector. Acknowledgment is acceptance, not consumption. | Add negotiated selection and occurrence-bound queued/consumed/dropped evidence with retention rules; keep controller scheduling distinct. [Control contract](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/drivers/acpx/turn-controls.ts#L1), [current UI actions](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/ui/src/components/task-chat/TaskChatQueuedMessages.tsx#L39). | +| P1 / child and session attribution | Cursor child tools have attributed summaries, not nested rich details. Copilot native notices lack originating-turn identity and therefore remain session-scoped; provider-session files/assets still require retrieval and registration. | Add a child-owned detail model; require a native origin contract before assigning Copilot notices to turns, and separately authorize artifact retrieval. [Cursor summary boundary](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts#L401), [Copilot provenance boundary](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts#L19). | +| P1 / accounting qualification | Token/compaction receipts, catalog price estimates and actual account billing have different authority; missing usage/cost fields remain unknown. | Preserve partial coverage and verify campaign totals against actual spend evidence. Never promote an estimate or model multiplier into a bill. [Usage distinctions](#capability-matrix) and the provider inventories remain authoritative. | +| P2 / optional discovery and controls | Commands, configuration/title updates and plan priority lack complete product projection. Pi native fork/export first require wrapper mapping; extension widgets and private invocation provenance are separate optional surfaces. | Add company-scoped controls and bounded fields only where useful, preserving acknowledgments and lineage. These are future features, not new qualification blockers. [Explicitly skipped status tags](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/provider-events.ts#L1195), [plan projection](https://github.com/paperclipai/paperclip/blob/41503eb38f03c436b1569f9f0204625bb4d58782/packages/paperclip-runner/src/provider-events.ts#L1097), [Pi remaining work](runner-pi-capabilities.md#remaining-event-and-qualification-work). | + +Pi retry/compaction notices are already surfaced as bounded activity. Remaining +field completeness and renderer parity are narrower follow-ups, not an entirely +missing notice implementation. Conversely, a missing paid callback does not +establish that a provider lacks the underlying capability. + ## Qualification ledger Combined ceiling: **$100**, including retries and infrastructure. Cursor allocation: @@ -223,7 +863,7 @@ tokens across twelve qualification requests and zero on-demand tokens. The resta rows showed 23.8K and 41.7K included tokens; denial used 24.4K. Displayed credit precision and delayed accounting do not establish a per-run zero receipt. These measurements are -partial, not a final all-provider total. No Daytona leases have been started. +partial, not a final all-provider total. At that historical ledger checkpoint, no Daytona leases had been started; subsequent Daytona attempts are recorded above. Registry downloads, fake-model fixtures and metadata-only authenticated discovery are separate from model inference. @@ -588,5 +1228,6 @@ the [Pi PR verification](https://github.com/paperclipai/paperclip/pull/14436) an [foundation PR verification](https://github.com/paperclipai/paperclip/pull/14430). Their immutable source and dependency identities remain distinct from the historical packaging proofs above. Image initialization does not certify paid -Daytona execution. The unresolved local database and remote account-verification -blockers prevent support promotion. +Daytona execution. Those were historical blockers at that checkpoint. The current production +checkpoint above records the present blockers; Daytona API authentication and +analytics access have since been verified. diff --git a/doc/architecture/runner-rich-acp-qualification-2026-09-29.json b/doc/architecture/runner-rich-acp-qualification-2026-09-29.json new file mode 100644 index 0000000000..6c628f8403 --- /dev/null +++ b/doc/architecture/runner-rich-acp-qualification-2026-09-29.json @@ -0,0 +1,542 @@ +{ + "schema": "paperclip.rich-acp-qualification-checkpoint.v1", + "updatedAt": "2026-09-29T21:39:23.882856+00:00", + "qualificationStatus": "pending", + "runtimeSource": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "modelReceiptPolicy": "Native per-run USD remains null/unpriced when absent. Included subscription credits, exclusive API-key charges and sandbox analytics are separate evidence. Unknown costs are never assumed zero.", + "evidenceScope": "Sanitized checkpoint of retained private receipts; historical reports retain earlier attempts. This file is not an executable conformance result or production qualification certificate.", + "hostBlocker": "Resolved for live testing: user-approved fixed-snapshot cleanup removed 4,892 stale PostgreSQL semaphore sets, skipping 11 whose creator PID was present. Credential-free startup and role-aware shutdown passed; subsequent paid Cursor and Copilot runs restored the 255-set baseline. No broad follow-up IPC deletion is authorized.", + "ciCostCoverage": "Public standard GitHub-hosted minutes and self-hosted GitHub runner charges are zero; external self-hosted fleet infrastructure costs and private evals CI incremental charges remain unmetered.", + "attempts": [ + { + "id": "copilot-v4-local-question-20260929-01", + "provider": "copilot", + "status": "failed_continuation", + "cell": "extended-harnesses.runner-acpx-copilot.local.question-resume-complete", + "sourceRevision": "807feaecf2b6c6e2a52f5c18dc868b05d7883054", + "profileVersion": 4, + "profileDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "providerPackDigest": "sha256:03b5c9f9520e70c77e63dd2aafbd56864569dbdb6d50b0519a904b00d37e4218", + "model": "gpt-5.6-luna", + "createdAt": "2026-09-29T17:23:42.459281+00:00", + "reconciledAt": "2026-09-29T17:26:32.220513+00:00", + "actualCostUsd": null, + "costCoverage": "Included-credit counter6\u21927; additionalusage remainsdisabled$0;nativeperrunUSDunknown", + "failure": "Question UI reached; run.attach rejected same settled ACPX provider profile and session during warm recovery", + "attemptCount": 1, + "providerInferenceTurns": 1, + "ownedProcessesRetired": true, + "ownedProcessCount": 59, + "maxRetries": 0, + "privateEvidenceName": "copilot-v4-807-question-01" + }, + { + "id": "pi-v7-local-hello-20260929-01", + "provider": "pi", + "status": "passed", + "cell": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "sourceRevision": "807feaecf2b6c6e2a52f5c18dc868b05d7883054", + "profileVersion": 7, + "profileDigest": "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761", + "providerPackDigest": "sha256:03b5c9f9520e70c77e63dd2aafbd56864569dbdb6d50b0519a904b00d37e4218", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "createdAt": "2026-09-29T17:25:01.741345+00:00", + "reconciledAt": "2026-09-29T17:28:37.702173+00:00", + "actualCostUsd": 0.000646464, + "costCoverage": "exclusive OpenRouter key delayed delta; native per-run USD unpriced", + "attemptCount": 1, + "providerInferenceTurns": 1, + "ownedProcessesRetired": true, + "ownedProcessCount": 46, + "observedAutomaticRetries": 0, + "maxRetries": 0, + "durationMs": 56780, + "privateEvidenceName": "pi-v7-local-hello-20260929-01" + }, + { + "id": "pi-v7-daytona-hello-20260929-01", + "provider": "pi", + "status": "failed_before_inference", + "cell": "extended-harnesses.runner-acpx-pi.daytona.hello-complete", + "sourceRevision": "807feaecf2b6c6e2a52f5c18dc868b05d7883054", + "profileVersion": 7, + "profileDigest": "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "image": "ghcr.io/paperclipai/paperclip-daytona-runner@sha256:340ab3df00cfba6e78c9602bb3b79d023da6ddcc00737661324e3647083cb4f6", + "createdAt": "2026-09-29T17:45:40.146562+00:00", + "reconciledAt": "2026-09-29T17:55:55.859885+00:00", + "actualCostUsd": 0.00040716400000000006, + "costCoverage": "Sandbox-specific usage interval and exclusive OpenRouter delta; conservative720secondinfraupperbound retained pendingdelayedconfirmation", + "costFinality": "provisional sandbox analytics; explicit closed interval, delayed observation pending", + "failure": "Controller rejected missing build-owned remote provider-pack/binary env before inference; supervisor additionally treated removed teardown config as ambiguous", + "attemptCount": 1, + "productRunCount": 1, + "providerInferenceTurns": 0, + "ownedProcessesRetired": true, + "ownedSandboxCount": 1, + "cleanupStatus": "observed_absent", + "maxRetries": 0, + "inferenceKeyDeltaUsd": 0, + "privateEvidenceName": "pi-v7-daytona-hello-20260929-01" + }, + { + "id": "copilot-v4-local-question-20260929-02", + "provider": "copilot", + "status": "failed_before_inference", + "cell": "extended-harnesses.runner-acpx-copilot.local.question-resume-complete", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 4, + "profileDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "providerPackDigest": "sha256:c19135b9175fffe9b18c06ca735ba1610e6cb2e36305c760d763c1f41fa67d81", + "model": "gpt-5.6-luna", + "createdAt": "2026-09-29T17:49:21.114937+00:00", + "reconciledAt": "2026-09-29T17:52:56.200598+00:00", + "actualCostUsd": 0, + "costCoverage": "No provider startup/inference; refreshed account remains7/1500includedcredits and additional0/0; nativeUSDreceipt remainsnull", + "failure": "Private embedded Postgres package postinstall was skipped; initdb aborted for missing dylib alias before server/provider startup", + "attemptCount": 1, + "productRunCount": 0, + "providerInferenceTurns": 0, + "ownedProcessesRetired": true, + "ownedProcessCount": 10, + "maxRetries": 0, + "receiptMetadataDiscrepancy": { + "reservedBuildProvenanceSha256": "a6b3a4f3933aa5b78b784bd7a2561886c4fa47a4ae80038fb05c425719318a6a", + "actualSnapshotSha256": "6ff74bde30217858c1c4ec87d2f1a07fb389678ed7e592e647c685df1ecf03f5", + "reason": "Agent added private Git excludes/Vitest graph metadata before launch after reservation; actualruntimeinventories unchanged and pre/post verified. Preserve discrepancy; subsequent reservations bind finalreceipt." + }, + "privateEvidenceName": "copilot-v4-bd4-question-01" + }, + { + "id": "copilot-v4-local-question-20260929-03", + "provider": "copilot", + "status": "passed", + "cell": "extended-harnesses.runner-acpx-copilot.local.question-resume-complete", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 4, + "profileDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "providerPackDigest": "sha256:c19135b9175fffe9b18c06ca735ba1610e6cb2e36305c760d763c1f41fa67d81", + "model": "gpt-5.6-luna", + "createdAt": "2026-09-29T17:53:59.166487+00:00", + "reconciledAt": "2026-09-29T17:56:55.359304+00:00", + "actualCostUsd": null, + "costCoverage": "GitHub account includedcreditdelta1, additionalusage disabled0/0; native perrunUSD unpriced", + "attemptCount": 1, + "productRunCount": 2, + "ownedProcessesRetired": true, + "ownedProcessCount": 59, + "matcherPassCount": 6, + "matcherCount": 6, + "invariantFailures": 0, + "postRunAuthorityPassed": true, + "maxRetries": 0, + "additionalUsageUsd": 0, + "includedCreditDelta": 1, + "providerSessionReused": true, + "privateEvidenceName": "copilot-v4-bd4-question-02" + }, + { + "id": "copilot-v4-local-remaining-20260929-01", + "provider": "copilot", + "status": "failed_last_cell", + "cells": [ + "extended-harnesses.runner-acpx-copilot.local.hello-complete", + "extended-harnesses.runner-acpx-copilot.local.plan-approve-complete", + "extended-harnesses.runner-acpx-copilot.local.structured-question-restart-resume", + "extended-harnesses.runner-acpx-copilot.local.file-edit-validate" + ], + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 4, + "profileDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "providerPackDigest": "sha256:c19135b9175fffe9b18c06ca735ba1610e6cb2e36305c760d763c1f41fa67d81", + "model": "gpt-5.6-luna", + "createdAt": "2026-09-29T17:59:30.648559+00:00", + "reconciledAt": "2026-09-29T18:08:08.105294+00:00", + "actualCostUsd": null, + "costCoverage": "Refreshed GitHub account included credits8\u219211; extra usage disabled$0/$0; native per-run USD unpriced", + "failure": "file-edit-validate: session.open timed out; earlier hello, plan and controller-restart cases passed", + "attemptCount": 4, + "ownedProcessesRetired": true, + "postRunAuthorityPassed": true, + "observedAutomaticRetries": 0, + "maxRetries": 0, + "additionalUsageUsd": 0, + "includedCreditDelta": 3, + "privateEvidenceName": "copilot-v4-bd4-remaining-01" + }, + { + "id": "pi-v7-daytona-hello-20260929-02", + "provider": "pi", + "status": "passed", + "cell": "extended-harnesses.runner-acpx-pi.daytona.hello-complete", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 7, + "profileDigest": "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "image": "ghcr.io/paperclipai/paperclip-daytona-runner@sha256:bff4c3f291087a0eeae37e4c20dd51857b92833eaf73ba3aca4157f37de1e109", + "createdAt": "2026-09-29T18:11:17.415830+00:00", + "reconciledAt": "2026-09-29T18:25:33.859370+00:00", + "actualCostUsd": 0.0053185282, + "costCoverage": "Exclusive OpenRouter key delta plus exact owned sandbox-specific delayed analytics; conservative infra lifetime bound retained", + "costFinality": "provisional provider analytics, stable at three delayed observations", + "attemptCount": 1, + "productRunCount": 1, + "providerInferenceTurns": 1, + "ownedProcessesRetired": true, + "ownedSandboxCount": 1, + "cleanupStatus": "observed_absent", + "matcherPassCount": 6, + "matcherCount": 6, + "postRunAuthorityPassed": true, + "inferenceKeyDeltaUsd": 0.000630644, + "daytonaObservedCostUsd": 0.0046878842, + "privateEvidenceName": "qualification-summary.json", + "privateEvidenceSha256": "1311b63488d5741a79c7a12a462e733f9eb3165d2f0ed429bab42f80e749cc82" + }, + { + "id": "copilot-v4-local-file-20260929-01", + "provider": "copilot", + "status": "failed_before_inference", + "cell": "extended-harnesses.runner-acpx-copilot.local.file-edit-validate", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 4, + "profileDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "providerPackDigest": "sha256:c19135b9175fffe9b18c06ca735ba1610e6cb2e36305c760d763c1f41fa67d81", + "model": "gpt-5.6-luna", + "createdAt": "2026-09-29T18:15:20.477112+00:00", + "reconciledAt": "2026-09-29T18:17:17.885254+00:00", + "actualCostUsd": 0, + "costCoverage": "No provider launch; GitHubaccount11\u219211 and extra0/0. No nativeUSDreceipt.", + "failure": "Embedded PostgreSQL bootstrap exited1 before webserver/API/provider startup; does not diagnose original PRP session.open timeout", + "attemptCount": 1, + "productRunCount": 0, + "providerInferenceTurns": 0, + "ownedProcessesRetired": true, + "ownedProcessCount": 20, + "productCleanupStatus": "not_started", + "postRunAuthorityPassed": true, + "maxRetries": 0, + "additionalUsageUsd": 0, + "includedCreditDelta": 0, + "privateEvidenceName": "verified-outcome-summary.json", + "privateEvidenceSha256": "a812a668d3acb37e1b9553adca95fbce6863f4998ba233c2545b2b5859c6a39e" + }, + { + "id": "copilot-v4-daytona-hello-20260929-01", + "provider": "copilot", + "status": "failed_before_inference", + "cell": "extended-harnesses.runner-acpx-copilot.daytona.hello-complete", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 4, + "profileDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "model": "gpt-5.6-luna", + "image": "ghcr.io/paperclipai/paperclip-daytona-runner@sha256:bff4c3f291087a0eeae37e4c20dd51857b92833eaf73ba3aca4157f37de1e109", + "createdAt": "2026-09-29T18:21:51.168258+00:00", + "reconciledAt": "2026-09-29T18:25:33.859370+00:00", + "actualCostUsd": 0, + "costCoverage": "Source-order proof: webserver initdb failed before health readiness, fixture creation and cloud/provider execution. Supervisor API admission only read account/analytics. No scope existed; retain machine unknown flag rather than claim a sandbox was deleted.", + "failure": "Embedded PostgreSQL bootstrap failed before fixture/task; credential-free exact initdb reproduction confirmed semget ENOSPC host semaphore exhaustion", + "attemptCount": 1, + "productRunCount": 0, + "providerInferenceTurns": 0, + "ownedProcessesRetired": true, + "machineCleanupStatus": "unresolved_scope_not_captured", + "productCleanupStatus": "not_started", + "cloudWritePathReached": false, + "postRunAuthorityPassed": true, + "privateEvidenceName": "verified-outcome-summary.json", + "privateEvidenceSha256": "f82411ec1b3c9ee9486e7d2d2c5dc81ecf8e603304a28d911e020341cda9b503" + }, + { + "id": "pi-v7-local-bd4-campaign-20260929-01", + "provider": "pi", + "status": "failed_second_cell_evidence_packaging", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 7, + "profileDigest": "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "cells": [ + "extended-harnesses.runner-acpx-pi.local.hello-complete", + "pi-native.runner-acpx-pi.local.native-questions", + "extended-harnesses.runner-acpx-pi.local.question-resume-complete", + "extended-harnesses.runner-acpx-pi.local.plan-approve-complete", + "extended-harnesses.runner-acpx-pi.local.structured-question-restart-resume", + "extended-harnesses.runner-acpx-pi.local.file-edit-validate", + "pi-native.runner-acpx-pi.local.agent-files-fresh-run", + "pi-native.runner-acpx-pi.local.restrictive-denial" + ], + "maxRetries": 0, + "createdAt": "2026-09-29T18:23:16.046245+00:00", + "actualCostUsd": 0.004005496, + "reconciledAt": "2026-09-29T20:31:33.209288+00:00", + "costCoverage": "Exclusive OpenRouter key delayed180s delta: hello .000637804 plus nativequestions .003367692; native perrunUSD remains unpriced.", + "attemptCount": 2, + "productRunCount": 2, + "ownedProcessesRetired": true, + "postRunAuthorityPassed": true, + "observedAutomaticRetries": 0, + "completedCells": 2, + "unlaunchedCells": 6, + "failure": "Native questions completed15/15 behavioral assertions but required snapshots/api-state.json missing; Product outcome failed and campaign stopped. Hello passed6/6.", + "privateEvidenceName": "campaign.json", + "privateEvidenceSha256": "e095503ab4b32eaabf34db807d41454fcd3417796bbbcdd9033167092c984383" + }, + { + "provider": "copilot", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 4, + "profileDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "providerPackDigest": "sha256:c19135b9175fffe9b18c06ca735ba1610e6cb2e36305c760d763c1f41fa67d81", + "model": "gpt-5.6-luna", + "cell": "extended-harnesses.runner-acpx-copilot.local.file-edit-validate", + "id": "copilot-v4-local-file-20260929-02", + "status": "passed", + "actualCostUsd": null, + "createdAt": "2026-09-29T20:07:12.133150+00:00", + "maxRetries": 0, + "attemptCount": 1, + "includedCreditDelta": 1, + "ownedProcessesRetired": true, + "ownedProcessCount": 55, + "postRunAuthorityPassed": true, + "costCoverage": "GitHub includedcredit11->12; additionalusage remainsdisabled$0/$0; nativeperrunUSDunknown", + "reconciledAt": "2026-09-29T20:18:07.543888+00:00", + "productRunCount": 1, + "matcherPassCount": 7, + "matcherCount": 7, + "privateEvidenceName": "review-receipt.private.json", + "privateEvidenceSha256": "c2699ecf375181f9722652535f54db6712117a3b4e0708d0c44e6500034edcf1" + }, + { + "provider": "cursor", + "profileVersion": 4, + "profileDigest": "sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "id": "cursor-v4-local-hello-20260929-01", + "status": "failed_supervisor_before_provider", + "actualCostUsd": null, + "createdAt": "2026-09-29T20:08:34.490882+00:00", + "cell": "extended-harnesses.runner-acpx-cursor.local.hello-complete", + "maxRetries": 0, + "attemptCount": 1, + "productRunCount": 0, + "ownedProcessesRetired": true, + "failure": "Process monitor CalledProcessError during PG startup; agent is confirming ps process-exit race. No Product run/browser/provider reached; root awaits complete failure proof before assigning actual cost0.", + "reconciledAt": "2026-09-29T20:10:39.989926+00:00", + "privateEvidenceName": "cursor-v4-bd4-hello-01" + }, + { + "provider": "cursor", + "profileVersion": 4, + "profileDigest": "sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "id": "cursor-v4-local-hello-20260929-02", + "status": "passed", + "actualCostUsd": null, + "createdAt": "2026-09-29T20:13:46.584978+00:00", + "cell": "extended-harnesses.runner-acpx-cursor.local.hello-complete", + "maxRetries": 0, + "attemptCount": 1, + "productRunCount": 1, + "matcherPassCount": 6, + "matcherCount": 6, + "ownedProcessesRetired": true, + "ownedProcessCount": 47, + "postRunAuthorityPassed": true, + "reconciledAt": "2026-09-29T20:18:07.543888+00:00", + "accountObservedChargeUsd": 0, + "costCoverage": "Matching24Kmodelrow is explicitlyFree in Cursor account; native token/cost receipt is unavailable. Do not fabricate native priced usage.", + "privateEvidenceName": "analysis.private.json", + "privateEvidenceSha256": "e558d1dbde30f49fa09f0519de34e2eddd65694ce3ab8aadeb71b79040de249c" + }, + { + "id": "cursor-v4-local-remaining-20260929-01", + "provider": "cursor", + "status": "failed_first_cell_continuation", + "cells": [ + "extended-harnesses.runner-acpx-cursor.local.question-resume-complete", + "extended-harnesses.runner-acpx-cursor.local.plan-approve-complete", + "extended-harnesses.runner-acpx-cursor.local.structured-question-restart-resume", + "extended-harnesses.runner-acpx-cursor.local.file-edit-validate" + ], + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 4, + "profileDigest": "sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "createdAt": "2026-09-29T20:28:30.857363+00:00", + "maxRetries": 0, + "actualCostUsd": null, + "attemptCount": 1, + "completedCells": 1, + "unlaunchedCells": 3, + "ownedProcessesRetired": true, + "ownedProcessCount": 56, + "postRunAuthorityPassed": true, + "failure": "Question continuation run.attach failed: ACPX sidecar session.open rejected; cause under investigation. Subsequent3cells not launched.", + "reconciledAt": "2026-09-29T20:33:09.782788+00:00", + "accountObservedChargeUsd": 0, + "costCoverage": "Matching20:29UTC gpt5.6-luna-medium accountrow is Free24.7Ktokens; accountonDemand0. Native perrunUSD unavailable, not fabricatedzero." + }, + { + "id": "copilot-v4-daytona-hello-20260929-02", + "provider": "copilot", + "status": "passed", + "cell": "extended-harnesses.runner-acpx-copilot.daytona.hello-complete", + "sourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "profileVersion": 4, + "profileDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "model": "gpt-5.6-luna", + "image": "ghcr.io/paperclipai/paperclip-daytona-runner@sha256:bff4c3f291087a0eeae37e4c20dd51857b92833eaf73ba3aca4157f37de1e109", + "createdAt": "2026-09-29T20:31:06.298515+00:00", + "maxRetries": 0, + "actualCostUsd": null, + "reconciledAt": "2026-09-29T20:43:18.535492+00:00", + "attemptCount": 1, + "productRunCount": 1, + "providerInferenceTurns": 1, + "matcherPassCount": 6, + "matcherCount": 6, + "ownedProcessesRetired": true, + "ownedProcessCount": 45, + "ownedSandboxCount": 1, + "cleanupStatus": "observed_absent_for_360_seconds", + "postRunAuthorityPassed": true, + "observedAutomaticRetries": 0, + "nativeUsd": null, + "costCoverage": "Exact owned sandbox delayed analytics$.0039682144. Copilot nativeUSDunknown; subscriptioncounter12->12 and additionalusage disabled0/0; accountcounter may lag. Source-bound720s infraupperbound$.053496 met. Runtimeestimate is separate from billing.", + "daytonaObservedCostUsd": 0.0039682144, + "costFinality": "Provisional sandbox-specific analytics stable across three delayed reads; invoice finality unknown.", + "privateEvidenceName": "review-receipt.private.json", + "privateEvidenceSha256": "2be810d807d12e35c237feb0b60d71b74e27727336f4c6a904a6dbbc0bfd2bab" + }, + { + "id": "pi-v7-local-bd4-campaign-20260929-02", + "provider": "pi", + "status": "failed_second_cell_final_message_projection", + "createdAt": "2026-09-29T20:44:04.035168+00:00", + "sourceRevision": "43c99f04403c0e1e5353b41b41dcab8fdade6a2f", + "runtimeSourceRevision": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "evalSourceRevision": "43c99f04403c0e1e5353b41b41dcab8fdade6a2f", + "evalPatchCommit": "7e7563ca8b8f351b74b922f8c06aee612dfc84fa", + "evalPatchSha256": "18119aef75b5f5a13aa10c1c9c7f7a761b13257dd67d395ec575842663cd8266", + "runtimeBuildPerformed": false, + "cells": [ + "native-questions", + "question-resume-complete", + "plan-approve-complete", + "structured-question-restart-resume", + "file-edit-validate", + "agent-files-fresh-run", + "restrictive-denial" + ], + "expectedProductRuns": 11, + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "profileVersion": 7, + "profileDigest": "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761", + "providerPackDigest": "sha256:c19135b9175fffe9b18c06ca735ba1610e6cb2e36305c760d763c1f41fa67d81", + "maxRetries": 0, + "actualCostUsd": 0.005707808, + "completedCells": 2, + "unlaunchedCells": 5, + "productRunCount": 3, + "attemptCount": 1, + "inferenceKeyDeltaUsd": 0.005707808, + "ownedProcessesRetired": true, + "postRunAuthorityPassed": true, + "reconciledAt": "2026-09-29T20:56:40.420584+00:00", + "failure": "Question continuation reached Done with two succeeded native runs, but final item included earlier pre-tool narration; exact final response matcher failed. Retained deltas after the last tool concatenate to the exact expected marker. Five later cells were not launched.", + "costCoverage": "Exclusive OpenRouter key delta for two cells after 0/30/90/180s settlement; native per-run USD remains unpriced.", + "settledCells": [ + { + "cell": "native-questions", + "status": "passed", + "matcherPassCount": 15, + "matcherCount": 15, + "productRunCount": 1, + "exclusiveKeyDeltaUsd": 0.003543848, + "ownedProcessesRetired": true, + "ownedProcessCount": 44, + "postRunAuthorityPassed": true + }, + { + "cell": "question-resume-complete", + "status": "candidate_failure", + "matcherPassCount": 5, + "matcherCount": 6, + "productRunCount": 2, + "exclusiveKeyDeltaUsd": 0.00216396, + "ownedProcessesRetired": true, + "ownedProcessCount": 52, + "postRunAuthorityPassed": true + } + ], + "privateEvidenceName": "campaign.json", + "privateEvidenceSha256": "d0bb6e7045dad5ba44162d66dd3ec6db6cd31e9fbd4c88e926e99b05b4e06ad9" + }, + { + "id": "cursor-v4-local-627-remaining-20260929-01", + "provider": "cursor", + "status": "failed_last_cell_marker_mismatch", + "createdAt": "2026-09-29T20:56:40.420584+00:00", + "sourceRevision": "627451ecf15641504a485c6f868497177bd1bce8", + "profileVersion": 4, + "profileDigest": "sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "cells": [ + "extended-harnesses.runner-acpx-cursor.local.question-resume-complete", + "extended-harnesses.runner-acpx-cursor.local.plan-approve-complete", + "extended-harnesses.runner-acpx-cursor.local.structured-question-restart-resume", + "extended-harnesses.runner-acpx-cursor.local.file-edit-validate" + ], + "expectedProductRuns": 7, + "maxRetries": 0, + "actualCostUsd": null, + "reconciledAt": "2026-09-29T21:06:39.868239+00:00", + "failure": "Question continuation, semantic plan and controller-restart question passed. File edit/validation, native run and Done succeeded but final marker added VALIDATION despite the exact requested marker. Five of seven file matchers passed; retain candidate_failure.", + "attemptCount": 4, + "completedCells": 4, + "unlaunchedCells": 0, + "productRunCount": 7, + "matcherPassCount": 23, + "matcherCount": 25, + "ownedProcessesRetired": true, + "postRunAuthorityPassed": true, + "costCoverage": "Seven time/model-correlated account rows are explicitly Free; on-demand0. Native priced USD unavailable. No automatic retries.", + "nativePerRunUsd": null, + "accountObservedChargeUsd": 0, + "privateEvidenceName": "batch-analysis.private.json", + "privateEvidenceSha256": "843a05914728174dd8bacac9ce7014c1e9570d65ee4e675cdeaef3e016c7e65a" + } + ], + "hostRecovery": { + "removedSets": 4892, + "removedSemaphores": 83164, + "skippedSets": 11, + "cleanupResultSha256": "105ad0e67c74078f8d3b62784ba08151712646c07289f93e7907fd0f306410ca", + "startupAndShutdownProofSha256": "0f9e42e295d87cbf45731be089f65bc9cd6d1a26e185ab1847f3a7d2d0291fbe", + "currentSupervisorHelperSha256": "c3c801ef7dc3ef969384dd9bb6d822fe6ab21025d40a4702abfdd9ee392d1148", + "scope": "The credential-free startup proof used an earlier role-aware helper revision. Current-helper cleanup is additionally proven by the paid Cursor hello and Copilot file runs. Private qualification supervisors changed; frozen runtime bytes did not." + }, + "implementationSource": "65b19549e0aac0cc7809142be9f33fb63e0a634c", + "historicalProfilePaidCoverage": { + "cursor4": "bd4 local hello passed. Rebuilt627451ecf passed question continuation, semantic plan and question continuation after controller restart. The file case created and validated exact bytes and reached Done, but its final marker added VALIDATION:5/7 file matchers passed. Four cells/seven runs fully settled,23/25 matchers passed overall. Native callbacks, restrictive permissions, Daytona and remaining platforms remain open.", + "copilot4": "bd4 all five basic local journeys and Daytona hello passed. Native permission denial and attached background settlement Product cases plus remaining remote/platform coverage are open. The original session.open timeout remains unexplained.", + "pi7": "Local and Daytona hello passed. After preserving an earlier missing-evidence failure, the harness-only repair on runtimebd4/eval43c99f0 produced a complete native-question pass15/15. The next question-continuation cell failed5/6: final-message aggregation included pre-tool narration. All three runs settled and owned cleanup passed; five later cells were not launched." + }, + "currentProfilePaidCoverage": { + "cursor5": { + "profileDigest": "sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b", + "status": "pending_fresh_runtime_and_paid_qualification" + }, + "copilot5": { + "profileDigest": "sha256:ece77e40876631a69a828b91813722001d71b6fc81be47ecd4b3dced84ff9473", + "status": "pending_fresh_runtime_and_paid_qualification" + }, + "pi8": { + "profileDigest": "sha256:843d30e419914529755c9827d9151a306da1b50b643be7eab1abe797641a37ce", + "status": "pending_fresh_runtime_and_paid_qualification" + } + } +} diff --git a/doc/architecture/runner-rich-acp-validation-2026-09-29.json b/doc/architecture/runner-rich-acp-validation-2026-09-29.json new file mode 100644 index 0000000000..c7eb43bbf2 --- /dev/null +++ b/doc/architecture/runner-rich-acp-validation-2026-09-29.json @@ -0,0 +1,226 @@ +{ + "schema": "paperclip.rich-acp-validation-checkpoint.v1", + "updatedAt": "2026-09-29T21:39:23.883903+00:00", + "qualificationStatus": "pending", + "runtimeSource": "bd4cc29c3017ed5e1484423e842fd48e3b2f49f3", + "validationSourcePatches": [ + "60dd81f2a709a8f8b41d7ec7b225230011df1dbd", + "2fa4f725eb8d0cd58ee8a1207b8065a664fb2363", + "28c82b358e0214f16a0a2cf47006331d89a4d3df", + "aa1a4ec328054bcece9637d33729f64cd97df419", + "f792c41f9f8bdaca0cc5e2877ed95ed881a7f7bb", + "4f2709a611de58b586b122167ca049a19e8d38d0", + "1ac2b2299065ce6767c5541f5cda39190bb436e4", + "ca28efc2461c43ca4b82822028ad60969e250502", + "0759fac2976a49331f65f08c4d9406dc89b8424c" + ], + "scope": "Credential-free validation of the bd4 runtime plus committed test, documentation, devtool-loader and verification-script repairs. Frozen paid runtime bytes were not changed. Earlier failed attempts remain retained. This checkpoint does not certify paid qualification.", + "repositoryBuildTypecheckAndTokens": [ + { + "name": "full-build", + "command": [ + "pnpm", + "build" + ], + "exitCode": 0, + "startedAt": "2026-09-29T17:46:17.343584+00:00", + "finishedAt": "2026-09-29T17:46:53.273027+00:00", + "logSha256": "1ccfe26fcfa853d1f89d0a16e5542cac9074e1a5ac29708763dd73c4072b7efc" + }, + { + "name": "full-typecheck", + "command": [ + "pnpm", + "-r", + "typecheck" + ], + "exitCode": 0, + "startedAt": "2026-09-29T17:46:53.273345+00:00", + "finishedAt": "2026-09-29T17:47:32.857318+00:00", + "logSha256": "d75eabf0e16eccc769550fbd228fbf30a915d00e4dbdd7ec4f96ec33afacc6ca" + }, + { + "name": "final-token-gates", + "command": [ + "pnpm", + "check:token-gates" + ], + "exitCode": 0, + "startedAt": "2026-09-29T17:47:32.858014+00:00", + "finishedAt": "2026-09-29T17:47:33.697535+00:00", + "logSha256": "7e234af112ebd5788a15cde62fc4e27c18997813fd4bfc23d0812d02af1fb33e" + } + ], + "repositoryTests": { + "command": [ + "pnpm", + "test:run" + ], + "exitCode": 1, + "passedTests": 11598, + "failedTests": 6, + "skippedTests": 2640, + "passedFiles": 536, + "failedFiles": 43, + "skippedFiles": 140, + "logSha256": "1d5f3789bfeab0461499354c171688ccb82128660bec756e159cbc0580bf7174", + "failureClasses": [ + { + "class": "embedded_postgres_bootstrap", + "failedCases": 4, + "detail": "The four cases fail before assertions at initdb after five attempts. Many other files fail setup or cleanup. This log has generic bootstrap errors; a separate exact-initdb reproduction records semget ENOSPC." + }, + { + "class": "unclassified_timeout", + "failedCases": 2, + "detail": "Authenticated Workspace docs.read times out at 15 seconds; real Git filename snapshot streaming times out at 300 seconds. No passing retry is credited." + } + ], + "completeGreenRun": false + }, + "runnerVerification": { + "status": "all_stages_pass_across_retained_retries", + "singleUninterruptedVerifyPassed": false, + "baseBuildTypecheckReplayTypescriptRustSdk": { + "logSha256": "1c3d8ce9bce225ed345ea7567a64065357c26b7853f2ac7585fd9244dfdef9e6", + "typescriptPassed": 2533, + "typescriptSkipped": 11, + "sdkPassed": 22, + "note": "The original aggregate invocation stopped at one scenario fixture failure. All preceding build, typecheck, replay, TypeScript, Rust and SDK stages passed; the repaired scenario stage passed below." + }, + "scenarios": { + "passed": 537, + "skipped": 3, + "logSha256": "54a62a3eceff47c4a6c617ed1f3d11b7a49cc8f02f572e8974208b94e951d9f6" + }, + "browser": { + "passed": 25, + "logSha256": "c2eb80b3b7352b204842b16b2a8284619cefdfd60a329da6d68b903128e76090" + }, + "browserSdk": { + "passed": 6, + "logSha256": "7c99f3c6e2558952ecbf7126f12be35e5748720a6a6aeba789fa68e245cf8964" + }, + "browserScenarioExplorerAndChat": { + "passed": 43, + "logSha256": "9880ec250725c7c69a109df764ade0e5b509cd61ebc8a10229a2959d44f327c1", + "note": "The first 43-case suite passed; the following issue-thread suite in that same command failed two stale expectations. Its repaired complete retry is separate below." + }, + "browserIssueThread": { + "passed": 112, + "logSha256": "827bb9f827936eb60c24cabd3064ef332af664770b978031734920d0de44cafb" + }, + "importScannerTests": { + "passed": 19, + "logSha256": "f0954fe6f478ada7044ebed789c15b9de855b420d4809222441533a0905dc35f" + }, + "importAndPackageGates": { + "forbidden": "passed", + "tracked": "passed", + "packageBoundary": "passed", + "receiptsSha256": [ + "5014d7931025f522ce522e76ee4fb27d7b4c82688c4db1e0f38cea5f41032954", + "3e6ba97dfd9d87781c1b3d9fc7b5e7713ea90a25943a0a8c8d6147167bfb1633" + ] + }, + "cleanConsumer": { + "name": "check:clean-consumers", + "startedAt": "2026-09-29T18:48:20.499452+00:00", + "finishedAt": "2026-09-29T18:50:25.705111+00:00", + "exitCode": 0, + "logSha256": "facbaee578adf24b76907c2e36114dca8f1e0389d04672f9fd5903fd6ae77de6" + }, + "docs": { + "files": 53, + "status": "passed", + "logSha256": "75d9bdc5d43cc60d514388e0e17091054d321d1ebebb089203e3563272bece27" + }, + "otherPassedStages": [ + "check:browser-tokens", + "check:numbered-milestones", + "check:conformance-parity", + "check:replay-parity", + "trace:conformance", + "replay:fixture", + "trace:local-runner" + ], + "otherStageReceiptSha256": "024be9b0a264f97d46ecaa724e72fed17ffd3c6380303dbfdc6a4596db2d5f56" + }, + "qualificationBlockers": [ + "Remaining native/local/Daytona workflows and macOS x64 execution coverage; Pi final-message aggregation failure is under repair.", + "A complete green repository test run is still missing; the retained full run failed and has not been repeated after host recovery.", + "Shared prerequisite CI has a failed server job; its bounded retry was canceled. Current final-head CI/review remains required.", + "Complete infrastructure cost coverage remains unavailable; native per-run USD is unpriced for the subscription providers." + ], + "hostRecovery": { + "status": "complete", + "removedStaleSets": 4892, + "skippedBecauseCreatorPidPresent": 11, + "startupAndGracefulShutdown": "passed", + "currentPaidCleanup": "Cursor hello and Copilot file each returned to the 255-set baseline", + "frozenRuntimeModified": false + }, + "sharedPrerequisiteCi": { + "recordedAt": "2026-09-29T20:26:11.895912+00:00", + "sourceRevision": "11da146c3c39aea5605fdfac31e619fd8024227b", + "jobId": 109571559222, + "jobConclusion": "failure", + "testStepConclusion": "cancelled", + "classification": "runner_shutdown_during_test_step", + "evidence": "19:09:17Z: The runner has received a shutdown signal; 19:09:29Z: The operation was canceled.", + "logSha256": "cb927b9d77810c5c38f9d5ff22adf3f1daa8f5d9bc35f967c330eb8d2ee6b8cf", + "newTestFailureEstablished": false, + "additionalRetryRequested": false + }, + "sourcePatchVerification": [ + { + "source": "627451ecf15641504a485c6f868497177bd1bce8", + "scope": "Cursor cold-session admission repair and prior shared integration", + "focusedTests": 94, + "runnerTypecheck": "passed", + "sidecarAndProviderPackBuild": "passed", + "daemonBuild": "passed; bytes unchanged", + "paidQualification": "Four cells/seven runs completed: question, plan and controller-restart passed; file case failed exact final response5/7 despite correct file bytes and successful task/run." + } + ], + "incrementalIntegrationChecks": { + "copilotProjectionSidecarTests": 57, + "mergedPiCopilotProductAndCatalogTests": 77, + "daytonaImageTests": 8, + "runnerTypescriptBuild": "passed", + "productTypecheck": "passed after rebuilding stale runner declaration output", + "typescriptBuildLogSha256": "41a44b38a17d1b6a23ccfb51891372f3da9c9bafc94230771bee103baea9e2f1", + "productTypecheckLogSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "note": "Targeted integration checks, not a new full-suite or final-source qualification." + }, + "latestIncrementalValidation": { + "source": "65b19549e0aac0cc7809142be9f33fb63e0a634c", + "fullRepositoryGates": "pending_on_new_source", + "runnerTypeScript": { + "passed": 389, + "skipped": 1, + "failed": 0, + "filesPassed": 13, + "logSha256": "aca4d7ae2b05c06674dfae5e492434ce36fcefc8f1c5082fd753cd04d107ca11" + }, + "rustAcpxUnits": { + "passed": 30, + "failed": 0 + }, + "serverModeRecovery": { + "passed": 19, + "skipped": 549, + "failed": 0 + }, + "runnerTypeScriptBuild": "passed", + "serverTypecheck": "passed", + "exactLockedAcpxPackageContracts": { + "passed": 22, + "failed": 0, + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "sdkVersion": "1.4.0", + "logSha256": "a3e60463b4bab0541aadd7f0dda3db91c3013137cf3828f8fb92b817e008d37a" + }, + "scope": "Incremental checks only. Historical broad test failure remains failed; fresh source still requires full repository gates and authenticated qualification." + } +} diff --git a/doc/architecture/runner-rich-acp-validation-2026-09-30-current.json b/doc/architecture/runner-rich-acp-validation-2026-09-30-current.json new file mode 100644 index 0000000000..bfee2d157d --- /dev/null +++ b/doc/architecture/runner-rich-acp-validation-2026-09-30-current.json @@ -0,0 +1,739 @@ +{ + "schema": "paperclip-runner-rich-acp-validation-checkpoint/v1", + "status": "current_candidates_not_qualified", + "preparedAt": "2026-09-30T06:58:25.927541+00:00", + "artifactPathBase": "campaign artifact root; relative references are evidence identifiers, not public download URLs", + "preserveHistoricalEvidence": { + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "originalFailuresRemainFailures": true, + "existingCapabilityMatrixPreserved": true + }, + "identities": { + "controllerSourceRevision": "ca7026182c2b861e3badbcb7e5b733445a6ee403", + "harnessSourceRevision": "ca7026182c2b861e3badbcb7e5b733445a6ee403", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "profiles": { + "cursor": 6, + "copilot": 6, + "pi": 9 + }, + "runtimeBuildsRelabeled": false + }, + "verification": { + "ci": { + "sourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "success": 53, + "skipped": 2, + "failed": 0, + "pending": 0, + "receipt": { + "path": "runtime-6-6-9-eof-preparation/pr14633-415-ci-final.json", + "sha256": "e03ce62df53f739ad8967df07dfe78d9253783862a815dfae2d7b914f801edf4" + } + }, + "greptile": { + "sourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "score": "5/5", + "receipt": { + "path": "runtime-6-6-9-eof-preparation/pr14633-415-greptile-final.json", + "sha256": "fcda878d5bb7617a6ce04852757a37e315448746ac5c5b609faa454dcd000660" + } + }, + "evalDefinitions": { + "pr": 33, + "state": "merged", + "mergeCommit": "7e416976800c799b5b920ec79d6027da5230644d", + "receipt": { + "path": "runtime-6-6-9-eof-preparation/evals-pr33-merge-receipt.json", + "sha256": "3130b74720c95a1451189fa238b1eda5b9f6ba3ab5e6364592a6fb1b83e9b729" + } + }, + "local": { + "typecheck": "passed", + "typecheckReceipt": { + "path": "runtime-6-6-9-eof-preparation/full-checks-415/typecheck.meta.txt", + "sha256": "9687c8d07a1c6c6f5ba059a70e6b7ce7640bb747cb4a769a9db52a9928b929ba" + }, + "tests": "original_full_command_failed; separate resumed coverage finished; no passing full invocation", + "diagnosis": "Original failures remain failed. Approved unchanged isolated cases passed. Workspace B direct invocations included generated duplicates; two legacy ACPX diagnostic failures passed against the separately verified corrected frozen dependency closure.", + "fullBuild": "passed", + "fullLocalPass": false, + "tokenGates": "passed", + "initialGeneralServer": { + "filesPassed": 713, + "filesFailed": 3, + "filesSkipped": 4, + "testsPassed": 14200, + "testsFailed": 4, + "testsSkipped": 70 + }, + "initialWorkspaceA": { + "uiTestsPassed": 6967, + "cliTestsPassed": 500, + "cliTestsFailed": 2 + }, + "unchangedTargetedReruns": { + "serverFailuresPassed": 4, + "cliTimeoutsPassed": 2, + "codexSourceAuthTimeoutPassed": 1, + "serializedImportFailuresPassed": 2, + "serializedCacheBoundPassed": 1 + }, + "originalRunReceipt": { + "path": "runtime-6-6-9-eof-preparation/full-checks-415/test-run-supported.meta.txt", + "sha256": "77709ae1e56b5389143b9598463e1ecaeb90404559d1c528aa85b0eef503098b" + }, + "resumedWorkspaceB": { + "observedPassedIncludingGeneratedDuplicates": 4490, + "observedSkipped": 31, + "observedFailed": 7, + "sourceFailures": 3, + "sourceFailureDetails": [ + "Two legacy ACPX long-diagnostic failures from stale patch metadata; fresh corrected frozen install passes.", + "One Codex auth-merge timeout; unchanged isolated source-only case passes." + ], + "manualInvocationGeneratedDuplicateFailures": 4, + "stableWrapperAlreadyExcludesDist": true, + "countsAreNotDeduplicatedSourceCoverage": true + }, + "serialized": "Original command stopped in file 39 of 149. Both failed import cases passed unchanged in isolation. All 110 unrun suites then completed separately: 109 files passed, 1 failed; 2,046 tests passed, 1 failed. The remaining cache-bound case passed unchanged in one narrow rerun.", + "dependencyClosure": "Existing source415 worktree installation differed from checked-in ACPX patches; corrected frozen-install evidence is separate.", + "sourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "resumedSerialized": { + "filesPassed": 109, + "filesFailed": 1, + "testsPassed": 2046, + "testsFailed": 1, + "testsSkipped": 0, + "failure": "Cache expected 256 entries but observed 248 after a 5.484s suite against a 5s TTL; unchanged isolated rerun passed.", + "fullInvocationPass": false + }, + "finalVerificationRecord": { + "path": "runtime-6-6-9-eof-preparation/full-checks-415/verification-record.json", + "sha256": "16731e6e2d45a34876c957dfb863cb54ab3e747170a74bc161e515a607820a77" + }, + "finalCleanSourceReceipt": { + "path": "runtime-6-6-9-eof-preparation/full-checks-415/source-clean-receipt.txt", + "sha256": "978fbb8e7ef99557759639446bf277d5f16d2aae2d717a3fed13f606f86dc884" + }, + "summaryReceipt": { + "path": "runtime-6-6-9-eof-preparation/full-checks-415/qualification-summary.md", + "sha256": "4f0205c9f9ff1879c3c9bb8047d17f9073933923312386afeb1ed64434a02315" + } + }, + "lockMetadataRepair": { + "receipt": { + "path": "acpx-lock-metadata-81c20ad/verification-receipt.json", + "sha256": "cab86c2725a0b7ddde34a4f8cb18e8789d5aa1b4aa03890ba31d60719d29dcaa" + }, + "frozenInstall": "passed", + "vendorBytesVerified": true, + "diagnosticCasesPassed": 4, + "packageContractsPassed": 25, + "dependencyVersionsChanged": false, + "nativeRuntimeBytesChanged": false, + "revertedSourceCommit": "9d27311635036ea54c9d78c01cc1d9c9489eb7e8", + "sourceRestoreCommit": "788e3b88ffe0e66e632fc2811087036dbb756f49", + "status": "verified_private_overlay_only_source_lock_restored", + "policy": "The repository refresh bot owns pnpm-lock.yaml. The pull request restores the exact prior bytes; the verified hash-only overlay is private qualification-install evidence.", + "privateOverlaySha256": "a54b018124b93fabf8e0aac36d8d3d4996b36e23fea8b74c6d0fb093ae596e85" + }, + "latestParentCiBeforeFixtureFixes": { + "sourceRevision": "6b1e96af0fc817f9ee4def1de94916503f339575", + "success": 49, + "skipped": 2, + "failed": 4, + "pending": 0, + "receipt": { + "path": "runtime-6-6-9-eof-preparation/pr14633-6b1-ci-before-fixture-fixes.json", + "sha256": "0eeac5e0ff847fac404e63f1051920757d6de23b80535dd1e16d24fdd687160b" + }, + "correctionCommit": "f401b831f2bf39b149821ab86bf2dfe55091765b", + "targetedEvidence": { + "path": "ci-stability-6b1e96af/handoff.receipt.json", + "sha256": "dcd2bd549b61b4db3765c809b24dbd8b92c470040971a5f8aa1b36284b05ca9d" + }, + "targetedResults": "26 route tests passed; attachment case passed with real announcement dismissal; no assertion or timeout relaxation. Subsequent aec34c CI completed with 53 successes and two skips.", + "unconfirmedOtherBranchFailure": "Cursor branch runtime-exposure firstcase passed unchanged under local instrumentation; CI stall cause is unconfirmed, no runtime change." + }, + "latestParentCiAfterFixtureFixes": { + "sourceRevision": "aec34c693f74fd27b97fa9d2e3862cbedbeef2d0", + "success": 53, + "skipped": 2, + "failed": 0, + "pending": 0, + "receipt": { + "path": "runtime-6-6-9-eof-preparation/pr14633-aec34-ci-final.json", + "sha256": "1ea6c3a4018350e75c76db7fd69e355a905181f4b7ae02060439f63a1837ffaa" + }, + "doesNotQualifyNewRuntimeCandidates": true + }, + "combinedCandidateDriverProfiles": { + "sourceRevision": "ceaf3d7fecefd2f5ae8d460c1812e8ccd563056d", + "testsPassed": 97, + "filesPassed": 2, + "purpose": "Verify both provider changes after retaining adjacent test additions during rebase.", + "receipt": { + "path": "runtime-6-6-9-eof-preparation/combined-7-7-9-driver-profile-tests.log", + "sha256": "6d81472044b2e7397da3bcbd6417891c7d1fdbe1e7ae46b4c754174ca6874009" + } + }, + "evalDefinitionsPending": { + "pr": 34, + "sourceRevision": "08ae9d4a231e52fc54af0821564cded3d3ec7f37", + "profiles": { + "cursor": 7, + "copilot": 7, + "pi": 9 + }, + "status": "draft", + "deterministicTestsPassed": 136, + "selectedCellsValidated": 21, + "providerCalls": 0 + } + }, + "platformBuilds": [ + { + "platform": "darwin-arm64", + "sourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "built_not_fully_qualified", + "receipt": { + "path": "combined-local-e822b614f/build-provenance.json", + "sha256": "48a57e0fa5961696ed29044a3ff8edba3e0dfb26c2b9a8663ded1c22de195d41" + } + }, + { + "platform": "darwin-x64", + "sourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "built_not_fully_qualified", + "receipt": { + "path": "runtime-6-6-9-eof-preparation/platform/macos-x64-e822b614f-v6-build/build-provenance.json", + "sha256": "fe435c765ffb0f74d0619ded9f2d0c125157671b01b9a6f502dab94812812354" + } + }, + { + "platform": "linux-x64", + "sourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "built_not_fully_qualified", + "receipt": { + "path": "runtime-6-6-9-eof-preparation/platform/linux-e822b614f-v6-build/build-provenance.json", + "sha256": "f8b7c252f1cb683b704982b88bb8b5459c221ad9a45cad657f6cb48ffa1c0dfc" + } + } + ], + "paidObservations": [ + { + "provider": "cursor", + "profile": 6, + "cell": "extended-harnesses.runner-acpx-cursor.daytona.hello-complete", + "controllerSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "harnessSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "root_reconciled_with_supervisor_tail_failure", + "productBehaviorPassed": true, + "matcherCount": 6, + "rootReceipt": { + "path": "runtime-6-6-9-eof-preparation/daytona-basic-v2/e822-cursor-remote-01/root-reconciliation.private.json", + "sha256": "6fc00b657a27090a3e5bad46016dcd1aac3347f71a88165ffe9e1a23aabd4320" + }, + "originalResult": { + "path": "runtime-6-6-9-eof-preparation/daytona-basic-v2/e822-cursor-remote-01/result.json", + "sha256": "36d28b7955481dde72767a863e3e5b5f4d767c22aaecda74f35b3940b8c47cf9" + }, + "originalSupervisorTailFailureRetained": true, + "cleanupAndEndAuditReconciled": true, + "newModelRetryForReconciliation": false, + "accountObservedChargeUsd": 0, + "nativePerRunUsd": null, + "infrastructureUpperBoundUsd": 0.086931, + "infrastructureAnalyticsFinal": false, + "providerQualified": false + }, + { + "provider": "copilot", + "profile": 6, + "cell": "copilot-protection.runner-acpx-copilot.local.native-permission-deny-write", + "controllerSourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "harnessSourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "root_reconciled_after_reader_correction", + "productBehaviorPassed": true, + "matcherCount": 10, + "caseQualified": true, + "providerQualified": false, + "rootReceipt": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v2/controller415-copilot-deny-01/root-reconciliation.private.json", + "sha256": "3d273539d36269c2591fda9f117b0f48a0a4dabc70b97131ba5d2e272adaae21" + }, + "originalResult": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v2/controller415-copilot-deny-01/result.json", + "sha256": "8c169c418575ed12215fd758b578940bb458169ba9a169b305c60690e37692d7" + }, + "additiveReaderCorrection": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v3/retained-product-revalidation.json", + "sha256": "850a105a43f50444e3c144e12fb9967300c27367a0e2ed4b5992b61be39639b2" + }, + "originalSupervisorPassed": false, + "originalReceiptsModified": false, + "newModelCallForReaderCorrection": false, + "accountIncludedCreditsBefore": 15, + "accountIncludedCreditsAfter": 15, + "postingMayLag": true, + "accountObservedChargeUsd": 0, + "additionalChargeUpperBoundUsd": 0, + "nativePerRunUsd": null, + "cleanupAndEndAuditPassed": true + }, + { + "provider": "cursor", + "profile": 6, + "cell": "cursor-native.runner-acpx-cursor.local.native-plan-reject-revise-accept", + "controllerSourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "harnessSourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "failed_controller_settlement_repair_pending_paid_requalification", + "productBehaviorPassed": false, + "caseQualified": false, + "providerQualified": false, + "nativeBridgeChecksPassed": 21, + "observedBridgeBehavior": [ + "native_plan_display", + "rejection_delivery", + "feedback", + "revised_plan", + "acceptance_delivery" + ], + "rootReceipt": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v4/controller415-cursor-plan-01/root-failed-reconciliation.private.json", + "sha256": "28cd3097459e79825850786279eb842541c89c7c67ac82c4785c53808d3cd1bd" + }, + "originalResult": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v4/controller415-cursor-plan-01/result.json", + "sha256": "9cbfa84182790c3d5124da9c0981ab56d009a05a96ae31dd9928c6c484cea0e0" + }, + "bridgeChecks": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v4/controller415-cursor-plan-01/product-results/cursor-native/runner-acpx-cursor/local/native-plan-reject-revise-accept/attempt-1/snapshots/cursor-native-checks.json", + "sha256": "461577b0459b56c42d5a68648b7f31538ec0e71787ab8391ff6703411dac1704" + }, + "terminalSnapshot": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v4/controller415-cursor-plan-01/product-results/cursor-native/runner-acpx-cursor/local/native-plan-reject-revise-accept/attempt-1/snapshots/api-state.json", + "sha256": "db372b34c3e9c64252d901746630b62412e9a59b4d7881b7e4e91f53b6075e54" + }, + "nativeTerminal": { + "eventType": "turn.completed", + "seq": 222, + "status": "completed", + "error": null, + "sourceSeq": 196 + }, + "controllerFailureLabel": "native_session_interrupted", + "controllerError": "native_finalization_missing: session returned no semantic result", + "interpretation": "Native planning ended normally. ACP plan acceptance does not start the interactive CLI Agent-mode execution turn. A separate controller fix preserves an in-progress passive wait for explicit task continuation; deterministic checks do not change this original failed case.", + "cleanupAndEndAuditPassed": true, + "automaticRetries": 0, + "nativePerRunUsd": null, + "accountObservedChargeUsd": 0, + "terminalDiagnosis": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/cursor-plan-terminal-diagnosis/evidence.json", + "sha256": "6f9fd527aecee6e79546a264d41f1d79e171cbe5d15af31f2587d05d7fddb7c2" + }, + "checkpointTerminal": { + "runTerminalState": "succeeded", + "turnTerminalState": "completed", + "reportedWorkDisposition": "yielded", + "mode": "plan", + "semanticResult": null + } + }, + { + "provider": "copilot", + "profile": 6, + "cell": "copilot-protection.runner-acpx-copilot.local.attached-async-settlement", + "controllerSourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "harnessSourceRevision": "41503eb38f03c436b1569f9f0204625bb4d58782", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "failed_supervision_not_qualified", + "productBehaviorPassed": false, + "caseQualified": false, + "providerQualified": false, + "nativeTurnAccepted": true, + "completedAttachedCommandBehaviorProven": false, + "rootReceipt": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v4/controller415-copilot-async-01/root-failed-reconciliation.private.json", + "sha256": "81f3eb82e51179d135bedc3b984c436a2e2f6503242858d0f7fffa8b8665a4c9" + }, + "originalResult": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/local-single-v4/controller415-copilot-async-01/result.json", + "sha256": "a12c689794308c987fe5053e48c75ef79bb7775fe90783e982e4139c7186e246" + }, + "diagnosis": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/copilot-async-failure-diagnosis/evidence.json", + "sha256": "5981b70003718c7dc306c0e800f31bec4a2193873abc44c58292dd4d38096dc7" + }, + "postAttemptPackRestoration": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/copilot-async-copy-cleanup/receipt.json", + "sha256": "b93dc16050c316113fd62dc590bebd0aa5a60fbfbb6a4a80c787e5abc235e2e0" + }, + "originalIpcObservationComplete": false, + "originalFullEndAuthorityPassed": false, + "laterPackAuthorityRestored": true, + "originalFailuresPreserved": true, + "ownedProcessesRetired": true, + "automaticRetries": 0, + "nativePerRunUsd": null, + "accountIncludedCreditsBefore": 15, + "accountIncludedCreditsAfter": 15, + "postingMayLag": true, + "additionalChargeUpperBoundUsd": 0 + }, + { + "provider": "copilot", + "profile": 6, + "cell": "copilot-protection.runner-acpx-copilot.local.attached-async-settlement", + "sourceRevision": "81c20ad22723a2d35d7bd693cc6a47002fb46125", + "controllerSourceRevision": "81c20ad22723a2d35d7bd693cc6a47002fb46125", + "harnessSourceRevision": "81c20ad22723a2d35d7bd693cc6a47002fb46125", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "failed_command_match_and_final_marker_native_message_boundary_under_investigation", + "caseQualified": false, + "providerQualified": false, + "taskDoneObserved": true, + "completedAttachedCommandBehaviorProven": false, + "rootReceipt": { + "path": "runtime-6-6-9-eof-preparation/controller-81c20ad/revision-01/local-single-v1/controller81-copilot-async-01/root-failed-reconciliation.private.json", + "sha256": "d970fca92c4184342f69136140bae2720a02f706dd76fd116d7772f215f8d18c" + }, + "originalResult": { + "path": "runtime-6-6-9-eof-preparation/controller-81c20ad/revision-01/local-single-v1/controller81-copilot-async-01/result.json", + "sha256": "0a0cead164531cf56349e2b6851e86dd19b84dc290f84d6db365656f0f50c2ed" + }, + "ownedProcessesRetired": true, + "ipcObservationComplete": true, + "fullEndAuthorityPassed": true, + "accountIncludedCreditsBefore": 15, + "accountIncludedCreditsAfter": 16, + "postingMayLag": true, + "additionalChargeUpperBoundUsd": 0, + "nativePerRunUsd": null, + "automaticRetries": 0, + "commandDiagnosis": "One leading ASCII space changes the raw digest. A later fixture-only commit admits at most eight leading SPACE/TAB bytes with a verified raw-to-canonical relation. The historical case is not regraded.", + "settlementEvidenceLimit": "The first assertion preceded independent marker/child/client observations and the post-cleanup marker reread. Native shell exit before terminal and successful process cleanup do not reconstruct the missing proof.", + "finalMarkerDiagnosis": "Two provider text-delta bursts bracket shell completion; one persisted final comment concatenates both. Original native message boundaries are absent from retained metadata. No equal-text deduplication is inferred.", + "offlineDiagnosis": { + "path": "runtime-6-6-9-eof-preparation/controller-81c20ad/revision-01/copilot-async-command-diagnosis/offline-revalidation-and-proposal.json", + "sha256": "fcdb5a3d3601159bca484b3ec26a69a9e914482ef8ddeb4a2012691455ca1fb6" + }, + "assistantBurstOrigin": { + "path": "runtime-6-6-9-eof-preparation/controller-81c20ad/revision-01/copilot-async-command-diagnosis/assistant-burst-origin.json", + "sha256": "2c9689f36834de163c87d996486aec488f22d419f049ea557fd6f7c6ac691308" + }, + "fixtureFollowup": { + "commit": "c9a0aaffa19510ab4c29924c7097d09121f34b81", + "targetedTestsPassed": 97, + "productTypecheck": "passed", + "rawNativeDigestPreserved": true, + "prefixCandidates": 511, + "historicalCaseRegraded": false, + "capturesIndependentEvidenceBeforeCommandAssertion": true, + "finalMarkerAssertionUnchanged": true + } + }, + { + "provider": "cursor", + "profile": 6, + "cell": "extended-harnesses.runner-acpx-cursor.local.native-plan-reject-revise-accept", + "controllerSourceRevision": "ca7026182c2b861e3badbcb7e5b733445a6ee403", + "harnessSourceRevision": "ca7026182c2b861e3badbcb7e5b733445a6ee403", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "status": "failed_native_plan_tool_identity_lost_before_controller_settlement", + "caseQualified": false, + "providerQualified": false, + "nativeBridgeChecksPassed": 21, + "workspaceBefore": {}, + "workspaceAfter": {}, + "runCount": 1, + "runStatus": "failed", + "issueStatus": "in_progress", + "nativeTerminal": "turn.completed / completed / no error", + "controllerFailure": "native_finalization_missing / native_session_interrupted", + "eventOrder": [ + { + "sourceSeq": 275, + "type": "runtime_request.created" + }, + { + "sourceSeq": 294, + "type": "tool.execution.started" + }, + { + "sourceSeq": 300, + "type": "runtime_request.resolved" + }, + { + "sourceSeq": 303, + "type": "tool.execution.completed" + }, + { + "sourceSeq": 304, + "type": "turn.completed" + } + ], + "rootReceipt": { + "path": "runtime-6-6-9-eof-preparation/controller-ca7026182/revision-02/local-single-v1/ca702-cursor-plan-01/root-failed-reconciliation.private.json", + "sha256": "cff8f781bf31253b6b89251d0b66cca57c0bf51e5dd75d7df574178b0c9eed6a" + }, + "originalResult": { + "path": "runtime-6-6-9-eof-preparation/controller-ca7026182/revision-02/local-single-v1/ca702-cursor-plan-01/result.json", + "sha256": "d888daf34caa0e1bc3fc197375bb8e0f3db74cb64ac2f745f0ce7ed73cb8c0a4" + }, + "diagnosis": { + "path": "runtime-6-6-9-eof-preparation/controller-ca7026182/revision-02/local-single-v1/ca702-cursor-plan-01/diagnosis.json", + "sha256": "0560e84990d279a3f00a6b111f581ed3f81bfe46ce8d6a5d4e8e7db1955d9d41" + }, + "nativeChecks": { + "path": "runtime-6-6-9-eof-preparation/controller-ca7026182/revision-02/local-single-v1/ca702-cursor-plan-01/product-results/cursor-native/runner-acpx-cursor/local/native-plan-reject-revise-accept/attempt-1/snapshots/cursor-native-checks.json", + "sha256": "65c4807fba8b3458cf22cd8d2a2895f2a53cc1df480802ba979d8268dcf25798" + }, + "fullEnd": { + "path": "runtime-6-6-9-eof-preparation/controller-ca7026182/revision-02/local-single-v1/ca702-cursor-plan-01/full-end-authority.json", + "sha256": "5fdb079934188355d88caf12228456a01d7174cfe14420a53538ee3587067c3a" + }, + "ownedProcessesRetired": true, + "ipcObservationComplete": true, + "fullEndAuthorityPassed": true, + "elapsedSeconds": 51.229, + "automaticRetries": 0, + "nativePerRunUsd": null, + "observedCursorOnDemandUsd": 0, + "postingMayLag": true, + "historicalCaseRegraded": false, + "followup": "Cursor7 carries validated originating tool identity to request.itemId; exact successful lifecycle is required. Paid requalification is pending." + } + ], + "pi": { + "profile": 9, + "qualification": "pending", + "optionalBudgetCandidateRevision": "6396fd744c2bf2fbb70c4e40a3d40d026b662ba3", + "candidateStatus": "frozen_unintegrated_not_live_qualified", + "candidateReceipt": { + "path": "runtime-6-6-9-eof-preparation/pi-spend-boundary-review-02/candidate.json", + "sha256": "9a6d8c86b17ed35a97925475928e1353dc7122e109db7fafd4262622a2d10599" + }, + "preferredSpendBoundary": "provider_capped_key", + "providerCappedKey": "unresolved", + "budgetIntegrationReview": { + "path": "pi-budget-integration-review/decision.md", + "sha256": "864fef69bdfc27d1fb387c2b4d518eabf6829061c7c576f2f1ea0d0274b7a313" + }, + "candidateLimit": "Source review found missing production allocation, launch scope, warm rotation, package closure and Daytona transport. Keep frozen; capped key remains the qualification prerequisite." + }, + "overallProviderQualification": { + "cursor": false, + "copilot": false, + "pi": false + }, + "budget": { + "combinedAuthorizationUsd": 100, + "providerAllocationsUsd": { + "cursor": 25, + "copilot": 25, + "pi": 25 + }, + "sharedReserveUsd": 25, + "knownTaskAttributedOpenRouterModelUsd": 0.040507624, + "conservativeCloudTotalUpperBoundUsd": 0.387846, + "nativeCursorCopilotPerRunUsd": null, + "failedAttemptsRetained": true, + "uncappedWholeKeyLifetimeUsageExcluded": true, + "attemptRecords": 46, + "activeReservations": 0, + "ledgerReceipt": { + "path": "budget-ledger.private.json", + "sha256": "2c77159d895e948b8d8b18b0cd8e9c15d53d0a94a2816ad2ff3a48cce0534981" + } + }, + "cursorSettlement": { + "pr": 14669, + "sourceRevision": "770dc88a11c830cc9ad32e9590448d826ec58c97", + "status": "cursor7_source_candidate_tested_not_built_or_paid_qualified", + "autoImplementation": false, + "modePromotion": false, + "taskDisposition": "in_progress_waiting_for_explicit_user_message", + "catalogUpgradeDoesNotWake": true, + "profileVersion": 7, + "commandDigest": "sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503", + "targetedTestsPassed": 228, + "runnerTypeScriptBuild": "passed", + "serverTypecheck": "passed", + "handoff": { + "path": "cursor7-plan-tool-binding-review/HANDOFF.json", + "sha256": "dec400c07a80457e65e2139758110e85c8b409ec765ad4ea6df729fbb018cc8c" + }, + "rootReview": { + "path": "cursor7-plan-tool-binding-review/root-review.json", + "sha256": "8916b1e197a5984849c21993d3c5c40a79923f54a8f46eef7acb2a0b2fd9ea9b" + }, + "historicalCursor6CommittedWaitsPreserved": true, + "newCursor6WaitAdmission": false + }, + "historicalReadiness415": { + "status": "root_reviewed_credential_free_pass", + "receipt": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/readiness-01/product-readiness.pending.json", + "sha256": "d9946bbab9881d50c46970aaaca9458d933c101a7d2fe28f8a58c770c23909e5" + }, + "rootReview": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/root-readiness-review.json", + "sha256": "371702b4363fbce6ed96fa823dc48b2566f441b7801250576fa6812f0df65d7f" + }, + "admission": { + "path": "runtime-6-6-9-eof-preparation/controller-41503eb/revision-02/readiness-01/admission-01/receipt.json", + "sha256": "d7188f37245290389271e6af913f52acf54248b1d15c7676ebad3cbed91423a6" + }, + "freshControllerAndHarness": true, + "oldReadinessReused": false, + "recoveryReady": true, + "ownedProcessesRetired": 37, + "ownedIpcRemaining": 0, + "providerQualified": false + }, + "readiness": { + "status": "root_reviewed_credential_free_pass_historical_cursor6_copilot6_pi9", + "controllerSourceRevision": "ca7026182c2b861e3badbcb7e5b733445a6ee403", + "harnessSourceRevision": "ca7026182c2b861e3badbcb7e5b733445a6ee403", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "receipt": { + "path": "runtime-6-6-9-eof-preparation/controller-ca7026182/revision-02/product-readiness.pending.json", + "sha256": "99b3bba9cbed5f3b5cff71663debb88739a4ac39f7258f4108e0e6d537a8ffd5" + }, + "rootReview": { + "path": "runtime-6-6-9-eof-preparation/controller-ca7026182/revision-02/root-launch-readiness-review.json", + "sha256": "e2cab22219829940bda3fd0f07b9362fa4c54c559293de8f6506a551ceb253a2" + }, + "freshControllerAndHarness": true, + "oldReadinessReused": false, + "ownedProcessesRetired": 31, + "ownedIpcRemaining": 0, + "providerQualified": false, + "notValidForProfiles7": true + }, + "copilotMessageIdentityCandidate": { + "pr": 14676, + "sourceRevision": "70b186b3c26a30a9f69f2d8bfd9e6024f9784e0f", + "profileVersion": 7, + "commandDigest": "sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526", + "status": "draft_source_candidate_not_built_or_paid_qualified", + "change": "Extract and patch verified owned inner distribution so ordered ACP start/delta/history updates preserve real native message IDs, including empty starts. Keep native executable unchanged and native IDs distinct even for identical text.", + "focusedJavaScriptTypeScriptTestsPassed": 49, + "runnerTypecheck": "passed", + "rustExactAdmissionTestsPassed": 1, + "nativeExecution": "ARM64 loopback only; three distinct equal-text native message IDs, empty starts and attached shell settlement", + "platformClosuresVerified": [ + "darwin-arm64", + "darwin-x64", + "linux-x64" + ], + "fullRepoGate": "pending", + "newPacksAndImages": "not built", + "paidQualification": "not run", + "originalPaidAsyncFailureRegraded": false, + "handoff": { + "path": "runtime-6-6-9-eof-preparation/copilot-message-identity-candidate/review-fixes-01/ROOT-REVIEW-HANDOFF.json", + "sha256": "6f85b0f8ff963edff6836906c0aa95cf72e146a1d07189b7ef21195d4d1c941d" + }, + "rootReview": { + "path": "runtime-6-6-9-eof-preparation/copilot-message-identity-candidate/review-fixes-01/root-review.json", + "sha256": "c781c30816159699aa4faa1d6b6746571fe893e336c18291f04c6b74567c9fce" + }, + "nativeLoopback": { + "path": "runtime-6-6-9-eof-preparation/copilot-message-identity-candidate/native-loopback-01.json", + "sha256": "fd44e4e3ccba573a45c867205f91e2dcce01d603ab59fe513e8c4278f0833a90" + }, + "integratedSourceRevision": "ceaf3d7fecefd2f5ae8d460c1812e8ccd563056d", + "originalSourceFocusedTestsPassed": 212, + "currentTestsMeaning": "23 Node materialization/rollback tests, 24 profile tests, two actual ACPX fresh/resumed transport tests; earlier native loopback remains separate unchanged-asset evidence.", + "prUpdate": { + "path": "runtime-6-6-9-eof-preparation/copilot-message-identity-candidate/review-fixes-01/pr-update-receipt.json", + "sha256": "de68251eafa25b4d6c3c9357399fbc78b3782618d5b81c50ecd590ba3d53f5ea" + }, + "reviewFixes": "Owned partial-output rollback preserves preexisting/replaced entries; replay IDs and empty-message starts are tested before fresh warm turns." + }, + "historicalController81Preparation": { + "controllerSourceRevision": "81c20ad22723a2d35d7bd693cc6a47002fb46125", + "harnessSourceRevision": "81c20ad22723a2d35d7bd693cc6a47002fb46125", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "change": "Replace a static UI demo string that matches the evidence secret detector; runtime source and profile pins unchanged.", + "readiness": { + "path": "runtime-6-6-9-eof-preparation/controller-81c20ad/revision-01/readiness-02/product-readiness.pending.json", + "sha256": "af88ba11f8db713faa5694d5714dd677b5bddbf822e254128f02c94eb3561e3f" + }, + "rootReview": { + "path": "runtime-6-6-9-eof-preparation/controller-81c20ad/revision-01/root-readiness-review.json", + "sha256": "90a0aeebb6a0195ddd640fb2dc84e2e2d1edbba65baae443178decaf159e0dc3" + }, + "providerCalls": 0, + "providerQualified": false + }, + "nextControllerPreparation": { + "profiles": { + "cursor": 7, + "copilot": 7, + "pi": 9 + }, + "status": "fresh_combined_runtime_pack_controller_catalog_and_readiness_pending", + "historicalEvidenceReusedAsQualification": false, + "paidAuthorized": false + }, + "historicalReadiness81": { + "status": "root_reviewed_credential_free_pass", + "controllerSourceRevision": "81c20ad22723a2d35d7bd693cc6a47002fb46125", + "harnessSourceRevision": "81c20ad22723a2d35d7bd693cc6a47002fb46125", + "runtimeSourceRevision": "e822b614fc368043f4b3d5e34a8d9bbda644e055", + "receipt": { + "path": "runtime-6-6-9-eof-preparation/controller-81c20ad/revision-01/readiness-02/product-readiness.pending.json", + "sha256": "af88ba11f8db713faa5694d5714dd677b5bddbf822e254128f02c94eb3561e3f" + }, + "rootReview": { + "path": "runtime-6-6-9-eof-preparation/controller-81c20ad/revision-01/root-readiness-review.json", + "sha256": "90a0aeebb6a0195ddd640fb2dc84e2e2d1edbba65baae443178decaf159e0dc3" + }, + "freshControllerAndHarness": true, + "oldReadinessReused": false, + "ownedProcessesRetired": 36, + "ownedIpcRemaining": 0, + "providerQualified": false + }, + "historicalCopilotMessageIdentityCandidate": { + "pr": 14676, + "sourceRevision": "8e104372f2e68b397dee7cfea0836ec085243f66", + "profileVersion": 7, + "commandDigest": "sha256:9f98bf11135944aa8df56635d5c2e24b6a073e23a752cdf86cb9c044bfea74a5", + "status": "draft_source_candidate_not_built_or_paid_qualified", + "change": "Extract and patch verified owned inner distribution so ordered ACP start/delta/history updates preserve real native message IDs, including empty starts. Keep native executable unchanged and native IDs distinct even for identical text.", + "focusedJavaScriptTypeScriptTestsPassed": 212, + "runnerTypecheck": "passed", + "rustExactAdmissionTestsPassed": 1, + "nativeExecution": "ARM64 loopback only; three distinct equal-text native message IDs, empty starts and attached shell settlement", + "platformClosuresVerified": [ + "darwin-arm64", + "darwin-x64", + "linux-x64" + ], + "fullRepoGate": "pending", + "newPacksAndImages": "not built", + "paidQualification": "not run", + "originalPaidAsyncFailureRegraded": false, + "handoff": { + "path": "runtime-6-6-9-eof-preparation/copilot-message-identity-candidate/pr-handoff-01.json", + "sha256": "b6bf8db199d946335e532b7202ea7e30e4e3a2a4c874b070ad800d1e9a48adb1" + }, + "rootReview": { + "path": "runtime-6-6-9-eof-preparation/copilot-message-identity-candidate/root-review-01.json", + "sha256": "f27fafdf88c89af3123a13f153dc87f17ba05a6c72b92ccbfb6a46de1791ea0c" + }, + "nativeLoopback": { + "path": "runtime-6-6-9-eof-preparation/copilot-message-identity-candidate/native-loopback-01.json", + "sha256": "fd44e4e3ccba573a45c867205f91e2dcce01d603ab59fe513e8c4278f0833a90" + } + } +} diff --git a/doc/architecture/runner-rich-acp-validation-2026-09-30.json b/doc/architecture/runner-rich-acp-validation-2026-09-30.json new file mode 100644 index 0000000000..9cc691344b --- /dev/null +++ b/doc/architecture/runner-rich-acp-validation-2026-09-30.json @@ -0,0 +1,849 @@ +{ + "schema": "paperclip-runner-rich-acp-validation-checkpoint/v1", + "status": "rolling_checkpoint_qualification_pending", + "artifactBase": "private artifact set paperclip-rich-acp-production-20260929", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "generatedAt": "2026-09-30", + "includedRootReconciledPaidCells": [ + { + "id": "cursor-01", + "status": "root_reconciled_pass", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "sourceRef": "qualification/current-profiles-5605", + "provider": "cursor", + "environment": "local", + "caseId": "hello-complete", + "profile": { + "id": "runner-acpx-cursor", + "version": 5, + "digest": "sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "runtimeMode": "native" + }, + "attempt": 1, + "runIds": [ + "4728607a-05f7-412f-8ca5-93a08f3a8527" + ], + "startedAt": "2026-09-30T01:10:00.361Z", + "finishedAt": "2026-09-30T01:10:39.730Z", + "duration": { + "caseDurationMs": 39369, + "supervisedRunDurationSeconds": 76.202 + }, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "matcherCount": 6, + "cleanup": { + "verifiedByRoot": true, + "exclusiveProductSlotReleased": true, + "remainingOwnedProcesses": 0, + "cleanupStatus": "stopped", + "screenshotReviewed": true + }, + "costProvenance": { + "coverage": "account_upper_bound", + "providerUsage": "included; no incremental on-demand charge", + "costUpperBoundUsd": 0, + "nativePerRunPrice": "unavailable" + }, + "evidence": { + "result": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-01/result.json", + "sha256": "68fa08c1c3522d1299d89a1c612dd6fbfd2a0cd050dffe8235cdfe62b355c754" + }, + "rootReconciliation": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-01/root-reconciliation.private.json", + "sha256": "eada0bec37de846fa10a65877fd196ef823792966dc8a6ce5d685e4a1352f189", + "status": "root_reconciled", + "observedAt": "2026-09-30T01:12:05.563827+00:00" + }, + "screenshots": [ + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-01/product-results/extended-harnesses/runner-acpx-cursor/local/hello-complete/attempt-1/final-state.png", + "sha256": "69a8b17fd3d8ae08afc1c2263069c5b56281dca6061d87bed53b2e994621416f", + "rootReviewed": true + } + ] + } + }, + { + "id": "cursor-02", + "status": "root_reconciled_pass", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "sourceRef": "qualification/current-profiles-5605", + "provider": "cursor", + "environment": "local", + "caseId": "question-resume-complete", + "profile": { + "id": "runner-acpx-cursor", + "version": 5, + "digest": "sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "runtimeMode": "native" + }, + "attempt": 1, + "runIds": [ + "cb5f4a0b-9c5e-4e5e-8fc4-6051b5beed4c", + "5272347e-996f-4627-bdd6-7cc9db784cd7" + ], + "startedAt": "2026-09-30T01:12:50.501Z", + "finishedAt": "2026-09-30T01:13:59.280Z", + "duration": { + "caseDurationMs": 68779, + "supervisedRunDurationSeconds": 99.186 + }, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "matcherCount": 6, + "cleanup": { + "verifiedByRoot": true, + "exclusiveProductSlotReleased": true, + "remainingOwnedProcesses": 0, + "cleanupStatus": "stopped", + "screenshotReviewed": true + }, + "costProvenance": { + "coverage": "account_upper_bound", + "providerUsage": "included; no incremental on-demand charge", + "costUpperBoundUsd": 0, + "nativePerRunPrice": "unavailable" + }, + "evidence": { + "result": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-02/result.json", + "sha256": "a402c35d9c003f8de6b67546cf170c77826e84b2bc1f1180d9756512ddcf1cd3" + }, + "rootReconciliation": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-02/root-reconciliation.private.json", + "sha256": "b2919a59a8bb1cd1cf3c7dda024a68abe99cfaa35a688744f912a6901b231970", + "status": "root_reconciled", + "observedAt": "2026-09-30T01:15:42.832650+00:00" + }, + "screenshots": [ + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-02/product-results/extended-harnesses/runner-acpx-cursor/local/question-resume-complete/attempt-1/question-pending.png", + "sha256": "463c225dee683ab3e563774b37fd3878337d5b7cadfd486d9fc4edacadb7d3f9", + "rootReviewed": true + }, + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-02/product-results/extended-harnesses/runner-acpx-cursor/local/question-resume-complete/attempt-1/final-state.png", + "sha256": "582309fce62e4d7f727233dbeabe9555204feb8db6338df84c0dfc909ea0aad1", + "rootReviewed": true + } + ] + } + }, + { + "id": "cursor-03", + "status": "root_reconciled_pass", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "sourceRef": "qualification/current-profiles-5605", + "provider": "cursor", + "environment": "local", + "caseId": "plan-approve-complete", + "profile": { + "id": "runner-acpx-cursor", + "version": 5, + "digest": "sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "runtimeMode": "native" + }, + "attempt": 1, + "runIds": [ + "3adc7e0c-27c5-4527-88be-825c0db85bbc", + "04e3efe4-6794-4868-abbd-33eeddd67782" + ], + "startedAt": "2026-09-30T01:17:42.745Z", + "finishedAt": "2026-09-30T01:18:51.884Z", + "duration": { + "caseDurationMs": 69139, + "supervisedRunDurationSeconds": 98.064 + }, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "matcherCount": 6, + "cleanup": { + "verifiedByRoot": true, + "exclusiveProductSlotReleased": true, + "remainingOwnedProcesses": 0, + "cleanupStatus": "stopped", + "screenshotReviewed": true + }, + "costProvenance": { + "coverage": "account_upper_bound", + "providerUsage": "included; no incremental on-demand charge", + "costUpperBoundUsd": 0, + "nativePerRunPrice": "unavailable" + }, + "evidence": { + "result": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-03/result.json", + "sha256": "68dd8f2b8f95cc81fa532f3286a7ca9f06291204391f3f035ed7cba46afbfece" + }, + "rootReconciliation": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-03/root-reconciliation.private.json", + "sha256": "b95cb93e3ab47cf155363be8b6111b592d9b3b66cc463c1059143947ddd143da", + "status": "root_reconciled", + "observedAt": "2026-09-30T01:21:11.176553+00:00" + }, + "screenshots": [ + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-03/product-results/extended-harnesses/runner-acpx-cursor/local/plan-approve-complete/attempt-1/plan-pending.png", + "sha256": "2eed6a09fea187742c1846947b2563016016990b753d5ece39d02cb74b18edb7", + "rootReviewed": true + }, + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-03/product-results/extended-harnesses/runner-acpx-cursor/local/plan-approve-complete/attempt-1/final-state.png", + "sha256": "3a3fa24da4a7558f481f981ea0cde8706b580b5d1fb3934cc6be67f0b7123278", + "rootReviewed": true + } + ] + } + }, + { + "id": "cursor-04", + "status": "root_reconciled_pass", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "sourceRef": "qualification/current-profiles-5605", + "provider": "cursor", + "environment": "local", + "caseId": "structured-question-restart-resume", + "profile": { + "id": "runner-acpx-cursor", + "version": 5, + "digest": "sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "runtimeMode": "native" + }, + "attempt": 1, + "runIds": [ + "41c0f689-8c7e-4d84-84c6-6736189e1b62", + "7a7710ae-01bd-4a35-891e-031b9d006bae" + ], + "startedAt": "2026-09-30T01:41:11.821Z", + "finishedAt": "2026-09-30T01:42:11.408Z", + "duration": { + "caseDurationMs": 59587, + "supervisedRunDurationSeconds": 72.36 + }, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "matcherCount": 6, + "cleanup": { + "verifiedByRoot": true, + "exclusiveProductSlotReleased": true, + "remainingOwnedProcesses": 0, + "cleanupStatus": "stopped", + "screenshotReviewed": true + }, + "costProvenance": { + "coverage": "account_upper_bound", + "providerUsage": "included; no incremental on-demand charge", + "costUpperBoundUsd": 0, + "nativePerRunPrice": "unavailable" + }, + "evidence": { + "result": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-04/result.json", + "sha256": "8da3a1a0aee98a2185906f648a6af90fb52efef536168f0ccdf168ff700c0ac8" + }, + "rootReconciliation": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-04/root-reconciliation.private.json", + "sha256": "e0bf9ccfdfde2bfb16e2c7d018750f3513c0ebad8f61abd00481e31186dc2fbe", + "status": "root_reconciled", + "observedAt": "2026-09-30T01:43:12.248240+00:00" + }, + "screenshots": [ + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-04/product-results/extended-harnesses/runner-acpx-cursor/local/structured-question-restart-resume/attempt-1/question-pending.png", + "sha256": "a4d4bc10ce66d3be5eca5a8164604ee3ce5c0ce367fd860a3471771d122a707a", + "rootReviewed": true + }, + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-04/product-results/extended-harnesses/runner-acpx-cursor/local/structured-question-restart-resume/attempt-1/question-pending-after-server-restart.png", + "sha256": "e3d8f46ab2d6ca2a97c319d6c35ee2120f6bdeea693ff7e154392e1765330429", + "rootReviewed": true + }, + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-04/product-results/extended-harnesses/runner-acpx-cursor/local/structured-question-restart-resume/attempt-1/final-state.png", + "sha256": "3019f52969cfc9f7d748111932744ddae7e7300ab5812ed5ba0a52672d12b1f4", + "rootReviewed": true + } + ] + } + }, + { + "id": "cursor-05", + "status": "root_reconciled_pass", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "sourceRef": "qualification/current-profiles-5605", + "provider": "cursor", + "environment": "local", + "caseId": "file-edit-validate", + "profile": { + "id": "runner-acpx-cursor", + "version": 5, + "digest": "sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "runtimeMode": "native" + }, + "attempt": 1, + "runIds": [ + "5ccfe41c-5856-40ea-ab3e-188b53e70d03" + ], + "startedAt": "2026-09-30T01:43:35.817Z", + "finishedAt": "2026-09-30T01:44:30.917Z", + "duration": { + "caseDurationMs": 55100, + "supervisedRunDurationSeconds": 66.805 + }, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + }, + { + "kind": "file_exact", + "passed": true + } + ], + "matcherCount": 7, + "cleanup": { + "verifiedByRoot": true, + "exclusiveProductSlotReleased": true, + "remainingOwnedProcesses": 0, + "cleanupStatus": "stopped", + "screenshotReviewed": true + }, + "costProvenance": { + "coverage": "account_upper_bound", + "providerUsage": "included; no incremental on-demand charge", + "costUpperBoundUsd": 0, + "nativePerRunPrice": "unavailable" + }, + "evidence": { + "result": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-05/result.json", + "sha256": "6561a436eda6f45539a8ac1dd11a50ae51e0b7370f95f38c8544b5004144fb75" + }, + "rootReconciliation": { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-05/root-reconciliation.private.json", + "sha256": "3460cfb6f300839b488cdf4dc4c2b61e51b7eece321da89059abfadb2e3e319f", + "status": "root_reconciled", + "observedAt": "2026-09-30T01:45:03.515329+00:00" + }, + "screenshots": [ + { + "path": "current-profiles-5605-campaign-preparation/current-5605-cursor-05/product-results/extended-harnesses/runner-acpx-cursor/local/file-edit-validate/attempt-1/final-state.png", + "sha256": "75940cbbc248ee23e9424dc7c04ed15dcc0083589afd541ef7793c6d502b1f46", + "rootReviewed": true + } + ] + } + }, + { + "id": "copilot-remote-02", + "status": "root_reconciled_pass", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "sourceRef": "refs/heads/codex/runner-acp-production", + "provider": "copilot", + "environment": "daytona", + "caseId": "hello-complete", + "profile": { + "id": "runner-acpx-copilot", + "version": 5, + "digest": "sha256:ece77e40876631a69a828b91813722001d71b6fc81be47ecd4b3dced84ff9473", + "model": "gpt-5.6-luna", + "runtimeMode": "native" + }, + "attempt": 1, + "runIds": [ + "19b3709c-3861-4997-bea1-781ccf55e0f8" + ], + "startedAt": "2026-09-30T01:32:33.450Z", + "finishedAt": "2026-09-30T01:33:56.309Z", + "duration": { + "caseDurationMs": 82859, + "supervisedRunDurationSeconds": 457.025 + }, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "matcherCount": 6, + "cleanup": { + "verifiedByRoot": true, + "exclusiveProductSlotReleased": true, + "remainingOwnedProcesses": 0, + "cleanupStatus": "observed_absent", + "screenshotReviewed": true + }, + "costProvenance": { + "providerUsage": "included with verified billing controls", + "infrastructureCost": "bounded, not zero", + "costUpperBoundUsd": 0.086931, + "nativePerRunPrice": "unknown; delayed analytics and full campaign-end authority pending" + }, + "evidence": { + "result": { + "path": "current-profiles-5605-daytona-preparation-v2/current-5605-copilot-remote-02/result.json", + "sha256": "ec51a5a679041cada551386eb7b9795dd8b76db7b607d1cb8774642b5619a4a5" + }, + "rootReconciliation": { + "path": "current-profiles-5605-daytona-preparation-v2/current-5605-copilot-remote-02/root-reconciliation.private.json", + "sha256": "3cfb9f58a9401d3b75f12c4a0437bd6ec073d07378827116447ba3fd4c0975a6", + "status": "root_reconciled", + "observedAt": "2026-09-30T01:40:43.538417+00:00" + }, + "screenshots": [ + { + "path": "current-profiles-5605-daytona-preparation-v2/current-5605-copilot-remote-02/product-results/extended-harnesses/runner-acpx-copilot/daytona/hello-complete/attempt-1/final-state.png", + "sha256": "8aa26e2004fd4e4bf20a2a5ee32a726c1ec065bfa3d8a916ab7b0942107d5d79", + "rootReviewed": true + } + ] + } + } + ], + "verifiedBuildIdentities": [ + { + "platform": "macos-arm64", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "status": "build_complete_not_qualified", + "provenance": { + "path": "combined-local-5605c350b/build-provenance.json", + "sha256": "9bb5fd97b217b966e5fc708b3f9bbab71a4d8eff27ee7c7e91046fc43f4b44ae" + }, + "providerPackDigest": "sha256:3940fbe1c7b197b964b8f8182c1f1ae548cff822a95ecb4539e0ca851b4f2032", + "providerPackManifestSha256": "88cd3c55cd7b3c0fe14cd37acb18fbaa962b1ec5f8d500049d75e09cb12ded06", + "daemonSha256": "2ca711268d04cf34a9924faf7948eb964acfef54f40156e0437eae7815cfc63f" + }, + { + "platform": "linux-x64", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "status": "built_not_qualified", + "provenance": { + "path": "platform-next-staging/v5/linux-5605c350b-v5-build/build-provenance.json", + "sha256": "2e47d9fe27717e6ff15069186503d97d599b6b936ee52b55013ed0411b004ef9" + }, + "providerPackDigest": "sha256:83702fae11105f5b9ee8f9731b86dc06becb12075f68bdc98dcdfe9f9c842473", + "providerPackManifestSha256": "bfc3ec392a6c836c5eb9fe51150b9b49fed74c8c092118d39164779118165c5c", + "daemonSha256": "9a47150d51fa056b4c8ab932a01e480318089a71d107b383a713a9a9ae39877b", + "immutableImage": "ghcr.io/paperclipai/paperclip-daytona-runner@sha256:16c8be9c512c28cd9b16fe9ad5331f3fd7b58c42c382d3ebbe5897f658de0bea", + "imageDigest": "sha256:16c8be9c512c28cd9b16fe9ad5331f3fd7b58c42c382d3ebbe5897f658de0bea", + "installedImageId": "sha256:16c8be9c512c28cd9b16fe9ad5331f3fd7b58c42c382d3ebbe5897f658de0bea" + }, + { + "platform": "macos-x64", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "status": "built_not_qualified", + "provenance": { + "path": "platform-next-staging/v6/macos-x64-5605c350b-v6-build/build-provenance.json", + "sha256": "b8e2d223726455145f10b9373db3c0903f43b667c4187990b08d56c55d56289a" + }, + "providerPackDigest": "sha256:7e53a6b022468a0eb74e0e9184152e64972b4549513da8636ee64bf793649d6c", + "providerPackManifestSha256": "056fe49171104a207a4030abbabc750662674e25a02a499ff18bcc5e3d2ad398", + "daemonSha256": "6063513591dfd808597d4e8a172e00bb6a57a34339a73739b66c0c7a64a5065f", + "qualificationLimit": "x86_64 processes ran under Rosetta on Apple Silicon; no paid native-Intel qualification." + } + ], + "sourceLevelModePersistenceFix": { + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "testedFiles": { + "packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts": "d1d0467ecc34b3e36f67fc9a7eb166094d120fd1a28f15650d7310dd84f71eaa", + "packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts": "a609f0121ddbcdaf17919dfc20cf5e3937df11fbd9a06fa90483da9086e738b8" + }, + "testFilesPassed": 2, + "testsPassed": 56, + "newRegressionCases": 9, + "testExitCode": 0, + "testDurationSeconds": 1.41, + "runnerTypeScriptBuildExitCode": 0, + "runnerTypeScriptBuildLogSha256": "52980f19636a56432dbd9b5f578708cf4fb969777454853c08c44b3b679d2c33", + "evidencePath": "cursor-mode-persistence-verification.json", + "evidenceSha256": "d0870762d5552e9559dcc89b7c2cf375d9c9310aef2f4dcd53f02c16a2d8c540", + "verifiedDetails": "56 tests passed across 2 files, including 9 regression cases; runner TypeScript build passed. See recorded test/build evidence and hashes in this record." + }, + "qualificationState": { + "status": "blocked_pending_runtime_fixes_and_full_matrix", + "allIncludedCellResultsProvisional": true, + "includedCellCount": 6, + "fullProfileMatrixComplete": false, + "note": "The six root-reconciled cells remain provisional behavioral evidence. Cursor native-question diagnostics v2 and v3 both failed without callbacks; model statements do not establish a missing-tool inventory. The old campaign audit remains failed. A separate credential-free plugin-closure preparation now passes static UI, plugin admission, and full-start authority checks, and was root-reviewed for two subsequent paid attempts; neither passed quality, and neither reclassifies prior results. Two additional paid cells on the prepared plugin-closure stage failed quality: Cursor rendered a native plan but a fixture/JSON-key-order mismatch prevented delivery of a decision; Copilot delivered reject_once and the tool failed as expected, but acknowledged cancellation did not interrupt the turn and the bounded wait timed out. Runtime cancellation and permission-provenance fixes are in progress; the fresh 375/runtime565 harness copy is not admitted.", + "campaignEndAuditPassed": false, + "blockingEvidence": { + "cursor06": { + "cell": "cursor-native.runner-acpx-cursor.local.native-question-reconnect", + "qualityStatus": "failed", + "failure": "No native question callback was captured; the model reported that the AskQuestion tool was unavailable. This does not establish that the tool was definitively absent from discovery.", + "reconciliationScope": "root-failed-reconciliation.private.json records cost and cleanup reconciliation only; it does not reconcile quality as a pass.", + "resultPath": "current-profiles-5605-campaign-preparation/current-5605-cursor-06/result.json", + "resultSha256": "fc8f9d195bb0fe744d997bbe8c057121ba8468d8625b53713ce8f8df37b750aa", + "qualityEvidencePath": "current-profiles-5605-campaign-preparation/current-5605-cursor-06/product-results/cursor-native/runner-acpx-cursor/local/native-question-reconnect/attempt-1/result.json" + }, + "campaignFullEndDependencyAudit": { + "status": "failed", + "finding": "During remote Copilot startup, 14,335 entries were added, 0 removed, all 111,348 baseline dependency entries were byte-identical, and all additions were under packages/plugins/sandbox-providers/daytona/node_modules/. One additional workspace-build metadata difference was found: packages/plugins/sdk/dist/dev-cli.js mode 0600\u21920755 with bytes unchanged. The pinned standalone plugin lock was unchanged.", + "implication": "Full campaign authority failed; the six included cells remain provisional behavioral evidence.", + "observedAt": "2026-09-30T01:32Z", + "diagnosisPath": "dependency-drift-5605-cursor06/diagnosis.md", + "diagnosisSha256": "221f98101a377ca57f8ce59a9e9d4f31247e0fd37be73fb3fdea303cc9078530", + "membershipDiffSha256": "e5a81e6449c389ce2193bcbeb12cb6de11c31fb9c95578ff806200ec220df42d", + "originalEntriesCheckSha256": "c2137210cdec6f8033d551cfaff1221c2de288e878fcb813e0b0f167bce45b7c", + "workspaceBuildCheckSha256": "88c41f0f8bfc19914d954121a378f7883f3da91c52913c53adb245053517a8b9", + "unboundDaytonaDistFiles": 60, + "unboundDaytonaDistComparisonPath": "plugin-closure-preparation-v2/plugin-dist-comparison.json", + "unboundDaytonaDistInterpretation": "All 60 Daytona dist entries were present in the frozen source tree but outside the authority closure at the time; bytes/modes matched there. This is retained as an authority-boundary issue, not a claim that those 60 files changed during the startup install.", + "unboundDaytonaDistComparisonSha256": "c177f70abfb9fbefa8b5b7bfe8fb47443c2ecce539fff757b4efbb587a7fc32f", + "workspaceBuildCheckPath": "dependency-drift-5605-cursor06/workspace-build-check.json" + } + }, + "campaignEndAuditScope": "Original campaign containing the six behavioral pass receipts", + "supplementedStageFullEndAuditsPassed": true + }, + "remainingLimitations": [ + "Campaign qualification is blocked: Cursor06 native-question-reconnect failed without a captured callback (the model reported AskQuestion unavailable; tool absence was not proven), and the full-end dependency audit found 14,335 additive Daytona plugin node_modules entries plus one SDK dev-cli mode change. All baseline dependency bytes and the standalone lock remained unchanged; the mutated dependency closure still fails the campaign authority audit.", + "The capped Pi qualification key is still pending account sign-in; the available OpenRouter key lacks enforced spending limits and account-management access.", + "The macOS x64 build and initialize evidence ran under Rosetta on Apple Silicon; paid macOS x64 qualification remains unproven (physical Intel execution has not been established).", + "The remote Copilot cell has an infrastructure cost bound but native per-run USD is unknown; delayed analytics and full campaign-end authority remain pending.", + "The broader profile matrices contain rows not represented by this checkpoint." + ], + "historicalFailures": [ + { + "status": "historical_failed_not_reclassified_as_pass", + "sourceRevision": "794e90a258763aac16923cd0247de43cea3bc312", + "cell": "extended-harnesses.runner-acpx-cursor.local.hello-complete", + "path": "current-profiles-794-campaign-preparation/remaining-794-cursor-01/result.json", + "sha256": "a93fa425fe02ca88a0f2070e4155e7f90253fad18c91fb01a03ea2440818ee3d", + "passed": false, + "note": "Earlier Cursor hello-complete failure on 794e90a is preserved as historical evidence; current 5605 root-reconciled cells are separate attempts." + } + ], + "privateEvidencePolicy": { + "containsRawEventLogs": false, + "containsAccountEmail": false, + "containsCredentialFields": false, + "artifactPathsRelativeToBase": true, + "costFields": "Only root-reconciled cost provenance and approved upper-bound controls are retained; no raw billing rows or usage events." + }, + "frozenSourceRef": "qualification/current-profiles-5605", + "actualGitBranch": "codex/runner-acp-production", + "independentVerificationEvidence": { + "localSegmentedVerification": { + "receiptPath": "verification-rollup/status.json", + "receiptSha256": "1832c7e760bba03a8eed8a4123a652fd086e3ecd892f029b8fe3d020396d888c", + "sourceHead": "11b2842d6919cab60d413f52960e0080c1e4a66d", + "actualGitBranch": "codex/runner-acp-production", + "initialAndFinalWorktreeClean": true, + "typecheck": "passed (60.484s)", + "tokenGates": "passed (0.925s)", + "build": "passed once after segmented test follow-up (26.87s)", + "testRun": "pnpm test:run failed in its single invocation (720 files: 714 passed, 2 failed, 4 skipped); two embedded-Postgres startup timeout files passed in one focused retry=0 run after recovery. This is segmented coverage, not a green aggregate invocation.", + "segmentedCoverage": "All 149 distinct serialized server suites were attempted; failed route/activity suites passed in focused one-shot reruns and the remaining 48 passed. Workspace A/B coverage and focused recovery are recorded in the referenced receipt. The legacy Claude group and Codex process monitor had initial failures followed by one-shot passes after lock-overlay/environment repair.", + "limitations": "No claim that one pnpm test:run invocation passed; original failures are retained in receipt." + }, + "currentHeadCI": { + "statusPath": "pr-handoff-staging/current-head-ci-review-status.json", + "statusSha256": "f37186f275b11db2569f9b7adf8984eea681414598964953d3faa2e91e91e3e9", + "rerunProvenancePath": "pr-handoff-staging/current-head-targeted-ci-reruns.json", + "rerunProvenanceSha256": "515148f8674e395eb367b207dedf40bd04654acdeee3b677817411b73909ae67", + "heads": [ + { + "pr": 14631, + "head": "1cf7125b0168436b6607b503a03088ba93ef8b6a", + "workflowRun": 36654044022, + "attempt": 2, + "conclusion": "success", + "checks": { + "success": 52, + "skipped": 2, + "failure": 0, + "pending": 0 + } + }, + { + "pr": 14633, + "head": "e154e853944b635305a120e4321d8a62975e3223", + "workflowRun": 36654030284, + "attempt": 2, + "conclusion": "success", + "checks": { + "success": 51, + "skipped": 0, + "failure": 0, + "pending": 0 + } + } + ], + "scope": "Current-head CI snapshot is separate evidence, not a qualification result for frozen source revision 5605 or the local 11b284 test aggregate." + } + }, + "additionalDiagnosticEvidence": { + "status": "failed_diagnostics_not_qualification_passes", + "interpretation": "Two separately admitted native-question diagnostics produced no native callback. Model narrative is not proof of tool inventory or provider-side absence.", + "attempts": [ + { + "cell": "cursor-native-question-gap/probe-preparation-v2/attempt-g6w5Va", + "resultPath": "cursor-native-question-gap/probe-preparation-v2/attempt-g6w5Va/result.private.json", + "resultSha256": "3a20c01e4f866b6da4023faec60f82549657de81d70b692b46ed0db891d18779", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "mode": "plan", + "instructionAckVerified": true, + "semanticMcpServers": [], + "callbacks": 0, + "deliveredAnswers": 0, + "status": "failed_native_callback_cost_reconciled" + }, + { + "cell": "cursor-native.runner-acpx-cursor.local.native-question-reconnect", + "resultPath": "cursor-native-question-gap/probe-preparation-v3/attempt-PYs5XB/result.private.json", + "resultSha256": "7bf2283962a986972d9ed6e6dd02876b80eb19419ea25c0e296ee8a49b0f2b51", + "reconciliationPath": "cursor-native-question-gap/probe-preparation-v3/root-failure-reconciliation.private.json", + "reconciliationSha256": "c65c39ba3acbf165fc870c7496d1845e8bd435ad65acb90928d55b6c4cf68163", + "model": "composer-2.5[fast=true]", + "mode": "plan", + "instructionAckVerified": true, + "semanticMcpServers": [], + "callbacks": 0, + "deliveredAnswers": 0, + "status": "failed_native_callback_cost_reconciled", + "billing": "Included usage 14.6K; on-demand 0. Cost reconciliation only; not a behavioral pass." + } + ] + }, + "newPluginClosurePreparation": { + "status": "root_reviewed_with_two_failed_paid_attempts", + "sourceRevision": "5605c350b2a4dae75be3779f164d7e1a0e4a5a87", + "priorCampaignFailureRemains": true, + "sourceCodeChanged": false, + "credentialsRead": false, + "providerCalls": 0, + "cloudRequests": 0, + "staticUiAndPluginAdmission": "passed; plugin was ready, auto-build disabled, pnpm tripwire not triggered; dependency inventory unchanged", + "dependencyInventoryEntries": 125683, + "workspaceBuildInventoryEntries": 5930, + "combinedSealedInventoryEntries": 131613, + "fullStartAuthority": "passed and root-reviewed before paid attempts", + "fullStartAuthorityPath": "plugin-closure-preparation-v2/local-single-v2/full-start-authority.prepared.json", + "fullStartAuthoritySha256": "55944930781554f9820090acdace7c661a790353785618cee0b72941f50d2a53", + "credentialFreeAdmissionPath": "plugin-closure-preparation-v2/admission-03/receipt.json", + "credentialFreeAdmissionSha256": "ac8f2e7eb856799403215cc54de4f51bbbd881f290b8e599adba445cdd1b4ee9", + "sealedPreparationPath": "plugin-closure-preparation-v2/preparation-supplement.final.json", + "sealedPreparationSha256": "7c51f24d402183b649c26a83bd47da022100df72ddcbe9a335d4082e6c68f32d", + "qualificationEffect": "Root reviewed the new complete closure. Both subsequent paid attempts failed behavior but passed full-end authority and cleanup. The original six behavioral passes remain provisional. Runtime cancellation/provenance repairs and the required matrix are incomplete.", + "rootReviewedBindingPath": "plugin-closure-preparation-v2/local-single-v2/campaign-binding.reviewed.json", + "rootReviewedBindingSha256": "e93fe9a834de445a162a4a40b6826e738d9800c256a3dcbf9f6209d0eb5addfe", + "credentialFieldsScope": "Credential-free preparation only; separately recorded paid attempts used explicitly bound credentials." + }, + "newFailedPaidCells": { + "status": "two_additional_failed_cells_cost_and_cleanup_reconciled_quality_failed", + "qualificationEffect": "These two paid cells add failure evidence only. The original six behavioral passes remain provisional; full qualification remains blocked.", + "cells": [ + { + "cell": "cursor-native.runner-acpx-cursor.local.native-plan-reject-revise-accept", + "campaign": "supplemented-5605-cursor-plan-01", + "provider": "cursor", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "durationSeconds": 149.418, + "nativePlanRendered": true, + "quality": "failed: fixture adapter mismatch and question-set JSON key ordering caused the planned decision not to be delivered; native plan was observed.", + "cost": "Included 26.7K; on-demand 0; native per-run USD unavailable.", + "resultPath": "plugin-closure-preparation-v2/local-single-v2/supplemented-5605-cursor-plan-01/result.json", + "resultSha256": "1df60114ba240e3a29f44a1d9d5aadd31f5a977afe76caa1bc93e9192ec88165", + "reconciliationPath": "plugin-closure-preparation-v2/local-single-v2/supplemented-5605-cursor-plan-01/root-failed-reconciliation.private.json", + "reconciliationSha256": "7a19c6365c00397eff6a6b7b2ce9bb7c5454bc3f6769db9d29a810ff57812351", + "fullEndAuthorityPassed": true, + "cleanupVerified": true, + "automaticRetries": 0, + "readonlyDiagnosisPath": "cursor-native-question-gap/native-plan-fixture-diagnosis/offline-proof.json", + "readonlyDiagnosisSha256": "d2e57c976596a9fe9796777273f0dba829ee360d1d2f805a54f046c0eb44eeb8", + "readonlyDiagnosis": { + "path": "cursor-native-question-gap/native-plan-fixture-diagnosis/offline-proof.json", + "sha256": "d2e57c976596a9fe9796777273f0dba829ee360d1d2f805a54f046c0eb44eeb8", + "planRequestMethod": "cursor/create_plan", + "provider": "cursor", + "boundedOriginAccepted": true, + "requestAndRunMatched": true, + "questionSetDeepEqual": true, + "questionSetSerializedEqual": false, + "terminal": "failed", + "terminalError": "native session timed out after 120000ms", + "uiCall": "goto issue page after task creation; no answer submission observed" + } + }, + { + "cell": "copilot-protection.runner-acpx-copilot.local.native-permission-deny-write", + "campaign": "supplemented-5605-copilot-deny-01", + "provider": "copilot", + "model": "gpt-5.6-luna", + "durationSeconds": 144.581, + "quality": "failed: native reject_once delivered and the exact tool failed with target absent; cancellation was acknowledged without durable turn interruption, then the 120-second process wait timed out and failed the run.", + "runtimeWatchComplete": false, + "additionalUsageEnabled": false, + "additionalUsageBudgetUsd": 0, + "additionalUsageSpentUsd": 0, + "nativePerRunUsd": null, + "resultPath": "plugin-closure-preparation-v2/local-single-v2/supplemented-5605-copilot-deny-01/result.json", + "resultSha256": "a545d12ea6d9f8fd1566a658cb752aa20de5bf9ead7b8d155c7fdfc15a80d518", + "reconciliationPath": "plugin-closure-preparation-v2/local-single-v2/supplemented-5605-copilot-deny-01/root-failed-reconciliation.private.json", + "reconciliationSha256": "c61f476cf71e8d0e69debe509d206c20a50a040f2f528fa8eda8a4ad84e18245", + "fullEndAuthorityPassed": true, + "cleanupVerified": true, + "automaticRetries": 0 + } + ] + }, + "runtimeRemediation": { + "status": "implementation_in_progress_not_verified", + "pending": "Runtime cancellation and permission-provenance fixes are under implementation; no new paid GO was issued.", + "testOnlyFixCommit": "375cdf6eb993b425bd5fc753a025fb06e5e8a2f6", + "testOnlyEvidence": "7 files; 880 fixture tests and typecheck passed. This does not establish a new PR-head CI green result.", + "freshHarness": "A 375/runtime565 copy exists but is not admitted and is not qualification evidence.", + "ciBoundary": "The previously recorded e154 PR head remains green; no claim is made for the new test-only commit or current implementation." + }, + "rootPublicationReview": { + "reviewedAt": "2026-09-30T02:25:28.227559+00:00", + "qualificationPassed": false, + "preparationCandidateSha256": "1aa79c0e695b71ecf0223afe045f941f5a4467ef40fb19c5ffc0f54d1f622a02", + "nativePerRunPricesRemainUnknown": true, + "originalFailuresPreserved": true + } +} diff --git a/doc/evals.md b/doc/evals.md index 61e0d385cf..97b1773349 100644 --- a/doc/evals.md +++ b/doc/evals.md @@ -55,6 +55,18 @@ Historical Everyday cases and production prompts are preserved. ## Selecting a family +Pi's explicit-only `pi-controls` Product suite tests Stop while native permission +is unanswered and browser-originated same-turn steering, locally and on Daytona. +Its [fixture contract](../tests/runner-e2e/FIXTURES.md#pi-active-controls) separates +control acknowledgment, actual message consumption and owned process retirement. +The current Pi matrix has 26 explicit cells (13 local and 13 Daytona), including +these four controls, pending-native-question controller restart, and a Daytona-only +exact-Pi-child provider-death journey. Provider death must expire the original +unanswered card and reject stale replies without a replacement run; any durable +fallback is distinct from restoration. File editing +also requires native edit/validation events and a registered artifact download. +All remain unqualified until measured on the exact candidate runtime and harness. + Use **Runner Evals** for a runner protocol, adapter, transport, native session, tool grant, or one-turn provider qualification question. The workflow checks out an exact `paperclip-evals` revision, builds the Runner and viewer, runs a @@ -428,6 +440,10 @@ Copilot and Pi ACP profiles on local and Daytona. See the The private Runner Evals campaign of the same name provides complementary semantic protocol cases; catalog membership is not live qualification. +The explicit local [Copilot protection fixtures](../tests/runner-e2e/FIXTURES.md#copilot-native-protection) +exercise native permission denial with operator cancellation and bounded attached +command settlement. Their registration remains separate from paid qualification; +source/pack provenance and complete persisted evidence are required. The explicit-only Product E2E `confirmation-replies` suite tests conversational approval and rejection, persisted message provenance, approval before execution, ambiguous proposals, and the existing card-click path with native Claude/Codex. diff --git a/doc/plans/2026-10-02-pi-production-readiness.md b/doc/plans/2026-10-02-pi-production-readiness.md new file mode 100644 index 0000000000..b7b6485ffb --- /dev/null +++ b/doc/plans/2026-10-02-pi-production-readiness.md @@ -0,0 +1,2808 @@ +# Pi production readiness — 2026-10-02 + +The release target is Pi 1.0.0 through the native Runner. Readiness is a finite +set of release gates. Pi remains a candidate until every required gate passes. +Keep behavioral qualification on the accepted fixtures. Do not expand it to +widgets, images, Cursor or Copilot qualification. The existing draft stack must be reviewed in dependency order. + +## Current delivery scope — 2026-10-08 + +**The two requested core paths pass.** The user narrowed delivery to normal task +completion and human controls. Accounting and the broad release matrix remain +deferred. No provider/platform expansion or local Docker is part of this work. + +All seven fresh cloud cases pass canonically on shipping source +`10dc43c9ec65d88c2f782d62afb296d09494f215`, committed harness +`1a4408a48cfb5a1f094a311141c257c92cd7a893` and immutable image +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:5b3a775b383591bda1b0c1889e509acc70ce7f37c53f09733c81d59037f02280`. The accepted fixture uses +`openrouter/anthropic/claude-sonnet-4.6` with native low thinking. Production +accepts the caller's explicit native model; no allowlist, hardcoded default or +fallback is added. Pi profile 19 has command digest +`sha256:b7647ebf97f802ca053ec3384c912bf0e8d18eba308d27397bb1d95a37220825`. + +An initial master integration checkpoint used Pi profile 20 (`sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e`). It bound the Codex-only sandbox change. This checkpoint is retained as history and is superseded by profile 21 before merge. + +The dependency correction checkpoint uses Pi profile 21 (`sha256:514cbf86e70c1eaedebdf924bc0f3de4753c7a9e0313a0bb38614b6b9481a9a1`). It restores master's explicit task-environment projection and patches the bundled `brace-expansion` dependency from 5.0.9 to the official 5.0.12 payload. Pi 1.0.0, pi-acp 0.0.33, Node 24.21.0, wrapper, helper, extension, native question/control delivery, model selection and recovery identity remain unchanged. The original three closure hashes were reproduced before calculating the patched ones. The dependency patch is hash-pinned, is included in installed setup tooling, and is checked against the complete locked package graph. The seven live results retain their original profile-19 source and are not relabeled as fresh profile-21 runs. Profile-21 integration and the narrow security correction require current-head tests, package materialization and CI before merge. Cursor keeps its existing qualified status. Copilot stays pending; its new profile-17 attestation only binds master's updated shared protocol validation sources. + +The assembled merge uses **Pi profile 22** (`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`). Profile 21 remains a historical declaration. Version 22 changes only the version and credential-policy source hashes: general AWS IAM keys are excluded from both static and custom provider credentials, selected task-environment projections, and the Pi sidecar launch. `AWS_BEARER_TOKEN_BEDROCK` remains supported. The dependency closures, model selection, wrapper, helper, extension and question/control behavior are unchanged from profile 21. Rust admission uses the current declaration and rejects previous profiles. Current-head tests and CI must pass before merge; the profile-19 paid proof is not relabeled. + +| Core path | Canonical campaign | Retained evidence | +| --- | --- | --- | +| Four typed questions, browser reconnects and exact saved answers | `pi-core19-0-1791477277` | 42 files; SHA256 `5c0c5fdfeca97b689a0f299935fd5cb62d65c38a056b8ff4f2dfc888cd795b24` | +| Pending question survives controller restart on the same native run | `pi-core19-1-1791477493` | 33 files; SHA256 `85b5db8c528c339b611ecf1fc2173135aa6403184e6c47a52978cc3f69c00f86` | +| File edit, one validation command, registered download, Done and copy-back | `pi-core19-2-1791477756` | 23 files; SHA256 `95731673cdba4b8373085a743d5570cae645059d5339f20337a4df55c97bba80` | +| Three follow-up turns preserve native session/process/workspace | `pi-core19-3-1791477969` | 23 files; SHA256 `9b43a3edd76b4ea71e1611c5f623da6f88ec13184a89411983a582e66168568a` | +| Browser plan approval resumes work and completion | `pi-core19-4-1791478363` | 22 files; SHA256 `a3b197d7c9b15ee962865c1c04588a51d29e96f64561d3e2ff36f9b8888ef3c3` | +| Same-turn browser steering and permission denial without effect or replay | `pi-core19-5-1791478652` | 39 files; SHA256 `763bcf72a1be3b78d911376c2ed01ed433ecf7d3043379c0f6717afeecaf1317` | +| Stop during pending permission cancels and retires the owned runtime | `pi-core19-6-1791478869` | 33 files; SHA256 `4b2b7c91e29ce8245750fbac37fe69495bbc8f99021b92f6e74619953f697d95` | + +All 215 canonical files (28,868,813 bytes) are independently +hash-verified. Every canonical cleanup check passes. Independent inspection +finds no owned runtime process or temporary root after each case. Automatic +retries are zero. The owned cloud host stops normally after retention. No live +result from an older shipping runtime is reused. + +Native definition 23 has SHA256 +`90f2e901d7f39bfb6bbadcf63bc78e410a3b071af980f3a23979747e3d5f2021`; +controls definition 10 has SHA256 +`00639f572e16e25e9945124d91d55a7c98aa9d36c1aa6493842ea7605ff7fcb8`. +File/plan definition 5 retains SHA256 +`5de4fac78d4d581bc0954f07b5cf2df2862d37f8b0205325eede6d9ef6d9f5e6`; +warm definition 2 retains SHA256 +`331b88d9538a132670789fb7864df7df5f4bf294ae19b62f53d8a02f901774f0`. + +Hosted Linux build/public-install job `37802923302` passes. Its 20 retained +normal archives match their source-bound hashes. A fresh cloud consumer passes +normal installation and lifecycle, profile-19 setup, full package/managed-asset +checks, normal companion import, the actual production pack reader, and +installed CLI health/UI startup. The matching build includes normal plugin +prepack and the separate private eval SDK. All 54 applicable CI checks pass on +harness head `1a4408a48`, with two expected skips and clear current-head review. +Focused checks pass: 345 cloud core/observer controls, 47 extension/installation +checks with one expected platform skip, 50 recovery decoder checks, 22 local +native backend tests, generated profile drift and whitespace checks. Repository +typecheck, tests and build use the normal CI dependency graph; the isolated +public consumer lacks source-only development declarations. + +The production correction exposes native question field types at the schema +root and preserves every method-specific constraint and runtime validator. +Empty arguments, stringified options and cross-method fields remain invalid. +All three previous closure hashes are reproduced from the retained full +manifest and reviewed Node pins; only `extensions/paperclip.js` changes. +Historical Pi profile-18 decoding retains its exact stored identity; current +launch admission still rejects it. Later changes are tests, harness and docs. +Shipping packages and image stay frozen at the source above. + +### Preserved failed attempts + +- `pi-core-0-1791470050` keeps its definition-4 candidate failure. The task editor + escaped an unfenced validation command, causing one failed Bash invocation + before a corrected invocation. Definition 5 fences the unchanged exact + command; the one-command grader stays unchanged. All 41 canonical files + (91,468,056 bytes) are retained with SHA256 + `065146a206da9f29b50999b5d931b54402edd5d30a516895a7ab8fb95e8a6029`. +- `pi-core-2-1791471416` keeps its profile-18 failure and original + `transient_infrastructure` classification. Empty question arguments and two + stringified option arrays are rejected before the 120-second native timeout. + All 45 files (177,803,509 bytes) are retained with SHA256 + `66c801596d81b909ac2ad8004d885ddf69b7478d9ab9a0cce11a52ebfe8a6e28`. +- `pi-core19-0-1791475984` keeps its definition-22 `cleanup_failure`. All four + real browser questions are answered and the one native run succeeds with the + task Done, but independent terminal evidence records `process_sample_failed`. + All 60 files (116,725,310 bytes) are retained with SHA256 + `a44d87624b57e995d19617886f27ad742f8afb29aff7578774f6a84d6048cc8f`. + The original observer lacks a narrower cause. Linux proc-exit read races are + an inferred cause. Definition 23 separately confirms proc-entry absence + after an unexpected read failure. Existing unreadable processes, identity + drift and all incomplete evidence still fail. Closed causes distinguish + reads, stat shape, runtime binding and terminal sealing. The full private + recovery state is retained with SHA256 + `90ae92b0ee5107145342bcbace6d6df7b17d629ea1c52eafb1220363d9376e49`; + only its backed-up disposable scratch root is removed after verifying no + owned process and the exact original sandbox's absence. No original grade + changes or partial results qualify a complete path. + +Cloud builds `37799803269` and `37800625049` are superseded before any model +call. The normal install probe needed profile-19 synchronization, then stored +profile-18 decoding needed preservation. Their handles and reasons remain +retained. The frozen build above includes both corrections. + +### Historical profile-18 core passes + +These earlier passes remain bound to runtime `b696e131ae32a82418e570b32f4727b5adb14e49`, +harness `ee3b094d35719e4dd7cbc791d1924205c4fa474f` and immutable image +`sha256:ce622e03c606cb93eedda824133b791449f7be752d85e220a2e316f825f37226`. +Their canonical results and independent cleanup pass; their host stops normally. +They do not substitute for the seven fresh profile-19 results above. + +The earlier profile-20 merge checkpoint is retained above as history; current merge verification uses profile 22. + +| Core path | Canonical campaign | Retained evidence | +| --- | --- | --- | +| File edit, one exact successful command, authenticated public download, registered deliverable, finish, Done and finalized copy-back | `pi-core-0-1791470879` | 23 files; archive SHA256 `9a1c41e2ccad75f89a069ff571a3aa9f8cb94ae80c1bd397f7c886338b9a09bd`; definition 5 | +| Three browser follow-up turns retain native session/process/workspace identity and append the earlier work | `pi-core-1-1791471072` | 23 files; archive SHA256 `d10310a2720a0d6c0636ce389fc6cc5faa887cc5c95bd697cdf0172619973609`; definition 2 | +| Human plan approval resumes work and completion through the public product | `pi-core-3-1791472300` | 22 files; archive SHA256 `8c069ff176cb94166429901957f80e8b22c36ddf0f2047193889c4b7059aedf3`; definition 5 | +| Same-turn browser steering and human denial stay on the original run | `pi-core-4-1791472621` | 39 files; archive SHA256 `ce1f35ceaa66e2edaed070a26d25fcb24251be72726fb4372d057ab36425e1c3`; controls definition 9 | +| Stop during pending native permission cancels work and retires the owned runtime | `pi-core-5-1791472865` | 33 files; archive SHA256 `771f132bdc192d08a106b2b35322ade1c8baafc837949770d76ea60e571770a5`; controls definition 9 | + +## Correct cloud memory-read authority — 2026-10-08 + +Frozen runtime/image `b696e131ae32a82418e570b32f4727b5adb14e49` passes all +54 applicable PR checks and cloud image/public-install job `37777555436`. +The job retains the exact 20 normal archives and their source-bound hash +manifest. Its immutable image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:ce622e03c606cb93eedda824133b791449f7be752d85e220a2e316f825f37226`. +A fresh cloud consumer passes normal install and lifecycle, complete package +archive and managed-Pi verification, normal companion import, the actual +installed production pack reader, and installed CLI health/UI startup. The +reader repair therefore reaches a real paid native Pi turn on Sonnet 4.6. + +Definition-21 campaign `pi-sonnet-memory-b696-daytona-1791464677` retains its +canonical candidate failure at `native-memory-write-verification`. Pi actually +writes the requested 33 bytes, performs one complete native read with the exact +nonce plus LF, receives the expected cross-root denial, saves the exact bytes +through the public managed-file API, and retires cleanly. The grader wrongly +requires a null projected target for cloud agent files. The remote projection +correctly names `.paperclip-runtime/agent-files///memory/pi-native.txt`; +local private-root reads remain withheld. This is a grader-authority mismatch, +not evidence of wrong file content. Restart and the fresh task were not reached. + +All 45 canonical files, 93,697,980 bytes, are retained and independently +verified. Archive SHA256 is +`59a746148b786e16442200fe4a525e189d168849a3fde5bc3c480c5a7421dcd5`. +Cleanup passes with no owned runtime processes or temporary roots. The +historical canonical grade is unchanged. + +Definition 22 requires the exact cloud target derived from the independently +known fixture agent and current run IDs. It keeps the completed, untruncated, +read-only native receipt, exact text/LF, duplicate-read rejection and no-shell +checks. Null or wrong agent/run targets cannot qualify a cloud read. Native +local behavior still requires the withheld target. Add controls for another +agent, a prior run, another file, truncated/wrong contents, and shell execution. +Only harness tests and this plan change. The production tree and normal +package/image bytes remain frozen at `b696e131ae32a82418e570b32f4727b5adb14e49`. +Committed harness `da8d4454ae165b557ff11730e9fe018c9b1cd9b9` passes free +calibration against the retained actual cloud read and public file bytes. Wrong +agent, wrong run and wrong content controls fail. Calibration does not regrade +the original failed campaign. + +Fresh definition-22 campaign `pi-sonnet-memory-native22-daytona-1791465718` +passes canonically on Sonnet 4.6 in 197,083 ms. Both native read receipts, exact +33-byte saved memory, public file-save receipt, controller restart, an independent +fresh task with an undisclosed nonce, remote readback, unchanged parent seed, +cross-root denial and both independent retirements pass. Automatic retries are +zero. No local Docker, BYOK or fallback model is used. This qualifies the cloud +memory cell for these exact runtime, image, harness and definition bytes. + +All 31 canonical files, 4,556,238 bytes, are retained and independently verified +file by file. Archive SHA256 is +`1718750f0484b47a5181f8cee988e91c2e889b5135ccb29282c5220b964017b5`. +No owned runtime process or temporary root remains. The owned qualification +host is stopped normally after retention. The dedicated key retains its $5 +lifetime cap within the approved $100 cumulative token ceiling; credit snapshots +remain provisional and do not prove zero cost. The remaining 25 Product cells +and seven Runner cells still need current-freeze qualification. Strict Runner +cost accounting remains unresolved. Pi is not production-qualified. + +All 173 focused cloud unit tests pass, including local and remote memory, +wrong-path/byte controls, incomplete terminal evidence and cleanup retention. +Definition-22 SHA256 is +`44167a91fac42d4af03187c4cd94412923502c7c26125ae06a0bf67120396195`. +The cloud source typecheck cannot pass with the production-only dependency +graph: source-only server dev types, chat and the private Runner source link +are absent. Its errors do not name the changed memory files. Use the normal +PR CI dependency graph for repository typecheck and build. + +## Retain cloud-built packages for the changed reader — 2026-10-08 + +Reader fix `111082ad429c3d21ba74e9241d1375da6e82a7cd` passes all 54 +applicable PR checks, with two intentional skips and no unresolved review +threads. Cloud build `37774605672` also passes its normal source build, +18-package clean npm installation, lifecycle isolation and Pi profile-18 +admission. Its immutable image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:098bd31be64612dd15490f2507ae0cdc08875cc6e35ce6fe5e2ef87fce7d0d69`. +These are credential-free checks; they do not qualify the live cloud memory +case or the remaining Product and Runner cases. + +The public installation verifier previously deleted its normal archive set +with its temporary consumer. The existing hosted Linux image job now opts in +to retaining those exact public archives, plus the normally prepacked Daytona +plugin and separately built private Runner eval SDK. The manifest binds all +archive hashes to the actual server build stamp and git revision. Existing +retained output cannot be replaced. Temporary package stages and caches are +removed; source manifests stay unchanged. Nine focused Node tests pass, +including actual plugin prepack and source/destination identity controls. + +Run the image job once on the new head to retain one matching image and +20-package set. Install that set normally on an owned cloud host, freeze its +full installed graphs, import the matching image companion through the public +CLI, and call the actual installed provider-pack reader before any paid turn. +Then run one cloud memory case with Sonnet 4.6 and zero automatic retries. +No local Docker is used. The earlier canonical failures and passes stay bound +to their original source and definition versions. + +## Sonnet memory verification and cloud reader repair — 2026-10-08 + +The user approved `openrouter/anthropic/claude-sonnet-4.6` as the explicit +qualification model. Production still accepts caller-selected native models and +custom providers. Native low thinking, case deadlines, permissions and cleanup +requirements remain unchanged. + +The content-bound definition-21 local memory case passes on harness +`3017852e9472130d1f14ec42d50b589556512083` and installed shipping source +`06a3d9739f402baa7f8be7aaa29dda6db7354bfb`. Its two native reads contain the exact +33-byte nonce and final LF. Public file bytes, controller restart, fresh-task +persistence, denied cross-root write and independent cleanup pass. Its canonical +23-file evidence set contains 4,156,756 bytes and is retained with archive SHA256 +`2763bda9dd460aecce56af1a6030d8810c3b02c1701520f4ac3a16299a5f55cd`. +All 172 focused checks and all 54 applicable CI gates on that harness pass, with +two expected skips and zero unresolved review threads. + +The cloud memory case fails before a provider turn with +`runner_remote_provider_artifact_incompatible: invalid candidate identity`. +Its original grade and 43-file, 70,371,196-byte evidence set remain retained; +cleanup passes. The image builder publishes Pi in both qualified `providers` +and `candidateProviders`, but the server reader only permits Cursor in +`providers`. The reader now admits qualified Pi there using its existing exact +field, qualification, path, digest and asset-tree checks. Regression coverage +uses the normal builder shape through both complete and metadata-only readers. + +Free calibration reproduces the rejection against the actual immutable image +and accepts its complete asset tree with the corrected source reader. Four +malformed Pi declarations still fail. This is source-fragment calibration, not a +fresh packaged runtime or live cloud qualification pass. The server unit run on +the restored qualification host could not load its missing `supertest` dev +dependency; full server tests, typecheck and build must run in CI. A fresh normal +Linux package set and image must include this production reader correction. +The prior source, installation and image receipts retain their exact identities. + +Cloud preparation also restored the test SDK dependency, ran the original +Daytona plugin prepack hook and used normal `runtime import-remote` installation. +All 60 compiled plugin files remain unchanged. The original 80,151 runtime files, +16,441 plugin files and separately declared 24,992-entry companion inventory +pass verification. The cloud account limits each host to 10 GB. Owned +copy-on-write staging and disposable npm cache cleanup let normal verified +import complete with 3.9 GB free. Failed imports and original package metadata +remain retained. No local Docker, production environment override, fallback +model, BYOK or automatic retry is used. + +Runner qualification remains 0/7: its first Sonnet case passes semantic checks +but fails strict cost coverage because the evaluation SDK lacks priced Sonnet +accounting. Unknown provider spend remains unknown. Do not insert a price row +without validating fresh input, cache-read and cache-write counter semantics. + +## Historical repairs before the current shipping freeze — 2026-10-07 + +Pi 1.0.0 uses immutable profile 18. Production models remain caller-selected; +the accepted OpenRouter model and low thinking are fixture inputs. The new +Runner snapshot-retirement repair requires fresh normal Linux packages, a cloud +image and all 33 accepted live cases. Provider wrapper, closure, profile and +model-selection declarations are unchanged by this host-lifecycle repair. +Current-source normal Linux, native ARM Mac and native Intel Mac admission pass. +Historical Mac evidence does not qualify the current shipping source. +The following repair stages are historical; +the release-gate table below and exact shutdown-source section record current +admission. Do not repeat completed builds or count passes on older artifacts. + +Source `6a393093411caa9c5f6f543340e63805311678e8` completed normal native Linux +workspace/public builds, normal public npm installation and lifecycle hooks, +full installed-graph verification, Pi setup, Chromium and real server/UI admission. +All 20 qualification archives and integrity receipts are retained. The free +public-install probe initially rejected the stale profile-17 pin; verifier-only +commit `9cd363d28d1560dcfeffb408be876b49869c486c` corrected it and admitted the +same installed bytes. The original rejection remains retained. + +That installed source passed Stop, same-turn steering and native questions with +canonical results and independent process cleanup. The persisted-agent-file case +then failed because the owned cloud host ran out of disk while writing durable +Runner state. Its original canonical failure and trace are retained; the fixture +reports cleanup passed. Inspection found native distribution snapshot directories +left after completed provider runs, including partial copies. Command retirement +returned immediately once its launch was consumed, leaving asynchronous snapshot +deletion unfinished when the Runner exited. + +Command retirement now waits for exact-child exit and complete snapshot deletion. +A surviving child produces a bounded failure after 10 seconds while its bytes +remain retained; it cannot hold the complete cleanup outcome indefinitely. +Deletion failures remain visible and owned for a bounded cleanup retry. It never +removes a live child's native bytes. A delayed-deletion regression fails on the +old code and passes with the correction; live-child and cleanup-failure regressions +also pass. No paid cases resume before rebuilding and admitting this repair. +The three passes on the previous Runner source do not qualify changed host bytes. + +Source `b061f31242a29374910b5c02b595e90d21e7fae9` then passed normal Linux +workspace/public builds, all installation hooks, graph audits, Pi setup and real +server/UI startup. All 20 archives are hash-verified and retained. Its hosted image +build, anonymous OCI admission and actual credential-free Daytona guest probe +passed; probe deletion was confirmed. Current-head CI passed 54 applicable checks, +with two expected skips and zero new Greptile comments or open review threads. + +The first Stop run on that source passed its unchanged canonical Product grader, +including process cleanup. Independent inspection nevertheless found two native +snapshot directories: one complete 547 MB distribution and one partial 20 MB +copy. The original canonical pass, failed independent cleanup observation, trace, +manifest and every remaining file hash are retained. Paid cases halted; this run +does not qualify the release. Only those two exact, quiescent owned directories +were reclaimed after rechecking their retained identities and bytes. + +Free regressions reproduce both remaining boundaries: native acquisition ignored +cancellation, and the Rust process-group supervisor killed Pi cleanup after only +two seconds. The new repair propagates a command-owner cancellation signal into +Pi snapshot acquisition, stops admitting copies on cancellation and drains all +admitted reads before deleting the partial tree. Pi gets a finite 30-second +sidecar shutdown grace; process-group KILL and inherited lifetime-fence proofs +remain authoritative. Normal cleanup still waits for exact-child exit. +The repair passes 271 focused TypeScript tests, one existing skip, all 16 Rust +transport tests and Runner typecheck. A credential-free Linux probe against the actual installed 15,671-entry, 540 MB +Pi closure takes 4.22 seconds to copy and 1.38 seconds to delete. Cancellation +during an admitted read drains and deletes the partial copy in 0.28 seconds; no +snapshots remain. This free repair-engine proof does not qualify packaged Runner +bytes. The source must be rebuilt and admitted before another paid attempt. All +33 release cases belonged to the earlier full-release scope. + +### Historical profile-18 release-gate snapshot + +The table below preserves the older full-release scope and its source-specific +evidence. It is historical. The current delivery gates are the seven passing +profile-19 daily-use and human-control paths in the opening section. Accounting +and the wider acceptance matrix are deferred; the old package/image freeze and +remaining case counts below are not current delivery instructions. + +| Historical release gate | Historical evidence | +| --- | --- | +| Offline cancellation proof | Profile 18 passes all 49 Pi ACP protocol checks. Historical profile 17 remains decodable; exact launch requires profile 18. | +| Snapshot retirement proof | Shipping source `06a3d9739` passes 271 focused TypeScript tests, one existing skip, 16 Rust transport tests and Runner typecheck. Actual installed native copy, cancellation and deletion probes pass. All six actual live attempts have independent zero-snapshot cleanup. Installed real Pi RPC recovery and pending cancellation also pass with exact-child retirement. | +| Normal Linux build and public installation | Source `b696e131ae32a82418e570b32f4727b5adb14e49` passes the normal hosted Linux build, clean 18-package npm install, lifecycle isolation and 20-archive retention in job `37777555436`. A fresh cloud consumer passes full public/plugin graph audits, normal Pi setup and companion import, the actual installed pack reader and server/UI startup before the definition-22 memory pass. | +| Normal ARM Mac installation | [Native hosted ARM check](https://github.com/paperclipai/paperclip/actions/runs/37694650556) passes on shipping `06a3d9739`: normal workspace/public builds, npm lifecycle, graph audits, Pi setup and packaged admission. All 18 archives and logs are retained and hash-verified; no provider prompts. | +| Normal Intel Mac installation | [Native Intel continuation](https://github.com/paperclipai/paperclip/actions/runs/37700732418) passes normal npm lifecycle, Pi setup, signed native daemon, graph audits and closed admission in 24,770 ms using the exact 18 retained archives. The original cancelled attempt remains retained; no Rosetta or provider prompts. | +| Final Product acceptance | Current-freeze Sonnet 4.6 coverage is 1/26 on installed runtime/image `b696e131ae32a82418e570b32f4727b5adb14e49` and harness `da8d4454ae165b557ff11730e9fe018c9b1cd9b9`: the definition-22 cloud memory cell passes both native reads, exact LF, controller restart, fresh-task readback, cross-root denial and independent cleanup. All 31 canonical files are retained and independently hash-verified. The remaining 25 Product cells need current-freeze qualification. The definition-21 local pass on `06a3d9739` and both historical cloud failures retain their original source and grades. Earlier Sonnet and DeepSeek receipts are historical evidence only. | +| Final Runner acceptance | Current-freeze coverage remains 0/7. Historical Sonnet coverage is 0/7 on definitions `bf8c509d`. The first get-task-context attempt passed all semantic assertions but retained canonical `accounting_failure/provider_budget_coverage_unknown`: the installed evaluation SDK has no Sonnet 4.6 price entry. The six remaining cases did not run. Native low, 120-second limits and scoring rules remain required; no automatic retries. | +| Cloud image | [Hosted Linux build and retained packages](https://github.com/paperclipai/paperclip/actions/runs/37777555436) pass for `b696e131ae32a82418e570b32f4727b5adb14e49`. Immutable image `sha256:ce622e03c606cb93eedda824133b791449f7be752d85e220a2e316f825f37226` passes normal companion import and installed pack admission, then the real definition-22 cloud memory case. Guest retirement and owned qualification-host stop are confirmed. Earlier image receipts remain historical. No local Docker is used. | +| PR CI and review | Frozen runtime source `b696e131ae32a82418e570b32f4727b5adb14e49` passes all 54 applicable checks with two skips. Memory harness `da8d4454ae165b557ff11730e9fe018c9b1cd9b9` passes 173 focused regressions and the live cloud case. Current-head CI and review remain required PR gates; a passing memory case does not qualify the full release. Prerequisite #14921 retains the valid premature-admission finding. No merge, release or rollout is authorized. | + +The earlier cancellation defect and all failed attempts remain retained. Pi omits +service-failure metadata only for its authoritative cancelled terminal, preserving +partial usage and ordinary provider failures. ACPX and server failure handling, +exact bytes, final line-feed, permissions, run attribution and cleanup grading +remain strict. Task creation binds the explicit public creation-response issue ID. + +The dedicated OpenRouter key retains its $5 lifetime cap inside the approved $100 +total token ceiling, with no reset, BYOK, account-key fallback or unchanged paid +retry. The post-Stop key snapshot on 2026-10-08 records $1.59509009 lifetime usage and +$3.40490991 remaining; this is a key-wide total, not a per-case invoice. Billing snapshots are provisional. Reclaim only inspected, +quiescent resources from the owned qualification host after retaining their evidence; +require disk headroom and zero leaked native snapshots before subsequent paid cases. + +## Exact shutdown source qualification — 2026-10-07 + +Production and installed packages are frozen at +`06a3d9739f402baa7f8be7aaa29dda6db7354bfb`; protocol definitions are frozen at +`d38ebc674d9138b941908d5128321e91db05a3a8`. Normal workspace/public builds, +consumer installation and lifecycle hooks, strict graphs, Pi setup and real +server/UI admission pass. All 20 archives are hash-verified and retained. +[Hosted image build](https://github.com/paperclipai/paperclip/actions/runs/37676853634) +passes. Image `sha256:5e62c8e294cd9d663316ba09b0aae1d7358a8938fe37d17936ef394cf1723ab2` +passes anonymous Linux admission and an actual credential-free Daytona probe; +probe deletion is confirmed. Current-source CI passes 54 checks with two skips. +Nine simultaneous runner shutdowns are retained as infrastructure interruptions; +a single failed-job rerun passes. Both PRs had zero unresolved review threads at shipping-source admission. +Fixture head `56d1e03ac` also passes 54 checks with two skips; its new review +finds the stale table corrected in this documentation update. + +Stop, same-turn steering and native questions pass on these installed bytes. +Independent cleanup after each finds zero native snapshots, temporary roots, +owned runtime processes or credential input files. The fresh memory case then +fails its unchanged exact-byte grader: the native read and public managed-file +API both return the expected 32-character value without its required line feed. +The original 38 MB of canonical evidence and failure classification are retained; +the sequence halts with no subsequent paid dispatch or automatic retry. + +A free probe against the actual installed Pi write/read modules proves that +32-byte and 33-byte inputs are written and read unchanged, with no newline +insertion or trimming. The task JSON independently decodes to the correct +33-byte value. The supported cause is model behavior, separate from the original +canonical candidate-failure classification. The only fixture correction explains +that write never appends a newline and complete read preserves it. Original +JSON, exact-byte graders, cross-root denial, model, low thinking, deadlines, +permissions and production profile 18 remain unchanged. Qualification must use +a recorded new harness revision for this changed prompt; shipping artifacts +remain frozen at `06a3d9739`. + +The owned 10 GB build host is resumed after its unsupported disk-resize endpoint +rejected a request. Four exact checksum-verified toolchain download caches are +reclaimed after rechecking all 20 package archives and daemon bytes. Installed +tools and all canonical evidence remain retained. The 2 GB disk admission floor, +finite host lifetime and independent zero-snapshot cleanup stay in force. +No local Docker is used and no rollout, release or merge is authorized. + +Harness `56d1e03acdb8f951436d66f56c8f0b74a3c5c423` changes only the memory +prompt clarification, its documentation and this plan. Cloud verification proves +all production trees unchanged from shipping `06a3d9739`; all 26 catalog +fingerprints remain identical. Fixture typecheck, nine focused fixture suites and +all seven definition validations pass with zero provider calls. The three +unaffected prior passes retain their original harness revision and exact archive +identities; they are not relabelled. + +The clarified memory attempt also fails the original exact-byte grader. Its +native complete read and public managed-file API agree on 32 bytes without the +required final line feed; the run succeeds and produces a durable save receipt. +The 31 MB canonical report, full trace and original failure remain retained. +Independent inspection again finds no snapshots, temporary roots, runtime +processes or credential inputs. The free installed-tool probe preserves both +32-byte and 33-byte arguments exactly. No supported production-code change is +identified. Paid qualification is held for a decision on the frozen fixture model; +no unchanged retry or subsequent paid case ran. + +An earlier launch of this clarified harness was rejected by the unchanged 2 GB +disk preflight before billing, a model call or a case claim. After verifying local +retained copies and remote hashes, only duplicate evidence files and writable +compile caches were retired. The root-owned image cache was left untouched. +The actual memory attempt began with 2,050,457,600 bytes free. The original +preflight rejection and both cache-reclamation diagnostics remain retained; +none is reclassified as a live qualification attempt. + +## Frozen-model memory encoding correction — 2026-10-07 + +The active goal keeps `openrouter/deepseek/deepseek-v4-flash-0731` and native +low thinking. The proposed replacement qualification model is not applied. +Shipping packages and the admitted image remain frozen at `06a3d9739`; no +production input or runtime profile changes. + +The existing real Pi RPC warm-recovery test passes against the exact installed +profile-18 wrapper. Its fragmented JSON write preserves the final LF on disk, +a fresh wrapper restores the session and agent home, and both owned Pi children +close. External sockets are denied before connect; responses come from an +owned synthetic loopback fixture. This is diagnostic proof, not live coverage. + +Pi native definition 17 changes the authoritative write JSON's LF encoding from +`\n` to `\u000a`. Both decode to the same 33 UTF-8 bytes; the final byte remains +10. The installed native write/read probe preserves that Unicode-escaped content +and an unchanged 32-byte negative control exactly. The original model failures +remain retained. No grader, permission, deadline, case ID or production behavior +changes. The new suite fingerprint must be recorded; prior native definition-16 +passes remain their original measurements. Unchanged controls retain their +original fingerprints and installed identities. + +Run the free harness checks against an exact new harness revision, then permit +one explicitly selected changed memory attempt under the same $5 key lifetime +cap and $100 campaign ceiling. Keep zero automatic retries, no BYOK or fallback, +and the same 2 GB disk floor. Stop and retain canonical and independent cleanup +evidence on any failure. An encoding change does not itself establish reliability +or production readiness. + +Documentation head `3fee270de` passes all 54 applicable CI checks with two skips +after one failed-job rerun of two simultaneous runner shutdowns. Original logs +remain retained. Its Greptile review is 5/5 with no unresolved threads; this new +harness change requires fresh current-head CI and review. All remaining Product, +Runner and platform installation gates remain required. Do not merge or release. + +## Current qualification disposition — 2026-10-07 + +**Not production ready.** Shipping packages and image remain frozen at +`06a3d9739f402baa7f8be7aaa29dda6db7354bfb`. Product harness +`5ccb4a346298351af5247cc8095e6cf8e2e0a539` uses Pi-native definition 17, +`e60021e6959cb2ed9b50b3d5b734b6ad5a78bafc7aca1ea1fca456fb3191f318`; +its catalog is `3827956ef849a665969254dda075e51f02d014c62f9f4de3e290ef05c5191800`. +All 201 focused fixture tests, harness typecheck, catalog capture and seven +Runner definition validations pass without provider calls. The initial free +fixture check's stale definition hash is retained; the corrected pin passes. + +The one selected changed memory attempt failed. It made eight native writes, +one read and three shell calls, still observed 32 bytes without the required LF, +and reached the unchanged 120-second native deadline. Preserve its canonical +`transient_infrastructure` classification separately from the tool timeline, +which supports repeated model behavior rather than a stopped transport. +The actual raw write arguments were not retained and their supplied bytes are +not asserted. All 18 canonical evidence files, totaling 49,717,073 bytes, and +their original hashes are retained. Original independent inspection confirms +launcher exit, zero temporary roots and credential-input removal within its +recorded coverage. Its process scan omitted executables in Pi snapshot roots. +A supplemental scan at 22:53:48 UTC covers those paths too and finds no owned +runtime processes or temporary roots; launcher 7869 and observed sidecar 8466 +are absent. This proves current quiescence and does not retroactively broaden +the original receipt. No subsequent paid case or automatic retry ran in that +qualification sequence. + +After the user's request to get qualification working, one separately named +input-boundary diagnostic ran on a fresh cloud host against those same packages, +harness, model, low thinking and graders. The original host had been automatically +deleted. The restored consumer's 391 packages and 80,151 files match the frozen +runtime inventory; four declared native-build configuration files regenerated. +Registry drift was corrected using the original dependency versions and retained +integrities before the diagnostic. No shipping inputs changed. + +That diagnostic retained actual Pi session tool inputs: all eight writes supplied +32-byte content without LF. Observed memory-file hashes matched the supplied +content hashes. This rules out newline removal by native write/storage for those +inspected calls; upstream raw provider SSE was not captured. The unchanged +120-second deadline still failed, and its canonical `transient_infrastructure` +grade remains intact. Cleanup passed, with no owned runtime processes or temporary +roots in the independent scan. All 42 canonical files, totaling 130,691,700 bytes, +were retained and hash-verified locally. Its provisional key-usage delta is +$0.004511326; cumulative key usage is $0.585187764 against the unchanged $5 lifetime +cap inside the approved $100 campaign ceiling. There were no automatic retries. + +On 2026-10-08 the user approved switching qualification to +`openrouter/anthropic/claude-sonnet-4.6` and requested the memory-test repair. +Pi native definition 18 uses one ordinary JSON write and exact complete read, +with all byte, persistence, permission and restart assertions retained. Native +low thinking and zero automatic retries remain required. Sonnet coverage starts +at 0/26 Product cells and 0/7 Runner cells; previous DeepSeek measurements remain +historical. Production model selection remains caller-controlled. The frozen +shipping source, packages and cloud image do not change for these test inputs. + +The owned Daytona host is now stopped normally after a fresh broad process/root +scan and renewed local verification of all six canonical attempts: 120 files, +127,249,447 bytes, plus all 20 Linux package archives. Original evidence remains +retained. Its configured 30-minute automatic deletion is unchanged; no idle +qualification host is kept running while the memory failure is unresolved. + +The actual installed Pi wrapper also passes the existing synthetic RPC recovery +and pending-cancellation scenarios. Two diagnostic variants split every JSON +character into separate provider SSE argument events, for both `\n` and +`\u000a`; both preserve the required 33-byte value on disk. The read-feedback +variant also verifies the exact LF-bearing text in the subsequent provider +request. Network connections outside the owned loopback fixture are denied +before connect, owned children close and shipping sources remain unchanged. +These checks identify no supported production correction and do not count as +live qualification passes. The approved Sonnet qualification keeps native low +thinking. Do not substitute a passing surrogate or weaken exact-byte grading. + +Native hosted ARM installation on the same shipping source passes normal npm +lifecycle, Pi setup, full graph hashes, native signed daemon identity and closed +public admission in 22,245 ms. Its daemon is +`sha256:89a972407fd78bf5c9ac6faa5046afd4d15cb9da17e2595ea96946738702f0af`. +All 18 archives and step logs are retained and independently hash-verified. +Build lock and platform-specific graph/archive identities remain attached to +that platform receipt; they are not substituted for the Linux member's bytes. +The original [native Intel job](https://github.com/paperclipai/paperclip/actions/runs/37694650556) +was cancelled by the agent based on a stale live log. Its retained receipt proves +the workspace/public builds, all 18 archives, normal npm installation and native +signature check had passed; cancellation interrupted Pi setup. Preserve the +original receipt and all hash-verified archives. The +[continuation](https://github.com/paperclipai/paperclip/actions/runs/37700732418) +binds that original receipt and artifact, uses the same package bytes on native +Intel hardware and emits synchronous progress. It passes normal npm lifecycle, +Pi setup, signature verification, complete installed-graph audits and closed +admission in 24,770 ms. The native Intel daemon is +`sha256:1918f0e8524abaa8ae454db7c1431e2f5437a587a4e0fe61b55c67047f8aaaa6`; +all 18 original archive hashes and both runs' completed step logs are verified. +Keep the final consumer graph identity with this platform receipt. Both runs +repeat maintainer authorization and immutable source checks on a separate +temporary orchestration branch. No shipping source, local Docker, provider +credentials or prompts are involved. All three normal platform installation +gates are now satisfied for shipping `06a3d9739`. + +Current coverage is 2/26 Product passes, one failed cell and 23 unexecuted cells, +plus Runner 0/7. The definition-16 native-question pass remains historical. +Prerequisite #14921 still has one valid production-admission finding; the other +three prerequisite PRs have no unresolved threads. The post-attempt key snapshot +is $0.561888353 usage with $4.438111647 remaining under the unchanged $5 lifetime +cap inside the approved $100 campaign ceiling. Settlement is provisional. +Keep paid execution halted until a concrete correction addresses the observed +memory failure. Keep the full remaining scope and do not merge, release or deploy. + +## Final fixture reconciliation — 2026-10-07 + +Fresh Linux CI passed the server, database, UI and package checks. It exposed +the remaining stale Pi profile-15 fixtures in the native backend, provisioning +suite and public-install probe. Those checks now bind profile 16. Thinking +changes are rejected while the unchanged identity can still attach successfully. +The Rust and TypeScript instruction composers now share master's current-turn +`AGENT_HOME` guidance; the shared positive and negative suffix fixtures remain +the attachment authority. + +The corrected native backend passes 22 integration tests, the Rust core passes +350 unit tests, and the shared instruction/provisioning suites pass 13 tests. +Recursive typecheck and the full workspace build pass. Product E2E free checks +pass 2,479 Vitest tests and 128 native completion checks, and token gates pass. +The final complete Rust run and fresh Linux CI are still required. Local +PostgreSQL integration remains blocked by this host's shared-memory capacity; +the corresponding Linux CI lanes passed before this fixture-only update. +The complete Rust run exposed two attachment fixtures sharing a temporary +directory namespace. Each provider now uses its own name; all three attachment +tests pass together. The final complete suite is being rerun after this fix. + +The dedicated qualification key's fresh read records $0.430881296 lifetime usage +and $4.569118704 remaining under its $5 cap, with no reset or BYOK usage. The +signed-in Default Workspace has all 72 BYOK providers unconfigured. These +account observations made zero model calls. Current-source installed admission, +the real task, and all 26 Product plus seven Runner cases remain unqualified. + +## Master safety restoration — 2026-10-07 + +The earlier merge retained obsolete accounting code and removed master budget +reservations, usage receipts, fractional billed amounts and cancellation policy +fences. Restore those implementations and their database/shared/API/UI contracts +from master. Reconcile the heartbeat and native executor with a three-way merge +so Pi warm instruction copies, thinking identity and remote companion selection +coexist with current accounting. Restore the native accounting regression tests +and the usage completeness marker that durable replay requires. + +Remove the obsolete Cursor-only plan receipt implementation. Recovery fixtures +now use master's provider-owned plan receipt path while preserving historical +Cursor6 authority. Local PostgreSQL integration verification is held because +this host exhausts System V shared memory during database bootstrap. No changes +to global host limits or other running databases are part of this task. + +Free checks pass 91 source-only server boundary tests, 78 native usage/accounting +tests, 11 Rust callback-resolution tests, and both public setup boundary tests. +The Linux canary exposed a separate setup bug: the provisioner wrote Pi assets +at the server root while discovery requires its vendored Runner directory. The +provisioner now publishes to that same fixed directory. Its packaged-layout +regression verifies corrupt installed bytes fail before any download or child. +A fresh Linux public install is still required. + +Qualification will use a fixed master integration at +`b67db12d90c1bd191f99975e35cc5c84137858e4`. No paid calls were made for these +repairs. Current-source Linux installation, exact-head CI/review and all 33 live +cases remain release gates; prior live evidence does not transfer. + +## Startup and merge repairs — 2026-10-07 + +The real sidecar process now accepts all four explicit Pi thinking levels in +`session.open`; invalid levels, foreign-provider levels and unknown fields still +fail before launch. Runtime identity verification independently checks thinking +level and retires rejected admissions without prompting. Regression tests run +with no ambient provider credentials and a caller-selected custom model. + +The empty ACPX patch hunk that broke GNU patch and Linux release packaging is +removed. Current profile 16 identities and Cursor's patch declaration are +regenerated; historical identities remain unchanged. Master-merge regressions +in historical plan receipts, duplicated Cursor controls and outdated tests are +reconciled. The PR's lockfile matches its dependency branch; CI resolves the +updated manifests under the repository's lockfile policy. + +The startup/host suites pass 136 tests, the targeted provider/receipt/environment +suites pass 187, the configuration/permission suites pass 71, the native Runner +unit suite passes 342, and packaging checks pass 16. These are free regression +checks. Production remains held until current-source CI, normal Linux package +installation, a real Pi task and the accepted live cases have passed. Mac work +remains deferred and no prior-source live result transfers to this candidate. + +## Master integration and model selection — 2026-10-06 + +This branch merges `origin/master` through `a9a20fb5c6c080884ced7ee0a4e3b04f9cbf3a6e`. +The original integration point was `a6306ba606eb87c89b9ef0344e9fe8e0025580f9`; +two additional master commits are included in the final merge. +Pi accepts the operator's explicit provider/model ID without a Paperclip model +allowlist. Catalog discovery does not gate selection. Exact native model +acknowledgment remains required before prompting. API credentials for built-in +providers and explicit native custom-provider configurations remain session-bound. +Custom-provider configuration changes also fence session recovery. + +Pi profile 16 binds the updated ACPX patch and configuration/recovery sources. +Runtime and native distribution pins remain Pi 1.0.0. Historical profile 15 +fixtures are preserved. This integration does not transfer prior live passes to +the new source. The task acceptance below belongs to source +`46ffa7aa3a8b219f5508448cffe297c09e38d810`. No paid model calls or platform +qualification runs are part of this merge. Mac coverage remains deferred. + +Merge validation runs in an independent checkout because this worktree's +node_modules links belong to a different checkout. Recursive typecheck, full +build, generated-profile parity and UI token gates pass. The complete Runner +suite passes 3,363 tests and reports five stale transport fixture expectations; +those fixtures are corrected and all 11 selected transport regressions pass. +All 2,473 offline Product harness tests pass after the final master merge. +The two new server test files pass 10 tests with one skip. The final focused Pi model, +credential, installation and session suite passes 280 tests, with one skip. +The repo-wide test command did not complete: its pre-fix run was stopped after +24 minutes. Targeted directory and session-identity regressions pass on the +corrected source. No full-repository green-suite claim is made. + +## Immediate execution focus — 2026-10-06 + +The operator has re-centered this work on getting Pi to run through the new +Runner. Use the already admitted native Linux package/image path for real task +acceptance. Additional Mac installation coverage is deferred to a separate +follow-up. It does not block work on this Linux path. Keep the remaining scope +on accepted task behavior, permissions, recovery and cleanup. Preserve all +failed evidence and the frozen model, low thinking, budget caps and zero +automatic retries. + +## Real Linux Pi task acceptance — 2026-10-06 + +The current installed runtime passes +`extended-harnesses.runner-acpx-pi.local.file-edit-validate` in **52.680 seconds**. +It uses native Pi 1.0.0 with the frozen model and low thinking. Pi reads the +seeded file, performs one native edit, runs the exact validation command once, +registers a real downloadable attachment, and finishes the task. Independent +workspace and downloaded bytes match, including the newline. All seven +canonical matchers and the strict file-evidence grader pass. The validation +has a native structured output receipt with exit code zero; the distinct typed +exit-code field remains null and is not invented. + +The durable events contain `turn.completed`, an accepted completion claim, and +a genuine `run.terminal` with state `succeeded`. Canonical cleanup passes. +Independent SDK checks confirm that the launcher, recorded Runner and Pi +provider processes, temporary fixture root, and uploaded credential file are +absent. The attempt has zero automatic retries. No held failed case is retried. + +The Linux task-acceptance freeze is +`sha256:ac66edb3a6c1804dfea1ce9c58cb2fcf1aced9170e84b7da5868a334f2290d8f`. +It binds current source, all 18 installed public archives, the immutable Linux +image, unchanged grading definitions, full package graphs and credential-free +admission. It does not certify other platforms or transfer historical passes. +The canonical result hash is +`8f75adabe5bdc4a4c2dba98775b52fd14622abe338b9f0910084aa838926adda`; +the strict file receipt records matching workspace and download hash +`83bfa78170b1bdd48a58ec2172e95c212b8fab4e0edead571ac18367507b8f77`. + +Current qualified coverage is **1/26 Product and 0/7 Runner**. Pi demonstrably +runs on this path; the remaining accepted behavioral cases still need evidence +before a full production-readiness claim. The later key snapshot reports +provisional total usage of **$0.383058202**, **$4.616941798** remaining, no reset +and zero BYOK usage. The observed increase for this attempt is $0.003865352; +it is not a final invoice. The $5 key cap and $100 campaign ceiling remain. + +## Current corrected source — 2026-10-06 + +Shipping source `46ffa7aa3a8b219f5508448cffe297c09e38d810` contains the +durable cleanup correction below. Full build, recursive typecheck and Product +harness typecheck pass. All 288 environment tests pass. Exact-head CI has 53 +successful checks and two optional skips. Greptile completes successfully; +the review-thread audit finds no unresolved threads or omitted pages. + +All 18 public archives are freshly packed and installed with verified integrity. +The public-payload audit excludes credentials, databases and run evidence. The +source-bound image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:e8c38507c99db401a23d62c680cea888c58ee213c3edc3b85bf3ff04e3e26090`. +Anonymous access and source/content labels pass. Normal native Linux CLI +installation, Pi setup and offline admission pass with lifecycle scripts and +the packaged daemon, without a binary override or model key. Admission takes +7.701 seconds, confirms profile 15 and Pi 1.0.0, and exits the Runner cleanly. +The temporary sandbox is deleted; an independent SDK lookup returns 404. + +Normal ARM Mac npm installation passes, but Pi setup cancels before admission. +Its failed grade remains preserved. The installation process, setup lock and +temporary setup directory are absent; no ARM runtime is admitted. The host log +records repeated thermal-emergency sleeps during this attempt. That correlation +does not prove which cancellation source fired. The original setup deadlines +remain unchanged, and no unchanged retry runs. Darwin x64 compilation passes; +normal x64 installation and physical Intel hardware proof remain outstanding. +These Mac observations are historical installation work. Additional Mac +coverage is deferred under the immediate execution focus above. + +Prerequisite #14922 now has 52 successful checks. Prerequisites #14923 and +#14924 each have 53 successful checks and two skips. Only their specific +cancelled jobs and dependent checks were resumed at verified, unchanged heads; +the original cancelled attempts remain preserved. All three have no open +review threads. The premature-admission finding on #14921 remains open until +the complete live qualification proves readiness. + +The new set now has **1/26 Product and 0/7 Runner qualified passes**, through +the real Linux task above. Additional platform coverage is deferred. Prior-set +8/26 Product and 5/7 Runner passes remain historical. The corrections and +installation checks use no model calls; the task above is the one new paid +attempt. Memory, steering, semantic tool-call and terminal failures +still require their own evidence-backed corrections before retries. Keep the +dedicated key's $5 lifetime cap and the $100 campaign ceiling. The frozen +model and low thinking remain unchanged. Production, merge and release remain +held. + +## Failed reusable-lease deletion correction — 2026-10-06 + +Review of `fc661a364` identifies a further crash window: issue/workspace +closure calls provider destruction before recording `pending_cleanup`. The +correction now shares one durable, scoped cleanup claim with environment +deletion. The atomic claim re-checks company, environment, issue/workspace, +lease policy, current status and holding-run liveness. It records an attempt +identity and renewable cleanup ownership before provider work, preventing +concurrent closures or sweeps from destroying the same resource. A failed +provider call or settlement retains the handle for recovery. Controller loss +leaves bounded ownership which the sweep can reclaim after expiry. + +All 288 environment tests and the complete server typecheck pass with this +correction, without provider credentials or changed test deadlines. Six new +regressions fail semantically on the preceding shipping source: two closure +paths lack the durable record, environment teardown lacks exclusive cleanup +ownership, and queued, scheduled-retry and running transitions bypass the stale +closure live-run check. The failed log hash is +`3e87d913a7ecfadf5a5e8988526b6f8236d602d18edc32d28aeb5406e370b203`; +the corrected suite hash is +`9a21949740b6cdc6055950bb847eae546a071dc7e4ce9dc5c410a3cf0d88f30b`. +The first free regression also contains two test-spy restoration failures; +that output remains preserved. Correcting only spy restoration permits the +six semantic failures above; no assertion or deadline changes. + +The preceding `fc661a364` revision completes full build, recursive typecheck, +Product harness typecheck and all 1,847 Product unit tests. All 18 freshly +packed archives install with verified integrity and its Darwin x64 binary +compiles. Its intermediate image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:4238d570faac69149ab718bc0a336876b2afbef6924ab2c1eccbaddb2a347d65`. +That exact head has 52 successful checks, two skips and one failed Greptile +cleanup review. These artifacts and checks stay historical for the new claim +correction; do not admit or qualify them as the final shipping set. No paid +attempt runs during these corrections. + +An independent check finds the Daytona pending-permission Stop child still +started after the canonical case passes and environment deletion returns 200. +The fixture environment and child labels match the actual run. Its canonical +pass is preserved, but it supplies no qualified pass. Exact-owned manual +teardown deletes the child; a new SDK lookup returns 404. The launcher, +temporary root and uploaded credential file are independently absent. The +actual retirement proof records `qualification: false`. Its canonical result +hash is `9154d3eed0cf909cb2299bc56dfd32d8275f8d9c333a81266005114f6ec50385`. +The lease's final database state is not retained, so this observation alone +does not establish the underlying lease transition. + +A separate free database regression proves a concrete deletion gap. A failed +run can successfully stop a reusable sandbox and record its lease as `failed`. +That lease still holds the provider handle, but the deletion preview, atomic +delete guard and scoped teardown all omit it. The original source fails three +regression cases. The correction includes failed reusable leases in all three +paths. It preserves explicit destruction consent, exact provider scope, the +live-run fence and the durable `pending_cleanup` claim before provider work. +All 279 environment service, runtime and route tests pass without provider +credentials or changed deadlines. The failed regression log hash is +`441f3d39f33f74c2b323b8bc63993f660a4b48016dc31cc88847ee3dc7b65682`; +the corrected-suite log hash is +`c809d93df9e104ad8eb865694cbb9817cd65a768ceeded44e0dd246d4283d977`. + +This changes shipping inputs. Rebuild every public archive and the Linux image, +then freeze and qualify that set. The previous `c0eba7f4d` set has 8/26 Product +and 5/7 Runner passes, including fresh Daytona hello-complete. Those passes +remain historical for this correction. The Daytona Stop case remains held +until a fresh corrected-set attempt proves cleanup without manual intervention. +The memory, steering and Runner terminal/tool-call failures still need their +own proven corrections; this cleanup change does not authorize their retries. +All 26 Product and seven Runner cases remain required on the final set. + +Review also identifies the issue/workspace closure path, which omitted failed +reusable leases. The follow-up includes them there while retaining company and +issue/workspace scope and the live-run check. All 282 environment tests pass, +including failed-lease closure for each scope and rejection of another company. +The complete server typecheck passes with all corrected services and tests. +The corrected-suite log hash is +`9b965b9cf0acc1dc49340f85d4efb5588f6f59b3bc2522df7ec96d3bb515443a`. +The first filtered review regression reaches its unchanged database startup +hook timeout before any test executes. That failure stays preserved and its +owned Postgres process is independently confirmed absent. It proves no semantic +regression result. + +Two unchanged pinned-runtime controls also pass with no provider credentials: +native agent-file write/read preserves the exact final LF, and serialized RPC +warm recovery preserves that memory across both sessions and reaches both +terminal turns at low thinking. The synthetic server supplies six requests; +external socket connections are denied before connect. The log hash is +`b902ea76b2d0ee377dc28c42e7fe96d91e9e92671060380fe2dd87901b801797`. +These controls narrow the failing live memory/terminal investigation. They do +not attest its missing raw write arguments or regrade either live failure. + +The first cleanup revision `3f6b11a1e` passes full build, recursive typecheck and +Product harness typecheck. Its qualification-only Linux image is built and +published at +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:ab05e949af8ccff1a1f94317272ce5d934398a9548b6197de273bfb942bf74a4`. +The issue/workspace follow-up changes shipping inputs again, so that intermediate +image is historical and is not admitted or qualified. Preserve its receipts. +No new paid attempts run in either correction. Latest-head CI and review remain +required. The first cleanup head fails its connection-intent browser scenario: +its continuation reaches `cancelled` where the unchanged test requires +`succeeded`. Keep that failure and inspect the actual run before changing code. +The first local diagnosis cannot launch Chromium from its isolated home. +Supplying the exact cached Chromium revision passes the unchanged scenario +with a full trace: both runs succeed, the provider is called once more, and +the task is done. This does not regrade the failed CI run or prove its cause. +The fresh `fc661a364` CI browser shard and aggregate subsequently pass. + + +## Linux process-birth correction — 2026-10-06 + +The prior-set local pending-question restart failed before controller shutdown because the +unchanged ownership guard rejects the public run's daemon birth receipt. Its +canonical failed grade and passed cleanup remain preserved. Actual SDK inspection +proves the launcher and temporary fixture root absent before releasing the claim. + +A zero-provider Linux control reproduces a concrete mismatch: production records +`/proc/PID` directory ctime, while the strict restart guard observes actual birth +through `ps`. The installed daemon's ctime is `18:46:34.102Z`, but its kernel +birth is `18:46:33.800Z`. The same unchanged guard rejects the former and accepts +the latter. It also rejects a deliberately wrong birth receipt. The daemon's +identity stays stable through delayed inspection and its process is retired. +The proof is hash-bound at +`32580fe4f97a9799049ae887ff5652f4b5243cc53c3c2e77404bb13e0c40f32e`. + +The correction shares a kernel-tick birth reader between server process receipts +and retained Runner maintenance. Malformed metadata fails closed. PID, group, +ancestry, role and cleanup checks stay intact. Parser, hot-restart, native-recovery +and process-owner checks pass 94 tests; one Linux-only parser integration is +skipped on Mac and the separate installed Linux daemon proof passes. Runner, +server and Product E2E typechecks pass. The rebuilt vendor-boundary set now +passes the corrected local restart and the fresh Daytona restart measurement, +with all six unchanged matchers in each case. Both preserve the original native +question and turn through controller restart. Canonical cleanup passes; actual +SDK observations prove owned launchers, temporary roots and uploaded credential +files absent. The Daytona child sandbox independently returns 404. The prior +set's 1/26 and 6/7 passes remain historical. Its canonical full Linux command +finishes with one failure caused by the driver forcing `GIT_CONFIG_GLOBAL` +into a test that expects the normal unset variable. Omitting that driver-only +setting passes the same unchanged test file without provider calls. The full +Linux command remains failed; the narrow correction does not regrade it. +The missing-LF memory failure and finish-task stream timeout still need a proven +correction. Production remains held. + +The first birth-reader build at `9fdcefcc3` passes full build, recursive +typecheck and harness typecheck, and all 18 packages are repacked. Its normal ARM +install and Pi setup pass, but offline admission fails because the server imports +the private Runner package instead of its bundled vendor boundary. The browser +CI shards and company-import test show the same missing-module error. Those +failed receipts and image remain preserved and supply no qualification evidence. +The follow-up routes the import through the existing vendor shim and mirrors the +compiled Runner export there. All 39 hot-restart and native-recovery tests pass +with that correction. The artifacts from this follow-up are rebuilt and admitted. +The exact nine Linux birth-reader tests also pass on Linux without skips or +provider calls. The 2026-10-06 18:58:24 UTC key snapshot records provisional +usage of $0.358793129 of $5, with $4.641206871 remaining and zero BYOK usage. + +The corrected vendor-boundary source is +`c0eba7f4d94e01bd74e4342a258842a2b10d798b`. Full build, recursive typecheck, +harness typecheck and all 1,847 Product fixture tests pass. All 18 public package +archives are freshly packed and integrity-verified. Normal ARM Mac, Darwin x64 +under Rosetta, and native Linux install, Pi setup and offline admission pass +without provider credentials or a binary override. Physical Intel hardware +remains unverified. Independent SDK observation confirms the Linux admission +sandbox absent, and the superseded Product controller is retired after both its +jobs finish and every runner process is absent. Their evidence stays preserved. + +The rebuilt immutable image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:6029b52f8266a64b84160b51b1cd68914b7ea42657896fa30358834254aab071`. +Anonymous digest/source-label verification passes. The freeze manifest is +`sha256:e15b2b70cad3890555e422a82e62718d1eadf55c3557920a662d6108b596cf2b`. +The evaluation consumer verifies 722 packages and 79,367 files with zero +mismatches. Its installed Runner matches all 1,334 freshly packed distribution +files. All seven pre-service Runner admission checks pass, with profile 14 +rejected. This rebuild and admission work makes zero model calls. Frozen-set +qualification on that source is Product **8/26** and Runner **5/7**. Local hello-complete, +human permission denial, restrictive denial, native controller restart, pending-permission Stop and native questions pass; +Daytona native controller restart and hello-complete pass. The packaged Runner task-context, context-before-action, document creation, context/document/progress and governed-wait +cases pass every unchanged check with native-confirmed low thinking, no retained +session and independent absence checks for each launcher, daemon and provider. Canonical +results, archives and actual cleanup proofs are hash-bound in +`qualified-cases-c0eba7f4d.json`. Historical passes do not qualify this set. +The canonical `pnpm test:run` finishes with 14,976 passes, two failed tests +and one additional failed suite. PostgreSQL startup and two timeouts fail. +A separate credential-free check preserves all three files and their deadlines. +It passes the PostgreSQL and managed-auth suites (71 tests). The Git streaming +suite still exceeds its original five-minute Mac deadline, then reports +`ENOTEMPTY` during fixture removal. Both failed commands remain failed. +The full log hash is `0b0b3f24808fbac4bb2139bfa00d89f4d9a408551f7dafae3c4a41c5f3bdfc7b`; +the isolated log hash is `13cfe5815aa26cbcc1bb008c05df085824150fec2a271c4f5d92744157772e4b`. + +Shipping head `c0eba7f4d` has 53 successful CI checks and two skips. Review +head `568b7023b` finishes CI with one browser scenario failure and a failed +aggregate; its other 45 CI jobs pass. The chat-retry fixture clicks during UUID +to canonical-agent navigation, which reloads the agent query and can discard +the retry mutation. Waiting for that existing redirect before clicking preserves +all denial, authority and navigation assertions. All nine unchanged retry +scenarios pass against the normal installed frozen UI without provider +credentials, changed deadlines or retries. This free browser fixture correction +changes no packaged runtime or Pi case definitions. Review head `7b674113efa1e927dcb3d29f3fd84d1f1cbd0e4a` passes 53 CI +checks with two skips. Greptile reports 5/5 on that exact head, and #14956 has +no unresolved review threads. Later evidence-only heads need their own checks +and review. Prerequisite #14921 retains its valid premature-admission finding. The missing-LF memory and finish-task stream failures stay +held for a proven correction. The historical memory request has no provider I/O +log, and its canonical archive contains no database or provider session file; raw +write arguments remain unattested. Keep the strict graders, model, low thinking +and budget caps. Drain active native Linux runs before updating or rolling back across the +process-birth change; never rewrite a live receipt to force adoption. Do not +merge or release. + +## Closed qualification failures — 2026-10-06 + +The fresh Runner human-confirmation case reaches its original 120-second +provider-turn deadline before any control-plane operation or human interaction. +The native transcript contains a DSML invocation of +`mcp__paperclip__request_human_input` as assistant text, followed by a claim that +a card was created. The mock state records no such operation or card. There is +no terminal turn. Provider metadata records Inceptron HTTP 499 after 119.7 seconds +at 5.3 tokens/second. This observation narrows the failure to native tool-call +output and cancellation; it does not establish which component produced that +text. Preserve the failed score and verified process retirement. Do not parse +assistant text into an authorized operation or repeat the attempt unchanged. +The artifact hash is +`310717c7af7dd031e7db79f09d61ec5cc86ae6828a2e1958112dd027c325df44`. + +The fresh local same-turn steering case fails before steering begins. Pi starts +`bash` and requests permission for `Pi bash`, despite the fixture requiring one +native `write` and forbidding bash. The required write permission never appears. +The native session reaches its unchanged 120-second deadline. The canonical +cleanup assertion remains failed because its required Stop/no-effect sequence +is incomplete. Separate SDK checks prove launcher, runner, provider, temporary +root and uploaded credential file absent. That retirement proof does not regrade +the failed case. Hold it against unchanged retries. Its canonical hash is +`7d8f15e1d5e96693b28853608337be540fd8725b9a59a51a7766befe9152d585`. + +The installed native SDK write/read control preserves both a 33-byte LF input +and a 32-byte no-LF input exactly. Source inspection shows the managed file +materializer writes buffers and the checkpoint copies byte chunks. Neither +control attests the historical model's raw write arguments, which remain +unavailable. The missing-LF failure still needs a proven correction. + +The 2026-10-06 21:01:14 UTC pre-attempt key snapshot records provisional usage of +$0.378666789 of $5, with $4.621333211 remaining and zero BYOK usage. Fresh signed +browser observations show all 72 providers unconfigured and the same key's $5 +TOTAL cap. Keep the $100 campaign ceiling, no reset, no fallback key/model and +zero automatic retries. Physical Intel installation remains unverified; the +existing repository workflows do not expose an Intel Mac installation lane. + +## Previous frozen-set measurements — 2026-10-06 + +The rebuilt `7f66a30ad` package/image set has Product **1/26** and Runner +**6/7** verified passes. The Daytona controller-restart case passes all six +unchanged matchers: the same unanswered native request survives the restart, +the browser supplies its exact answer once, the original native turn completes, +and independent retirement and canonical cleanup pass. Actual SDK observation +also proves the child sandbox, launcher and temporary fixture root absent. +The former restart failure remains unchanged; its canonical hash is retained. + +The new local memory measurement fails. Its first invocation stops before any +provider construction because Daytona-only plugin pins were supplied to a local +selection. Removing only those invocation pins passes actual installed CLI +admission plus positive and negative local/Daytona controls without tokens or +shipping/grader changes. The single corrected measurement then reaches the +provider and fails at the original 120-second native-session deadline. Closed +native shell output independently reports 32 bytes without LF before the timeout; +the provider also used an extra shell inspection. Cleanup passes, its launcher +and temporary root are verified absent, and its failure stays failed. The raw +write arguments are not attested, so the location where LF was lost remains +unproven. Do not infer a transport defect or repeat the paid attempt unchanged. + +The native packaged Runner passes task context, context-before-action, document +creation, human confirmation, context/document/progress and governed-wait +workflows on this same freeze, model and low thinking. +Every measured case has one attempt, a canonical score, verified native identity, +no retained session and no raw-key leak. The historical finish-task timeout remains +held pending a concrete correction; a successful task mutation alone is +insufficient terminal evidence. + +The new Linux controller completes all free admission: its consumer graph checks +722 packages and 101,216 files with zero mismatches, the public plugin graph +checks 191 packages and 16,365 files with zero mismatches, actual Linux observer +controls and browser arguments pass, and all 26 cells collect. Local execution +cells may run on this Linux controller; their `local` driver, real server, +native provider and unchanged graders remain in use. Mac installation evidence +is separate from the controller platform. + +Documentation head `99e823164` has 53 successful checks, two skips and no open +#14956 review threads. The corrected source already passes full build, +recursive typecheck, harness typecheck and 1,847 Product fixture tests. The old +canonical Linux full test command completes with 15,077 passes and 57 skips in +its first lane, but later fails because `npm` is absent from the test PATH. The +normal installed npm CLI is added only to the owned test tools; the exact failed +packaging test passes on current source. The subsequent storage preflights remain +failed records. Two orphan HTTP fixture processes are retired only after their +exact process identities and old test-root bindings are verified. One closed +test root is fully archived before removal, restoring the original disk reserve; +the second root remains intact. Current-source recursive typecheck and full build pass. The canonical full +workspace tests run on their retained session with unchanged deadlines. Never +stop or restart a live handle based on an observation timeout. + +The release gates remain open. Keep the `$100` campaign and `$5` lifetime key +caps, 72 unconfigured BYOK providers, frozen model and low thinking. That earlier +measurement recorded provisional key usage of $0.357317585 of $5, with zero +BYOK usage; the newer snapshot above supersedes that accounting. The count +above contains only current artifact measurements; historical passes do not +qualify changed shipping inputs. Keep rollout held and do not merge or release. + +## Sandbox GitHub housekeeping correction — 2026-10-06 + +The shipping correction moves sandbox GitHub launchers, upload locks and +per-command configuration below `.paperclip-runtime/paperclip-runner/github`. +One shared path now binds staging, the native callback bridge and cleanup. +Local and SSH locations keep their existing behavior. The native watcher, +excluded subtree, exact byte grades, timeouts and lifecycle assertions do not +change. Shipping inputs change, so rebuild and freeze packages and the Linux +image before any further paid qualification. The new set starts at Product +0/26 and Runner 0/7; do not carry passes across changed shipping inputs. + +The corrected shipping source is `7f66a30ad7723c1fbcb6ef64b3281eaebddada29`. +All 18 public package archives are rebuilt and frozen. The immutable Linux image +is `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:8e744211ded7c19015aececeb87da4e1ee994ca9d85c6fbbebcd916682425231`. +Anonymous pull, source labels, Daytona import and normal Linux installation, +Pi setup and offline admission pass; the admission sandbox is deleted. Normal +ARM Mac and x64 Node under Rosetta install/setup/admission also pass. Physical +Intel hardware remains unverified. The evaluation consumer checks 722 packages +and 79,363 files against public archives with zero mismatches. A separate normal +ARM native-build reproducibility diagnostic retains differing generated bytes; +that failed proof supplies no qualification evidence. + +The freeze manifest digest is +`3ec8199c8a933fb887920cfab60e4c6883fee04874b823b20d0413609b3638b7`. +All 1,847 Product fixture tests, harness typecheck, full build and recursive +typecheck pass on the corrected source. That commit has 53 successful CI checks, +two skips, Greptile 5/5 and no open findings on #14956. Later documentation heads +still require their own CI and review. The superseded owned Product controller +is verified absent after all its processes retire. A new controller prepares the +frozen corrected packages, companion and browser environment without provider +credentials. Its free admission and exact live restart measurement remain open. +No paid attempts run during this rebuild; the new counts remain 0/26 and 0/7. + +The retained `0cff2b20b` set has Product 1/26 and Runner 3/7 passes. Its corrected +memory case writes exactly 33 bytes with LF, durably saves them and reads them +from a fresh task after server restart. Runner context, context-before-action +and document creation pass. The finish-task case applies exactly one successful +task mutation but never reaches a terminal turn before its unchanged 120-second +deadline. Read-only provider metadata shows the post-mutation stream cancelled +without a finish reason near that deadline. Its cause remains unproven. The +corrected Linux restart retains its +pending request and accepts the exact browser answer, but still fails terminal +evidence and canonical cleanup. Its new closed diagnostic is +`unwatched_directory`; all observed runner processes have retired. Independent +cleanup proves the child sandbox, controller process and temporary root absent. +Neither failure is regraded or retried unchanged. + +Two zero-model Linux controls identify a concrete housekeeping defect. Restaging +the same launcher bytes creates temporary upload locks outside the excluded +runtime and reproduces `unwatched_directory` with 22 workspace mutations. A Git +command creates the same failure with five mutations. Both processes retire. +With the correction, the unchanged actual Linux observer returns complete +terminal evidence for both controls; launcher bytes and user files stay intact. +All 35 affected Linux lifecycle and launcher tests pass, including the real +watcher regression and its outside-runtime negative control. All 60 affected +Mac launcher, credential and native-session lifecycle tests pass; the Linux +inotify regression is platform-specific. Shared and adapter-utils compile. +This proves the correction, not the cause of an unrecorded path in an older +failure. A fresh live restart measurement is still required after the rebuild. + +The old-set Linux build and recursive typecheck pass. Its full test command +retains 15,076 passes, 57 skips and one disk-reserve failure. Removing only the +unused owned pnpm download store restores space without changing installed +files, source or assertions. The failed test then passes, and the canonical +full test command runs again with its unchanged 90-minute deadline. The owned +sandbox retention covers that command; host settings and unrelated services +remain untouched. Latest-head checks and the complete commands must also pass +for the newly committed source. Keep all old results as historical evidence. + +No paid attempt runs during this correction. The last provisional dedicated-key +accounting is $0.34323782 used of its $5 lifetime cap. The signed-in default +workspace still has 72 unconfigured BYOK providers. Keep the approved $100 +campaign cap, frozen model, low thinking and zero automatic paid retries. +Rollout remains held. Do not merge or release. + +## Historical rebuilt candidate and exact write arguments — 2026-10-06 + +Shipping source is now `0cff2b20b5ea785880ad119dde59e6f011a3f9e1`. +All 18 public workspace tarballs are freshly packed from that source. The Linux +image is `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:f8cfc51909edbc728063e815a2c81ce9169ab58784d1f7c26f7a9f422b3f718f`. +Anonymous pull, source labels, immutable Daytona import and normal Linux npm +installation, Pi setup and admission pass. Its owned admission sandbox is deleted. +Normal npm lifecycle installation, Pi setup and admission also pass on ARM Mac +and with x64 Node under Rosetta on ARM Mac. The latter proves the Intel package +path under emulation; it is not a physical Intel hardware test. + +Pi native definition v15 is +`d4fdf90fbed8741f53f2cbea921ef1b86d181f63f00c10beb999cf40f2e43578`. +The memory prompt now supplies native write arguments directly. Only the +AGENT_HOME path prefix may change; content keeps its JSON newline escape. +The exact 33-byte/LF grade, one write/read, cross-root denial, fresh-task readback, +deadlines and zero automatic retries remain unchanged. The failed live input +was not retained, so the prompt correction still needs a new live measurement. +Free installed Pi parser/agent-loop/permission/native-tool calibration takes the +new argument object: 33 bytes stay 33 and the 32-byte negative still fails. + +All 1,847 Product E2E fixture tests across 93 files and harness typecheck pass. +The first broad run retains one failure in an existing Copilot fixture: its +compound prohibition on publication and attachment triggered the production +delivery gate. Separate prohibitions fix the fixture; a requested attachment +still triggers that same gate. Copilot definitions advance to v9 and remain +pending. No production delivery rule or qualification grader changes. + +Build and recursive typecheck pass at the shipping source. Fixture head +`6c09e4c87` has 53 successful GitHub checks, two skips, Greptile 5/5 and no +unresolved review threads. The first full local workspace run retains 15,042 +passes, 87 skips and five plugin auto-build failures. Its launcher globally set +`PAPERCLIP_DISABLE_PLUGIN_AUTOBUILD=1`. Removing that test-environment flag makes +all 11 affected tests pass with unchanged source and assertions. The corrected +full `pnpm test:run` child reaches terminal failure after its launcher was +interrupted: 12,920 passes, 2,210 skips, four failed tests and 36 failed files. +The terminal log and process retirement are retained separately from its stale +launcher receipt. PostgreSQL startup failures account for many failed hooks; +three other tests fail on timing/socket recovery. Read-only inspection finds all +32 host shared-memory slots occupied. A separate owned bootstrap probe passes, +so resource pressure is not an established cause of every failure. Host settings +and unrelated services remain untouched. A separate native Linux full check +starts only after Mac retirement, using the frozen image, exact private dependency +lock and Rust 1.97.0. Its initial missing-header and header-permission failures +remain retained. Installing all 2,810 matching official Node development headers +in the owned test sandbox leaves Node bytes unchanged. The canonical build, +typecheck and full tests now run there with unchanged source and assertions. +Fixture-only edits do not change package or image build inputs. + +The fresh Linux controller passes installed CLI startup, health and browser +checks. Its exact consumer graph checks 722 packages and 101,216 files; its plugin +graph checks 191 packages and 16,365 files. Both have zero mismatches. The real +observer calibration keeps the absent-parent and old inside-memory scratch +controls failed. Seeded memory, native sync and corrected outside-memory fallback +have complete terminal evidence and retired processes. All native memory writes +retain 33 bytes and LF. The current browser POST has the same complete native +argument object, public parent setup leaves the target absent, and owned cleanup +passes. Fixture checks and collection of all 26 Product cases pass. The failed +browser helper path and subsequent write-once log collision remain retained; +the corrected free phase reuses its already passing graph and observer controls. + +Private Runner definitions are now `dc97fdcbef5de3ea061bc1f0a3b68af6e617f0bc` +in draft [Evals PR #44](https://github.com/paperclipai/paperclip-evals/pull/44). +They bind native profile 15 and its platform closure digests. All 76 admission, +roster and campaign tests pass with supported Python. All seven requests pass the +actual installed CLI checks before provider construction; profile 14 is still +rejected there. The normal Runner tar matches all 1,330 compiled files in the +frozen installed server. Case and roster hashes, scoring function bodies, model, +low thinking and limits are unchanged. The final correction names the matching +profile 15 pack in the run guide. Latest-head CI passes, Greptile gives 5/5 and +the guide finding is resolved. These free checks do not qualify a model. +No new paid attempts have run. Current qualified counts remain Product 0/26 and +Runner 0/7. Keep rollout held for the live newline/restart proof, all 33 cases, +complete checks and prerequisite review. Do not merge or release. + +## Restart transfer correction — 2026-10-06 + +The current source changes shipping inputs. The `f5024863e` packages and image +remain historical evidence; rebuild and freeze a new set before any live +qualification. No new paid attempt has run. Current qualified counts remain +Product 0/26 and Runner 0/7. + +A credential-free regression reproduces the fallback restore defect after +controller recovery: file bytes survive, but transfer creates a reserved +`.paperclip-runtime/agent-files` directory inside native `AGENT_HOME`. The +product's complete agent-directory probe rejects that directory. Transfer +scratch now lives in the original materialization's host runtime area, outside +native memory. Both staging and recovery use the same lease-confined path. +Owned cleanup removes that path with the corresponding agent copy. The strict +probe, byte checks and qualification graders are unchanged. The actual fallback +regression fails before this correction and passes afterward. This identifies +a product defect and matches the prior Linux watch calibration; it does not +regrade or identify the missing reason in the original v13 restart receipt. + +A stronger free byte calibration exercises the installed OpenRouter stream +parser, Pi agent loop, real RPC serializer, permission extension/bridge and +native write/read. A synthetic response split at every argument character +preserves 33 bytes and LF through each boundary. Its 32-byte negative control +stays 32 bytes and fails the exact grade. It makes no network requests or model +calls and does not qualify the model. The retained Mac and Linux run logs have +no native argument fields, so neither failed live input's byte count is known. +The earlier statement below about 33-byte native input is corrected to describe +the task specification. A live newline correction is still required. + +The initial database tests could not start because the owned dependency copy +omitted its pinned Postgres package's library-symlink postinstall. Restoring its +17 declared links fixes `initdb --version`; no binary, host setting, unrelated +service or shipping dependency changes. Preserve the failed test logs. Keep +rollout held until the new package/image set, all 33 cases, platform installs, +full checks and prerequisite reviews pass. No merge or release is authorized. + +All 92 memory/probe/cleanup tests and 146 sandbox transport controls pass. +Adapter-utils and server typechecks pass. Broad transport discovery also ran +stale compiled tests from `dist/`; its failures remain retained. The canonical +stable runner excludes `**/dist/**`. The source-only run has 1,421 passes, +11 skips and one crash-helper failure. Its helper launched the holder through +the `tsx` CLI. Direct Node with an imported loader binds SIGKILL and exit to +the actual holder; all 12 lock controls then pass. The original broad failures +remain retained. A complete rerun on the final source is still required. +New-head CI, full build/typecheck/tests and new distribution proof remain open. + +## Case corrections — 2026-10-05 + +The historical profile-14 proofs are **28/33: Product 21/26 and +Runner 7/7**. They do not qualify the current profile-15 runtime. The two corrected +Runner prompts pass at private definitions revision `7b112ffe3` with unchanged +graders, one attempt per correction and zero automatic retries. Their original +failures remain retained. The Mac file-edit correction also passes. + +### Current fixture corrections and remaining failures — 2026-10-05 + +Shipping runtime remains `f5024863e`; these changes affect only fixtures and +documentation. Pi native definition v14 is +`8a93306b6df0008ff10b5398049fad7b4bfa7a7cbf4f4ee0dedfe76eed6f0111`. +The model, profile 15, low thinking, byte grades, deadlines and automatic retry +count remain unchanged. All 693 Pi fixture tests across 20 files and the final +harness typecheck pass. + +The diagnostic-storage regression also reproduces a review finding: a failed +diagnostic write replaced the original incomplete-terminal error. Both the main +flow and cleanup now preserve that original error. Failed diagnostic writes +remain eligible for another save during cleanup. Successful saves stay unique. +The regression fails before the correction and passes afterward; this evidence +fix does not authorize a paid retry. + +The two explicit v13 attempts at harness `b3318d6be` remain failed. Restart +retains the original question, run, turn, session and producer and accepts the +exact browser answer. Its provider run succeeds, but the observer is incomplete +and canonical cleanup fails after lease release. The memory attempt also +completes its provider turn. Its retained public managed-file response has +32 bytes without LF, so the exact 33-byte assertion fails before the fresh +task. Its observer and canonical cleanup also fail. Both evidence manifests +have no missing files or reported leaks. Independent cleanup verifies both +owned child sandboxes absent. Original grades and receipt hashes are unchanged. + +The observer now retains bounded, closed failure reasons. A valid terminal +receipt can close the observer without a second RPC after lease release only +when it proves captured processes retired and has complete evidence or known +filesystem-watch failures. Unknown causes, reused process identities and live +attached processes still require cleanup proof. An incomplete filesystem +receipt still fails qualification and cannot supply qualified file bytes. +The released-lease regression fails before this correction and passes afterward. + +Before memory-task admission, the fixture uses the public managed-file API to +create `memory/.pi-e2e-parent.txt`. The actual memory target remains absent. +Both turns must preserve the setup file. This avoids a new directory racing +strict watcher installation. Actual Linux observer calibration reproduces the +failure with an absent parent and passes with the seeded parent. Installed Pi +native write preserves 33 bytes in both controls. Generic transfer scratch +creation also reproduces an unwatched-directory failure; native sync avoids it. +This supports a restart-path hypothesis, but the v13 receipt does not identify +the actual restart cause. No speculative shipping correction is made. + +Normal installed Mac startup, the public parent API and actual browser prompt +submission now pass with zero model calls and cleanup passing. The submitted +JSON content contains all 33 bytes, including LF. The initial Mac startup +failure remains recorded: shared-memory capacity was exhausted. Capacity later +freed without host setting changes or stopping unrelated services. The Linux +parent API check also passes; its combined auxiliary browser probe remains +failed because it selected an unavailable browser cache. Earlier installed +Linux browser proof remains a separate passing receipt. + +The live v13 memory tool input is unavailable after owned temporary-root +cleanup. The saved 32-byte file does not prove whether the model omitted LF or +a product boundary removed it. Parent setup and cleanup corrections do not +address that byte failure and do not permit an unchanged paid memory retry. +Keep both live cases held until a concrete correction addresses the observed +failure. Then run one explicit attempt per correction, with zero automatic +retries. Fresh full Product 26/26 and Runner 7/7 proof, Intel installation, +complete workspace tests, latest-head CI/review and prerequisite disposition +remain required. Current-runtime qualified counts remain zero. + +Final read-only key accounting observes $0.324966594 used, $4.675033406 remaining, +zero BYOK usage and the unchanged $5 lifetime cap without reset. Billing remains +provisional. The $100 campaign ceiling and no-merge/no-release instruction remain. + +### Prior v13 boundary corrections — 2026-10-05 + +The next fixture correction preserves the same `f5024863e` shipping inputs. +The observer now accepts a directory notification only for the same device/inode +with an already registered recursive watch; every event still counts. An actual +generated-observer regression fails before the correction and passes afterward, +including nested transient writes. New directories, replacement inodes, symlinks, +unknown notifications, and recycled process identities remain failures. +This reproduces a fixture defect, but does not identify the missing original +restart terminal snapshot's exact cause. A corrected live result is still needed. + +Pi native definition v13 removes the competing bare nonce from the memory +instructions. Its sole JSON content specification still decodes to 33 bytes with +one final LF. Frozen native parser/validator/write/read preserve those bytes +without provider calls. The failed run's task specification contained 33 bytes; +its native input was not retained. Its native read returned 32; original managed +content was not captured. No byte +conversion was found in the wrapper or public file API. The prompt correction +addresses a copying ambiguity; it does not establish a product byte-loss cause. +The missing-LF negative control and exact saved/readback assertions stay in place. +All 207 focused fixture regressions and the harness typecheck pass. One unrelated +Copilot bootstrap prompt assertion remains a preserved failure in the broader +support run. The affected live cases remain pending until their new receipts close. + +Current shipping artifacts are bound to `f5024863e9ed014e2661e6d7f23fc0e6b32e0911` +and profile 15. The normal public ARM graph verifies 722 packages and 79,373 +files with zero mismatches. Full build and recursive typecheck pass. Normal +Linux public install, companion import, native admission and browser startup +pass against immutable image +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:20c7fffddeee4298830ead1ea3f0b70819f61e549279588b605440d1155f7260`. +Its controller passes 342 fixture checks, 21 native fault checks and collection +of all 26 Product cells with zero model calls. An installed-UI intercepted POST +proves one fenced prompt with valid JSON, all 33 content bytes and no duplicate. + +The canonical-root Mac attempt uses shipping runtime `f5024863e` and harness +`df748c997`; the recorded diff contains only fixture infrastructure and the plan. +The provider now completes its first turn and writes to the correct native +agent directory. Its native read returns the 32-character nonce without LF. +The exact managed-byte assertion fails; canonical cleanup passes. The public +description independently contains one valid JSON content value of 33 bytes. +The original managed-file response was not retained before that assertion, so +its exact returned bytes cannot be claimed from the saved evidence. A free +check of the frozen Pi JSON parser, argument validator, native write and native +read preserves all 33 bytes. There is no proven byte-trimming product defect. +Do not weaken the byte grade, add a newline in the product, or repeat this paid +attempt without a concrete correction. + +The next Linux restart attempt retains the same native request, turn, run, +session and producer across controller restart and accepts the exact browser +answer. The original run succeeds. Its independent observer returns incomplete +terminal evidence; canonical cleanup also fails after public lease release. +Both owned child and controller are separately verified absent, and the active +campaign claim is closed. The canonical result stays failed. The missing +incomplete receipt prevents a precise observer-cause attribution; do not claim +that public run success proves the final independent file or cleanup grades. +The initial controller preparation also fails before provider calls because +consumer and plugin shared-package archives collide by basename. Separating +their staging namespaces fixes preparation with every archive digest unchanged. + +The fixture now records the managed-file response before grading and retains a +closed, validated summary of an incomplete terminal receipt before rethrowing. +It omits raw RPC content and file bodies. A missing-LF regression fails before +the capture-order correction and still fails the byte grade afterward while +retaining the response. These are evidence-only changes; no runtime input, +model, timeout, assertion or original result changes. They do not justify a +paid rerun solely to collect diagnostics. +All 207 affected fixture tests pass with filesystem-watch access, and the +harness typecheck passes. The initial sandbox run retains three failed watch +observations; it is not reported as a passing run. + +At `df748c997`, 53 CI checks pass with two skips. This is source-bound CI, not +proof for a later evidence-only commit. Current-source live qualification is +still incomplete: neither failed attempt qualifies its case, and historical +profile-14 or earlier profile-15 passes cannot certify this runtime. Review and +the complete workspace unit command remain held. Last read-only dedicated-key +usage is $0.319832563 against its $5 lifetime cap; the immediate restart delta +of $0.000785504 is provisional. No merge or release is authorized. + +The next explicit Mac attempt at `f5024863e` confirms that the description is +no longer duplicated, but still times out with cleanup passing. Its runtime +context advertises `/tmp/.../live`, while the native Pi grant binds the physical +`/private/tmp/.../live` directory. The fixture now canonicalizes its owned temp +root before configuring the instance and validates cleanup against that same +physical parent. A symlink-ancestor regression fails before the change. The +normal installed Pi policy rejects the old advertised alias, accepts the +corrected physical root, and continues to deny an unrelated root, with zero +model calls. The byte and timeout assertions remain unchanged. This correction +only changes the harness; shipping runtime artifacts remain `f5024863e`. + +The explicit version-12 Mac memory attempt at `867fa4711` still times out +at the unchanged native-session limit; cleanup passes. Its valid fenced JSON +arrives in a duplicated description: the Markdown paste capture inserts the +parsed content, then Lexical inserts the same plain text. The editor now stops +that handled paste before it reaches the inner editor. A regression fails on +the old propagation, and all 51 editor tests pass after the fix, including an +ordinary-text paste control. The token gates pass. This is a new shipping input; +the previous image and live result remain bound to `867fa4711`, and neither +is qualification of the corrected editor. No paid retry is justified until a +zero-model browser check verifies one submitted prompt with intact JSON bytes. + +Normal public installs of runtime `b012b3aebe` admit profile 15 on Mac ARM, +Mac Intel (through Rosetta), and native Linux. The native Linux image is +`sha256:3279d92405a59b4654cb6af5de27bfacffee73a2c57311bf5f2d5ff9272b9b67`. +The corrected remote provider-death case passes all six matchers and cleanup; +the native-questions case passes all 16 matchers and cleanup. These two passes +are source-bound evidence, not qualification of the whole shipping roster. + +The next Mac memory attempt fails at the unchanged 120-second native-session +limit, with cleanup passing. Its public description contains invalid JSON: +the shared issue validator converts the fenced content's literal `\\n` into a +real newline. Browser `.fill()` also exports an escaped paragraph instead of +a Markdown code fence. A credential-free actual installed-UI probe reproduces +that second boundary and confirms that Markdown paste retains the valid JSON +and all 33 content bytes. Both task submissions are intercepted, so neither +can create provider work. The shared validator now preserves real multiline +bodies; legacy single-line self-escaped descriptions retain their recovery. +The exact old/new validator proof fails before and preserves 33 bytes afterward. +All 837 shared tests pass using the canonical `/private/tmp` directory. + +The next remote restart attempt fails during controller process cleanup with +`Owned process group identity became uncertain`, before any replacement +controller starts. Its independently observed remote run retirement passes; +canonical cleanup remains failed. The exact owned child sandbox is separately +deleted and verified absent. A regression reproduces rejection of a replacement +group member whose ancestry still belongs to a separately validated owner. +Cleanup now admits only that proven ancestry; recycled groups and mixed live +ownership still fail before signaling. All 31 ownership/restart tests pass, +including a negative control for a recycled controller ancestry anchor. +This addresses a reproducible cleanup condition; the original failure lacks +the process table needed to attribute its exact uncertain group. + +`pi-native` definition version 12 records the prompt transport and cleanup +changes. The nonce-plus-LF, cross-root denial, fresh-task persistence, native +request identity, and deadline assertions are unchanged. Fresh installed +artifacts and explicit corrected live attempts remain required. The previous +`5a23ef659` head's 53 CI checks pass with two skips; this is not CI proof for +the new boundary changes. No merge or release is authorized. + +The provider-death fixture now admits the production runner's stable symlink +while still checking the resolved bytes, live Node inode and exact Pi-child +pidfd. All 21 Linux fault tests and 297 Linux fixture checks pass. The next live +attempt signals the correct Pi child and passes cleanup, but exposes a runtime +bug: its provider-loss expiry creates a durable question fallback that the +server mistakes for successful yielded completion. + +The runtime correction preserves that fallback while preventing governed-wait +settlement after provider loss. The Runner emits `turn.failed` for the lost +provider instead of `turn.interrupted`. The regression fails before the fix; +169 Runner tests and all 574 native-server tests pass afterward. This changes +shipping runtime inputs. Existing `0bd040093` artifacts and paid passes do not +qualify the corrected runtime; fresh public artifacts and source-bound live +qualification remain required. + +The memory prompt now states its nonce-plus-final-LF contract in plain text and +bounds the native write/read sequence. A free call to the frozen Pi tools +retains all 33 bytes, including the LF. The corrected local live attempt still +times out after native policy rejects paths outside the assigned roots. +Cleanup passes. Both failed corrected local attempts remain failed; the memory +case is not qualified. No byte assertion, timeout, model or permission boundary +is relaxed. Production remains held; no merge or release is authorized. + +The corrected Linux native-questions attempt delivers the first three typed +answers, then its editor call fails with `Pi question has unsupported fields`. +The original schema advertises fields that the selected method cannot accept. +The schema correction separates the four method contracts while retaining +strict handler validation. All 37 Pi extension tests pass. A credential-free +calibration proves that the installed old schema accepts an editor placeholder +that its handler rejects, while the corrected schema rejects that input before +execution. This correction also requires fresh runtime artifact qualification. + +The corrected Linux controller-restart attempt preserves the original pending +question and accepts the actual browser answer, then fails with +`native_remote_recovery_lease_mismatch`. Recovery was acquiring another sandbox +under the admitted ephemeral lease policy. The source correction reads and +validates the original active lease before any provider acquisition, retaining +the downstream process-generation and authenticated PRP identity checks. +The focused recovery suite passes 630 tests and the source-bound server +typecheck passes. The canonical cleanup failure remains failed; a separate +owned-resource check confirms the original sandbox is absent. + +The ordered local memory attempt reaches its registered directory and records +the expected cross-root denial, but completion rejects personal memory as an +unpublished file. Its qualifier was separated from the write by another +sentence, and its denial wording did not match the existing internal-file +rule. The fixture correction places the qualifier immediately after the write, +uses the recognized native-denial instruction, supplies a fenced JSON content +value with the final LF, and names the required objective `evidenceRefs` field. +The installed frozen server's free classifier check rejects the original +prompt, accepts this correction, and still requires publication of a separate +requested report. This failed attempt stays failed. All five remaining cells +and qualification of the new shipping artifacts remain outstanding. + +The first new Linux image build fails closed at the unchanged profile-14 +closure pin because the question extension changed. The profile-15 declaration +now binds that exact extension and all three regenerated platform closure pins. +Each regeneration verifies the retained manifest against its independent +profile-14 source pin and changes only the extension entry; actual new normal +installation on all three platforms remains required. The historical profile +fixtures remain intact. Profile 14 results cannot qualify profile 15. Pi 1.0.0, +pi-acp 0.0.33, ACPX 0.13.1, Node 24.21.0, the model and native low do not change. + +The first normal profile-15 ARM/Linux admission probes reject the descriptor +before any prompt: Rust still binds profile 14 while TypeScript sends profile +15. Those failed artifact proofs remain failed; the owned Linux admission +sandbox is deleted. The correction synchronizes Rust admission and its current +fixtures, adds a published-identity regression that fails before the correction, +and retains explicit rejection of profile 14. The remote-memory unit fixture +now parses the fenced JSON and independently checks all 33 bytes. Full build +and recursive typecheck pass at the prior source; new artifacts and all live +qualification remain required after this binding correction. + +The source-bound `b012b3aeb` artifacts now pass normal public profile-15 +admission on ARM Mac (8.618 seconds) and native Linux (8.498 seconds). The new +immutable image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:3279d92405a59b4654cb6af5de27bfacffee73a2c57311bf5f2d5ff9272b9b67`. +Anonymous pull, source identity, normal companion import, and Linux installed +server health/browser startup pass with zero provider calls. The temporary +image-admission sandbox is deleted. The Linux qualification controller remains +owned and bounded. Mac installed server startup fails before a model call; +its stderr-free database failure is still unexplained, and a fresh direct +initdb probe succeeds. Intel admission and fresh live qualification remain +outstanding. + +Current-head CI exposes two fixture races. The lease test constructs a second +timestamped fixture instead of comparing with the saved lease; all 37 tests +pass after retaining that fixture. A GitHub callback fixture reproduces its +failure when the root finishes before callback replay. Waiting for durable +admission and draining the subsequently scheduled work makes both orderings +pass without changing production worker code, assertions, or timeouts. These +test-only changes do not relabel the frozen runtime artifacts. Their new head +requires fresh CI. The prior full local test command remains failed; complete +local tests and release review are still required. + +## Frozen target + +- Pi: `@earendil-works/pi-coding-agent@1.0.0`. +- Wrapper: `pi-acp@0.0.33`; ACPX: `0.13.1`; Node: `24.21.0`. +- Current candidate: Pi profile 18. Historical profiles through 17 remain + decodable; only the exact current profile can launch. +- Current wrapper cancellation preserves partial usage and suppresses service + failure metadata for an acknowledged cancelled terminal. Ordinary failures + remain failures. Copilot profile 16 and Cursor profile 15 remain unchanged. +- Fixture model: `openrouter/deepseek/deepseek-v4-flash-0731`, native-confirmed + low thinking. Production models remain caller-selected, without fallback. +- Record exact source, definitions, installed package graph, daemon, image, + environment, cleanup and cost evidence for each attempt. + +## Release gates + +The qualification table at the top of this document is authoritative. The +profile-18 candidate requires new normal Linux installation, all 26 Product +cells and seven Runner cases, exact-head CI/review and a matching cloud image. +Retained older results are historical. They do not authorize launching or +qualifying an older profile. Read-only key usage at 2026-10-07 17:44 UTC is +$0.471827388 of $5, with no reset or BYOK usage; snapshots remain provisional. +All 73 visible BYOK provider rows are unconfigured. No merge, release or rollout +is authorized. The cloud builds use GitHub hosted Linux and an isolated Linux +package-build sandbox; Docker does not run on the developer Mac. + +## Bounded execution + +Run one explicit failed lifecycle cell after a specific source correction, with +zero automatic retries. Keep each failed attempt, its machine grade and its +cause. Do not regrade a failed attempt as a pass or rerun an unchanged failure. +After restart and warm continuity pass, run the remaining exact cells against +the fixed candidate. Use the canonical Product E2E and Runner report pipelines. + +Pi paid qualification uses the existing dedicated OpenRouter key with a $5 +lifetime credit limit. Do not reset the limit or fall back to an account key. +Check remaining credit and that BYOK usage stays zero before and after each +attempt. API key deltas are provisional billing observations. Pi model-catalog +prices are estimates. Unpriced usage must stay unpriced in the ledger and UI. + +## Historical qualification snapshot — 2026-10-05, before corrected passes + +This retained snapshot predates the completed corrections reported above. +At that point all 33 required cells were attempted: **25 pass and eight fail**, with none running or unattempted. +Product has 20 passes and six failures; Runner has five passes and two failures. +Remaining failures are local agent-files and file-edit; Daytona native questions, +agent-files, native controller restart and provider death; and Runner +context-before-action and finish-task. Preserve every original grade and source. + +The corrected Daytona file-edit attempt at harness `baaf444ed` passes all seven +matchers and canonical cleanup on extended definitions 3. Native identity proves +profile 14, the frozen model and effective low thinking. Its child sandbox is +verified absent, all four controller commands are terminal, the uploaded key +file is absent and the owned controller is deleted. Its corrected free preflight +passes 297 fixture tests, including 17 native Linux fault cases, and all 13 +browser-case collections. The initial 296-pass/one-failure command stays failed. + +The equivalent Mac correction is held before paid dispatch. Normal installed Pi +admission and 175 focused fixture tests pass. A complete audit verifies 101,216 +consumer files and all 24,352 imported companion entries. Fresh installed server +startup fails. A separate owned `initdb` probe explicitly reports SysV +shared-memory exhaustion, with all 32 host slots in use. That probe does not +retrospectively classify the earlier stderr-free failure. Use a Mac with capacity +for a fresh owned database. Do not repeat unchanged startup, delete shared +resources, change host limits or stop unrelated servers. No paid Mac correction +attempt has been dispatched. + +A network-blocked native Pi/Runner calibration uses one fixed loopback response +and zero real model calls. It reaches the unanswered `elicitation/create` +question with matching thread/turn/request bindings. The unchanged observer +signals only the exact Pi child through pidfd and seals complete retirement +without target effects. All 15 scoped strict checks pass. Its synthetic +controller records `runner did not durably suspend before checkpoint` on close; +production qualification and paid retry remain held. Two earlier calibrations +fail to reach the question and remain failed. The first wrapper's premature +success label is corrected by its strict verdict. All three owned sandboxes are +deleted. The original paid rejection stage remains unproven. + +Prerequisite fixes preserve every previous commit. The latest heads are +#14921 `7f452cbd7`, #14922 `b7e692f48`, #14923 `cca38f29a`, and #14924 +`c2e2e39db`. They carry the scoped UI/profile assertions and notice-display +corrections. The first prerequisite also receives the existing package-local +Runner probe import repair and remaining admission assertions already present +downstream. The complete repair includes both public probe export surfaces. +At `89f444850`, all 91 affected server tests and 75 sidecar tests pass, and +the complete Runner TypeScript package typechecks. A real, unmocked shim import +verifies all three probe function identities without invoking any probes. +The later ancestry merges retain identical tracked source trees to the +`0a3f265c3`/`30410cf2b`/`26802337c` 166-test proofs. No provider calls occur. The earlier +`f8bbeeb4c`/`c057c5746`/`78b02e6ba` heads pass 42 focused UI/live tests each; +`cee5c3fc6` passes 92. Their previous source-bound UI token checks pass. Retain +those source labels. Earlier commands with stale linked builds, incomplete +verification aliases, an incorrect config path, or a missing child Node path +remain failed or insufficient evidence. The corrected private verification uses +exact source aliases and the pinned Node directory; no repository configuration +changes occur. + +The preceding readiness integration `146f578c0` completes 53 successful CI +checks, two skips and a current-head 5/5 review. The new additive integration +changes only the six scoped installer/fixture/documentation files before this +plan update. The preceding `aef3b84ce` completes 53 successful +CI checks and two skips after one bounded rerun of the serialized sidebar job. +All six sidebar tests also pass locally. The original HTTP 500 cause remains +unproven; the failed log is retained. The earliest prerequisite's original +signoff-policy browser failure is separate: its issue-bound heartbeat run does +not become available. The intervening `337ca6bd1` CI retains a hosted-runner shutdown/cancellation +and a ten-second timeout in an unchanged Cursor fixture. All five current-source +Cursor tests pass locally; the original timeout cause remains unproven. The +partial import repair at `32b5e275d` also retains its three missing-export build +errors; the complete repair above supplies those exports. New-head CI and review +require their own terminal results. Old passes do not qualify a new head. +No repeated unchanged rerun is authorized. + +Pi execution inputs, profile 14, model/low and suite fingerprints are unchanged. +The public installer is corrected separately below; original installation +artifacts do not become proof of the updated bytes. +The source still declares Pi qualified: the production hold is the draft/release +gate, not a closed code admission gate. That admission review remains open. +No GitHub PR merge, release, automatic paid retry, fallback key or model change +occurs. + +## Public installer review corrections — 2026-10-05 + +The SDK fixture now derives the publication version from the actual SDK +manifest instead of pinning 0.3.1. Exact dependency identity, byte seals, +canonical roots and all existing ambient-Node negative fixtures stay enforced. + +The explicit CLI setup child and its bundled provisioner now preserve the same +closed allowlist: `PATH`, fixed `LANG`, optional `LC_ALL`, `HTTP_PROXY`, +`HTTPS_PROXY`, `NO_PROXY`, their lowercase equivalents, `SSL_CERT_FILE`, `SSL_CERT_DIR` and +`NODE_EXTRA_CA_CERTS`. The CLI enables Node's environment proxy handling before +the helper starts. Provider keys, `HOME`, npm configuration, `NODE_OPTIONS`, +`NODE_PATH` and TLS-validation bypass remain excluded. These settings apply to +explicit public downloads; the Pi execution closure and profile do not change. + +At prerequisite #14922 `fcef1eae9`, six CLI tests, eight installed-plugin tests +and two bundled boundary tests pass with zero provider calls/downloads. The +real CLI child verifies proxy activation and credential exclusion. The actual +bundled provisioner verifies its post-sanitization environment and rejects a +corrupt cache with network/child creation denied. At #14924 `5120ddc8f`, all +18 focused checks pass, including the two pre-existing ambient-Node negative +fixtures. The corrections are forwarded through additive merges. An initially +over-broad SDK whole-file equality guard is retained as a local verification +failure; the exact correction delta and preserved negative tests then pass. +Local missing-Commander and incomplete Product source-alias setup failures are +retained separately; no repository test configuration changes occur. + +Fresh review at `5120ddc8f` identifies omitted lowercase proxies. The scoped +follow-up at #14922 `b7e692f48` preserves `http_proxy`, `https_proxy` and +`no_proxy` through the CLI, provisioner and npm-download allowlists. All seven +CLI tests, eight SDK fixtures and two bundled checks pass there. Additive merges +preserve the later prerequisites' distinct runtime/build pins; their bundled +checks also pass. The overly broad whole-materializer equality guard remains a +local verification failure; exact six-file correction deltas pass afterward. + +A separate loopback-only calibration at #14924 `c2e2e39db` exercises the actual +bundled CLI and generated provisioner. Without its owned custom CA, the TLS +certificate is rejected before any archive request. With that CA, lowercase +proxies carry exactly one GET for the pinned Node archive; the owned endpoint +returns a deliberate 503 before any package is installed. Both children retire, +setup staging/locks disappear, the owned proxy/certificate fixture is removed +and no outside networking, real credentials or model calls occur. This is a +limited setup-boundary calibration with a private egress-denial prefix, not a +normal public artifact/install proof. The two earlier private calibration +startup failures remain retained after their shebang/guard repairs. + +Normal updated CLI/server tar installation and setup on ARM Mac, Intel Mac and +Linux remain required before releasing these installer bytes. Original +`0bd040093` artifacts and all 33 paid grades retain their exact source labels. +Current production results remain 25 passes and eight failures. No paid retry, +model/profile change, lockfile edit, workflow edit, merge or release occurs. + +## Accepted corrections — 2026-10-04 + +The operator accepts the scoped Product E2E fixture corrections and authorizes +necessary corrected attempts within the existing approved campaign limit. +The dedicated Pi key retains its existing lifetime cap. Automatic paid retries, +fallback keys, new models, GitHub Actions edits and lockfile commits remain +excluded. The previously rejected SDK-corrected attempt remains undispatched +and preserved; the new authorization applies to a separate explicit phase. + +The canonical-closure fixture correction is committed at `585b43d3a`. All 14 +native Linux fault tests pass, including ownership/pidfd and malformed metadata +calibration. That head has 53 successful CI checks, two expected skips and no +unresolved root review findings. These free checks do not qualify provider death. + +A fresh owned Linux controller passes normal public installation, normal Pi +setup and admission in 7.207 seconds. Installed startup passes health/UI with +zero companies and cleanup. Complete audits pass 101,226 consumer files and +16,356 plugin files. All 13 Daytona browser cases collect, and the pinned SDK +constructor passes without credentials or provider calls. The two expired +controllers remain absent. The rejected 30 GiB allocation created no sandbox; +the prepared controller uses the account's 10 GiB limit and a finite lifetime. + +The restrictive fixture correction approves only the exact published setup-file +read through the public operator API after observer arming. It retains the +completed read, resolution and unchanged file hash. All tested write permissions, +Stop/steering/denial assertions and retirement/no-effect proofs remain required. +Controls definitions advance to 7 and native definitions to 5. Prior attempt +fingerprints and grades remain unchanged. All 112 targeted native Linux tests +pass, including positive Stop/steering settlement and negative setup approvals. +The browser selectors require exactly one visible card with a pending decline +button, allowing the resolved setup-read receipt to remain in the transcript. +Two actionable cards still fail before any control or denial is sent; the +public native request and exact browser POST checks remain required. +The local pure oracle tests pass; the local ownership flows still fail their +unchanged file-watch completeness gate. Local Product typecheck is blocked by +stale dependency declarations in the existing linked build. Fresh CI must verify +the committed head. Live proof on the new definitions is still required. + +Head `e22a8dfb7` completes 50 successful checks and two skips, while the browser +aggregate/shard and Greptile checks fail. Greptile identifies the resolved setup +card count corrected above. The separate agent-run retry feedback test passes +unchanged in a retained credential-free native Linux browser trace against the +installed frozen server. Its CI failure remains preserved; this diagnostic does +not establish its cause or make the failed CI check pass. + +Private receipts retain credential and provisional spend checks. Paid dispatch +requires fresh checks under the approved bounded execution phase. No paid +provider attempt occurs during this resumed preparation. + +## Corrected live attempts — 2026-10-05 + +The latest retained matrix has **24 passed cells and nine failed cells**, with +all 33 cells attempted and none running. The first four separately bounded live +attempts retain frozen shipping source +`0bd040093`, exact harness source `14c48ff40`, profile 14/model/low and zero +automatic retries. The original campaign, grades and receipts remain unchanged. +Human permission denial passes all nine canonical matchers and cleanup. Its +independent journal proves no target effect through exact provider retirement; +public native identity confirms the frozen model and low thinking. + +Agent-files reaches successful native work and a durable managed-file save, but +the saved content lacks the required final newline. The exact-byte assertion +fails before fresh-task readback. Its cleanup separately fails on an expired +lease; the owned child sandbox is subsequently verified absent. Provider-death +reaches the unchanged unanswered native input but its one-shot fault returns an +incomplete observer response. Independent retirement and canonical cleanup +pass, while the case remains failed and expiry/stale-answer proof is unreached. +Neither case has an unchanged retry. + +The corrected Stop attempt closes the exact permission under the operator's +Stop and rejects a stale response, but its cleanup oracle reports a process +identity change. Retained public responses show `processStartedAt` changing +between launch annotations for the same PID. All three independent Linux +snapshots instead retain the same PID, boot ID and start ticks; the final seal +has no live processes and a complete zero-mutation journal. The owned child +sandbox is deleted after evidence collection, without regrading failed cleanup. + +The fixture correction uses the bound independent remote birth identity already +required by the remote oracle. Local process-authority checks remain intact. +Controls definitions advance to 8 and explicitly name PID/start-ticks/boot-ID +identity. Two timestamp-drift regressions fail before the correction and pass +afterward; actual remote birth rotation and local authority changes still fail. +All four new local regressions and nine selected remote flow tests pass without +provider calls. The synchronized fixture head `04f639eeb` passes all 116 targeted +native Linux tests and all 13 browser-case collections. One corrected Stop +attempt passes all five matchers and cleanup; the first steering attempt passes +all eight matchers and cleanup on controls definitions 8. Both retain the frozen +model, native-confirmed low thinking and original failure evidence. All six +resumed attempts are terminal, and their phases are retired. No further first +attempt remains. Every failed paid case still requires a concrete correction. + +The owned Linux controller passes normal public installation, Pi admission in +6.949 seconds, complete 101,226-file consumer and 16,356-file plugin audits, +all 13 browser-case collections and 112 targeted free native Linux tests. +These preparation checks do not qualify a live behavior. Last verified root +head `4f2300d5b` has 53 successful checks, two skips, Greptile 5/5 and no open +root review threads; this fixture follow-up requires its own completed checks. +Prerequisite reviews/standalone CI and the remaining failed production gates +stay open. Rollout remains held; no merge or release occurs. + +A later credential-free audit identifies another concrete fault-fixture +incompatibility. The production native bootstrap launches the wrapper through +its held executable FD 7 (or FD 3 without lifetime/credential fences). Linux +retains `/proc/self/fd/N` in its argv, while the fault helper requires the +physical snapshot Node pathname. The existing direct-path calibration misses +that launch form. A network-blocked native Linux regression rejects both actual +descriptor launches before the correction with `wrapper_parent`. The correction +requires the corresponding held descriptor and wrapper executable to match the +sealed snapshot Node inode, while retaining every other ownership check. All +17 fault tests pass afterward, including both real descriptor launches and +foreign/missing-descriptor negatives. The probe uses only synthetic owned +processes, no provider credentials or model calls, and its sandbox is deleted. +Native definitions advance from 5 to 6; their new fingerprint is +`6511c44fd0997ba56b8627d788d04d7e4924b8992b4c2a3085d5ee9aa56cd56b`. +The local helper/catalog tests pass. The broader local remote-observer suite +still fails its unchanged transient filesystem-watch test; its other 76 tests +pass. Preserve that failed command. This is a concrete fixture correction for a +separately bounded provider-death attempt after fresh preparation and guards; +no new paid attempt has occurred. The previous live error does not retain its +internal rejection stage, so complete live qualification remains unproven. +The retained production matrix stays 24 passes and nine failures. All six +earlier resumed attempts and their phases are terminal. Their controller and +child sandboxes are deleted with canonical evidence retained. + +## Free fault calibration and file-prompt correction — 2026-10-05 + +Head `b224e4338` completes 53 successful checks and two skips, including the +full Linux build and typecheck, with Greptile 5/5 and no unresolved root review +threads. The separately guarded descriptor-corrected provider-death attempt is +terminal and failed: its unchanged native question appears, but the observer +returns incomplete evidence without a fault-signal receipt. Cleanup separately +fails on a closed observer socket. The third failed attempt, both prior failures +and their source fingerprints are retained. Its owned child and controller are +deleted after collection. The matrix remains **24 passes and nine failures**; +none are running or unattempted. Production remains held. + +Four subsequent network-blocked Linux calibrations use no provider credentials +or model prompts. Both actual Pi-wrapper launches pass inspection: direct +held-FD launch and production lifetime fences. The real Rust Runner then admits +the frozen native model/low profile and passes exact child inspection. Finally, +the unchanged observer successfully performs its one-shot pidfd-bound Pi-child +fault and seals complete retirement. Every owned calibration sandbox is deleted. +These calibrations use a synthetic controller/lease caller and an idle provider; +they do not prove the paid pending-input journey, regrade that failure, establish +its missing rejection stage, or authorize an unchanged paid retry. + +Both original file-edit attempts complete the exact edit, validation and public +artifact, then fail the required single-Bash-execution oracle after an additional +metadata command. The old prompt specifies an exact validation command but does +not explicitly forbid other Bash calls. The correction states exactly one Bash +call for the whole task and directs registration to use the supplied byte size +and hash after exact-byte validation. The grader remains unchanged. Negative +calibration rejects extra metadata executions both before and after validation, +even when file and download bytes are correct. Extended definitions advance +from 2 to 3; original paid attempts retain their definitions and failed grades. +Free fixture verification, exact-source Linux preparation and fresh spend guards +must complete before separately bounded corrected file-edit attempts. No paid +attempt occurs during this correction, and its live outcome is unproven. +Both focused file/catalog files pass all 103 local tests. The broader name +selection also includes an unchanged report-catalog test that fails on a missing +generated result; its failed command remains retained. Local Product typecheck +still fails on six stale linked Runner/adapter declarations. Fresh latest-head +CI must verify typecheck, tests and build before a PR-ready handoff. The new +extended-suite fingerprint is +`121f4cf75267bcdb003e5ed59e165755b2ac0c3d31c82dc217a62c8abe3d1b4d`. + +The first expanded Linux preflight at `120eb7f01` passes 296 tests and fails +one cross-suite coverage assertion that still expects extended definitions 2. +Normal public install, Pi admission, startup, graph audits and all 13 browser +collections pass. Its failed test command is retained. The follow-up synchronizes +that assertion's version and fingerprint with definitions 3 without changing the +task or oracle. All three focused local files then pass 175 tests. The same owned +controller requires a separate corrected preflight before any paid dispatch. +Head `120eb7f01` completes 53 successful CI checks and two skips, with Greptile +5/5 and no unresolved root review threads. This test synchronization requires +fresh checks on its own head. No paid attempt occurs during either preparation. + +## Qualification snapshot — 2026-10-03 + +The October 3 ledger has 21 passes, ten failed cells, two unattempted cells and +zero running cases across the required 26 Product and seven Runner gates. +The original failed warm attempt remains preserved. No production admission +is claimed. No automatic paid retry, fallback key, model change, workflow edit, +or lockfile commit occurs. + +Normal installed Mac-to-Linux authority now passes the documented +`paperclipai runtime import-remote` path. The companion comes from immutable +image `sha256:342f1fd5cb8cabfa2242f38f4f686608b28b6536aa879c54b0cb0da6fba9ef47`, +contains 24,352 inventoried entries, and has manifest SHA256 +`2c9d337c7d3753db6b8c44c5b347e195a2544b574670af7f32e5dfaffc0b4017`. +The installed runtime selects its exact Linux daemon/provider pack without +binary or pack environment overrides. Extraction never starts its container; +import and selection make no provider calls. This qualification copy is not +release publication: retain the companion and trusted manifest digest with the +release as described in `doc/architecture/runner-pi-capabilities.md`. + +Historical corrections and evidence as of October 3: + +- Local agent-files and Runner context-before-action reach successful tools, + then exceed the unchanged 120-second terminal bound. No supported product + correction is identified yet; both paid failures stay held. A fresh + read-only power-log audit places the original Runner context timeout between + a full wake and the next sleep, with no state event during its 11:54–11:57 UTC + attempt. The separate workflow-context/document sleep correction does not + establish a correction for this failure. +- Runner finish-task reports the run through `paperclip_finish` without invoking + the advertised `finish_task` mutation. Preserve the mock authority distinction + and the failed behavior grade. The actual failed attempt advertises and + authorizes `finish_task`; runtime instructions already explain the distinction. + A missing-tool or permission-denial correction is not established. +- Local file-edit edits, validates and publishes the correct downloadable bytes, + but an extra shell command obtains artifact metadata. The frozen exactly-one + native validation-execution gate fails. Do not relax that gate or retry without + a correction. +- Daytona Stop initially lacks the controller companion. After normal import, + one explicit corrected retry starts Pi but waits on permission to read the + operator setup file. Restrictive native-read delegation is deliberate. A + scoped bootstrap operator approval must preserve the pending write and all + original Stop assertions; production permissions must not be broadened. + Steering and human-denial first attempts are held for the same known setup + gap. Both Stop failure grades remain; their sandboxes are separately retired. +- Daytona native-questions fails a remote command transport/deadline during + observer setup with the existing unrestricted fixture policy. Canonical + cleanup passes. Preserve this separate failure; investigate remote command + readiness before another paid attempt or remaining first-attempt dispatch. +- A credential-free diagnostic executes the exact frozen observer readiness RPC + against the immutable image three times in 0.48–0.64 seconds, within the + unchanged 12-second RPC budget. Its sandbox is deleted. This proves current + transport availability; it does not regrade or authorize a retry of the failed + native-questions case. +- Daytona hello-complete passes its canonical grade, public native state, + screenshots and cleanup. The next controller-restart first attempt fails the + observer receipt deadline after a long preparation gap; its public cancel + request also exceeds its bound. That canonical cleanup failure stays failed. + Its separately identified sandbox is subsequently deleted. Normal installed + companion selection verifies all 24,352 entries in 34.6 seconds in a free + measurement; this does not prove the cause of the original longer gap. +- Daytona question-resume, plan-approve and structured-question restart/resume + pass their canonical grades, native profile-14/low checks, hash-bound + screenshots and cleanup. These are first attempts with the frozen installed + runtime/image and unchanged Product definitions; the descendant harness at + `6cc13a7f3` differs only in the recorded documentation and two ordinary test + files. They do not regrade the separate native controller-restart failure or + authorize a retry of any failed case. The subsequent key snapshot reports + $4.8004 remaining with zero BYOK usage; billing deltas remain provisional. +- Daytona warm continuity fails in 8.780 seconds before a browser test worker + starts: the fresh installed Mac controller cannot initialize PostgreSQL. No + native run or API-state snapshot is produced; canonical cleanup is + `not_started`. The absent initdb stderr leaves the underlying cause + unclassified. Preserve this failed grade. Prepare a separate owned native + Linux installed controller and prove credential-free health/UI/admission + before a paid correction attempt. Do not infer a Pi continuity failure from + this controller-startup failure. +- A separately installed native Linux controller passes normal public Pi + admission in 6.702 seconds. Its image-derived companion retains every file + byte and link target across 24,352 entries. Linux symlink modes differ from + the recorded Mac copy, so its normal native companion uses its own manifest + digest `0907c5be08ed804e4a02b0016703d261a4142c21db6e66f3a01dcca6e775b0cc`; + the original Mac digest remains unchanged. Normal import passes. Preserve + both explicit `ERR_PNPM_ENOSPC` dependency failures at the 10 GiB limit. + Removing only unused owned staging and failed tool dependencies permits + the smaller unchanged-harness dependency installation and normal Chromium + setup. A later credential-free startup identifies a skipped standard + PostgreSQL lifecycle: required native library links are absent and initdb + exits 127. Standard `npm rebuild @embedded-postgres/linux-x64` repairs those + links without changing archived bytes. The real installed launcher then + passes health/UI with zero companies, zero provider calls and full scratch + cleanup in 12.101 seconds. Strict complete installed audits pass 101,213 + controller files and 16,356 plugin files; the importer authority receipt is + validated exactly. Normal CLI/plugin admission and all 13 Daytona catalog + configurations pass with frozen profile 14/model/low. This prepares a + healthy controller; it is not a paid qualification pass. Fresh BYOK/billing + guards and an explicit one-case Linux execution phase remain required. +- Daytona agent-files retains two failed setup attempts: collection initially + cannot resolve the declared shared helper, then the one corrected attempt + cannot resolve the declared Daytona SDK. Source-only dependency links to the + reviewed installed shared package and SDK 0.203.0 repair both resolutions. + All 13 unchanged Daytona cases collect, and the SDK constructor passes without + credentials or provider calls. Automatic approval review rejects a further + agent-files attempt because the proposed retry guard exceeds the bounded + correction authorization. That rejected attempt stays held and unchanged; no + key was read or remote work dispatched by that action. The October 4 human + authorization now permits a separate corrected attempt after fresh guards. +- Daytona provider-death reaches a real unanswered native question, then fails + its exact-child fault observer receipt. Canonical independent retirement and + cleanup pass; provider-loss and stale-answer assertions are not reached. + The unchanged Linux fault calibration passes all nine tests, including a real + pidfd signal. A separate free image inspection finds raw metadata file hash + `a82188d45ef98396c50880c1352a0dc77e81283ccaefe587156b3468d34e8c0b`, + while admitted profile 14 pins the canonical entries hash + `2957c0ec20ca1ace64d1a2b10c4a99f47f59e0c5c33a89161c1d5b48341c2b25`. + The unchanged production parser accepts all 15,671 entries in that exact + image-derived metadata. The fixture incorrectly compares the raw file hash + to the canonical entries pin, so its closure check rejects correct metadata. + The image's closure is valid; the fixture's hash representation needs repair. + The original observer error masks its + specific internal cause; this diagnosis does not regrade that failure. + The accepted canonical-closure correction passes 14 free Linux tests: nine exact-child + ownership/pidfd calibrations and five actual-metadata, formatting and negative + pin regressions. The operator accepts the scoped fixture correction on October 4; those free + tests do not qualify the failed paid case. +- The unrestricted Daytona file-edit first attempt edits and publishes the + correct file and passes cleanup, but performs extra native shell executions. + It fails the unchanged exactly-one validation-execution gate. Both platform + failures remain held without a supported correction; supplied artifact hashes + and the exact validation command were already included in the request. +- One explicit warm-continuity correction attempt now uses the proven native + Linux controller after normal PostgreSQL lifecycle repair. The frozen + runtime, profile/model/low, image and matchers stay unchanged. The phase permits + only one correction after the one original failed attempt, with zero automatic + retries and fresh BYOK/billing guards. The attempt passes all nine canonical + matchers and cleanup in 298 seconds. Three turns preserve the native session, + Runner instance, provider session, Runner PID and process-start identity; + lease acquisition is created/resumed/resumed with one provider lease and + execution workspace. All three retained event streams independently confirm + the native session and Runner instance. The retained API snapshot has fewer + full native run records, so retrospective reinspection of the other identity + fields is limited to the live canonical checks. Removing only its unused + owned pnpm store restores 1.65 GiB + free after checking all 1,181 symlinks and finding no installed reference into + the store. Installed graph identities and all original evidence are preserved. +- A free counterexample with the unchanged controls oracle proves that an + approved bootstrap read followed by the pending write creates two permissions + and is rejected. An external approval alone cannot repair the gate. The operator + now accepts scoped Product E2E fixture/oracle corrections. GitHub Actions and + production permissions need no change. +- The previously unexecuted workspace-A group passes 7,176 tests and fails one + save-navigation assertion. The API update is already observed, but navigation + follows asynchronous query invalidation. Waiting for the same form-close + assertion corrects the test race; all 32 targeted tests and token gates pass. + The corrected full UI suite then passes all 7,177 tests. Workspace A reaches + the CLI suite, which passes 505 tests and fails one archive-inflation test at + its unchanged five-second limit. That suite passes all 17 tests in isolation. + Workspace B passes shared (836 tests) and skills catalog (20 tests), then + fails the database recovery-migration case with an explicit System V + `shmget` allocation error (`No space left on device`, 56-byte segment). This + proves host shared-memory exhaustion for that new failure; it does not + retrospectively classify the earlier stderr-free initdb failure. Subsequent + projects and serialized groups remain unexecuted. Use an isolated test + environment rather than repeating database checks on the exhausted host. + Every original failed grade and the full-command failure remain unchanged. +- The protected full local command passes 15,018 tests, but one suite hook cannot + initialize embedded PostgreSQL after five attempts. Its 31 tests then pass in + isolation. Host shared-memory usage is near its 32-slot limit; missing initdb + stderr prevents proving the original cause. Do not regrade the full command, + change host limits, or stop unrelated servers as part of that inference. + +The October 3 snapshot above is historical. The current release-gate table and +October 5 results record 24 passes, nine failures and no unattempted cells. +Corrected Stop, steering and human denial are complete; do not dispatch them +as new first attempts. Failed paid cases require a concrete correction before +retry. Latest-head CI/review and prerequisite dispositions remain required. +Keep rollout held until every release gate is proven. + +## Evidence so far + +- At `dc2b053f3`, the fresh installed profile-14 steering journey receives a + canonical pass in 52 seconds, including cleanup. It proves the exact browser + comment, one acknowledgement, denial of the original native write, the hidden + instruction in the persisted final comment, succeeded/Done, process retirement + and continuous no-effect evidence through cleanup. The original failed grades + stay unchanged. No automatic retry or fallback key occurs. +- Final controls definitions advance to version 6. Steering now requires both + linked control-plane records, the matching accepted result, and succeeded/ + completed/done terminal values. Stop keeps its separate cancellation contract. + Missing, foreign, duplicate, premature and contradictory records fail. All + 129 controls/flow/catalog tests and Product E2E typecheck pass. The successful + installed receipt also passes this stricter replay, with no provider call. + The version-5 canonical pass remains version-5 evidence; the full final-source + matrix remains a release gate. + +- At `603ad3726`, the installed steering journey posts Steer (HTTP 200) and + Deny (HTTP 202) to the original request. The provider consumes the hidden + instruction, produces its exact final marker, and reaches succeeded/Done. + Its canonical grade remains failed: the oracle rejects the legitimate + `run.result.accepted` and `run.terminal` control-plane records as non-runner + events. All seven journalled provider processes retire with no target effect. + The corrected oracle validates those two records against the same run, turn, + session and linked control producer, after the one native terminal. It still + rejects foreign, duplicate, reordered and premature records. All 121 controls, + flow and catalog tests pass, as does Product E2E typecheck. Replaying the exact + retained public evidence passes the corrected oracle; replay does not regrade + the original attempt or prove a fresh cleanup receipt. Controls definitions + advance to version 5. A fresh installed journey remains the qualification gate. +- At `603ad3726`, public profile-14 setup passes on ARM and Intel Mac. The + first ARM closed-admission probe rejects with `provider_lifetime_owned`; + a separate credential-free diagnostic with retained state passes in 35.0 + seconds. Preserve both observations; ownership contention remains unclassified. + Intel closed admission passes in 54.1 seconds. Neither host submits a prompt. +- Full workspace build passes at `3728bb45f`; only the tested UI request-order + correction changes executable code afterward. Workspace typecheck and UI + build pass at `603ad3726`. All 282 UI/projection/performance tests and token + gates pass. Core native tests pass 333 cases. The broader local native + integration run fails one Codex interrupt recovery case with `provider startup + ownership remains unadmitted`; isolated diagnosis reproduces it. Do not count + that broader run as passing. +- At `603ad3726`, 52 CI checks pass and Greptile is 5/5 with both findings + resolved. Linux Canary is the one failed check: `session_handshake_timeout`. + The local exact-source Linux build compiles but exceeds its fixed 30-minute + deadline during image import. It produces no verified usable image. Its + task-owned builder is stopped, its cache and failed receipt remain, and no + provider credentials or calls occur. Linux and Daytona remain held. +- At `342287678`, installed steering proves delivery and one acknowledgement, + but the UI marks the still-pending permission cancelled and hides Deny. The + correction uses whole-run lifecycle state, carries pending cards to the live + tail, and leaves closed cards at their original position. Older carried cards + precede newer requests. The real widget regression clicks Deny for the + original run, request and provider turn. The paid failure stays failed. +- At `36e712104`, installed steering returns HTTP 200 and the native command + journal records accepted delivery. The public API retains the correct + correlation-bound acknowledgement at source sequence 65 and also a + rehydrated transport echo at 66. The exact-one acknowledgement gate rejects + that duplicate before permission denial. A regression using the actual + rehydration function reproduces both items; suppressing the transport echo + retains the one authoritative item. The interrupted attempt remains failed. +- The shared ACPX patch also requires portable hunk metadata and new byte-bound + identities. Pi advances to 14, Cursor to pending 11, and Copilot to pending + 15. Historical fixtures remain immutable, and old installed identities fail + closed. The corrected patch passes all 16 packaging checks; identity and + steering regressions pass 244 tests. No other provider qualification expands. +- The credential-free Intel public installation at `36e712104` passes closed + admission in 44 seconds. Its Greptile review is 5/5, but CI is held by the + stale patch-bound profiles, portable patch metadata and Linux admission. + The paid attempt and task-owned Linux build were stopped when that identity + mismatch was found. Their evidence is retained; the owned server and database + processes are retired. Qualification must use newly installed profile 14. +- At `4237bc369`, the native turn-binding correction advances the steering + journey past `steering_stale_turn`, but the provider boundary still rejects + delivery. The real patched ACPX client lacks `requestExtension`: its types + and runtime callers declare it, while its implementation omits it. A real + package regression fails with that exact TypeError before the correction, + then passes steering and follow-up during an unanswered prompt afterward. + This is a separate runtime correction and needs a fresh installed journey. +- The corrected Intel public package at `4237bc369` passes credential-free + closed startup in 37.8 seconds. Its CI typecheck, build, native tests and + browser shards pass. CI retains two failures: a resumed durability test + inherits the deliberately killed attempt's 500 ms timeout; the Linux public + Pi probe reaches `session_handshake_timeout`. The former gets an explicit + resumed-turn bound. The latter remains an admission blocker; accepting a + timeout as successful installation would weaken the release gate. +- The local Linux image build at `4237bc369` fails before a provider starts + because the committed lock does not match the source patch configuration. + The official image workflow already regenerates its private build lock; + local builds must do the same and record that resolved lock's digest. + No repository lockfile or workflow changes are required. + +- `2b50801b2`: installed restart failed before native answer delivery. The + durable request turn ID differs from the provider turn ID. The browser's + issue-identifier route also differed from the matcher's UUID route. +- `780471702`: the exact retained browser answer was delivered and the original + run reached Done with the correct independent file. The attempt still failed + because recovery emitted a second `runtime_request.created`. The oracle + correctly requires one creation. The next change restores the ledger without + repeating its creation event. +- `f5f57e380`: the fresh installed restart journey passes all six matchers, + including one native creation/resolution, the original process and turn, exact + browser answer, independent file and cleanup. The revised three-turn warm + journey also passes all nine matchers with the same native process/session. + Earlier failed attempts remain unchanged. +- The full workspace typecheck, 332 native core tests and 58 focused governance, + cost and session tests pass. The PR's Linux timestamp precision finding is + fixed with positive and negative calibration and the real process fixture. +- Credential-free ARM startup through the installed CLI, server and database + passed. Normal Pi setup verifies the profile-13 closure. Full transport and + recovery regressions at `780471702` passed 216 tests. +- At `f5f57e380`, the explicit Runner `get-task-context` case passes. The next + case, `context-before-action`, times out after 120 seconds: four context tools + succeed, but the requested progress mutation and terminal do not arrive. + Its canonical timeout grade remains unchanged; no automatic retry occurred. +- The local pending-permission Stop attempt at `f5f57e380` fails. Retained + Product events contain the native write and pending permission. The oracle + incorrectly rejects legitimate earlier null paths while arguments stream, + so it never sends Stop. The correction admits those partial rows only until + the same execution proves the exact path. Missing/conflicting/lost paths and + foreign executions still fail. All 66 control calibrations pass; the failed + paid attempt stays failed and requires a fresh journey after the correction. +- The public-install verifier now packs the public CLI as well as the server, + runs normal explicit Pi setup in its isolated consumer, then proves closed + startup offline. Its standalone ARM probe passes in 7.9 seconds with no + credentials, prompts, binary override or borrowed workspace package. +- Draft #14956 at `2b3d7ff0a` has a fresh Greptile 5/5, the Linux finding is + resolved, and its CI checks pass. Subsequent changes require a fresh review. +- At `03dd6ef93`, fresh pending-permission Stop passes: the original callback is + cancelled, a stale decline is rejected, the owned processes retire and the + continuous watcher records no file effect. The subsequent steering attempt + retains its failed grade. Its browser successfully posts one queued comment, + but the oracle compares plain input with the editor's Markdown-escaped body + and never clicks Steer. The correction binds to the exact browser POST body; + 67 calibrations include escaped content and rejection of an altered queue. +- The `03dd6ef93` full build passes. The broad unit run reports 14,984 passes + and one HTTP socket failure; all 12 tests in that unchanged suite pass in + isolation. Fresh review is 5/5. CI's public installer reaches Pi setup but + exhausts its 256 MiB scratch mount. Pi assembly now gets at most 2048 MiB, + retaining the same unprivileged, read-only sandbox and 3 GiB memory limit. + All seven sandbox checks pass. CI's separate legacy signoff browser failure + received one diagnostic shard rerun; it is not counted as passing yet. +- Earlier evidence in `doc/architecture/runner-pi-capabilities.md` is historical + and must not be counted as qualification of a new source revision. +- At `2a6107c04`, normal public Pi setup and closed admission pass on ARM and + Intel Mac (7.5 and 29.5 seconds). Intel uses a fresh compiled x64 daemon, + packaged before installation through the normal public CLI/server graph. +- The fresh steering attempt at `2a6107c04` reaches the real public Steer API + but receives `409 steering_stale_turn`. Rust checks the durable command ID + against the live provider turn even though the facade supplies a separate + `providerTurnId`. The correction uses that explicit provider binding, rejects + malformed bindings without fallback and keeps the existing live-turn fence. + All 333 core tests and 68 control calibrations pass. The browser fixture now + ends immediately on a rejected steering POST before sending any denial. +- Linux CI now completes normal Pi setup with bounded larger scratch space. + Its offline launch probe still returns an unclassified startup rejection; + the verifier gives that launch's private runtime snapshots the same bounded + scratch capacity. This is not counted as a passing Linux receipt yet. + +## Resumed goal — 2026-10-02 21:50 CDT + +- Current-head `6cfc79b50` CI completes with two failures: Linux Canary + admission and a 15-second issue-document route test timeout. Runner, build, + typecheck and the browser shards pass. The prerequisite stack remains open. +- The Linux admission failure is reproduced through normal public packages. + Pi setup verifies profile 14, then admission times out in 37.3 seconds. + A credential-free direct native RPC response arrives in 2.4 seconds. + The actual Docker scratch mount reports `noexec`; executing the verified + snapshot fails with `EACCES`. The offline runtime probe now uses executable + scratch. Lifecycle and download probes explicitly retain `noexec`. + The same installed Linux packages then pass the unchanged public admission + assertions in 17.2 seconds, with clean Runner exit and zero prompts. + This receipt uses historical shipping source `dc2b053f3` and native inputs + from `3728bb45f`. It diagnoses and validates the sandbox correction; it does + not qualify the new native source or the final Daytona image. +- A provider-free regression proves that receipt-limit deadline settlement + attempted to restart the provider when polling terminal evidence. The run + now closes permanently at that deadline. Existing startup admission fences + remain intact. All 333 native core tests and all 90 enabled native provider + integration tests pass after the correction; two pre-existing tests remain + ignored. The accepted-deadline fixture now expects the closed lifecycle and + checks that polling from both controllers adds no provider resume. The core + regression also retains unacknowledged terminal evidence across reconnect. + The earlier broader failure and its stale lifecycle assertion remain in + private evidence; they are not regraded. +- Runner progress evidence contains four successful reads and continued model + output before the 120-second cutoff, including unrelated fixture notes. + Bounded direct-eval instructions now ask for the minimum context needed, + the requested action, then turn completion. Case assertions and timeout + stay unchanged. All 35 Runner session-contract tests and TypeScript + typecheck pass. The retained paid failure remains unchanged. Fresh exact + source packaging and profile-14 eval definitions must precede a paid retry. +- No paid call, key reset, fallback credential, workflow change, lockfile + commit, merge or release occurs in this resumed diagnosis. + +## Qualification update — 2026-10-02 22:50 CDT + +- All 55 CI checks pass at `df424c902`. Linux Canary proves normal public + CLI/server installation, profile-14 setup and credential-free closed admission + in 8.036 seconds, with clean Runner exit. ARM public admission passes in + 7.988 seconds at the same source. Intel public admission passes in 34.059 + seconds at `666cb3ecc`; the next commit changes only Rust test formatting. + These receipts do not prove the immutable Daytona image or the full matrix. +- Installed Runner qualification uses exact source `df424c902`, private eval + definitions `a9e0e7e0`, frozen Pi profile 14, the exact model and low thinking. + Context-before-action, get-task-context, create-task-document, finish-task, + request-human-confirmation and workflow-context-document-progress all pass. + Each has one attempt and zero infrastructure retries. Original failures + remain unchanged. +- Workflow-governed-wait creates its requested approval and wake and completes + the provider turn without finishing the mock task. Its canonical grade is + `infrastructure_failure`: cleanup never proves durable Runner suspension. + Retained stderr proves provider drain and semantic tool settlement, with zero + pending provider events; suspension alone fails. The owned Runner is killed. + The eval program deletes its temporary workspace, limiting further diagnosis. + No paid retry is authorized by an unchanged failure; investigate and correct + the close boundary first. +- The two #14924 notice findings are reproduced and corrected downstream. + Error severity retains the Error label even with informational status. + Distinct notices share one compact category so work and a later error remain + visible. All 44 focused UI tests, token gates, isolated UI typecheck and UI + build pass. Root UI dependency links point to a different frozen checkout; + validation uses this task's own dependency-complete private source. +- The existing image-only run 37092761291 remains queued for EC2 job + 111116414966. It uses no provider credentials or prompts. Do not dispatch a + duplicate on an observation timeout. Its authorization binds `df424c902`; + source changes require new exact-source qualification evidence. +- The dedicated key's latest API observation is $0.092409367 lifetime usage, + $4.907590633 remaining, and zero BYOK usage. The $5 lifetime cap and $100 + campaign limit remain. Billing observations are provisional, not invoices. + Paid work is held until a concrete governed-wait correction and fresh + exact-source packaging. The full 26 Product cells remain required. + +## Idle suspension correction — 2026-10-03 00:05 CDT + +- The original governed-wait failure remains unchanged. A credential-free, + digest-bound native fixture reproduces a close-budget defect: `turn.stop` + reports an idle Pi provider already settled, leaving an eight-second RPC close + for a suspension phase that reserves only 2.5 seconds. The original regression + fails after 8.55 seconds. The corrected regression passes in 0.62 seconds. +- Close preparation now stops idle Pi through its exact native process owner. + Native code rejects an active turn or pending callback on the idle path, proves + release of the original inherited lifetime fence, and retains the attested + identity. Drain and suspension still require their durable receipts. Native + suspension and the TypeScript checkpoint gate both reject unconfirmed exits. + A held-quorum regression verifies the non-reusable boundary and unchanged + state after polling. Remote Pi uses the same native guard before checkpoint. +- The complete native suite passes with no failures and two pre-existing ignored + tests. All 236 transport and eval-session tests pass; Runner typecheck passes. + Earlier test failures remain in private evidence, including a corrected + negative-test expectation: safe polling returns no events and preserves the + unconfirmed state rather than requiring an exception. +- Typed native suspension failures now retain allowlisted command/lifecycle/ + identity diagnostics in eval artifacts and stay non-retryable. Diagnostic + collection reuses the barrier observation without extending the close bound. +- All 55 CI checks and Greptile 5/5 pass at `00c7a4510`, with no new finding. + The subsequent native correction requires fresh-head review and CI. The + superseded image run 37092761291 is terminal/cancelled; its queue state and + cancellation reason remain. No duplicate or replacement image is dispatched. +- The paid campaign remains held for fresh exact-source packaging. Its launcher + now rejects a mismatched or dirty harness and unpinned definitions before any + provider call. Only the recorded private resolved build lock may differ. + The next paid call is one explicit governed-wait retry after this correction; + the original failure is not regraded. All seven final-source Runner cases, + all 26 Product cells, platform receipts and the immutable image remain gates. + +## Frozen candidate qualification — 2026-10-03 00:45 CDT + +- Runtime and Product harness are frozen at `b148b73ea`. The public server build + stamp, CLI/server package integrity, installed native digest and separate + Runner tar are verified. The Runner tar's daemon, eval CLI and transport bytes + equal the normal server-vendored files. Reused workspace package inputs are + unchanged from their retained tar source. The private resolved build lock is + recorded and is not committed. +- `3d75626bf` changes only the held-lifetime test. Linux's port-zero allocation + can fall below the identity contract's allowed dynamic-port range. The fixture + now reserves three valid distinct ports; it keeps the production validation + intact. All 22 backend tests and Rust formatting pass. Shipping and harness + inputs are unchanged. +- All seven installed Runner cases pass, including governed-wait in 35.7 s. + The original failed attempt is preserved. Every case reports profile 14 and + effective low thinking; all use the same package and native digests. The + canonical scrubbed Evalbook renders seven attempts with zero rendering + provider calls. Real Chromium verifies the report's chat, read-only controls, + navigation, reload and narrow viewport. Its $0.008861636 aggregate estimate + is not an authenticated bill; all seven provider-dollar receipts are unpriced. +- Normal ARM, Intel and Linux public installations pass in 8.043, 37.681 and + 5.698 s respectively. Each uses the normal installed daemon, verifies profile + 14, submits no prompt and observes clean Runner exit. Workspace build and + typecheck pass. The broad local Vitest attempt completes with 47 failed files, + 533 passed files and 163 skipped files. Private Postgres library symlinks are + missing, and the restricted test PATH omits macOS lsof. Both setup causes are + corrected only in the task-owned dependency environment. The focused DB and + process-owner checks pass 95 tests with three existing skips. The corrected + broad run completes with 738 passed files, four skipped files, 14,984 passed + tests and 84 skips. One native integration suite cannot start because Cargo + is absent from the restricted PATH. With the pinned Rust toolchain added, + that native Codex result/resume integration test passes. Both failed broad + logs remain intact; neither is a passing full-command claim. The final-source + run must include the pinned Rust toolchain from the start. +- The local Product controller-restart cell passes with canonical evidence. + The next cell, warm-three-turn, completes turn 1 but fails before turn 2 + provider work: native `run.attach` reaches its 30-second command timeout. + Canonical disposition remains `transient_infrastructure`, and cleanup passes. + No automatic retry occurs; the campaign closes immediately. Eleven remaining + local cells and all 13 Daytona cells are still unexecuted. +- A separate installed no-prompt warm-admission diagnostic opens Pi in 7.2 s, + then rejects attachment with `session_resume_required`. It is not a replay + of the completed-turn failure and does not qualify warm continuity. Its strict + cleanup receipt fails even though native state reports suspended and Runner + exits cleanly; both observations are retained. No model prompt is submitted. +- A free completed-turn regression proves the warm-admission mismatch. It + finishes turn 1 through the native semantic bridge, checkpoints the sidecar, + and delays the replacement's exact-identity admission by 32 s. The unchanged + controller times out at `run.attach` and strict suspension fails. The corrected + controller uses Pi's existing absolute 60 s cold-process admission budget for + warm attachment and aborts admission on close. The same fixture then completes + turn 2, retains one Runner, starts exactly two sidecars without a retry, and + passes strict cleanup. TypeScript, all 201 transport tests, and 37 eval-session + contract/entrypoint tests pass. The native 60 s bound and profile-14 bytes are unchanged. + The paid three-turn failure remains failed. Fresh exact-source packaging and + one explicit paid retry are still required; no live resolution is claimed. +- The corrected shipping candidate at `0d65753fe` passes full build and typecheck, + normal public CLI/server packaging and fresh Runner pack/vendor equivalence. + ARM, Intel and Linux public installation pass in 7.788, 31.185 and 8.071 s. + The prior candidate manifests and Runner tar are preserved. +- All 55 CI checks and Greptile 5/5 are terminal at `96a0e329b`. Greptile is + also 5/5 at `0d65753fe`; all 55 current-head CI checks are terminal and green. The superseded + immutable image job 37099122706 is canceled because shipping inputs changed. + Its one replacement, 37102904889, is authorized and queued for the corrected + source. Track this exact handle without another dispatch. Qualification, + prerequisites and image/companion gates still hold production. + +## Qualification follow-up — 2026-10-03 02:15 CDT + +- The explicitly authorized corrected-source warm retry at `0d65753fe` passes + all nine matchers with three succeeded turns, one Runner process and session, + exact independent file bytes, and strict cleanup. The original `b148b73ea` + warm timeout stays failed. No automatic paid retry occurs. +- The final-source local matrix then passes controller restart, + pending-permission Stop, same-turn steering, and native questions. Its next + agent-files attempt fails with `native_session_interrupted` after the unchanged + 120-second bound; cleanup passes. The canonical grade remains + `transient_infrastructure`. Retained tool outcomes prove the managed-memory + write/read and expected cross-root denial succeeded. Completion is rejected + because the server incorrectly recognizes these internal/negative-test + instructions as a requested downloadable output. The provider continues after + that rejection until the timeout. Treat the completion false positive as the + observed product cause; preserve the machine classifier separately. +- The campaign closes after that failure. Its held coordinator is retired only + after the provider attempt is terminal and cleanup has passed. The key keeps + its $5 lifetime cap, zero BYOK usage and about $4.87 remaining. Its API deltas + remain provisional. Five local passes are retained, without regrading the + agent-files failure. No paid retry is authorized by a presentation-only change. +- The prerequisite #14921 exit/catch notice finding is reproduced through the + actual extension-turn binding: two identical failure inputs produce two + canonical notices. The first correction at `f62b8510a` changes the frozen + notice-projection source hash, so its 115 focused passes do not qualify it. + Restore that source byte-for-byte and coalesce only the board's consecutive + identical display rows, scoped to the complete run, turn and session. Both raw + PRP facts remain. Different reasons/severity, retry activity and later turns + remain observable. The frozen binding/extension tests pass 11 cases (one + optional host probe skipped); UI projection tests pass 145 cases. Token gates + pass. No profile, wrapper, deadline, terminal or approval-authority change occurs. +- The completion regression reproduces four false positives without model calls: + file-tool names, managed personal memory, an explicitly internal assertion + file, and an expected denied native-write attempt. The correction preserves + actual downloadable output requirements, mixed requests, and publication + evidence enforcement. The first correction passes 59 tests, but fresh review + identifies two mixed-output publication bypasses. Restrict the internal + qualifier and denied-attempt scope; 64 tests pass. At `1a8900958`, fresh review + identifies two valid instruction variants that this narrowing still rejects. + Bind each file object to its own creation verb instead. An internal qualifier + applies only to a single requested file across the preceding sentence, even + if a later clause checks it. All 68 unit/database integration tests pass, + including both publication bypasses and both internal/denied variants. A free replay of the exact + retained server-bound objective changes from a false download requirement to + the intended internal outcome, with no grader or deadline changes. +- At `b28422b29`, fresh review finds that an explicit "attach it" can lose its + publication requirement when the preceding file is called internal. The free + unit/database regressions reproduce that bypass. The correction preserves + attachment/export references; 74 completion tests pass. Fresh review at + `53923e5ad` finds that inferring publication from "return it" also blocks an + explicit inline response. Remove that extra inference. Explicit attachment/ + export directives still require publication, while internal contents returned + in chat do not. All 76 completion tests pass; the exact failed agent-files + objective still needs no download. Wait for clean source review before + rebuilding public packages again. +- At `fef9e8456`, review identifies a missing explicit "send it" delivery + requirement. Preserve delivery references to the preceding file, including + across sentences, while explicit inline/chat content remains inline. + Attachment/export directives always retain publication requirements. All 84 + completion tests pass, including both delivery and inline instructions. The + exact failed agent-files objective still needs no download; no paid retry runs + on these intermediate candidates. +- At `062902cea`, review identifies an inline code-block response that the + delivery rule still treats as a download. Recognize explicit response/reply, + code-block and plain-text formats as inline content. An actual attachment + named in that same response still requires publication. All 89 completion + tests pass, including the database-bound reviewed example. Keep the paid + campaign closed until clean review and fresh installed qualification. +- The subsequent download-link finding does not reproduce at `63e5d6443`. + Its existing `download` object matching requires publication for the exact + reported objective, even when the link belongs in a response. Free replay + returns true and the database completion gate rejects missing delivery + evidence. Add both as regressions: all 91 completion tests pass. No runtime + change is needed for this finding; retain the failed review as evidence. +- The `0d65753fe` broad local suite is terminal with 736 passed files, four + skipped files, 14,982 passed tests and three failures: a Git scan load + single-flight count (497 versus 498) and two HTTP socket resets. One associated + unhandled socket rejection is retained. This is not a passing full-command + claim. The two socket-reset suites pass in isolation. The Git load failure repeats + (496 joins versus 498). Its fixture creates 500 ephemeral listeners; the + correction sends all 500 concurrent requests through one listening server and + handles every rejection during teardown. The unchanged assertions then prove + 500 HTTP 200 responses, two scans, 498 joins and 2.4 ms health p99. All 138 + load/redaction/recovery tests pass. The full failed command remains retained. +- Image run 37102904889 is terminal/cancelled after the shipping source changes. + Its successor 37106313185 remains queued and pins `f62b8510a` in its completed + authorization job. It cannot qualify the corrected source. Retain this handle + and supersede once the tested correction is frozen. No workflow edit, lockfile + commit, new model, fallback key, merge or release occurs. + +## Broader release follow-ups — deferred for current delivery + +1. Cloud memory acceptance is complete for the frozen `b696e131ae32a82418e570b32f4727b5adb14e49` + runtime/image and definition-22 harness `da8d4454ae165b557ff11730e9fe018c9b1cd9b9`. + Both native reads, exact LF, controller restart, fresh-task bytes, denied + cross-root write and cleanup pass. All 31 files are retained and verified. + The earlier local pass and cloud failures remain source-bound history. + All 173 focused memory, bootstrap and installed CLI/plugin checks pass. +2. Complete the remaining 25 Product and seven Runner cases on Sonnet 4.6 with native low + thinking and the frozen profile-18 package/image set. Definition source + `bf8c509df49a40e72509e8732b77516aea02bbbd` changes only qualification model, + its matching test declaration and estimated pricing; all seven case bodies, + scoring rules and 120-second limits remain unchanged. Seven free definitions + and 63 eval-program/roster tests pass. Current-freeze definition-22 Product coverage is one cloud memory pass out of + 26 cells. Definition-21 local and cloud receipts remain historical. Resolve strict Runner price/counter accounting before + more Runner calls; + historical model/fixture passes are not transferred. Preserve spending caps, + zero automatic retries and independent cleanup. Normal Linux, native ARM and + native Intel installation already pass for shipping `06a3d9739`; their own + immutable identities stay attached. +3. Close the valid prerequisite #14921 premature-admission finding with + source-specific qualification; #14922–14924 currently have no unresolved + threads. Finish latest-head typecheck, tests, build, CI and review. Follow the + rollout/rollback procedure only after all gates pass and separate release + authorization is given. No merge, release or rollout is authorized here. + +## Historical execution sequence + +The following command results and counts describe earlier source-bound snapshots. +They are retained history, not the current remaining-case roster. + +1. Preserve the completed full native Linux typecheck, test and build evidence. + Keep the original Mac command failures and the unclassified + OAuth socket failure. The first Linux command compiles the Runner but fails + staging because the check wrapper sets Cargo's target outside the staging + script's expected path. Correcting that owned check environment permits one + credential-free repeat; it does not change source or regrade the failure. + That command passes typecheck, server (15,054 tests), UI (7,177), CLI (507) + and shared (836), then fails the skills-catalog pack test because npm is + absent from the closed PATH. Restore the immutable image npm path and + preserve that failed command. The next repeat fails six suites after free + disk drops below the unchanged workspace reserve. Reclaiming only the unused + owned pnpm store restores 2.75 GiB; all 75 tests in those six suites pass + unchanged. The current full repeat passes typecheck and all tests: 29,447 + passes and 69 skips across 164 groups, including every serialized suite. + Build then fails immediately on missing Node headers. The complete official + Node 24.21.0 archive verifies against its checksum and contains the identical + pinned executable. Installing it only in the owned build tools supplies the + matching headers without changing the sealed provider pack. The corrected + build then exits 137; the kernel records one OOM kill and a peak at the + 8 GiB cap. The resize request fails with an unavailable API endpoint. + The full native Linux CI build independently passes `pnpm build` across all + 35 build projects using Node 24.21.0 and Rust 1.97.1. [Job 111317358779](https://github.com/paperclipai/paperclip/actions/runs/37162051919/job/111317358779) + checks out `5e36276bd`; its complete Git tree exactly equals PR head `e90143a5c`. + Shipping inputs also match the frozen artifacts. This proves the required + full build command without regrading either failed Daytona build or claiming + a passing combined Daytona wrapper. + Normal ARM/Intel/native-Linux installation and immutable Daytona import now + pass; retain their exact-source receipts and original failures. +2. Correct the nine remaining failed gates on the frozen shipping artifacts. + All 26 Product cells and seven Runner cases have been attempted. Product has + 19 passes and seven failures; Runner has five passes and two failures. The + total is 24 passes, nine failures, zero unattempted and zero running cells. + Local agent-files and file-edit remain failed. Daytona native-questions, + native controller-restart, agent-files, provider-death and file-edit remain + failed. Runner context-before-action and finish-task remain failed. + Stop, steering and human denial are complete. Preserve their original + failed attempts; do not redispatch them as first attempts. + Every failed paid case remains held until a concrete correction addresses + its observed failure. A documentation change or unrelated fixture correction + does not permit retry. Keep zero automatic retries and reuse only evidence + whose executable inputs, definitions and recorded source match. + +3. Finish prerequisite dispositions and latest-head CI/review. Four findings + have downstream corrections; premature production admission remains held + until all 33 cases pass. Preserve the original Codex interruption failures + and corrected integration evidence. Apply the recorded operator rollout + only after qualification. No merge or release is authorized here. + +## Rollout and rollback + +These are operator instructions for a later authorized release. Production +remains held until all 33 qualification cases, integration checks and prerequisite +reviews pass. This goal does not publish, merge or deploy the candidate. + +Failed reusable leases retain provider resources until destruction succeeds. +Before removing an environment, inspect its deletion preview and request the +normal consented reusable-resource teardown. A live holding run or +`pending_cleanup` lease must continue blocking deletion. Let the recorded, +attempt-fenced cleanup finish; do not clear its status or bypass the guard. +After controller loss, cleanup ownership can take up to 15 minutes to expire +before the sweep reclaims it. Confirm actual provider absence independently of +the database status. Drain or recover recorded cleanup before rolling back to +a release that cannot interpret its metadata, and preserve the provider handle +and receipts throughout that operation. + +1. Record `paperclipai --version`, the prior CLI/server package versions, the + current company configuration and the exact retained package set. Run + `paperclipai db:backup --json` against the intended instance. Retain the + reported backup path and size. Record the backup file SHA256 before updating. + Drain active native runs on every platform before updating. On Linux, also + drain before crossing between ctime-based and kernel-birth receipts. Never + rewrite an active identity to force adoption. +2. Use the exact qualified published version. For a managed npm installation, + preview and apply the pinned update below. The operator must supply + `PI_RELEASE_VERSION` after publication. Keep the default pre-update backup. + A managed Git installation follows its recorded Git reference; use its + separately reviewed install procedure rather than this npm version command. + + ```sh + paperclipai update --version "$PI_RELEASE_VERSION" --dry-run --json + paperclipai update --version "$PI_RELEASE_VERSION" + paperclipai runtime setup pi + ``` + +3. For Daytona, select the exact image in the completed release qualification + record. The candidate image in the current gate table is not yet qualified. + Do not select a historical image from this plan. Obtain the + matching Linux companion and its trusted `companion.json` SHA256 from the + same release. The operator must supply both values below. Use the normal + import path; retain its receipt. The importer validates the server build, + profile and complete inventory. The qualification copies and their + platform-specific manifest digests are evidence, not published release assets. + + ```sh + paperclipai runtime import-remote "$PI_RELEASE_COMPANION" --sha256 "$PI_RELEASE_COMPANION_SHA256" + ``` + +4. After all release gates pass, begin with one operator-owned Linux Pi company using profile 18, + the accepted qualification model `openrouter/anthropic/claude-sonnet-4.6` + and explicit low thinking. This canary input is not a production model + allowlist: later companies may select any model acknowledged by their native + provider or explicit custom-provider configuration. Check normal startup, one question and answer, Stop, three warm turns, + terminal task state and usage visibility before expanding. Preserve the + existing company permissions and budget hard stop. Record the installed + package, runtime, companion and image identities with each canary run. + Verify an exact memory write with its final LF and read it in a fresh task. + Current-artifact exact-byte memory acceptance must pass before expansion; + historical memory results do not satisfy this canary or qualification gate. + Verify controller recovery while a native question is pending: answer the + original question once, retain the original run and producer identities, and + prove complete terminal evidence and cleanup. Stop expansion if either fails. + +On any failed qualification gate, keep admission held. On a rollout regression, +stop new Pi dispatch and retire active work through the control-plane Stop path. +For a managed installation, verify that the preview names the recorded prior +payload before applying rollback: + +Complete the owned Stop path before rolling native Runner payloads back on any +platform. On Linux, also drain before crossing the process-birth format change. +Preserve terminal and cleanup evidence. + +```sh +paperclipai update --rollback --dry-run --json +paperclipai update --rollback +``` + +The CLI restores the retained prior managed payload and restarts an active +managed service. It does not reverse database migrations. If the prior version +cannot use the current database, stop the service and restore the verified +pre-update backup through the instance's database recovery procedure before +resuming. Preserve post-backup run evidence separately. Non-managed installations +must restore the recorded prior published CLI/server set through their install +method; `--rollback` is supported only for managed installations. + +Restore the recorded company configuration and verify service health before +enabling dispatch. Reopen incompatible sessions. Do not replay uncertain provider +actions or present expired callbacks as live questions. Preserve run history and +all failed release evidence. + +## Native image and frozen-artifact qualification — 2026-10-03 07:16 CDT + +- Shipping artifacts remain frozen at `0bd040093dd33b5a31cd0ecd1f170f0d94600fce`, + with profile 14, the same model, and native-confirmed low thinking. Documentation + and fixture-only follow-ups must record their exact diff and prove shipping + inputs unchanged. They do not relabel artifacts or permit an unchanged failed + paid-case retry. Any shipping-input change needs fresh package/image evidence. +- The local direct registry export publishes the exact-source immutable image + listed above. Anonymous inspection verifies its source/content labels, digest + and Linux AMD64 platform. Actual Daytona import, normal public CLI/server + installation, Pi setup and closed admission pass in 8.699 seconds. The Linux + daemon is `sha256:af4bb4b2934c01891f7f916e0f33bcce4fac2d59ee7d74c8e832ef8720154938`. + All 18 archive integrities pass. The derived server archive changes only the + public Linux binary; normal repacking omits six bundled changelogs. All other + retained files match byte-for-byte. Public-source provenance and credential/ + user-state exclusion are checked before upload. The sandbox is deleted. +- Public graph audits pass 722 CLI packages and 79,360 files, the 150-package Pi + closure, and 191 Daytona plugin packages with 16,356 files. The separately + packed Runner matches all 1,330 vendored distribution files. Installed browser + startup passes health/UI with zero companies, credentials or provider calls. +- The local AMD64 Docker admission failures persist across bind-mounted and + native-volume installs. The process observer sees Rosetta executable ownership. + Native Linux succeeds with the exact-source image. Rosetta is a supported + diagnostic explanation to investigate, not a regrade of any failed attempt. +- The latest local agent-files failure completes bash and native memory write, + but neither native read nor finalization. Its earlier completion-publication + rejection is absent. No supported product correction is identified from this + attempt yet. Its cleanup passes, and the original failure remains unchanged. +- Two first-attempt Runner cases use the frozen installed package and definitions + `a9e0e7e025152e9941cca08e54d97c54f6490908`: get-task-context passes; + context-before-action times out after successful context and progress tools. + Native config/process metadata confirms low thinking, and Runner exits cleanly. + There is one attempt per case and no automatic retry. The sequence stops. + The strict shipping ledger is one pass, two failures and 30 unexecuted cases. +- The first full local command fails a comment-wake timeout and setup-token + socket hang-up. Both suites pass all 58 tests in isolation. The controlled + repeat fails a close-progress reaction assertion and an OAuth socket hang-up; + its earlier failures pass. Each command stops in the first server group with + 737 suites passed, four skipped, 15,026 test passes and 83 skips. Neither is a + complete workspace test pass. Seven selected cases then pass in isolation; + instrumented diagnostic copies of both suites pass all 1,431 tests. +- A controlled deferred-worker fixture reproduces the close assertion with one + late removal belonging to `setup-follow-up`, not the working message. The + correction settles that exact setup receipt before measuring working-run + removals. All four prompt/state cases pass under the same deferred-worker + condition, and all 28 close-progress cases pass with normal scheduling. The + original assertions and production worker code remain unchanged. A complete + local command with this fixture correction remains required; the OAuth socket + cause is unproven, and no speculative socket workaround is added. +- The key retains its $5 lifetime cap without reset or BYOK; its last pre-Runner + snapshot has $4.862597588 remaining. Immediate usage deltas remain provisional. + All 72 account BYOK provider rows are unconfigured. The campaign retains its + $100 budget, frozen model/profile and failed-case holds. Recorded rollout/rollback + remains conditional on every release gate passing. + + +## Native Linux admission and Pi credential forwarding — 2026-10-07 + +- Frozen runtime `efd4df03d5c472dc8051461f38e16b5f3b62ce01` was built natively + with Node 24.21.0 and Rust 1.97.1 on an owned disposable Linux host. Normal + public CLI/server packages installed and `runtime setup pi`, installed graph + verification, public daemon admission/retirement, health and browser onboarding + passed. External probe source is `f332f79e6e9fc40fa69fbf8d9dfc544acc531e7f`; + its change is probe metadata only. Pi remains profile 16, thinking low. +- Latest `f332f79e` PR checks were 54 successful, two skipped, no failures, with + an exact-head Greptile 5/5 review and no unresolved review threads. The normal + Canary public-install probe passed; its merge source is distinct from `efd4`. +- The first real `extended-harnesses.runner-acpx-pi.local.file-edit-validate` + attempt failed during `session.open`, with independently confirmed cleanup + and no token usage observed. Before/after key usage was USD 0.445246261, BYOK + zero, lifetime limit USD 5. No unchanged paid retry was made. +- A separate invalid-key diagnostic reproduced the startup rejection. Captured + runtime context and the Rust-projected dynamic tool catalog were valid. Direct + installed-sidecar startup exposed `ENOSPC` while copying the verified Pi + distribution. Removing only the completed npm download cache reclaimed + 1,116,352 KiB without changing installed packages or archived package bytes. + Direct admission with the captured context then passed with no prompt. A full + installed diagnostic subsequently reached the provider turn and failed with + the expected invalid-key service error; startup and cleanup both succeeded. + These diagnostics are not qualification passes. +- The Rust sidecar launcher had an independent production restriction: Pi + forwarded only `OPENROUTER_API_KEY`. This change forwards bounded credential + names from the authenticated controller's Pi session binding, including native + provider keys and explicit custom provider references. The sidecar retains its + exact session/configuration validation. Process-control variables and malformed + bindings fail closed. No model allowlist, model fallback or new default is added. +- A real child-process regression passes OpenRouter, Gemini and custom-provider + bindings while excluding unrelated credentials; malformed/duplicate bindings + and process-control names are rejected. Full Rust validation passed 687 + tests (two ignored), plus the final binding guard and 15 process-transport + tests. The follow-up above aligns reserved names with the controller contract. + Runtime declarations and fixture + membership are unchanged; the new shipping source must be frozen and rebuilt. +- The owned image workflow `37621428993`, pinned to `efd4`, remained unassigned + on the EC2 fleet. A concrete GitHub-hosted Linux fallback patch is prepared, + preserving maintainer authorization and source pins. The explicit workflow + exception approval remains pending; no workflow edit, merge or deployment was + performed. Real accepted Product/Runner qualification still needs 26 + 7 + passes on the corrected installed artifact set. Historical passes do not transfer. 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 077f533643..31bc6acc4b 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -1,14 +1,24 @@ +import { resolvePaperclipRunnerPiThinkingLevel } from "./paperclip-runner-permissions.js"; import { describe, expect, it } from "vitest"; import { PAPERCLIP_RUNNER_DEFAULT_MODELS, + PAPERCLIP_RUNNER_ACPX_PROFILES, paperclipRunnerTransitionConfig, isPaperclipRunnerProvider, resolvePaperclipRunnerModel, resolvePaperclipRunnerPermissionMode, + resolvePaperclipRunnerCursorMode, } from "./paperclip-runner-permissions.js"; describe("Paperclip Runner permission defaults", () => { + it("admits Pi with provider credentials while keeping Copilot pending", () => { + expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi")) + .toMatchObject({ qualified: true, credentialEnvironment: expect.arrayContaining(["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"]) }); + for (const agent of ["copilot"]) { + expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === agent)?.qualified).toBe(false); + } + }); it("defaults Codex to the only qualified non-interactive mode", () => { expect(resolvePaperclipRunnerPermissionMode("codex", undefined)).toBe( "never", @@ -69,3 +79,31 @@ describe("Paperclip Runner permission defaults", () => { expect(resolvePaperclipRunnerModel("codex", " gpt-5.5 ")).toBe("gpt-5.5"); }); }); + + +describe("Cursor session mode admission", () => { + it.each([undefined, "agent", "plan", "ask"])("retains mode %s with an explicit Cursor default", mode => { + expect(resolvePaperclipRunnerCursorMode("acpx", "cursor", mode)).toBe(mode ?? "agent"); + }); + it.each([null, "", "auto", "PLAN", true, {}, ["plan"]])("rejects invalid mode %j", mode => { + expect(() => resolvePaperclipRunnerCursorMode("acpx", "cursor", mode)).toThrow("Cursor session mode"); + }); + it.each([["codex", "cursor"], ["acpx", "copilot"], ["acpx", "pi"], ["acpx", "claude"]])("rejects mode on %s/%s", (provider, agent) => { + expect(resolvePaperclipRunnerCursorMode(provider, agent, undefined)).toBeUndefined(); + expect(() => resolvePaperclipRunnerCursorMode(provider, agent, "plan")).toThrow("only for Cursor"); + }); +}); + +describe("Pi thinking configuration", () => { + it.each([undefined, "off", "low", "high", "max"] as const)("persists exact level %s with low as the fresh-config default", value => { + expect(resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", value)).toBe(value ?? "low"); + }); + it.each(["medium", "minimal", "xhigh", "", null, 1])("rejects unsupported alias %s", value => { + expect(() => resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", value)).toThrow(); + }); + it("rejects foreign provider settings", () => { + expect(() => resolvePaperclipRunnerPiThinkingLevel("codex", "pi", "low")).toThrow(/only/); + expect(() => resolvePaperclipRunnerPiThinkingLevel("acpx", "cursor", "low")).toThrow(/only/); + expect(resolvePaperclipRunnerPiThinkingLevel("codex", undefined, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 93976c8ebc..68e126a8b1 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: true, 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..3a6853b927 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"; @@ -107,6 +108,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record { diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts index fa9c0f2551..e907fa9513 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -251,7 +251,7 @@ test.describe("Capability issue thread", () => { await always.click(); await panel.getByRole("button", { name: /get_task_context/ }).click(); const dialog = page.getByRole("dialog", { name: "Get active task context" }); - await expect(dialog).toContainText("Read the active mock task, actor, wake, ancestors, budget, and interaction results."); + await expect(dialog).toContainText("Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan."); await expect(dialog).toContainText("Input schema"); await dialog.getByRole("button", { name: "Close tool details" }).click(); await expect(dialog).toHaveCount(0); @@ -723,9 +723,12 @@ test.describe("Capability clean-room chat", () => { await page.getByTestId("evidence-toggle").click(); const panel = page.getByTestId("evidence-panel"); const tabs = panel.getByRole("tab"); - await expect(tabs.first()).toHaveText(/Evidence/); - await expect(tabs.nth(1)).toHaveText(/Timeline/); - await expect(panel.locator(".pit-devtools-tabs .pit-icon")).toHaveCount(8); + await expect(tabs).toHaveText([ + "Evidence", "Timeline", "State", "Diff", "Documents", "Protocol", + "Provider trace", "Runtime", "Authority", + ]); + await expect(tabs).toHaveCount(9); + await expect(panel.locator(".pit-devtools-tabs .pit-icon")).toHaveCount(9); const centerOffsets = await tabs.evaluateAll((elements) => elements.map((element) => { const icon = element.querySelector(".pit-tab-glyph")?.getBoundingClientRect(); const label = element.querySelector(".pit-tab-glyph + span")?.getBoundingClientRect(); diff --git a/packages/paperclip-runner/docs/adr/0001-runner-testing-eval-package-boundaries.md b/packages/paperclip-runner/docs/adr/0001-runner-testing-eval-package-boundaries.md index 14554af461..eebd41fb15 100644 --- a/packages/paperclip-runner/docs/adr/0001-runner-testing-eval-package-boundaries.md +++ b/packages/paperclip-runner/docs/adr/0001-runner-testing-eval-package-boundaries.md @@ -113,6 +113,21 @@ separately staged runnerd artifact digest. The consumer uses no workspace protocol, source-relative import, or deep package path. This is the packaging gate; workspace tests alone are not proof. +For installed dependencies without bundled dependencies, the gate packs a private +copy outside pnpm's dependency tree, excluding only the package-root +`node_modules` directory. Manifest bytes, file modes, symlinks, and npm file +selection rules remain unchanged. Packages declaring bundled dependencies keep +the original pack path so their dependency payload is preserved. Temporary pack +inputs are removed after success or failure; installed sources are not modified. + +Run the focused staging regression from the repository root: + +```sh +node --test packages/paperclip-runner/scripts/installed-package-pack.test.mjs +``` + +This regression also runs in the Runner's `test:typescript:prep` gate. + ## Consequences - Existing tests importing mock/conformance values from the package root must diff --git a/packages/paperclip-runner/docs/capability-live-runnerd-codex.md b/packages/paperclip-runner/docs/capability-live-runnerd-codex.md index 7ba6106c9d..42598ff3fd 100644 --- a/packages/paperclip-runner/docs/capability-live-runnerd-codex.md +++ b/packages/paperclip-runner/docs/capability-live-runnerd-codex.md @@ -3,7 +3,7 @@ > **Reference lab, not the production sandbox topology.** This API remains the > runner-lab/session implementation used by UI and recovery tests. Production > sandbox execution and live protocol evals use the Rust-owned bridge described -> in [`../../../doc/plans/2026-08-20-single-daemon-runner-tool-bridge.md`](../../../doc/plans/2026-08-20-single-daemon-runner-tool-bridge.md): external control plane → PRP → one Rust `paperclip-runnerd` → provider. The TypeScript dispatcher below does not run in the sandbox. +> in [the runner architecture](architecture.md): external control plane → PRP → one Rust `paperclip-runnerd` → provider. The TypeScript dispatcher below does not run in the sandbox. Capability binds the provider-neutral semantic catalog to a real package-local `paperclip-runnerd` process and a real Codex app-server session. Paperclip data diff --git a/packages/paperclip-runner/docs/durable-recovery.md b/packages/paperclip-runner/docs/durable-recovery.md index 0a9d5d9a7a..6a9b6e3fbb 100644 --- a/packages/paperclip-runner/docs/durable-recovery.md +++ b/packages/paperclip-runner/docs/durable-recovery.md @@ -314,6 +314,14 @@ the lease TTL to match its required revocation bound. ## Restart and reconciliation +Local Linux process receipts use the kernel's `/proc/PID/stat` start ticks, +`/proc/stat` boot time and `CLK_TCK` to record process birth. The server and +retained Runner maintenance use the same reader. `/proc/PID` directory ctime +can differ from birth and must not grant process ownership. Invalid or missing +birth metadata fails closed. PID, process group and owner checks remain required. +Drain active Linux runs before updating or rolling back across the previous +ctime-based receipt format; do not rewrite a live receipt to force adoption. + A socket drop keeps the same Rust process and in-memory lease. The process reconnects and reloads the mock-core command and event cursor. diff --git a/packages/paperclip-runner/docs/protocol-compatibility.md b/packages/paperclip-runner/docs/protocol-compatibility.md index afadb64769..1df8f015f9 100644 --- a/packages/paperclip-runner/docs/protocol-compatibility.md +++ b/packages/paperclip-runner/docs/protocol-compatibility.md @@ -107,6 +107,21 @@ Codex answer objects, OpenCode answer arrays, and ACP typed content exist only inside their adapters; `origin` may retain the provider method and adapter name for diagnostics but never provider response data. +Text-mode questions may include `initialText` (at most 100,000 Unicode code points, +within the existing 196 KiB question-set bound). It supplies an editable starting +draft, including empty text and whitespace. ACP plain-string `default` values map +without trimming; numeric defaults become editable numeric text. Select questions +do not accept this field. The UI preserves saved drafts and existing responses +ahead of provider defaults. Initial text never submits or resolves a request: +the operator must explicitly submit, and all existing response validation still +applies. Canonical text answers retain submitted whitespace and literal escape +sequences; legacy and select custom answers retain their existing normalization. +JSON Schema, TypeScript and Rust count draft Unicode code points alike. Existing +submitted-answer limits still count UTF-16 code units; a draft can require editing +before it satisfies those limits or field-specific constraints. Required blank +answers remain invalid. Redaction, company scope and durable +replay rules remain in force. + Question and option order is significant, while answers are keyed by stable question IDs and selections reference stable option IDs. The canonical modes are `text`, `single_select`, and `multi_select`. Text validation is repeated at diff --git a/packages/paperclip-runner/docs/runner-protocol-live-evals.md b/packages/paperclip-runner/docs/runner-protocol-live-evals.md index 36b36fbded..b7f8efad0c 100644 --- a/packages/paperclip-runner/docs/runner-protocol-live-evals.md +++ b/packages/paperclip-runner/docs/runner-protocol-live-evals.md @@ -119,6 +119,20 @@ from the default branch and provide: marked disabled; - `max_infrastructure_retries`: zero through three, applied only when an attempt explicitly reports a retryable infrastructure failure. + +Native suspension failures are non-retryable infrastructure failures. The CLI +retains only allowlisted close diagnostics: suspension and provider-drain +booleans, the suspension-command status, the durable Runner lifecycle, and +whether its identity matches. Missing or conflicting suspension evidence never +becomes a pass. Preserve the original artifact before correcting a close defect +and explicitly authorizing a new attempt. + +Pi cleanup stops an idle provider during close preparation through the exact +native process owner. Native code rejects active turns or pending callbacks on +that idle path. It proves release of the original inherited lifetime fence and +preserves the attested provider identity. The subsequent drain and suspension +barriers still require their durable receipts. An unconfirmed provider exit +cannot certify a reusable checkpoint. - `grok_authentication`: `api_key` (the compatibility default) or `subscription`. Subscription requires an explicitly selected Grok roster and the owner-approved `GROK_AUTH_JSON` secret in the protected `runner-e2e-paid` environment. API mode @@ -154,6 +168,16 @@ versions and patched ACP server bytes. Do not replace this step with a fresh `patchedDependencies`, so the resulting ACPX executables no longer match their qualified digests. +Candidate ACPX evals require both `--candidate-profile ` and +`--expected-acpx-profile `. The eval program supplies the exact configured +profile as the JSON argument. Before creating a runtime context or provider +service, the built CLI compares every profile field, including model, version +and command digest, with its own resolved profile. Missing, extra or mismatched +fields fail admission. Older CLIs reject the new flag before execution; there +is no retry without the check. The daemon's own profile admission and binary +digest check remain independent gates. Post-run scoring still compares the +retained profile as evidence, but cannot replace this pre-launch check. + The direct eval CLI also materializes a minimal immutable native runtime context in each isolated attempt workspace. This keeps the direct layer on the same `paperclip.native-execution-input.v3` contract as production, including @@ -186,6 +210,9 @@ replacement objective or proof that a newly requested action is already done. Finishing the provider turn does not authorize an unrequested mock task-state change or completion comment. These harness instructions keep single-operation cases bounded while leaving their operation and state-effect assertions intact. +For a bounded request, the harness also asks for only the context needed to act. +A brief progress request does not require an investigation of unrelated history +or documents. Its grader still requires the actual successful mutation. ACPX accounting uses the qualified server's billable token semantics: Claude and Codex already include reasoning in output, and Codex has no cache-write diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json index 9c99672009..f58accf57c 100644 --- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json +++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json @@ -1 +1 @@ -[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use one complete payload.questionSet for text and choice questions. Paperclip generates compatibility questions; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"allOf":[{"if":{"properties":{"interactionKind":{"const":"questions"}},"required":["interactionKind"]},"then":{"properties":{"payload":{"anyOf":[{"required":["questionSet"]},{"required":["questions"]}],"required":["version"]}},"required":["payload"]}}],"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For confirmation, payload may be {}.","properties":{"questionSet":{"additionalProperties":false,"properties":{"description":{"maxLength":100000,"type":"string"},"questions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"not":{"required":["customAnswer"]},"properties":{"options":{"maxItems":0,"type":"array"}}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"properties":{"options":{"minItems":1,"type":"array"}},"required":["options"]}}],"properties":{"answerMode":{"enum":["single_select","multi_select","text"]},"customAnswer":{"additionalProperties":false,"properties":{"enabled":{"const":true},"label":{"maxLength":1000,"type":"string"},"placeholder":{"maxLength":1000,"type":"string"}},"required":["enabled"],"type":"object"},"header":{"maxLength":1000,"type":"string"},"helpText":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":false,"properties":{"description":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"},"recommended":{"type":"boolean"}},"required":["id","label"],"type":"object"},"maxItems":128,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"textValidation":{"additionalProperties":false,"properties":{"inputType":{"enum":["text","number","integer"]},"maxLength":{"maximum":100000,"minimum":0,"type":"integer"},"maximum":{"type":"number"},"minLength":{"maximum":100000,"minimum":0,"type":"integer"},"minimum":{"type":"number"},"pattern":{"maxLength":1000,"type":"string"}},"type":"object"}},"required":["id","prompt","required","answerMode"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"schema":{"const":"paperclip.question_set.v1"},"submitLabel":{"maxLength":200,"type":"string"},"title":{"maxLength":1000,"type":"string"}},"required":["schema","questions"],"type":"object"},"questions":{"items":{"additionalProperties":true,"properties":{"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":true,"properties":{"freeText":{"type":"boolean"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"}},"required":["id","label"],"type":"object"},"maxItems":129,"minItems":1,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"selectionMode":{"enum":["single","multi"]}},"required":["id","prompt","selectionMode","options"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"version":{"const":1}},"type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"description":"The nextCursor returned by the previous page.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"limit":{"description":"Page size (default 50).","maximum":50,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents; null when assigning a person.","type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedAssigneeUserId":{"description":"Current assigneeUserId from search_tasks; omit only when there is no person assigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"update_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.","inputSchema":{"additionalProperties":false,"properties":{"expectedVersionId":{"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"maxLength":200000,"minLength":1,"type":"string"},"skillId":{"minLength":1,"type":"string"}},"required":["skillId","markdown","expectedVersionId","idempotencyKey"],"type":"object"},"name":"update_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_monitor","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse with identical arguments on retries.","maxLength":240,"minLength":1,"type":"string"},"monitor":{"additionalProperties":false,"properties":{"externalRef":{"maxLength":500,"minLength":1,"type":["string","null"]},"kind":{"enum":["external_service",null],"type":["string","null"]},"maxAttempts":{"description":"Optional cumulative attempt limit for this task's monitor.","maximum":100,"minimum":1,"type":["integer","null"]},"nextCheckAt":{"description":"Future UTC timestamp for the next check. This is one shot, not a recurring interval.","type":"string"},"notes":{"description":"What to check on the next run; do not include secrets.","maxLength":500,"minLength":1,"type":"string"},"recoveryPolicy":{"enum":["wake_owner","create_recovery_issue","escalate_to_board",null],"type":["string","null"]},"serviceName":{"maxLength":120,"minLength":1,"type":["string","null"]},"timeoutAt":{"description":"Optional deadline later than nextCheckAt.","type":["string","null"]}},"required":["nextCheckAt","notes"],"type":["object","null"]},"taskId":{"description":"Owned task UUID. Omit for the active task.","type":"string"}},"required":["idempotencyKey","monitor"],"type":"object"},"name":"set_task_monitor","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_complaint","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Yeet a brief, completely unstructured complaint about agent tooling or the experience of doing agent work. Preserve your raw voice: fragments, profanity, all caps, and exasperation are fair game. No diagnosis, solution, diplomacy, category, or template required. Use submit_suggestion when you want to express a useful improvement. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_complaint","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_suggestion","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Quietly report a concrete improvement that would make agents more effective. Use for material, generalizable friction directly observed during this run. Explain what happened, its impact, and a plausible improvement in your own words, with minimal useful sanitized context. No required format. Avoid duplicate root causes and aim for at most three suggestions per run. Use submit_complaint for the raw reaction. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_suggestion","outputSchema":{"additionalProperties":true,"type":"object"}}] +[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use one complete payload.questionSet for text and choice questions. Paperclip generates compatibility questions; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"allOf":[{"if":{"properties":{"interactionKind":{"const":"questions"}},"required":["interactionKind"]},"then":{"properties":{"payload":{"anyOf":[{"required":["questionSet"]},{"required":["questions"]}],"required":["version"]}},"required":["payload"]}}],"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For confirmation, payload may be {}.","properties":{"questionSet":{"additionalProperties":false,"properties":{"description":{"maxLength":100000,"type":"string"},"questions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"not":{"required":["customAnswer"]},"properties":{"options":{"maxItems":0,"type":"array"}}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"not":{"required":["initialText"]},"properties":{"options":{"minItems":1,"type":"array"}},"required":["options"]}}],"properties":{"answerMode":{"enum":["single_select","multi_select","text"]},"customAnswer":{"additionalProperties":false,"properties":{"enabled":{"const":true},"label":{"maxLength":1000,"type":"string"},"placeholder":{"maxLength":1000,"type":"string"}},"required":["enabled"],"type":"object"},"header":{"maxLength":1000,"type":"string"},"helpText":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"initialText":{"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds.","maxLength":100000,"type":"string"},"options":{"items":{"additionalProperties":false,"properties":{"description":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"},"recommended":{"type":"boolean"}},"required":["id","label"],"type":"object"},"maxItems":128,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"textValidation":{"additionalProperties":false,"properties":{"inputType":{"enum":["text","number","integer"]},"maxLength":{"maximum":100000,"minimum":0,"type":"integer"},"maximum":{"type":"number"},"minLength":{"maximum":100000,"minimum":0,"type":"integer"},"minimum":{"type":"number"},"pattern":{"maxLength":1000,"type":"string"}},"type":"object"}},"required":["id","prompt","required","answerMode"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"schema":{"const":"paperclip.question_set.v1"},"submitLabel":{"maxLength":200,"type":"string"},"title":{"maxLength":1000,"type":"string"}},"required":["schema","questions"],"type":"object"},"questions":{"items":{"additionalProperties":true,"properties":{"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":true,"properties":{"freeText":{"type":"boolean"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"}},"required":["id","label"],"type":"object"},"maxItems":129,"minItems":1,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"selectionMode":{"enum":["single","multi"]}},"required":["id","prompt","selectionMode","options"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"version":{"const":1}},"type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"description":"The nextCursor returned by the previous page.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"limit":{"description":"Page size (default 50).","maximum":50,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents; null when assigning a person.","type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedAssigneeUserId":{"description":"Current assigneeUserId from search_tasks; omit only when there is no person assigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"update_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.","inputSchema":{"additionalProperties":false,"properties":{"expectedVersionId":{"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"maxLength":200000,"minLength":1,"type":"string"},"skillId":{"minLength":1,"type":"string"}},"required":["skillId","markdown","expectedVersionId","idempotencyKey"],"type":"object"},"name":"update_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_monitor","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse with identical arguments on retries.","maxLength":240,"minLength":1,"type":"string"},"monitor":{"additionalProperties":false,"properties":{"externalRef":{"maxLength":500,"minLength":1,"type":["string","null"]},"kind":{"enum":["external_service",null],"type":["string","null"]},"maxAttempts":{"description":"Optional cumulative attempt limit for this task's monitor.","maximum":100,"minimum":1,"type":["integer","null"]},"nextCheckAt":{"description":"Future UTC timestamp for the next check. This is one shot, not a recurring interval.","type":"string"},"notes":{"description":"What to check on the next run; do not include secrets.","maxLength":500,"minLength":1,"type":"string"},"recoveryPolicy":{"enum":["wake_owner","create_recovery_issue","escalate_to_board",null],"type":["string","null"]},"serviceName":{"maxLength":120,"minLength":1,"type":["string","null"]},"timeoutAt":{"description":"Optional deadline later than nextCheckAt.","type":["string","null"]}},"required":["nextCheckAt","notes"],"type":["object","null"]},"taskId":{"description":"Owned task UUID. Omit for the active task.","type":"string"}},"required":["idempotencyKey","monitor"],"type":"object"},"name":"set_task_monitor","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_complaint","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Yeet a brief, completely unstructured complaint about agent tooling or the experience of doing agent work. Preserve your raw voice: fragments, profanity, all caps, and exasperation are fair game. No diagnosis, solution, diplomacy, category, or template required. Use submit_suggestion when you want to express a useful improvement. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_complaint","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_suggestion","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Quietly report a concrete improvement that would make agents more effective. Use for material, generalizable friction directly observed during this run. Explain what happened, its impact, and a plausible improvement in your own words, with minimal useful sanitized context. No required format. Avoid duplicate root causes and aim for at most three suggestions per run. Use submit_complaint for the raw reaction. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_suggestion","outputSchema":{"additionalProperties":true,"type":"object"}}] diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index c3fb478f7c..8c1249044c 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -55,6 +55,12 @@ "styles.css", "README.md" ], + "publishConfig": { + "executableFiles": [ + "./dist/bin/paperclip-runnerd", + "./dist/bin/paperclip-runnerd.exe" + ] + }, "sideEffects": false, "scripts": { "build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && pnpm run check:semantic-contracts && pnpm run check:runner-workflow-traceability && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check", @@ -75,7 +81,7 @@ "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", "test:typescript": "pnpm run test:typescript:prep && vitest run", - "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/provider-pack-executable-shims.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs", "test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json index adec9d83d2..bbfdfd4693 100644 --- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json +++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json @@ -24,7 +24,7 @@ "prpVersion": 1, "nativeExecutionVersion": 1, "catalogVersion": 1, - "catalogSha256": "sha256:c9b3d3a06f08f045c272b13f8f8148f01a0942a32bf4cfe6f2c1f5a540e69e3b", + "catalogSha256": "sha256:ff3e2bdc66e564cef9500f0940b9afe9d1878e7c8ca3d5a5a0c986b23ffe79de", "driverContractVersion": 1, "driverKind": "paperclip-deterministic", "driverVersion": "1.0.0" diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index 0f1262b6d5..2eefd4f306 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -80,7 +80,7 @@ { "path": "schemas/provider-descriptor.schema.json", "id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json", - "sha256": "b3d42a51be5a632c0404194f036c20e94b03097537f3fe031a908e61ccd29085" + "sha256": "bf691bda4a58490dba680b74559bfe1c9254b19e3265a603d9cbf3e169094519" }, { "path": "schemas/provider-event.schema.json", @@ -100,7 +100,7 @@ { "path": "schemas/question-set.schema.json", "id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json", - "sha256": "6aeb2b880a60356cb48acfe26605acd9412a1df65f5308397d3ec521db5ea70f" + "sha256": "81dc76610e40665dbc4a42e4029296dffc597ba0bbee1fe92a130dffc0c07cba" }, { "path": "schemas/request.schema.json", @@ -175,7 +175,7 @@ }, { "path": "fixtures/evals/native-execution-seeded.json", - "sha256": "adbfc38d274256936cef174fb8a7c322a3983c0fa720cf13497f860869f68b2e", + "sha256": "248c366cd272e1cc13506886bee4ee15fd63813280583b5859011446035ebbbb", "expectation": "accept", "compatibilityCase": "canonical" }, diff --git a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json index 1d285c52ad..89e6242f5b 100644 --- a/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json +++ b/packages/paperclip-runner/protocol/provider-schemas/acpx-sidecar.schema.json @@ -52,10 +52,42 @@ "params": { "type": "object" } }, "additionalProperties": false, - "allOf": [{ - "if": { "properties": { "command": { "const": "turn.steer" } } }, - "then": { "properties": { "params": { "$ref": "#/$defs/turnControl" } } } - }] + "allOf": [ + { + "if": { "properties": { "command": { "const": "turn.steer" } } }, + "then": { "properties": { "params": { "$ref": "#/$defs/turnControl" } } } + }, + { + "if": { "properties": { "command": { "const": "session.open" } } }, + "then": { "properties": { "params": { "$ref": "#/$defs/sessionOpenThinkingLevel" } } } + } + ] + }, + "piThinkingLevel": { "enum": ["off", "low", "high", "max"] }, + "sessionOpenThinkingLevel": { + "type": "object", + "properties": { + "agent": { "type": "string" }, + "piThinkingLevel": { "$ref": "#/$defs/piThinkingLevel" } + }, + "if": { "properties": { "agent": { "const": "pi" } }, "required": ["agent"] }, + "then": { + "required": ["piThinkingLevel"], + "properties": { + "piThinkingLevel": { "$ref": "#/$defs/piThinkingLevel" }, + "expectedIdentity": { + "type": ["object", "null"], + "properties": { "piThinkingLevel": { "$ref": "#/$defs/piThinkingLevel" } }, + "required": ["piThinkingLevel"] + } + } + }, + "else": { + "properties": { + "piThinkingLevel": false, + "expectedIdentity": { "type": ["object", "null"], "properties": { "piThinkingLevel": false } } + } + } }, "response": { "type": "object", diff --git a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json index d4f1833057..660054b6f7 100644 --- a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json +++ b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json @@ -145,9 +145,37 @@ }, "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/protocol/schemas/question-set.schema.json b/packages/paperclip-runner/protocol/schemas/question-set.schema.json index 5921ad3446..cd8f731bf6 100644 --- a/packages/paperclip-runner/protocol/schemas/question-set.schema.json +++ b/packages/paperclip-runner/protocol/schemas/question-set.schema.json @@ -60,6 +60,7 @@ "helpText": { "type": "string", "maxLength": 4000 }, "required": { "type": "boolean" }, "answerMode": { "enum": ["single_select", "multi_select", "text"] }, + "initialText": { "type": "string", "maxLength": 100000, "description": "Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds." }, "options": { "type": "array", "maxItems": 128, @@ -80,6 +81,7 @@ "if": { "properties": { "answerMode": { "enum": ["single_select", "multi_select"] } }, "required": ["answerMode"] }, "then": { "required": ["options"], + "not": { "required": ["initialText"] }, "properties": { "options": { "type": "array", "minItems": 1 } } } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs index b40c18aafa..2610dbf1ae 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs @@ -396,7 +396,7 @@ fn sanitize_question_set(mut value: Value) -> Result { }; // IDs, answer modes, and validation patterns are protocol values: // changing them would break response correlation or semantics. - redact_object_text(question, &["header", "prompt", "helpText"]); + redact_object_text(question, &["header", "prompt", "helpText", "initialText"]); if let Some(options) = question.get_mut("options").and_then(Value::as_array_mut) { for option in options { if let Some(option) = option.as_object_mut() { @@ -633,6 +633,15 @@ pub(crate) fn validate_question_set(value: &Value) -> Result<(), LocalRunnerErro "ACPX input question ids must be unique", )); } + if question + .get("initialText") + .and_then(Value::as_str) + .is_some_and(|text| text.chars().count() > 100_000) + { + return Err(LocalRunnerError::invalid( + "ACPX initial text exceeds its Unicode code-point bound", + )); + } let mut option_ids = BTreeSet::new(); for option in question .get("options") diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index b3beb1897a..3757c6ea68 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -18,7 +18,7 @@ use sha2::{Digest, Sha256}; use crate::acpx_provider_capabilities::{run_attachment_policy, RunAttachmentPolicy}; use crate::acpx_provider_session::{ AcpxPermissionMode, AcpxProviderRuntimePolicy, AcpxProviderSession, AcpxProviderSessionConfig, - AcpxProviderSessionIdentity, AcpxTurnControlCapabilities, + AcpxProviderSessionIdentity, AcpxTurnControlCapabilities, PiThinkingLevel, }; use crate::acpx_sidecar_transport::AcpxSidecarTransportConfig; #[cfg(test)] @@ -36,7 +36,7 @@ use crate::provider_bridge::{ use crate::provider_events::{ project_acpx_state_event, AcpxEventProjectionContext, NormalizedProviderEvent, }; -use crate::qualified_launch::verify_launch_artifact; +use crate::qualified_launch::{verify_executable_launch_artifact, verify_launch_artifact}; fn is_reserved_terminal_operation_id(operation_id: &str) -> 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,41 @@ 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-v22-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"), + include_str!("../../../../test-fixtures/pi-acp/profile-v19-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v20-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v21-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 +3263,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,12 +3466,32 @@ 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!([]); descriptor_value["runtimeDirectory"] = json!(runtime); descriptor_value["cwd"] = json!(workspace); + let prior_root = directory.join("old-registered-copy"); + let current_root = directory.join("new-registered-copy"); + fs::create_dir_all(&prior_root).unwrap(); + fs::create_dir_all(¤t_root).unwrap(); + descriptor_value["instructions"] = json!(format!( + "Current AGENT_HOME: {}. Custom entry.", + prior_root.display() + )); + descriptor_value["runtimeContext"] = json!({ + "aggregateDigest": "a".repeat(64), + "prompt": {"revision": "pinned", "digest": "b".repeat(64)}, + "instructions": { + "entryPath": "AGENTS.md", + "bundle": {"digest": "c".repeat(64), "rootPath": "/old-bundle"}, + "workingCopy": {"kind": "agent_files", "entryPath": "AGENTS.md", "rootPath": prior_root}, + }, + "skills": [{"key": "skill-1", "bundle": {"digest": "d".repeat(64), "rootPath": "/old-skill"}}], + "mcp": {"assignmentSetId": "assignment-1", "digest": "e".repeat(64), "bindingId": "old-run-binding"}, + "futurePolicy": {"companyId": "company-1"}, + }); let original_descriptor: AcpxProviderDescriptor = serde_json::from_value(descriptor_value.clone()).unwrap(); let identity = AcpxProviderSessionIdentity { @@ -3091,6 +3506,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(); @@ -3193,11 +3609,57 @@ mod tests { .contains("requires run.attach before commands from a new run")); descriptor_value["runId"] = json!("run-2"); + fs::remove_dir_all(&prior_root).unwrap(); + descriptor_value["instructions"] = json!(format!( + "Current AGENT_HOME: {}. Fresh custom entry.", + current_root.display() + )); + descriptor_value["runtimeContext"]["instructions"]["workingCopy"]["rootPath"] = + json!(current_root); + descriptor_value["runtimeContext"]["instructions"]["bundle"]["rootPath"] = + json!("/new-bundle"); + descriptor_value["runtimeContext"]["skills"][0]["bundle"]["rootPath"] = json!("/new-skill"); + descriptor_value["runtimeContext"]["mcp"]["bindingId"] = json!("new-run-binding"); attached .attach_run(&json!({"provider": descriptor_value})) .unwrap(); assert_eq!(attached.state.as_ref().unwrap().descriptor.run_id, "run-2"); assert!(!marker.exists()); + let refreshed = &attached.state.as_ref().unwrap().descriptor; + assert_eq!( + refreshed.runtime_context, + descriptor_value["runtimeContext"] + ); + assert_eq!(refreshed.instructions, descriptor_value["instructions"]); + let session_config = refreshed + .session_config( + attached.state.as_ref().unwrap().tool_set.clone(), + attached.state.as_ref().unwrap().identity.clone(), + Some(&launch_profile), + ) + .unwrap(); + assert_eq!( + session_config.runtime_context, + descriptor_value["runtimeContext"] + ); + assert_eq!( + session_config.system_instructions, + descriptor_value["instructions"] + ); + // Both persistence and the sidecar launch config receive the new grant. + let persisted: AcpxDurableState = + serde_json::from_slice(&fs::read(attached.state_path()).unwrap()).unwrap(); + assert_eq!( + persisted.descriptor.runtime_context, + descriptor_value["runtimeContext"] + ); + let mut same_run_mutation = descriptor_value.clone(); + same_run_mutation["instructions"] = json!("A different grant in the same run"); + assert!(attached + .attach_run(&json!({"provider": same_run_mutation})) + .unwrap_err() + .to_string() + .contains("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; @@ -3227,7 +3689,7 @@ mod tests { json!("other-session"); assert!(original.attach_run(&wrong_session).is_err()); let mut changed_profile = warm_payload.clone(); - changed_profile["provider"]["instructions"] = json!("different profile"); + changed_profile["provider"]["cwd"] = json!("/different-workspace"); assert!(original.attach_run(&changed_profile).is_err()); let mut refreshed = warm_payload.clone(); refreshed["provider"]["runtimeContext"]["mcp"] = json!({ "digest": "after" }); @@ -3263,7 +3725,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(); @@ -3329,6 +3791,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(); @@ -3622,6 +4085,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(); @@ -3794,6 +4258,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 b34deeed7e..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 @@ -10,7 +10,8 @@ use std::os::unix::fs::DirBuilderExt; use serde::{Deserialize, Serialize}; use crate::acpx_provider_session::{ - AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity, + 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 046ea2d626..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 @@ -36,6 +36,24 @@ pub enum AcpxPermissionMode { DenyAll, } +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CursorMode { + Agent, + Plan, + 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 { @@ -52,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], } @@ -73,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, @@ -88,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")?; } @@ -161,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", @@ -315,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 } @@ -947,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(()); @@ -1243,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); } @@ -1356,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() @@ -1395,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 @@ -1486,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::*; @@ -1602,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(), @@ -1628,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], } } @@ -1636,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!( @@ -1743,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..331f6ed17f 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,110 @@ 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" + | "AWS_ACCESS_KEY_ID" + | "AWS_SECRET_ACCESS_KEY" + | "AWS_SESSION_TOKEN" + ); + 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 +213,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 +268,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 +316,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 +326,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 +429,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 +930,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/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index a034728147..8943d40f57 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -3449,6 +3449,30 @@ mod tests { ); } + #[test] + fn durable_question_initial_text_survives_serialized_reload_without_resolution() { + let mut config = config(PathBuf::from("unused")); + config.max_outbox_bytes = 512 * 1024; + config.max_frame_bytes = 256 * 1024; + let mut state = DurableState::new(&config); + let text = " Editable draft 漢字\n".repeat(1_000); + state.enqueue_event(&config, "runtime_request.created", EventPriority::P0, json!({ + "request": {"schema":"paperclip.runtime_request.v2", "requestKind":"runtime", "requestId":"edit-1", + "type":"input", "status":"pending", "input":{"schema":"paperclip.question_set.v1", "questions":[{ + "id":"draft", "prompt":"Edit", "answerMode":"text", "required":true, "initialText":text + }]}} + })).unwrap(); + let reloaded: DurableState = + serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap(); + let request = reloaded.outbox[0] + .envelope + .pointer("/payload/payload/request") + .unwrap(); + assert_eq!(request["input"]["questions"][0]["initialText"], json!(text)); + assert_eq!(request["status"], "pending"); + assert!(request.get("response").is_none()); + } + #[test] fn durable_question_sets_preserve_safe_identity_and_redact_display_text() { let config = config(PathBuf::from("unused")); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 1da99fcaad..28f4d2a775 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:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", 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:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", 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/src/question_response.rs b/packages/paperclip-runner/runner/crates/runner-core/src/question_response.rs index 0987d0dd7c..b3116f7dd2 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/question_response.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/question_response.rs @@ -272,6 +272,15 @@ fn validate_text_constraints( } fn validate_persisted_question(question: &Value) -> Result<(), LocalRunnerError> { + if question + .get("initialText") + .and_then(Value::as_str) + .is_some_and(|text| text.chars().count() > 100_000) + { + return Err(LocalRunnerError::invalid( + "persisted initial text exceeds its Unicode code-point bound", + )); + } let question_id = question .get("id") .and_then(Value::as_str) 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 b30c56523f..cc94d4834c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f"; + "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -231,10 +231,11 @@ fn pi_prepare_payload(directory: &Path, mode: &str) -> Value { provider["agentServerPackage"] = json!("pi-acp"); provider["agentServerVersion"] = json!("0.0.33"); provider["agentRuntimePackage"] = json!("@earendil-works/pi-coding-agent"); - provider["agentRuntimeVersion"] = json!("0.84.2"); + provider["agentRuntimeVersion"] = json!("1.0.0"); 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/runner/crates/runner-core/tests/question_response.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/question_response.rs index acbf3cfa3c..2ead09c0b4 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/question_response.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/question_response.rs @@ -318,3 +318,21 @@ fn rejects_malformed_or_oversized_response_envelopes() { code_unit_bounded["answers"]["notes"] = json!({"text":"😀".repeat(50_001)}); assert!(validate_question_response(&unconstrained_set, &code_unit_bounded).is_err()); } + +#[test] +fn initial_text_is_never_an_implicit_answer_or_validation_bypass() { + let mut questions = question_set(); + questions["questions"][2]["initialText"] = json!("YES"); + validate_question_response(&questions, &valid_response()).unwrap(); + let mut absent = valid_response(); + absent["answers"].as_object_mut().unwrap().remove("notes"); + assert!(validate_question_response(&questions, &absent).is_err()); + let mut invalid = valid_response(); + invalid["answers"]["notes"] = json!({"text":"bad"}); + assert!(validate_question_response(&questions, &invalid).is_err()); + questions["questions"][2]["initialText"] = json!(format!("{}😀", "a".repeat(99_999))); + assert!(serde_json::to_vec(&questions).unwrap().len() < 196 * 1024); + validate_question_response(&questions, &valid_response()).unwrap(); + questions["questions"][2]["initialText"] = json!(format!("{}😀", "a".repeat(100_000))); + assert!(validate_question_response(&questions, &valid_response()).is_err()); +} diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs new file mode 100644 index 0000000000..73baa55a9a --- /dev/null +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -0,0 +1,100 @@ +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"; +import { isAbsolute, join, resolve } from "node:path"; +import { gunzipSync } from "node:zlib"; +import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribution } from "./materialize-copilot-binary.mjs"; + +const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; +const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; +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 } = {}) { + if (typeof outputRoot !== "string" || !isAbsolute(outputRoot) || resolve(outputRoot) !== outputRoot || outputRoot.includes("\0")) { + throw new Error("Copilot outputRoot must be a normalized absolute build directory"); + } + const distribution = resolveCopilotDistribution(platform, architecture); + const packageBasename = distribution.packageName.slice("@github/".length); + const archiveUrl = `https://registry.npmjs.org/${distribution.packageName}/-/${packageBasename}-${COPILOT_VERSION}.tgz`; + const response = await fetchImpl(archiveUrl, { redirect: "error", credentials: "omit", signal: AbortSignal.timeout(120_000) }); + if (!response.ok || !response.body) throw new Error(`Pinned Copilot archive download failed (${response.status})`); + const advertisedLength = response.headers.get("content-length"); + if (advertisedLength !== null && (!/^\d+$/.test(advertisedLength) || Number(advertisedLength) > MAX_ARCHIVE_BYTES)) { + await response.body.cancel(); throw new Error("Pinned Copilot archive exceeds its download bound"); + } + const chunks = []; let total = 0; + for await (const chunk of response.body) { + total += chunk.length; + if (total > MAX_ARCHIVE_BYTES) throw new Error("Pinned Copilot archive exceeds its download bound"); + chunks.push(chunk); + } + const archive = Buffer.concat(chunks, total); + verifyCopilotArchiveIntegrity(archive, distribution.archiveIntegrity); + const entries = readCopilotArchiveEntries(gunzipSync(archive, { maxOutputLength: MAX_EXPANDED_BYTES })); + const metadata = JSON.parse(entries.get("package/package.json").toString("utf8")); + if (metadata.name !== distribution.packageName || metadata.version !== COPILOT_VERSION) throw new Error("Copilot archive package identity differs from its immutable profile"); + const binary = entries.get("package/copilot"); + if (binary.length !== distribution.size || createHash("sha256").update(binary).digest("hex") !== distribution.executableDigest) { + throw new Error("Copilot archive executable differs from its pinned distribution"); + } + // Never extract archive paths. Only the two admitted files enter this private + // temporary package, and no package scripts or executables are invoked. + const temporary = await mkdtemp(join(tmpdir(), "paperclip-copilot-build-")); + try { + await writeFile(join(temporary, "package.json"), entries.get("package/package.json"), { flag: "wx", mode: 0o600 }); + await writeFile(join(temporary, "copilot"), binary, { flag: "wx", mode: 0o700 }); + await mkdir(outputRoot, { recursive: true, mode: 0o755 }); + if (!(await lstat(outputRoot)).isDirectory() || await realpath(outputRoot) !== outputRoot) throw new Error("Copilot build directory must not redirect through links"); + const materialized = materializePinnedCopilotBinary({ packageRoot: temporary, targetDirectory: outputRoot, platform, architecture }); + return { version: COPILOT_VERSION, profileDigest: PROFILE_DIGEST, closureDigest: `sha256:${materialized.closureSha256}` }; + } finally { await rm(temporary, { recursive: true, force: true }); } +} + +export function verifyCopilotArchiveIntegrity(bytes, integrity) { + if (!/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) throw new Error("Copilot archive integrity pin is malformed"); + const expected = Buffer.from(integrity.slice(7), "base64"); + if (!timingSafeEqual(createHash("sha512").update(bytes).digest(), expected)) throw new Error("Copilot archive SHA-512 integrity mismatch"); +} + +/** Strict npm tar subset: no links, traversal, PAX overrides, duplicates or hidden trailers. */ +export function readCopilotArchiveEntries(tar) { + if (!Buffer.isBuffer(tar) || tar.length < 1024 || tar.length > MAX_EXPANDED_BYTES || tar.length % 512 !== 0) throw new Error("Copilot tar archive has invalid framing"); + const permitted = new Set(["package/copilot", "package/package.json", "package/LICENSE.md", "package/README.md"]); + const entries = new Map(); let offset = 0; let ended = false; + const field = (header, start, end) => { + const bytes = header.subarray(start, end); const nul = bytes.indexOf(0); + if (nul >= 0 && bytes.subarray(nul).some(byte => byte !== 0)) throw new Error("Copilot tar field contains hidden bytes"); + return bytes.subarray(0, nul < 0 ? undefined : nul).toString("ascii"); + }; + const octal = (header, start, end) => { + const value = header.subarray(start, end).toString("ascii").replace(/\0/g, "").trim(); + if (!/^[0-7]+$/.test(value)) throw new Error("Copilot tar numeric field is malformed"); + const parsed = Number.parseInt(value, 8); + if (!Number.isSafeInteger(parsed)) throw new Error("Copilot tar numeric field exceeds its bound"); + return parsed; + }; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); offset += 512; + if (header.every(byte => byte === 0)) { + if (offset + 512 > tar.length || tar.subarray(offset).some(byte => byte !== 0)) throw new Error("Copilot tar archive has a hidden or truncated trailer"); + ended = true; break; + } + const checksum = header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0); + if (checksum !== octal(header, 148, 156)) throw new Error("Copilot tar header checksum mismatch"); + const name = field(header, 0, 100); + const size = octal(header, 124, 136); + if (!permitted.has(name) || entries.has(name) || field(header, 345, 500) !== "" || field(header, 157, 257) !== "" + || header[156] !== 48 || field(header, 257, 263) !== "ustar" || header.subarray(263, 265).toString("ascii") !== "00") { + throw new Error("Copilot tar archive contains an unrecognized or unsafe entry"); + } + if (size < 1 || size > (name === "package/copilot" ? MAX_EXPANDED_BYTES : 256 * 1024) || offset + size > tar.length) throw new Error("Copilot tar entry exceeds its bounded payload"); + const paddedSize = Math.ceil(size / 512) * 512; + if (offset + paddedSize > tar.length || tar.subarray(offset + size, offset + paddedSize).some(byte => byte !== 0)) throw new Error("Copilot tar entry has invalid padding"); + entries.set(name, tar.subarray(offset, offset + size)); + offset += paddedSize; + } + if (!ended || entries.size !== permitted.size) throw new Error("Copilot tar archive is incomplete"); + return entries; +} diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.test.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.test.mjs new file mode 100644 index 0000000000..5607b6469f --- /dev/null +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { test } from "node:test"; +import { buildPinnedCopilotDistribution, readCopilotArchiveEntries, verifyCopilotArchiveIntegrity } from "./build-copilot-distribution.mjs"; + +function header(name, body, type = "0") { + const block = Buffer.alloc(512); block.write(name, 0, 100, "ascii"); + block.write("0000755\0", 100); block.write(body.length.toString(8).padStart(11, "0") + "\0", 124); + block.fill(32, 148, 156); block.write(type, 156); block.write("ustar\0", 257); block.write("00", 263); + block.write(block.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, "0") + "\0 ", 148); + return Buffer.concat([block, body, Buffer.alloc((512 - body.length % 512) % 512)]); +} +function archive(replace = {}) { + const entries = ["package/copilot", "package/package.json", "package/LICENSE.md", "package/README.md"]; + return Buffer.concat([...entries.map(name => header(name === "package/copilot" ? replace.name ?? name : name, + Buffer.from(name === "package/copilot" ? "binary" : "data"), name === "package/copilot" ? replace.type ?? "0" : "0")), Buffer.alloc(1024)]); +} + +test("strict archive reader admits only complete pinned npm entry shape", () => { + const entries = readCopilotArchiveEntries(archive()); + assert.equal(entries.get("package/copilot").toString(), "binary"); + for (const invalid of [archive({ name: "../../copilot" }), archive({ type: "2" }), archive({ type: "x" }), + archive({ name: "package/package.json" }), archive().subarray(0, -512), Buffer.concat([archive(), Buffer.from("hidden")])]) { + assert.throws(() => readCopilotArchiveEntries(invalid)); + } + const changed = archive(); changed[20] ^= 1; + assert.throws(() => readCopilotArchiveEntries(changed), /checksum/); + const padding = archive(); padding[512 + 6] = 1; + assert.throws(() => readCopilotArchiveEntries(padding), /padding/); +}); + +test("SHA-512 archive verification rejects corruption and malformed pins", () => { + const bytes = Buffer.from("pinned archive"); const pin = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + verifyCopilotArchiveIntegrity(bytes, pin); + assert.throws(() => verifyCopilotArchiveIntegrity(Buffer.from("tampered"), pin), /mismatch/); + assert.throws(() => verifyCopilotArchiveIntegrity(bytes, "sha512-invalid"), /malformed/); +}); + +test("builder uses only exact pinned registry URL and refuses a corrupt download before writes", async () => { + let called = false; + await assert.rejects(buildPinnedCopilotDistribution({ outputRoot: "/unused-copilot-build", platform: "linux", architecture: "x64" }, { + fetchImpl: async (url, options) => { + called = true; + assert.equal(url, "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.88.tgz"); + assert.equal(options.redirect, "error"); assert.equal(options.credentials, "omit"); + return new Response("corrupt"); + }, + }), /integrity mismatch/); + assert.equal(called, true); + await assert.rejects(buildPinnedCopilotDistribution({ outputRoot: "relative", platform: "linux", architecture: "x64" }), /normalized absolute/); +}); + +test("builder bounds download metadata before streaming", async () => { + await assert.rejects(buildPinnedCopilotDistribution({ outputRoot: "/unused-copilot-build", platform: "linux", architecture: "x64" }, { + fetchImpl: async () => new Response("small", { headers: { "content-length": String(129 * 1024 * 1024) } }), + }), /download bound/); +}); 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..a58e8cb315 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -20,8 +20,8 @@ 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"; +import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const workspaceRoot = resolve(packageRoot, "../.."); @@ -167,7 +167,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..306a626d30 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs @@ -106,9 +106,37 @@ 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"], + ["../../patches/brace-expansion@5.0.9.patch", "brace-expansion.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 88a85c816d..c11110baba 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -1,47 +1,53 @@ 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"] }); -}); -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)); } await assert.rejects(materializeCandidateProviderPack({ provider: "arbitrary", outputRoot: "/tmp/unused" }), /Unknown candidate/); }); + +test("Copilot pack selection reaches only the pinned native archive builder", async t => { + const requests = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + requests.push({ url, redirect: options.redirect, credentials: options.credentials }); + return new Response("corrupt archive"); + }); + await assert.rejects(materializeCandidateProviderPack({ provider: "copilot", outputRoot: "/unused-copilot-build" }), /integrity mismatch/); + assert.equal(requests.length, 1); + assert.match(requests[0].url, /^https:\/\/registry\.npmjs\.org\/@github\/copilot-(darwin-(arm64|x64)|linux-x64)\/-\/copilot-.*-1\.0\.88\.tgz$/); + assert.deepEqual({ redirect: requests[0].redirect, credentials: requests[0].credentials }, { redirect: "error", credentials: "omit" }); +}); + +test("Pi pack selection reaches its pinned builder and rejects unsafe output before downloading", async t => { + let requests = 0; + t.mock.method(globalThis, "fetch", async () => { + requests += 1; + throw new Error("Unexpected download for invalid Pi output"); + }); + await assert.rejects(materializeCandidateProviderPack({ provider: "pi", outputRoot: "relative-pi-output" }), /Pi distribution output must be absolute/); + assert.equal(requests, 0); +}); diff --git a/packages/paperclip-runner/scripts/check-clean-consumers.mjs b/packages/paperclip-runner/scripts/check-clean-consumers.mjs index 65b9310404..f3ad0550e5 100644 --- a/packages/paperclip-runner/scripts/check-clean-consumers.mjs +++ b/packages/paperclip-runner/scripts/check-clean-consumers.mjs @@ -15,6 +15,7 @@ import { import { basename, dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { withInstalledPackagePackInput } from "./lib/installed-package-pack.mjs"; const runnerRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const scratchParent = process.env.PAPERCLIP_RUN_SCRATCH_DIR @@ -42,7 +43,8 @@ try { "--bin", "paperclip-runnerd", ], runnerRoot); - const runnerTarball = await pack(runnerRoot, artifactsRoot); + run(process.execPath, ["scripts/stage-runner-binary.mjs"], runnerRoot); + const runnerTarball = await pack(runnerRoot, artifactsRoot, { runner: true }); const runtimeDependencyTarballs = await packRunnerRuntimeDependencies(artifactsRoot); const runnerdArtifact = await stageRunnerdArtifact(artifactsRoot); const conformanceRecord = resolve(artifactsRoot, "paperclip-runner-consumer-conformance.json"); @@ -76,9 +78,19 @@ try { } } -async function pack(packageRoot, destination) { +async function pack(packageRoot, destination, { runner = false } = {}) { const before = new Set(await readdir(destination)); - run("npm", ["pack", "--ignore-scripts", "--pack-destination", destination], packageRoot, { quiet: true }); + // These installed dependencies are pack inputs, not our development cwd. + // Keep their publisher devEngines intact without adopting their toolchain; + // normal tarball contents and clean-consumer runtime engine checks still apply. + if (runner) { + // Exercise the pinned release packer, including executableFiles normalization. + run("pnpm", ["pack", "--pack-destination", destination], packageRoot, { + quiet: true, env: { npm_config_ignore_scripts: "true" }, + }); + } else { + run("npm", ["pack", resolve(packageRoot), "--ignore-scripts", "--pack-destination", destination], destination, { quiet: true }); + } const created = (await readdir(destination)) .filter((entry) => entry.endsWith(".tgz") && !before.has(entry)) .sort(); @@ -104,7 +116,8 @@ async function packRunnerRuntimeDependencies(destination) { const identity = `${manifest.name}@${manifest.version}`; let tarball = packed.get(identity); if (tarball === undefined) { - tarball = await pack(concreteRoot, destination); + tarball = await withInstalledPackagePackInput(concreteRoot, scratchRoot, + input => pack(input, destination)); packed.set(identity, tarball); } overrides[overrideKey] = tarball; @@ -154,7 +167,7 @@ async function resolveInstalledDependencyRoot(packageRoot, dependencyName) { async function stageRunnerdArtifact(destination) { const suffix = process.platform === "win32" ? ".exe" : ""; - const source = resolve(runnerRoot, `runner/target/release/paperclip-runnerd${suffix}`); + const source = resolve(runnerRoot, `dist/bin/paperclip-runnerd${suffix}`); const executablePath = resolve(destination, `paperclip-runnerd-${process.platform}-${process.arch}${suffix}`); await copyFile(source, executablePath); if (process.platform !== "win32") await chmod(executablePath, 0o755); @@ -192,6 +205,40 @@ async function verifyRunnerConsumer({ }, pnpm: { overrides: localOverrides(runtimeDependencyTarballs) }, }, null, 2)}\n`); + await writeFile(resolve(consumerRoot, "verify-default-runnerd.mjs"), ` +import { execFileSync } from "node:child_process"; +import { constants } from "node:fs"; +import { access, readFile, realpath, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defaultCapabilityRunnerdBinary } from "@paperclipai/paperclip-runner"; +import { parsePaperclipRunnerdBuildMetadata, PAPERCLIP_RUNNER_BUILD_METADATA } from "@paperclipai/paperclip-runner/evals"; +// This is the installed package's default lookup. The separate eval artifact +// below must not hide an unusable bundled daemon or repair its permissions. +const bundledRunnerd = defaultCapabilityRunnerdBinary(); +const installedEntry = fileURLToPath(import.meta.resolve("@paperclipai/paperclip-runner")); +const expectedBundledRunnerd = resolve(dirname(installedEntry), "bin", + "paperclip-runnerd" + (process.platform === "win32" ? ".exe" : "")); +if (await realpath(bundledRunnerd) !== await realpath(expectedBundledRunnerd)) { + throw new Error("default runnerd escaped the installed package"); +} +await access(bundledRunnerd, constants.X_OK); +const bundledRunnerdDigest = "sha256:" + createHash("sha256") + .update(await readFile(bundledRunnerd)).digest("hex"); +if (bundledRunnerdDigest !== process.env.EXPECTED_RUNNERD_SHA256) { + throw new Error("installed default runnerd differs from the built binary"); +} +const bundledMetadata = JSON.parse(execFileSync(bundledRunnerd, ["--build-metadata"], { + encoding: "utf8", timeout: 15_000, maxBuffer: 1024 * 1024, + env: { PATH: process.env.PATH, HOME: process.cwd() }, +})); +if (parsePaperclipRunnerdBuildMetadata(bundledMetadata).binaryContractVersion !== PAPERCLIP_RUNNER_BUILD_METADATA.contracts.runnerdArtifact) { + throw new Error("installed default runnerd did not start with the expected contract"); +} + +await writeFile("bundled-runnerd-proof.json", JSON.stringify({ executable: true, started: true, binaryOverride: false, sha256: bundledRunnerdDigest }) + "\\n"); +`); await writeFile(resolve(consumerRoot, "verify.mjs"), ` import { createHash } from "node:crypto"; import { readFile, stat, writeFile } from "node:fs/promises"; @@ -344,6 +391,7 @@ await writeFile(process.env.PAPERCLIP_CONFORMANCE_RECORD, JSON.stringify({ mockControlPlaneConformance: report, harnessDriverConformance: driverConformance, runnerdDigest: true, + bundledDefaultRunnerd: JSON.parse(await readFile("bundled-runnerd-proof.json", "utf8")), semanticConformance: { schema: semanticConformance.schema, rowCount: semanticConformance.rows.length, @@ -388,17 +436,21 @@ function installAndRun(consumerRoot, extraEnv = {}) { "--config.auto-install-peers=false", "--reporter=append-only", ], consumerRoot, { env: { NODE_ENV: "development" } }); + run(process.execPath, ["verify-default-runnerd.mjs"], consumerRoot, { + inheritEnv: false, + env: { PATH: process.env.PATH, HOME: consumerRoot, EXPECTED_RUNNERD_SHA256: extraEnv.PAPERCLIP_RUNNERD_SHA256 }, + }); run(process.execPath, ["verify.mjs"], consumerRoot, { env: extraEnv }); } -function run(command, args, cwd, { quiet = false, env = {} } = {}) { +function run(command, args, cwd, { quiet = false, env = {}, inheritEnv = true } = {}) { const usesPnpm = command === "pnpm"; const executable = usesPnpm ? pnpmInvocation.executable : command; const effectiveArgs = usesPnpm ? [...pnpmInvocation.prefixArgs, ...args] : args; const result = spawnSync(executable, effectiveArgs, { cwd, encoding: "utf8", - env: { ...process.env, CI: "true", ...env }, + env: { ...(inheritEnv ? process.env : {}), CI: "true", ...env }, stdio: quiet ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"], ...(quiet ? { maxBuffer: 32 * 1024 * 1024 } : {}), }); diff --git a/packages/paperclip-runner/scripts/check-forbidden-imports.test.mjs b/packages/paperclip-runner/scripts/check-forbidden-imports.test.mjs index f4dc12e447..faa3b01543 100644 --- a/packages/paperclip-runner/scripts/check-forbidden-imports.test.mjs +++ b/packages/paperclip-runner/scripts/check-forbidden-imports.test.mjs @@ -1,9 +1,13 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; import { checkForbiddenImports, defaultPackageRoot, + findSpecifiers, } from "./lib/forbidden-imports.mjs"; test("the package passes its standalone boundary", async () => { @@ -59,3 +63,141 @@ test("a negative fixture proves that an SDK consumer deep import is rejected", a assert.equal(violations.length, 1); assert.match(violations[0].reason, /may not deep-import/); }); + +test("the parser ignores embedded fixtures but preserves executable imports and types", () => { + const source = [ + '// import "comment";', + '/* export * from "comment-export"; */', + 'const fixture = "import value from \\"fixture\\"";', + 'const template = `require("fixture-require"); import("fixture-dynamic")`;', + 'const pattern = /import\\("fixture-regex"\\)/;', + 'import "side-effect";', + 'import type { T } from "types";', + 'export { value } from "barrel";', + 'export * from "star";', + 'import equal = require("equal");', + 'type Imported = import("import-type").T;', + 'const cjs = require("cjs");', + 'const dynamic = import("dynamic", { with: { type: "json" } });', + 'const literal = import(`static-template`);', + 'const escaped = import("escaped\\u002dname");', + 'const interpolation = `text ${import("executed-interpolation")}`;', + ].join("\n"); + const found = findSpecifiers(source); + assert.deepEqual(found.map(({ specifier }) => specifier), [ + "side-effect", "types", "barrel", "star", "equal", "import-type", "cjs", + "dynamic", "static-template", "escaped-name", "executed-interpolation", + ]); + assert.deepEqual(found.map(({ offset }) => source.slice(0, offset).split("\n").length), + [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); +}); + +test("the parser treats JSX fixture text separately from expression imports", () => { + assert.deepEqual(findSpecifiers( + '
require("text") {import("executed")}
', + "fixture.tsx", + ).map(({ specifier }) => specifier), ["executed"]); +}); + +test("Rust include macros and path modules retain their boundary checks", () => { + assert.deepEqual(findSpecifiers([ + 'include!("../included.rs");', + 'include_str!("../text.txt");', + 'include_bytes!("../bytes.bin");', + '#[path = "../module.rs"] mod module;', + ].join("\n"), "source.rs").map(({ specifier }) => specifier), + ["../included.rs", "../text.txt", "../bytes.bin", "../module.rs"]); +}); + +async function fixtureBoundary(t, { manifest = {}, files = {} } = {}) { + const root = await mkdtemp(resolve(tmpdir(), "runner-import-boundary-")); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(resolve(root, "src"), { recursive: true }); + await writeFile(resolve(root, "package.json"), JSON.stringify(manifest)); + for (const [file, source] of Object.entries(files)) { + await mkdir(dirname(resolve(root, file)), { recursive: true }); + await writeFile(resolve(root, file), source); + } + return checkForbiddenImports({ packageRoot: root, scanRoots: ["src"], cargoRoots: [] }); +} + +const kernel = "@paperclipai/paperclip-eval-kernel"; +const harness = "src/eval/workflow-harness.ts"; +const reviewedManifest = { + exports: { "./evals": "./dist/evals.js" }, + devDependencies: { [kernel]: "workspace:*" }, +}; + +test("only exact declared public subpaths are admitted", async (t) => { + const violations = await fixtureBoundary(t, { + manifest: reviewedManifest, + files: { "src/consumer.ts": [ + 'import "@paperclipai/paperclip-runner/evals";', + 'import "@paperclipai/paperclip-runner/evals/private";', + 'import "@paperclipai/paperclip-runner/undeclared";', + ].join("\n") }, + }); + assert.deepEqual(violations.map(({ specifier }) => specifier), [ + "@paperclipai/paperclip-runner/evals/private", "@paperclipai/paperclip-runner/undeclared", + ]); +}); + +test("ADR0001 permits only the private development harness and reviewed package", async (t) => { + assert.deepEqual(await fixtureBoundary(t, { + manifest: reviewedManifest, + files: { [harness]: `import { scenario } from "${kernel}";`, "src/evals.ts": "export const publicApi = true;" }, + }), []); + for (const [file, specifier] of [ + ["src/other.ts", kernel], [harness, `${kernel}/private`], [harness, "@paperclipai/other"], + ]) { + const violations = await fixtureBoundary(t, { + manifest: reviewedManifest, files: { [file]: `import "${specifier}";` }, + }); + assert.equal(violations.length, 1); + assert.equal(violations[0].specifier, specifier); + } + assert.equal((await fixtureBoundary(t, { + files: { [harness]: `import "${kernel}";` }, + })).length, 1, "the exception requires the reviewed devDependency declaration"); +}); + +test("the reviewed kernel is still forbidden in every production dependency group", async (t) => { + for (const group of ["dependencies", "optionalDependencies", "peerDependencies"]) { + const violations = await fixtureBoundary(t, { + manifest: { ...reviewedManifest, [group]: { [kernel]: "workspace:*" } }, + }); + assert.equal(violations.length, 1, group); + assert.equal(violations[0].specifier, kernel); + } +}); + +test("direct or transitive public harness exposure revokes the dev-only exception", async (t) => { + for (const entry of [ + "./dist/eval/workflow-harness.js", + "./dist/evals.js", + ]) { + for (const imported of ["./eval/workflow-harness.js", "./eval/workflow-harness"]) { + const violations = await fixtureBoundary(t, { + manifest: { ...reviewedManifest, exports: { "./evals": { types: "./dist/evals.d.ts", import: entry } } }, + files: { + [harness]: `import "${kernel}";`, + "src/evals.ts": 'export * from "./barrel.js";', + "src/barrel.ts": `export * from "${imported}";`, + }, + }); + assert.equal(violations.length, 1, `${entry} via ${imported}`); + assert.equal(violations[0].specifier, kernel); + } + } +}); + + +test("unexpanded wildcard exports cannot expose the reviewed development harness", async (t) => { + const violations = await fixtureBoundary(t, { + manifest: { ...reviewedManifest, exports: { "./eval/*": "./dist/eval/*.js" } }, + files: { [harness]: `import "${kernel}";` }, + }); + assert.equal(violations.length, 1); + assert.equal(violations[0].specifier, kernel); + assert.match(violations[0].reason, /outside the standalone boundary/); +}); diff --git a/packages/paperclip-runner/scripts/check-tracked-imports.test.mjs b/packages/paperclip-runner/scripts/check-tracked-imports.test.mjs index 79778857de..66151ddc83 100644 --- a/packages/paperclip-runner/scripts/check-tracked-imports.test.mjs +++ b/packages/paperclip-runner/scripts/check-tracked-imports.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { test } from "node:test"; import { @@ -71,3 +74,26 @@ test("the fixture itself is tracked so the package-wide check stays green", asyn assert.ok(tracked.has(resolve(defaultPackageRoot, fixtureRoot, name)), name); } }); + + +test("embedded fixture imports are ignored while real missing imports still fail", async (t) => { + const root = await mkdtemp(resolve(tmpdir(), "runner-tracked-imports-")); + t.after(() => rm(root, { recursive: true, force: true })); + const initialized = spawnSync("git", ["init", "--quiet", root], { encoding: "utf8" }); + assert.equal(initialized.status, 0, initialized.stderr); + await mkdir(resolve(root, "src")); + const consumer = resolve(root, "src/consumer.mts"); + await writeFile(consumer, [ + 'const fixture = `import "./fixture-only.js"; require("./other-fixture.js")`;', + 'const actual = import("./missing.js", { with: { type: "json" } });', + 'export { tracked } from "./tracked.js";', + ].join("\n")); + const tracked = resolve(root, "src/tracked.ts"); + await writeFile(tracked, "export const tracked = true;"); + const violations = await checkTrackedImports({ + packageRoot: root, scanRoots: ["src"], trackedFiles: [consumer, tracked], + }); + assert.deepEqual(violations.map(({ specifier, line, reason }) => ({ specifier, line, reason })), [{ + specifier: "./missing.js", line: 2, reason: "does not resolve to any tracked file", + }]); +}); diff --git a/packages/paperclip-runner/scripts/copilot-inner-distribution.mjs b/packages/paperclip-runner/scripts/copilot-inner-distribution.mjs new file mode 100644 index 0000000000..4c87971139 --- /dev/null +++ b/packages/paperclip-runner/scripts/copilot-inner-distribution.mjs @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; +import { gunzipSync } from "node:zlib"; + +export const COPILOT_APP_SHA256 = "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0"; +export const COPILOT_INNER_DISTRIBUTIONS = Object.freeze({ + "darwin-arm64": Object.freeze({ offset: 92954063, size: 55323492, sha256: "49f5caa0582945a04c5619f8ea81680b1c8c2765888290cf8acf903e2985315b", entries: 230 }), + "darwin-x64": Object.freeze({ offset: 95169991, size: 64572726, sha256: "a4c41f1f480a259db2e4da952d5d2e92d19ae54ae1d6af97143341b6c4918aea", entries: 231 }), + "linux-x64": Object.freeze({ offset: 108781435, size: 60742828, sha256: "c386269ee1bf44bac514da2bb0c6b2a47546631f02e294fc5a8c20d1b1b990ff", entries: 216 }), +}); +export const COPILOT_MESSAGE_MAPPING_ANCHOR = 'case"assistant.message_start":return null;case"assistant.message_delta":return{sessionUpdate:"agent_message_chunk",content:{type:"text",text:e.data.deltaContent}};case"assistant.message":return null;'; +export const COPILOT_MESSAGE_MAPPING_REPLACEMENT = 'case"assistant.message_start":return{sessionUpdate:"agent_message_chunk",messageId:e.data.messageId,content:{type:"text",text:""}};case"assistant.message_delta":return{sessionUpdate:"agent_message_chunk",messageId:e.data.messageId,content:{type:"text",text:e.data.deltaContent}};case"assistant.message":return null;'; +export const COPILOT_REPLAY_MAPPING_ANCHOR = 'case"assistant.message":return t.data.content?{sessionUpdate:"agent_message_chunk",content:{type:"text",text:t.data.content}}:null;'; +export const COPILOT_REPLAY_MAPPING_REPLACEMENT = 'case"assistant.message":return{sessionUpdate:"agent_message_chunk",messageId:t.data.messageId,content:{type:"text",text:t.data.content??""}};'; +const hash = bytes => createHash("sha256").update(bytes).digest("hex"); +const MAX_TAR_BYTES = 256 * 1024 * 1024; + +/** Only called after the complete native executable's independent pin matches. */ +export function readPinnedCopilotInnerDistribution(binary, target) { + const pin = COPILOT_INNER_DISTRIBUTIONS[target]; + if (!pin || !Buffer.isBuffer(binary) || binary.length < pin.offset + pin.size) throw new Error("Copilot inner distribution target or framing is invalid"); + const archive = binary.subarray(pin.offset, pin.offset + pin.size); + if (hash(archive) !== pin.sha256) throw new Error("Copilot embedded archive digest mismatch"); + const entries = readCopilotInnerTar(gunzipSync(archive, { maxOutputLength: MAX_TAR_BYTES })); + if (entries.size !== pin.entries) throw new Error("Copilot embedded archive entry count changed"); + const metadata = JSON.parse(entries.get("package.json")?.bytes.toString("utf8") ?? "null"); + if (metadata?.name !== "@github/copilot" || metadata.version !== "1.0.88" + || !entries.has("index.js") || !entries.has(`prebuilds/${target}/runtime.node`)) throw new Error("Copilot inner distribution identity is invalid"); + const app = entries.get("app.js"); + if (!app) throw new Error("Copilot inner app is missing"); + return new Map([...entries].map(([path, entry]) => [path, path === "app.js" ? { ...entry, bytes: patchPinnedCopilotMessageIdentity(app.bytes) } : entry])); +} + +/** Keep native message identity on the already serialized standard ACP stream. */ +export function patchPinnedCopilotMessageIdentity(bytes) { + if (!Buffer.isBuffer(bytes) || hash(bytes) !== COPILOT_APP_SHA256) throw new Error("Copilot message mapping source digest mismatch"); + let source = bytes.toString("utf8"); + for (const [anchor, replacement] of [[COPILOT_MESSAGE_MAPPING_ANCHOR, COPILOT_MESSAGE_MAPPING_REPLACEMENT], [COPILOT_REPLAY_MAPPING_ANCHOR, COPILOT_REPLAY_MAPPING_REPLACEMENT]]) { + if (source.indexOf(anchor) < 0 || source.indexOf(anchor) !== source.lastIndexOf(anchor)) throw new Error("Copilot message mapping patch anchor is not unique"); + source = source.replace(anchor, replacement); + } + return Buffer.from(source); +} + +/** Closed ustar subset: all pinned inner assets are regular files, with no links. */ +export function readCopilotInnerTar(tar) { + if (!Buffer.isBuffer(tar) || tar.length < 1024 || tar.length > MAX_TAR_BYTES || tar.length % 512) throw new Error("Copilot inner tar framing is invalid"); + const entries = new Map(); let offset = 0; let ended = false; + const field = (header, start, end) => { + const bytes = header.subarray(start, end); const nul = bytes.indexOf(0); + if (nul >= 0 && bytes.subarray(nul).some(byte => byte !== 0)) throw new Error("Copilot inner tar field contains hidden bytes"); + const value = bytes.subarray(0, nul < 0 ? undefined : nul); + if (value.some(byte => byte < 32 || byte > 126)) throw new Error("Copilot inner tar field is not ASCII"); + return value.toString("ascii"); + }; + const octal = (header, start, end) => { + const raw = header.subarray(start, end).toString("ascii"); + if (!/^[0-7]+[\0 ]*$/.test(raw)) throw new Error("Copilot inner tar numeric field is invalid"); + const value = raw.replace(/[\0 ]+$/, ""); + const parsed = Number.parseInt(value, 8); + if (!Number.isSafeInteger(parsed)) throw new Error("Copilot inner tar numeric field exceeds its bound"); + return parsed; + }; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); offset += 512; + if (header.every(byte => byte === 0)) { + if (offset + 512 > tar.length || tar.subarray(offset).some(byte => byte !== 0)) throw new Error("Copilot inner tar trailer is invalid"); + ended = true; break; + } + if (header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0) !== octal(header, 148, 156)) throw new Error("Copilot inner tar checksum mismatch"); + const prefix = field(header, 345, 500); const leaf = field(header, 0, 100); + const name = prefix ? `${prefix}/${leaf}` : leaf; + if (!name.startsWith("package/") || name.length > 4096 || name.includes("\\") || name.split("/").some(part => !part || part === "." || part === "..") + || header[156] !== 48 || field(header, 157, 257) || field(header, 257, 263) !== "ustar" || header.subarray(263, 265).toString("ascii") !== "00") throw new Error("Copilot inner tar entry is unsafe or unsupported"); + const path = name.slice(8); const size = octal(header, 124, 136); const mode = octal(header, 100, 108); + if (entries.has(path) || entries.size >= 1000 || size > MAX_TAR_BYTES || mode & ~0o777 || mode & 0o022) throw new Error("Copilot inner tar entry metadata is invalid"); + const padded = Math.ceil(size / 512) * 512; + if (offset + padded > tar.length || tar.subarray(offset + size, offset + padded).some(byte => byte !== 0)) throw new Error("Copilot inner tar entry padding is invalid"); + entries.set(path, { bytes: tar.subarray(offset, offset + size), executable: Boolean(mode & 0o111) }); offset += padded; + } + if (!ended || !entries.size) throw new Error("Copilot inner tar is incomplete"); + // Refuse a file that is also another entry's parent before any write begins. + for (const path of entries.keys()) for (let i = path.indexOf("/"); i >= 0; i = path.indexOf("/", i + 1)) { + if (entries.has(path.slice(0, i))) throw new Error("Copilot inner tar file conflicts with a directory"); + } + return entries; +} diff --git a/packages/paperclip-runner/scripts/copilot-inner-distribution.test.mjs b/packages/paperclip-runner/scripts/copilot-inner-distribution.test.mjs new file mode 100644 index 0000000000..752956aba4 --- /dev/null +++ b/packages/paperclip-runner/scripts/copilot-inner-distribution.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; +import { COPILOT_MESSAGE_MAPPING_REPLACEMENT, COPILOT_REPLAY_MAPPING_REPLACEMENT, patchPinnedCopilotMessageIdentity, readCopilotInnerTar, readPinnedCopilotInnerDistribution } from "./copilot-inner-distribution.mjs"; + +function file(name, content = "data", { type = "0", mode = 0o644, prefix = "" } = {}) { + const bytes = Buffer.from(content); const h = Buffer.alloc(512); + h.write(name, 0, 100, "ascii"); h.write(mode.toString(8).padStart(7, "0") + "\0", 100); + h.write(bytes.length.toString(8).padStart(11, "0") + "\0", 124); + h.fill(32, 148, 156); h.write(type, 156); h.write("ustar\0", 257); h.write("00", 263); h.write(prefix, 345, 155); + h.write(h.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, "0") + "\0 ", 148); + return Buffer.concat([h, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)]); +} +const tar = (...files) => Buffer.concat([...files, Buffer.alloc(1024)]); + +test("inner reader preserves every regular asset and ustar prefix without executing it", () => { + const entries = readCopilotInnerTar(tar(file("package/index.js", "throw new Error('never execute')", { mode: 0o755 }), file("asset", "", { prefix: "package/assets" }))); + assert.equal(entries.size, 2); + assert.equal(entries.get("index.js").executable, true); + assert.equal(entries.get("assets/asset").bytes.length, 0); +}); + +test("inner reader rejects traversal, links, duplicate entries, writable files and file parents", () => { + for (const files of [[file("package/../escape")], [file("/package/index.js")], [file("package/a\\b")], + [file("package/link", "", { type: "2" })], [file("package/hardlink", "", { type: "1" })], + [file("package/a", "", { mode: 0o777 })], [file("package/a"), file("package/a")], + [file("package/a"), file("package/a/b")], [file("package/meta", "", { type: "x" })]]) { + assert.throws(() => readCopilotInnerTar(tar(...files))); + } +}); + +test("inner reader rejects corrupt checksums, truncated framing and hidden padding/trailer", () => { + const valid = tar(file("package/a")); + const checksum = Buffer.from(valid); checksum[0] ^= 1; + const padding = Buffer.from(valid); padding[517] = 1; + const trailer = Buffer.from(valid); trailer[trailer.length - 1] = 1; + for (const bytes of [checksum, padding, trailer, valid.subarray(0, -512), valid.subarray(0, -1)]) assert.throws(() => readCopilotInnerTar(bytes)); +}); + +test("pinned archive and source refuse unknown upstream bytes before patching", () => { + assert.throws(() => patchPinnedCopilotMessageIdentity(Buffer.from(COPILOT_MESSAGE_MAPPING_REPLACEMENT)), /source digest/); + assert.throws(() => readPinnedCopilotInnerDistribution(Buffer.alloc(1), "darwin-arm64"), /framing/); + assert.throws(() => readPinnedCopilotInnerDistribution(Buffer.alloc(1), "linux-arm64"), /target/); +}); + +test("ordered mapping retains native IDs including an empty start and never echoes completion text", () => { + const map = runInNewContext(`e=>{switch(e.type){${COPILOT_MESSAGE_MAPPING_REPLACEMENT}default:return null}}`); + const events = [ + { type: "assistant.message_start", data: { messageId: "first" } }, + { type: "assistant.message_delta", data: { messageId: "first", deltaContent: "same" } }, + { type: "assistant.message", data: { messageId: "first", content: "same" } }, + { type: "tool.execution_complete", data: {} }, + { type: "assistant.message_start", data: { messageId: "second" } }, + { type: "assistant.message_delta", data: { messageId: "second", deltaContent: "sa" } }, + { type: "assistant.message_delta", data: { messageId: "second", deltaContent: "me" } }, + { type: "assistant.message", data: { messageId: "second", content: "same" } }, + { type: "assistant.message_start", data: { messageId: "empty-final" } }, + { type: "assistant.message", data: { messageId: "empty-final", content: "" } }, + ]; + assert.deepEqual(JSON.parse(JSON.stringify(events.map(map).filter(Boolean))).map(e => [e.messageId, e.content.text]), + [["first", ""], ["first", "same"], ["second", ""], ["second", "sa"], ["second", "me"], ["empty-final", ""]]); +}); + +test("the patched replay branch emits full messages once, with ordered identities and empty finals", () => { + const live = runInNewContext(`e=>{switch(e.type){${COPILOT_MESSAGE_MAPPING_REPLACEMENT}default:return null}}`); + const replay = runInNewContext(`t=>{switch(t.type){${COPILOT_REPLAY_MAPPING_REPLACEMENT}default:return null}}`); + const history = [ + { type: "assistant.message", data: { messageId: "old-first", content: "same" } }, + { type: "assistant.message", data: { messageId: "old-second", content: "same" } }, + { type: "assistant.message", data: { messageId: "old-empty", content: "" } }, + ]; + assert.deepEqual(JSON.parse(JSON.stringify(history.map(replay))).map(e => [e.messageId, e.content.text]), + [["old-first", "same"], ["old-second", "same"], ["old-empty", ""]]); + assert.deepEqual(history.map(live), [null, null, null]); + // Completion has opposite roles: historical content is replayed once, while + // the active mapper must not echo text already emitted as live deltas. + const final = history[0]; + assert.equal(replay(final).content.text, "same"); + assert.equal(live(final), null); +}); diff --git a/packages/paperclip-runner/scripts/copilot-provider-pack-smoke.mjs b/packages/paperclip-runner/scripts/copilot-provider-pack-smoke.mjs new file mode 100644 index 0000000000..3eeb6cac83 --- /dev/null +++ b/packages/paperclip-runner/scripts/copilot-provider-pack-smoke.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { withCopilotSmokeResources } from "./copilot-smoke-resources.mjs"; + +// Build verification only. No prompt, credential, model request, or runtime +// installation occurs; the provider runs offline with isolated state. +const pack = await realpath(resolve(process.argv[2] ?? "provider-pack")); +const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8")); +// Bind the audited pack explicitly, just as the verified sidecar launcher does. +process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack; +process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json"); +const candidate = manifest.payload.candidateProviders?.copilot; +assert.equal(candidate?.version, "1.0.88"); +assert.equal(candidate.qualification, "pending"); +const { assertAcpxProfileEnvironment, verifyAcpxProfileInstallation } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js"))); +const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js"))); +const { COPILOT_ACP_CLIENT_CAPABILITIES } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/copilot-events.js"))); +// This explicit value exercises profile admission only; no model is selected +// or used by initialize, and this is not a claim of model availability. +const profile = resolveQualifiedAcpxProfile("copilot", "gpt-4.1"); +assert.throws(() => assertAcpxProfileEnvironment("copilot", {}), { code: "COPILOT_AUTH_REQUIRED" }); +const installation = await verifyAcpxProfileInstallation(profile); +assert.equal(installation.commandDigest, candidate.profileDigest); +await withCopilotSmokeResources(installation, async ({ lease, root, fixture, fixtureRequests }) => { + let child; + let exited; + try { + const environment = { PATH: "/usr/bin:/bin", COPILOT_OFFLINE: "true", COPILOT_AUTO_UPDATE: "false", NO_COLOR: "1", + COPILOT_PROVIDER_BASE_URL: `http://127.0.0.1:${fixture.address().port}`, COPILOT_PROVIDER_TYPE: "openai", + COPILOT_PROVIDER_MODEL_ID: "gpt-4.1", COPILOT_MODEL: "gpt-4.1", + }; + for (const name of ["HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "COPILOT_HOME", "COPILOT_CACHE_HOME"]) { + environment[name] = join(root, name.toLowerCase()); + await mkdir(environment[name], { mode: 0o700 }); + } + await writeFile(join(environment.COPILOT_HOME, "config.json"), JSON.stringify({ autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, ide: { autoConnect: false } }), { mode: 0o600 }); + child = lease.spawn([], { cwd: root, env: environment, stdio: "pipe" }); + exited = once(child, "exit"); + const initialized = new Promise((done, reject) => { + let buffer = ""; + let stderr = ""; + let total = 0; + const timeout = setTimeout(() => reject(new Error(`Copilot ACP initialize timed out: ${stderr.replaceAll(root, "/fixture/workspace").replaceAll(pack, "/fixture/provider-pack").slice(0, 4000)}`)), 20_000); + timeout.unref(); + child.once("error", reject); + child.once("exit", () => reject(new Error(`Copilot exited before initialize: ${stderr.replaceAll(root, "/fixture/workspace").replaceAll(pack, "/fixture/provider-pack").slice(0, 4000)}`))); + child.stdout.on("data", chunk => { + total += chunk.length; + if (total > 1024 * 1024) { reject(new Error("Copilot ACP output exceeded bound")); return; } + buffer += chunk.toString("utf8"); + while (buffer.includes("\n")) { + const newline = buffer.indexOf("\n"); + const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); + if (!line.trim()) continue; + let response; + try { response = JSON.parse(line); } catch { reject(new Error("Copilot emitted malformed ACP JSON")); return; } + if (response.id === 0 && response.method === undefined) { + clearTimeout(timeout); + if (response.error || !response.result) reject(new Error("Copilot rejected ACP initialize")); + else done(response.result); + } + } + }); + child.stderr.on("data", chunk => { stderr = (stderr + chunk.toString("utf8")).slice(-16_384); }); + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: 1, clientInfo: { name: "paperclip-pack-offline-smoke", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES } })}\n`); + const result = await initialized; + assert.equal(result.protocolVersion, 1); + assert.equal(result.agentInfo?.version, "1.0.88"); + child.stdin.end(); + const timer = setTimeout(() => child.kill("SIGTERM"), 1_000); + const [exitCode, signal] = await within(exited, 10_000); + clearTimeout(timer); + assert.equal(exitCode, 0, "Copilot did not exit cleanly after stdin EOF"); + assert.equal(signal, null); + assert.equal(fixtureRequests.some(request => request.method !== "GET"), false, "Unexpected model inference request"); + const safeInitialize = JSON.parse(JSON.stringify(result, (key, value) => + key === "command" && typeof value === "string" && value.endsWith("/distribution/copilot") ? "/fixture/verified/copilot" : value)); + const executable = await readFile(join(pack, candidate.path, "copilot")); + process.stdout.write(`${JSON.stringify({ + schema: "paperclip.copilot-provider-pack-smoke/v1", sourceRevision: manifest.payload.runnerSourceRevision, + providerPackDigest: manifest.digest, platform: process.platform, architecture: process.arch, + candidate, executableSha256: `sha256:${createHash("sha256").update(executable).digest("hex")}`, + registryLaunch: "verifyAcpxProfileInstallation/openCommand/spawn", initializeRequestId: 0, + initialize: safeInitialize, missingCredentialPreflight: "COPILOT_AUTH_REQUIRED", cleanExit: true, inheritedCredentials: false, promptSent: false, + networkMode: "COPILOT_OFFLINE=true; loopback metadata-only provider", fixtureRequests, protocolFixtureModel: "gpt-4.1 (not a qualified GitHub model)", costUsd: 0, + qualification: "pending: no credential, entitlement, model execution or Daytona", + }, null, 2)}\n`); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + child.stdin.end(); + child.kill("SIGTERM"); + await within(exited, 5_000); + } + } +}); + + +async function within(promise, timeoutMs) { + let timer; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Copilot smoke cleanup was not confirmed")), timeoutMs); + })]); + } finally { clearTimeout(timer); } +} diff --git a/packages/paperclip-runner/scripts/copilot-smoke-resources.mjs b/packages/paperclip-runner/scripts/copilot-smoke-resources.mjs new file mode 100644 index 0000000000..403603ad94 --- /dev/null +++ b/packages/paperclip-runner/scripts/copilot-smoke-resources.mjs @@ -0,0 +1,37 @@ +import { once } from "node:events"; +import { createServer } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Own every setup resource immediately, including failures before native launch. */ +export async function withCopilotSmokeResources(installation, callback, dependencies = { mkdtemp, rm, createServer }) { + const lease = await installation.openCommand(); + let root; + let fixture; + const fixtureRequests = []; + try { + root = await dependencies.mkdtemp(join(tmpdir(), "paperclip-copilot-pack-smoke-")); + fixture = dependencies.createServer((request, response) => { + fixtureRequests.push({ method: request.method, path: request.url?.split("?")[0] }); + response.writeHead(request.method === "GET" ? 200 : 503, { "content-type": "application/json" }); + response.end(JSON.stringify(request.method === "GET" ? { object: "list", data: [{ id: "gpt-4.1", object: "model", owned_by: "fixture" }] } : { error: "This initialize-only fixture cannot run inference" })); + }); + fixture.listen(0, "127.0.0.1"); + await once(fixture, "listening"); + return await callback({ lease, root, fixture, fixtureRequests }); + } finally { + try { + if (fixture) { + fixture.closeAllConnections(); + await new Promise((resolve, reject) => fixture.close(error => { + if (error && error.code !== "ERR_SERVER_NOT_RUNNING") reject(error); + else resolve(); + })); + } + } finally { + try { await lease.close(); } + finally { if (root) await dependencies.rm(root, { recursive: true, force: true }); } + } + } +} diff --git a/packages/paperclip-runner/scripts/copilot-smoke-resources.test.mjs b/packages/paperclip-runner/scripts/copilot-smoke-resources.test.mjs new file mode 100644 index 0000000000..5ba83bb320 --- /dev/null +++ b/packages/paperclip-runner/scripts/copilot-smoke-resources.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { withCopilotSmokeResources } from "./copilot-smoke-resources.mjs"; + +function resources(failure) { + const actions = []; + const fixture = new EventEmitter(); + fixture.listen = () => queueMicrotask(() => fixture.emit(failure === "listen" ? "error" : "listening", new Error("listen_failed"))); + fixture.closeAllConnections = () => actions.push("connections_closed"); + fixture.close = callback => { actions.push("server_closed"); callback(failure === "listen" ? Object.assign(new Error("not running"), { code: "ERR_SERVER_NOT_RUNNING" }) : undefined); }; + const installation = { openCommand: async () => ({ close: async () => { actions.push("lease_closed"); if (failure === "lease_close") throw new Error("close_failed"); } }) }; + const dependencies = { + mkdtemp: async () => { if (failure === "workspace") throw new Error("workspace_failed"); actions.push("workspace_created"); return "/fixture/private"; }, + rm: async (root, options) => { assert.equal(root, "/fixture/private"); assert.deepEqual(options, { recursive: true, force: true }); actions.push("workspace_removed"); }, + createServer: () => { if (failure === "server_create") throw new Error("server_create_failed"); return fixture; }, + }; + return { actions, installation, dependencies }; +} +for (const failure of ["workspace", "server_create", "listen"]) { + test(`${failure} setup failure releases all already acquired resources before callback`, async () => { + const r = resources(failure); + await assert.rejects(withCopilotSmokeResources(r.installation, () => assert.fail("provider must not launch"), r.dependencies), new RegExp(`${failure}_failed`)); + assert.deepEqual(r.actions, failure === "workspace" ? ["lease_closed"] + : failure === "server_create" ? ["workspace_created", "lease_closed", "workspace_removed"] + : ["workspace_created", "connections_closed", "server_closed", "lease_closed", "workspace_removed"]); + }); +} +test("provider callback failure still closes server, lease, and workspace", async () => { + const r = resources(); + await assert.rejects(withCopilotSmokeResources(r.installation, async () => { throw new Error("native_cleanup_failed"); }, r.dependencies), /native_cleanup_failed/); + assert.deepEqual(r.actions, ["workspace_created", "connections_closed", "server_closed", "lease_closed", "workspace_removed"]); +}); +test("lease close failure cannot bypass workspace removal", async () => { + const r = resources("lease_close"); + await assert.rejects(withCopilotSmokeResources(r.installation, async () => "ok", r.dependencies), /close_failed/); + assert.equal(r.actions.at(-1), "workspace_removed"); +}); +test("successful callback result is returned after all resource cleanup", async () => { + const r = resources(); + assert.equal(await withCopilotSmokeResources(r.installation, async ({ root, fixtureRequests }) => { assert.equal(root, "/fixture/private"); assert.deepEqual(fixtureRequests, []); return "ok"; }, r.dependencies), "ok"); + assert.deepEqual(r.actions, ["workspace_created", "connections_closed", "server_closed", "lease_closed", "workspace_removed"]); +}); diff --git a/packages/paperclip-runner/scripts/discover-copilot-acp.mjs b/packages/paperclip-runner/scripts/discover-copilot-acp.mjs new file mode 100644 index 0000000000..dc0b1fc5fe --- /dev/null +++ b/packages/paperclip-runner/scripts/discover-copilot-acp.mjs @@ -0,0 +1,130 @@ +// Metadata only. Supply an explicit token; this closed protocol client never prompts. +// Usage: node discover-copilot-acp.mjs [exact-model-id] +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const discoveryMethods = new Set(["initialize", "session/new", "session/set_model", "session/set_config_option", "session/close"]); +export function validateDiscoveryModel(model) { + assert.ok(model === undefined || (typeof model === "string" && model.trim() && model.trim().toLowerCase() !== "auto"), "model must be an exact model ID, not auto"); +} + +export async function withDiscoveryWorkspace(install, callback, filesystem = { mkdtemp, rm }) { + const lease = await install.openCommand(); + let root; + try { + root = await filesystem.mkdtemp("/tmp/paperclip-copilot-auth-discovery-"); + return await callback(lease, root); + } finally { + try { await lease.close(); } + finally { if (root) await filesystem.rm(root, { recursive: true, force: true }); } + } +} + +export function createDiscoveryRpc(child, { deadlineMs = 25_000, maxBytes = 2_097_152 } = {}) { + const pending = new Map(), observedMethods = {}; + let nextId = 0, buffer = "", bytes = 0, failure; + function fail(code) { + failure ??= code; + for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error(failure)); } + pending.clear(); + } + function fatal(code) { fail(code); child.kill("SIGTERM"); } + child.once("close", () => fail("provider_exited")); + child.once("error", () => fail("provider_spawn_failure")); + child.stdin.on("error", () => fail("provider_stdin_failure")); + child.stderr.on("data", () => {}); + child.stdout.on("data", chunk => { + bytes += chunk.length; + if (bytes > maxBytes) { fatal("provider_wire_limit"); return; } + buffer += chunk.toString(); + while (buffer.includes("\n")) { + const end = buffer.indexOf("\n"), line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + if (!line.trim()) continue; + let message; + try { message = JSON.parse(line); } catch { fatal("provider_malformed_json"); return; } + if (!message || typeof message !== "object" || Array.isArray(message)) { fatal("provider_malformed_message"); return; } + if (message.method) { + observedMethods[message.method] = (observedMethods[message.method] ?? 0) + 1; + if (message.id !== undefined) child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Discovery does not support inbound methods" } })}\n`); + continue; + } + const item = pending.get(message.id); + if (item) { clearTimeout(item.timer); pending.delete(message.id); message.error ? item.reject(new Error(`provider_rpc_error:${message.error.code}`)) : item.resolve(message.result); } + } + }); + return { + observedMethods, + request(method, params) { + assert.ok(discoveryMethods.has(method), "metadata discovery cannot send this method"); + if (failure) return Promise.reject(new Error(failure)); + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, deadlineMs); + pending.set(id, { resolve, reject, timer }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + }); + }, + close() { fail("discovery_closed"); }, + }; +} + +export async function discoverCopilot(packInput, output, model, token) { + validateDiscoveryModel(model); + assert.ok(typeof token === "string" && token.trim() && !token.includes("\0"), "explicit Copilot token required"); + const pack = await realpath(packInput); + const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8")); + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack; + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json"); + const { verifyAcpxProfileInstallation, assertAcpxProfileEnvironment } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js"))); + const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js"))); + const { COPILOT_ACP_CLIENT_CAPABILITIES } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/copilot-events.js"))); + assertAcpxProfileEnvironment("copilot", { COPILOT_GITHUB_TOKEN: token }); + const install = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("copilot", model ?? "discovery-unselected")); + return withDiscoveryWorkspace(install, async (lease, root) => { + const env = { PATH: "/usr/bin:/bin", COPILOT_GITHUB_TOKEN: token, COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", NO_COLOR: "1" }; + for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "COPILOT_HOME", "COPILOT_CACHE_HOME"]) { + env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 }); + } + await writeFile(join(env.COPILOT_HOME, "config.json"), JSON.stringify({ autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, ide: { autoConnect: false } }), { mode: 0o600 }); + let child, exitPromise, rpc; + try { + child = lease.spawn([], { cwd: root, env, stdio: "pipe" }); + exitPromise = new Promise(resolve => child.once("close", (code, signal) => resolve({ code, signal }))); + rpc = createDiscoveryRpc(child); + const initialized = await rpc.request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-authenticated-model-discovery", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES }); + const opened = await rpc.request("session/new", { cwd: root, mcpServers: [] }); + const safe = { schema: "paperclip.copilot-authenticated-discovery/v1", observedAt: new Date().toISOString(), harnessVersion: initialized.agentInfo?.version, + sourceRevision: manifest.payload.runnerSourceRevision, providerPackDigest: manifest.digest, profileDigest: install.commandDigest, + agentCapabilities: initialized.agentCapabilities, models: opened.models ?? null, configOptions: opened.configOptions ?? [], promptSent: false, + promptRequestsSent: 0, inferenceVerified: false, qualification: "pending; metadata discovery only", observedMethods: rpc.observedMethods }; + if (model) { + assert.ok(opened.models?.availableModels?.some(value => value.modelId === model), "requested exact model must be advertised"); + const selected = await rpc.request("session/set_model", { sessionId: opened.sessionId, modelId: model }); + const configured = await rpc.request("session/set_config_option", { sessionId: opened.sessionId, configId: "model", value: model }); + assert.equal(configured.configOptions?.find(option => option.id === "model")?.currentValue, model); + safe.modelSelection = { requestedModel: model, succeeded: true, response: selected, configEcho: configured }; + } + try { await rpc.request("session/close", { sessionId: opened.sessionId }); safe.sessionClosed = true; } + catch { safe.sessionClosed = false; } + const text = `${JSON.stringify(safe, null, 2)}\n`; + assert.ok(!text.includes(token)); assert.ok(!text.includes(opened.sessionId)); assert.ok(!text.includes(root)); + await writeFile(output, text, { mode: 0o600 }); + return { output, sha256: createHash("sha256").update(text).digest("hex"), harnessVersion: safe.harnessVersion, modelCount: safe.models?.availableModels?.length ?? 0, + configOptionIds: safe.configOptions.map(value => value.id), modelIds: safe.models?.availableModels?.map(value => value.modelId) ?? [], promptSent: false }; + } finally { + rpc?.close(); + if (child && child.exitCode === null && child.signalCode === null) { + child.stdin.end(); + const term = setTimeout(() => child.kill("SIGTERM"), 1_000), kill = setTimeout(() => child.kill("SIGKILL"), 5_000); + try { await exitPromise; } finally { clearTimeout(term); clearTimeout(kill); } + } + } + }); +} + +if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) { + console.log(JSON.stringify(await discoverCopilot(process.argv[2], process.argv[3], process.argv[4], process.env.COPILOT_GITHUB_TOKEN))); +} diff --git a/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs b/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs new file mode 100644 index 0000000000..d33b281700 --- /dev/null +++ b/packages/paperclip-runner/scripts/discover-copilot-acp.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { createDiscoveryRpc, validateDiscoveryModel, withDiscoveryWorkspace } from "./discover-copilot-acp.mjs"; + +test("exact model evidence rejects auto before any provider operation", () => { + for (const model of ["auto", "AUTO", " auto ", ""]) assert.throws(() => validateDiscoveryModel(model), /exact model ID/); + validateDiscoveryModel("gpt-5.6-luna"); validateDiscoveryModel(undefined); +}); +test("workspace failure always releases the acquired native command lease", async () => { + const closed = [], removed = []; + const install = { openCommand: async () => ({ close: async () => closed.push(true) }) }; + await assert.rejects(withDiscoveryWorkspace(install, () => assert.fail(), { mkdtemp: async () => { throw new Error("mkdir_failed"); }, rm: async (...args) => removed.push(args) }), /mkdir_failed/); + assert.equal(closed.length, 1); assert.equal(removed.length, 0); + await assert.rejects(withDiscoveryWorkspace(install, async () => { throw new Error("config_write_failed"); }, { mkdtemp: async () => "/fixture/private", rm: async (...args) => removed.push(args) }), /config_write_failed/); + assert.equal(closed.length, 2); assert.deepEqual(removed, [["/fixture/private", { recursive: true, force: true }]]); +}); +function childFixture() { + const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = new EventEmitter(); + child.written = []; child.stdin.write = value => child.written.push(JSON.parse(value)); child.killed = []; + child.kill = signal => child.killed.push(signal); return child; +} +test("provider exit rejects pending discovery immediately with its actual failure", async () => { + const child = childFixture(), rpc = createDiscoveryRpc(child); + const pending = rpc.request("initialize", {}); child.emit("close", 1, null); + await assert.rejects(pending, /provider_exited/); + await assert.rejects(rpc.request("session/new", {}), /provider_exited/); +}); +test("malformed provider output rejects pending requests instead of waiting for timeout", async () => { + const child = childFixture(), rpc = createDiscoveryRpc(child); + const pending = rpc.request("initialize", {}); child.stdout.emit("data", Buffer.from("not-json\n")); + await assert.rejects(pending, /provider_malformed_json/); assert.deepEqual(child.killed, ["SIGTERM"]); +}); +test("closed discovery protocol preserves numeric zero and rejects prompts", async () => { + const child = childFixture(), rpc = createDiscoveryRpc(child); + const pending = rpc.request("initialize", {}); assert.equal(child.written[0].id, 0); + child.stdout.emit("data", Buffer.from('{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1}}\n')); + assert.deepEqual(await pending, { protocolVersion: 1 }); + assert.throws(() => rpc.request("session/prompt", {}), /cannot send/); rpc.close(); +}); diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index 38669bd34e..afdd2013b6 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-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v17-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/lib/forbidden-imports.mjs b/packages/paperclip-runner/scripts/lib/forbidden-imports.mjs index 5132fe4519..be6b0f6830 100644 --- a/packages/paperclip-runner/scripts/lib/forbidden-imports.mjs +++ b/packages/paperclip-runner/scripts/lib/forbidden-imports.mjs @@ -1,15 +1,17 @@ import { readFile, readdir } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { API } from "typescript/unstable/sync"; +import { createVirtualFileSystem } from "typescript/unstable/fs"; +import { SyntaxKind } from "typescript/unstable/ast"; -const SOURCE_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".rs", ".ts", ".tsx"]); -const IMPORT_PATTERNS = [ - /\b(?:import|export)\s+(?:type\s+)?(?:[^"'`;]{0,500}?\s+from\s+)?["']([^"']+)["']/g, - /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, - /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, +const SOURCE_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".mts", ".cts", ".rs", ".ts", ".tsx"]); +const RUST_IMPORT_PATTERNS = [ /\b(?:include|include_str|include_bytes)!\s*\(\s*["']([^"']+)["']\s*\)/g, /#\[path\s*=\s*["']([^"']+)["']\]/g, ]; +const REVIEWED_EVAL_KERNEL = "@paperclipai/paperclip-eval-kernel"; +const REVIEWED_EVAL_HARNESS = "src/eval/workflow-harness.ts"; const libraryDirectory = dirname(fileURLToPath(import.meta.url)); export const defaultPackageRoot = resolve(libraryDirectory, "../.."); @@ -44,15 +46,97 @@ async function collectSourceFiles(target) { return files; } -export function findSpecifiers(source) { - const found = []; - for (const pattern of IMPORT_PATTERNS) { - pattern.lastIndex = 0; - for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) { - found.push({ specifier: match[1], offset: match.index }); +/** Parse a whole scan in one compiler session; never execute scanned source. */ +export function findSpecifiersInFiles(files) { + const results = new Map(files.map(({ file }) => [file, []])); + const javascript = files.filter(({ file }) => extension(file) !== ".rs"); + for (const { file, source } of files.filter(({ file }) => extension(file) === ".rs")) { + for (const pattern of RUST_IMPORT_PATTERNS) { + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + results.get(file).push({ specifier: match[1], offset: match.index }); + } } } - return found; + if (javascript.length === 0) return results; + const root = "/__paperclip_import_check__"; + const names = javascript.map(({ file }, index) => `input-${index}${extension(file) || ".ts"}`); + const virtualFiles = Object.fromEntries(javascript.map(({ source }, index) => [`${root}/${names[index]}`, source])); + virtualFiles[`${root}/tsconfig.json`] = JSON.stringify({ + compilerOptions: { noLib: true, noResolve: true, allowJs: true }, files: names, + }); + const api = new API({ fs: createVirtualFileSystem(virtualFiles) }); + try { + const snapshot = api.updateSnapshot({ openProjects: [`${root}/tsconfig.json`] }); + const program = snapshot.getProject(`${root}/tsconfig.json`).program; + for (const [index, { file }] of javascript.entries()) { + const sourceFile = program.getSourceFile(`${root}/${names[index]}`); + if (!sourceFile) throw new Error(`Import scanner could not parse ${file}`); + const found = results.get(file); + function add(literal) { + if (literal?.kind === SyntaxKind.StringLiteral || literal?.kind === SyntaxKind.NoSubstitutionTemplateLiteral) { + found.push({ specifier: literal.text, offset: literal.getStart(sourceFile) }); + } + } + function visit(node) { + if (node.kind === SyntaxKind.ImportDeclaration || node.kind === SyntaxKind.ExportDeclaration) add(node.moduleSpecifier); + else if (node.kind === SyntaxKind.ImportEqualsDeclaration && node.moduleReference.kind === SyntaxKind.ExternalModuleReference) add(node.moduleReference.expression); + else if (node.kind === SyntaxKind.ImportType && node.argument.kind === SyntaxKind.LiteralType) add(node.argument.literal); + else if (node.kind === SyntaxKind.CallExpression && ( + node.expression.kind === SyntaxKind.ImportKeyword || + (node.expression.kind === SyntaxKind.Identifier && node.expression.text === "require") + )) add(node.arguments[0]); + node.forEachChild(visit); + } + visit(sourceFile); + } + } finally { + api.close(); + } + return results; +} + +export function findSpecifiers(source, file = "source.ts") { + return findSpecifiersInFiles([{ file, source }]).get(file); +} + +function declaredPublicImports(manifest) { + return new Set(Object.entries(manifest.exports ?? {}) + .filter(([key, target]) => key.startsWith("./") && !key.includes("*") && target !== null) + .map(([key]) => `@paperclipai/paperclip-runner/${key.slice(2)}`)); +} + +function publicSourceGraph(packageRoot, manifest, specifiers) { + const known = new Set(specifiers.keys()); + const sourceCandidates = (path) => { + const source = path.replace(/\/dist\//, "/src/"); + const stem = source.replace(/(?:\.d)?\.(?:[cm]?js|jsx|[cm]?ts|tsx)$/, ""); + // Follow every matching source candidate conservatively, including barrels + // and extensionless imports. A public graph must never hide the dev harness. + return [source, ...[".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.tsx", "/index.js"] + .map((suffix) => `${stem}${suffix}`)].filter((candidate) => known.has(candidate)); + }; + const targets = (value) => typeof value === "string" ? [value] : value && typeof value === "object" ? Object.values(value).flatMap(targets) : []; + const exportSources = (value) => targets(value).flatMap((target) => { + // Wildcard exports can expose the development harness. Until this gate + // resolves their full mapping, conservatively treat all sources as public. + if (target.includes("*")) return [...known]; + return sourceCandidates(resolve(packageRoot, target)); + }); + const queue = exportSources(manifest.exports); + const reachable = new Set(); + while (queue.length > 0) { + const file = queue.pop(); + if (reachable.has(file)) continue; + reachable.add(file); + for (const { specifier } of specifiers.get(file) ?? []) { + if (specifier.startsWith(".")) queue.push(...sourceCandidates(resolve(dirname(file), specifier))); + else if (specifier.startsWith("@paperclipai/paperclip-runner/")) { + queue.push(...exportSources(manifest.exports?.[`./${specifier.slice("@paperclipai/paperclip-runner/".length)}`])); + } + } + } + return reachable; } // The Live console component decision record adapts shadcn/ui and AI Elements @@ -79,16 +163,9 @@ function isForbiddenBrowserPackage(specifier) { ); } -function violationReason({ file, packageRoot, specifier }) { +function violationReason({ file, packageRoot, specifier, publicRunnerImports, reviewedEvalHarness }) { const relativeFile = relative(packageRoot, file).split(/[\\/]/).join("/"); const isExampleConsumer = relativeFile.startsWith("examples/"); - const publicRunnerImports = new Set([ - "@paperclipai/paperclip-runner/browser", - "@paperclipai/paperclip-runner/react", - "@paperclipai/paperclip-runner/standalone", - "@paperclipai/paperclip-runner/testing", - "@paperclipai/paperclip-runner/styles.css", - ]); if ( specifier.startsWith("@paperclipai/paperclip-runner/") && !publicRunnerImports.has(specifier) @@ -101,7 +178,8 @@ function violationReason({ file, packageRoot, specifier }) { if ( specifier.startsWith("@paperclipai/") && specifier !== "@paperclipai/paperclip-runner" && - !publicRunnerImports.has(specifier) + !publicRunnerImports.has(specifier) && + !(specifier === REVIEWED_EVAL_KERNEL && reviewedEvalHarness && relativeFile === REVIEWED_EVAL_HARNESS) ) { return "Paperclip workspace packages are outside the standalone boundary"; } @@ -152,7 +230,7 @@ async function manifestViolations(packageRoot) { const unreviewedDevelopmentDependencies = Object.keys( manifest.devDependencies ?? {}, ).filter( - (name) => name.startsWith("@paperclipai/"), + (name) => name.startsWith("@paperclipai/") && !(name === REVIEWED_EVAL_KERNEL && manifest.devDependencies[name] === "workspace:*"), ); return [...runtimeDependencies, ...unreviewedDevelopmentDependencies] .filter( @@ -221,19 +299,20 @@ export async function checkForbiddenImports({ files.push(...(await collectSourceFiles(resolve(packageRoot, root)))); } - for (const file of files.sort()) { - const source = await readFile(file, "utf8"); - for (const { specifier, offset } of findSpecifiers(source)) { - const reason = violationReason({ file, packageRoot, specifier }); - if (reason === null) { - continue; - } - violations.push({ - file, - line: source.slice(0, offset).split("\n").length, - specifier, - reason, - }); + const manifest = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8")); + const sources = await Promise.all(files.sort().map(async (file) => ({ file, source: await readFile(file, "utf8") }))); + const specifiers = findSpecifiersInFiles(sources); + const publicRunnerImports = declaredPublicImports(manifest); + const publicSources = publicSourceGraph(packageRoot, manifest, specifiers); + // ADR0001 permits only this private development harness to use the kernel. + // A public export reaching it revokes the exception, including through barrels. + const reviewedEvalHarness = manifest.devDependencies?.[REVIEWED_EVAL_KERNEL] === "workspace:*" + && !publicSources.has(resolve(packageRoot, REVIEWED_EVAL_HARNESS)); + for (const { file, source } of sources) { + for (const { specifier, offset } of specifiers.get(file)) { + const reason = violationReason({ file, packageRoot, specifier, publicRunnerImports, reviewedEvalHarness }); + if (reason === null) continue; + violations.push({ file, line: source.slice(0, offset).split("\n").length, specifier, reason }); } } diff --git a/packages/paperclip-runner/scripts/lib/tracked-imports.mjs b/packages/paperclip-runner/scripts/lib/tracked-imports.mjs index d11dd14e93..97a98efca8 100644 --- a/packages/paperclip-runner/scripts/lib/tracked-imports.mjs +++ b/packages/paperclip-runner/scripts/lib/tracked-imports.mjs @@ -4,13 +4,13 @@ import { readFile } from "node:fs/promises"; import { dirname, relative, resolve } from "node:path"; import { promisify } from "node:util"; -import { defaultPackageRoot, findSpecifiers } from "./forbidden-imports.mjs"; +import { defaultPackageRoot, findSpecifiersInFiles } from "./forbidden-imports.mjs"; export { defaultPackageRoot }; const execFileAsync = promisify(execFile); -const SCANNED_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx"]); +const SCANNED_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".mts", ".cts", ".ts", ".tsx"]); // TypeScript resolves an ESM ".js" specifier against its ".ts" source sibling, // so a tracked import may legitimately name a file that never exists on disk. const JS_TO_SOURCE_EXTENSIONS = new Map([ @@ -129,9 +129,10 @@ export async function checkTrackedImports({ .sort(); const suspects = []; - for (const file of files) { - const source = await readFile(file, "utf8"); - for (const { specifier, offset } of findSpecifiers(source)) { + const sources = await Promise.all(files.map(async (file) => ({ file, source: await readFile(file, "utf8") }))); + const specifiers = findSpecifiersInFiles(sources); + for (const { file, source } of sources) { + for (const { specifier, offset } of specifiers.get(file)) { if (!isRelative(specifier) || /[?*]|\$\{/.test(specifier)) { continue; } diff --git a/packages/paperclip-runner/scripts/live-console-browser-server.mjs b/packages/paperclip-runner/scripts/live-console-browser-server.mjs index 777837d3fe..fc67929dea 100644 --- a/packages/paperclip-runner/scripts/live-console-browser-server.mjs +++ b/packages/paperclip-runner/scripts/live-console-browser-server.mjs @@ -11,8 +11,22 @@ import { resolve } from "node:path"; * `PAPERCLIP_LIVE_CONSOLE_DRIVER=codex` swaps in the real Codex app-server driver * behind exactly the same routes. */ -async function loadRunner() { - return import(new URL("../dist/index.js", import.meta.url).href); +export async function loadLiveConsoleRunner(loadModule = (url) => import(url)) { + // Demo helpers are intentionally absent from the public runtime entrypoint. + // Load their owning modules, as the local-runner devtool transport does. + const [server, manifests, scripted, codex] = await Promise.all([ + "../dist/mock-core/live-console-demo-server.js", + "../dist/mock-core/live-console-demo-manifests.js", + "../dist/mock-core/live-console-scripted-driver.js", + "../dist/drivers/codex/codex-app-server-driver.js", + ].map((path) => loadModule(new URL(path, import.meta.url).href))); + return { + assertLiveConsoleLoopbackBindHost: server.assertLiveConsoleLoopbackBindHost, + LiveConsoleDemoServer: server.LiveConsoleDemoServer, + liveConsoleDemoManifestCatalogue: manifests.liveConsoleDemoManifestCatalogue, + LiveConsoleScriptedDriver: scripted.LiveConsoleScriptedDriver, + CodexAppServerDriver: codex.CodexAppServerDriver, + }; } async function createWorkingDirectory() { @@ -22,7 +36,7 @@ async function createWorkingDirectory() { } export function createLiveConsoleBrowserMiddleware(options = {}) { - const load = options.loadRunner ?? loadRunner; + const load = options.loadRunner ?? loadLiveConsoleRunner; const driverMode = options.driverMode ?? process.env.PAPERCLIP_LIVE_CONSOLE_DRIVER ?? "demo"; const chunkDelayMs = Number.parseInt( options.chunkDelayMs ?? process.env.PAPERCLIP_LIVE_CONSOLE_CHUNK_DELAY_MS ?? "45", diff --git a/packages/paperclip-runner/scripts/live-console-browser-server.test.mjs b/packages/paperclip-runner/scripts/live-console-browser-server.test.mjs index cde0b7b97c..3eefda1889 100644 --- a/packages/paperclip-runner/scripts/live-console-browser-server.test.mjs +++ b/packages/paperclip-runner/scripts/live-console-browser-server.test.mjs @@ -1,8 +1,36 @@ import { describe, expect, it } from "vitest"; -import { createLiveConsoleBrowserMiddleware } from "./live-console-browser-server.mjs"; +import { createLiveConsoleBrowserMiddleware, loadLiveConsoleRunner } from "./live-console-browser-server.mjs"; describe("Live console Vite transport bootstrap", () => { + it("loads the demo helpers from their owning modules instead of the public barrel", async () => { + const paths = []; + const runner = await loadLiveConsoleRunner(async (url) => { + const path = new URL(url).pathname; + paths.push(path.slice(path.lastIndexOf("/dist/") + 6)); + // Exercise real exports without requiring generated dist in unit tests. + return import(url.replace("/dist/", "/src/").replace(/\.js$/, ".ts")); + }); + expect(paths).toEqual([ + "mock-core/live-console-demo-server.js", + "mock-core/live-console-demo-manifests.js", + "mock-core/live-console-scripted-driver.js", + "drivers/codex/codex-app-server-driver.js", + ]); + expect(runner.assertLiveConsoleLoopbackBindHost).toBeTypeOf("function"); + expect(runner.LiveConsoleDemoServer).toBeTypeOf("function"); + expect(runner.LiveConsoleScriptedDriver).toBeTypeOf("function"); + expect(runner.CodexAppServerDriver).toBeTypeOf("function"); + expect(runner.liveConsoleDemoManifestCatalogue().length).toBeGreaterThan(0); + expect(() => runner.assertLiveConsoleLoopbackBindHost("0.0.0.0")).toThrow(); + const middleware = createLiveConsoleBrowserMiddleware({ + workingDirectory: "/server-owned-workspace", + loadRunner: async () => runner, + }); + await middleware.prepare("127.0.0.1"); + await middleware.close(); + }); + it("uses the runner's shared bind guard before constructing middleware", async () => { const calls = []; const middleware = createLiveConsoleBrowserMiddleware({ diff --git a/packages/paperclip-runner/scripts/materialize-copilot-binary.mjs b/packages/paperclip-runner/scripts/materialize-copilot-binary.mjs new file mode 100644 index 0000000000..1d8cafe78c --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-copilot-binary.mjs @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import { constants, closeSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readPinnedCopilotInnerDistribution } from "./copilot-inner-distribution.mjs"; + +export const COPILOT_VERSION = "1.0.88"; +// Extracted executable SHA-256 pins from npm archives after registry SHA-512 verification. +export const COPILOT_DISTRIBUTIONS = Object.freeze({ + "darwin-arm64": Object.freeze({ packageName: "@github/copilot-darwin-arm64", executableDigest: "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", size: 152595280, archiveIntegrity: "sha512-gt52dl+ja+G88RjLuSec59fA8C9HNfdGxKwwqE7eAIJXZYSKwKvuMy4B79WlCtlpnH/QKbs5Z9VsU0BTxe3EAg==" }), + "darwin-x64": Object.freeze({ packageName: "@github/copilot-darwin-x64", executableDigest: "85eb919f6b9b9dd833ce5e326cbf974b3ee2d4a9ac525c59d4ec9c9ec085715b", size: 165041200, archiveIntegrity: "sha512-GO5KwVR5vD1vNRe0jDDXFdpOHpS780ytVSRklk0DT6bK6HIpWXqbfnOasP9OZpPClaAYO4CShkGMhHv6vKRxaA==" }), + "linux-x64": Object.freeze({ packageName: "@github/copilot-linux-x64", executableDigest: "0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748", size: 169544512, archiveIntegrity: "sha512-wNigl8rqixvtYoRtygNf1NQIXx77MWC6XyqoV7fcfvSwWI3GJCltnczaNLbC611K9Hex39KyiUR5ZmGhQ+LzLw==" }), +}); + +export function resolveCopilotDistribution(platform = process.platform, architecture = process.arch) { + const distribution = COPILOT_DISTRIBUTIONS[`${platform}-${architecture}`]; + if (!distribution) throw new Error(`Copilot distribution is not pinned for ${platform}/${architecture}`); + return distribution; +} + +/** + * Build-time verifier/materializer only. Runtime admission must still use the + * shared descriptor-pinned command lease; this path grants no execution authority. + * Never run npm-loader.js, package postinstall, or an ambient PATH executable. + */ +export function materializePinnedCopilotBinary(options = {}) { + const distribution = resolveCopilotDistribution(options.platform, options.architecture); + const require = createRequire(import.meta.url); + const packageRoot = realpathSync(options.packageRoot ?? resolve(require.resolve(`${distribution.packageName}/package.json`), "..")); + const manifest = readPinnedFile(join(packageRoot, "package.json"), 256 * 1024); + const metadata = JSON.parse(manifest.toString("utf8")); + if (metadata.name !== distribution.packageName || metadata.version !== COPILOT_VERSION) { + throw new Error(`Expected ${distribution.packageName}@${COPILOT_VERSION}`); + } + const source = join(packageRoot, "copilot"); + const bytes = readPinnedFile(source, 384 * 1024 * 1024, true); + const sourceDigest = createHash("sha256").update(bytes).digest("hex"); + if (sourceDigest !== distribution.executableDigest) throw new Error("Copilot executable digest does not match its pinned distribution"); + const inner = readPinnedCopilotInnerDistribution(bytes, `${options.platform ?? process.platform}-${options.architecture ?? process.arch}`); + const output = options.targetDirectory === undefined ? packageRoot : realpathSync(options.targetDirectory); + const target = options.targetDirectory === undefined ? source : join(output, "copilot"); + const entries = [{ path: "copilot", sha256: sourceDigest, size: bytes.length, executable: true }]; + const files = options.targetDirectory === undefined ? [] : [{ path: "copilot", bytes, executable: true }]; + // Preserve every embedded asset; only the hash-pinned app's message mapping + // changes. Never import or execute any archive entry during materialization. + for (const [path, entry] of inner) { + const relative = `distribution/${path}`; + files.push({ path: relative, ...entry }); + entries.push({ path: relative, sha256: createHash("sha256").update(entry.bytes).digest("hex"), size: entry.bytes.length, executable: entry.executable }); + } + entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const closureSha256 = createHash("sha256").update(JSON.stringify(entries)).digest("hex"); + const manifestPath = join(output, ".paperclip-copilot-closure.json"); + files.push({ path: ".paperclip-copilot-closure.json", bytes: Buffer.from(`${JSON.stringify({ schema: "paperclip.native_distribution_closure.v1", entries })}\n`), executable: false }); + writeCopilotMaterialization(output, files); + return { packageName: distribution.packageName, version: COPILOT_VERSION, sourceDigest, target, manifestPath, closureSha256 }; +} + +/** Build-only writer for already verified entries; never grants launch authority. + * Roll back only entries this invocation exclusively created, even when a write + * failed partway through. Never recursively remove a caller's output directory. + */ +export function writeCopilotMaterialization(output, files) { + if (realpathSync(output) !== output || !lstatSync(output).isDirectory()) throw new Error("Copilot target must be an owned real directory"); + const root = lstatSync(output, { bigint: true }); + const created = []; + const same = (a, b) => a.dev === b.dev && a.ino === b.ino; + const assertRoot = () => { + const current = lstatSync(output, { bigint: true }); + if (!current.isDirectory() || !same(root, current) || realpathSync(output) !== output) throw new Error("Copilot output identity changed"); + }; + try { + for (const entry of files) { + if (typeof entry.path !== "string" || entry.path.includes("\\") || entry.path.split("/").some(part => !part || part === "." || part === "..")) throw new Error("Unsafe Copilot output entry"); + assertRoot(); + const parts = entry.path.split("/"); + for (let i = 1; i < parts.length; i++) { + const directory = join(output, ...parts.slice(0, i)); + try { + mkdirSync(directory, { mode: 0o755 }); + created.push({ path: directory, stat: lstatSync(directory, { bigint: true }), directory: true }); + } catch (error) { if (error.code !== "EEXIST") throw error; } + if (!lstatSync(directory).isDirectory() || realpathSync(directory) !== directory) throw new Error("Copilot inner destination redirects through links"); + } + const path = join(output, entry.path); + const mode = entry.executable ? 0o755 : 0o644; + const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, mode); + try { + created.push({ path, stat: fstatSync(fd, { bigint: true }), directory: false }); + writeFileSync(fd, entry.bytes); + fchmodSync(fd, mode); + fsyncSync(fd); + } finally { closeSync(fd); } + } + } catch (error) { + const cleanupErrors = []; + for (const entry of created.reverse()) { + try { + assertRoot(); + if (realpathSync(dirname(entry.path)) !== dirname(entry.path)) throw new Error("Copilot cleanup parent identity changed"); + let current; + try { current = lstatSync(entry.path, { bigint: true }); } catch (missing) { if (missing.code === "ENOENT") continue; throw missing; } + if (!same(entry.stat, current) || (entry.directory ? !current.isDirectory() : !current.isFile())) throw new Error("Copilot cleanup entry identity changed"); + if (entry.directory) rmdirSync(entry.path); else unlinkSync(entry.path); + } catch (cleanupError) { cleanupErrors.push(cleanupError); } + } + if (cleanupErrors.length) throw new AggregateError([error, ...cleanupErrors], "Copilot materialization failed and owned cleanup is incomplete"); + throw error; + } +} + +function readPinnedFile(path, maxBytes, executable = false) { + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > BigInt(maxBytes)) throw new Error("Copilot distribution file is not a bounded regular file"); + if (executable && ((before.mode & 0o111n) === 0n || (before.mode & 0o022n) !== 0n)) throw new Error("Copilot executable has unsafe permissions"); + const bytes = readFileSync(fd); + const after = fstatSync(fd, { bigint: true }); + const entry = lstatSync(path, { bigint: true }); + if (!entry.isFile() || bytes.length !== Number(before.size) || before.dev !== entry.dev || before.ino !== entry.ino + || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) throw new Error("Copilot distribution changed during verification"); + return bytes; + } finally { closeSync(fd); } +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const args = process.argv.slice(2); + if (args.some((arg, index) => index % 2 === 0 && !["--package-root", "--target-directory", "--platform", "--architecture"].includes(arg)) || args.length % 2 !== 0) throw new Error("Usage: materialize-copilot-binary.mjs [--package-root PATH] [--target-directory PATH] [--platform darwin|linux] [--architecture arm64|x64]"); + const argsMap = Object.fromEntries(Array.from({ length: args.length / 2 }, (_, index) => [args[index * 2], args[index * 2 + 1]])); + process.stdout.write(`${JSON.stringify(materializePinnedCopilotBinary({ packageRoot: argsMap["--package-root"], targetDirectory: argsMap["--target-directory"], platform: argsMap["--platform"], architecture: argsMap["--architecture"] }))}\n`); +} diff --git a/packages/paperclip-runner/scripts/materialize-copilot-binary.test.mjs b/packages/paperclip-runner/scripts/materialize-copilot-binary.test.mjs new file mode 100644 index 0000000000..4bf5b105cd --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-copilot-binary.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import * as fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "node:test"; +import { COPILOT_DISTRIBUTIONS, materializePinnedCopilotBinary, resolveCopilotDistribution, writeCopilotMaterialization } from "./materialize-copilot-binary.mjs"; +const directories = []; +afterEach(async () => { await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); }); +async function fixture(version = "1.0.88") { + const root = await mkdtemp(join(tmpdir(), "paperclip-copilot-materializer-")); directories.push(root); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "@github/copilot-linux-x64", version })); + await writeFile(join(root, "copilot"), "#!/bin/sh\nexit 98\n", { mode: 0o755 }); + return fs.realpathSync(root); +} +test("pins every requested platform and rejects unqualified platforms", () => { + assert.deepEqual(Object.keys(COPILOT_DISTRIBUTIONS), ["darwin-arm64", "darwin-x64", "linux-x64"]); + for (const [key, value] of Object.entries(COPILOT_DISTRIBUTIONS)) { + assert.match(value.executableDigest, /^[a-f0-9]{64}$/); + assert.match(value.archiveIntegrity, /^sha512-/); + assert.equal(resolveCopilotDistribution(...key.split("-")), value); + } + assert.throws(() => resolveCopilotDistribution("linux", "arm64"), /not pinned/); +}); +test("refuses a different version before opening or executing the binary", async () => { + const packageRoot = await fixture("1.0.89"); + assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }), /Expected/); +}); +test("refuses executable tampering even when package metadata claims the right version", async () => { + const packageRoot = await fixture(); + assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }), /digest/); +}); +test("refuses executable symlinks, directory entries and writable binaries", async () => { + const packageRoot = await fixture(); const binary = join(packageRoot, "copilot"); + await chmod(binary, 0o777); + assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }), /permissions/); + await rm(binary); await symlink(join(packageRoot, "package.json"), binary); + assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" })); + await rm(binary); await mkdir(binary); + assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }), /regular file/); +}); + +const outputFiles = () => [ + { path: "copilot", bytes: Buffer.from("verified executable"), executable: true }, + { path: "distribution/nested/first.js", bytes: Buffer.from("verified first asset"), executable: false }, + { path: "distribution/nested/last.js", bytes: Buffer.from("verified last asset"), executable: false }, + { path: ".paperclip-copilot-closure.json", bytes: Buffer.from("verified closure"), executable: false }, +]; + +test("partial inner writes roll back only owned entries and permit a clean retry", async t => { + const root = await fixture(); const output = join(root, "output"); await mkdir(output); + await writeFile(join(output, "caller-file"), "preserve"); + const originalWrite = fs.writeFileSync; let calls = 0; + const mocked = t.mock.method(fs.default, "writeFileSync", (fd, bytes, ...args) => { + if (++calls === 3) { originalWrite(fd, bytes.subarray(0, 3)); throw Object.assign(new Error("injected partial write"), { code: "ENOSPC" }); } + return originalWrite(fd, bytes, ...args); + }); + syncBuiltinESMExports(); + try { assert.throws(() => writeCopilotMaterialization(output, outputFiles()), /injected partial write/); } + finally { mocked.mock.restore(); syncBuiltinESMExports(); } + assert.deepEqual(await readdir(output), ["caller-file"]); + assert.equal(await readFile(join(output, "caller-file"), "utf8"), "preserve"); + writeCopilotMaterialization(output, outputFiles()); + for (const file of outputFiles()) assert.deepEqual(await readFile(join(output, file.path)), file.bytes); +}); + +test("failure preserves preexisting binary, directories, assets and closure", async () => { + for (const collision of outputFiles()) { + const root = await fixture(); const output = join(root, "output"); await mkdir(output); + await mkdir(join(output, "distribution/nested"), { recursive: true }); + await writeFile(join(output, collision.path), "preexisting"); + assert.throws(() => writeCopilotMaterialization(output, outputFiles()), /EEXIST/); + assert.equal(await readFile(join(output, collision.path), "utf8"), "preexisting"); + const kept = (await readdir(join(output, "distribution/nested"))).sort(); + assert.deepEqual(kept, collision.path.startsWith("distribution/") ? [collision.path.split("/").at(-1)] : []); + assert.equal(fs.statSync(join(output, "distribution")).isDirectory(), true); + } +}); + +test("rollback refuses a replaced owned entry and never removes foreign files", async t => { + const root = await fixture(); const output = join(root, "output"); await mkdir(output); + const originalWrite = fs.writeFileSync; let calls = 0; + const mocked = t.mock.method(fs.default, "writeFileSync", (fd, bytes, ...args) => { + if (++calls === 3) { + fs.renameSync(join(output, "copilot"), join(output, "caller-retained-copy")); + originalWrite(join(output, "copilot"), "foreign replacement", { flag: "wx" }); + throw new Error("injected replacement race"); + } + return originalWrite(fd, bytes, ...args); + }); + syncBuiltinESMExports(); + try { assert.throws(() => writeCopilotMaterialization(output, outputFiles()), error => error instanceof AggregateError && error.errors.some(e => /entry identity changed/.test(e.message))); } + finally { mocked.mock.restore(); syncBuiltinESMExports(); } + assert.equal(await readFile(join(output, "copilot"), "utf8"), "foreign replacement"); + assert.equal(await readFile(join(output, "caller-retained-copy"), "utf8"), "verified executable"); +}); + +test("owned cleanup never follows a preexisting destination symlink", async () => { + const root = await fixture(); const output = join(root, "output"); const outside = join(root, "outside"); + await mkdir(output); await mkdir(outside); await symlink(outside, join(output, "distribution")); + assert.throws(() => writeCopilotMaterialization(output, outputFiles()), /redirects through links/); + assert.deepEqual(await readdir(outside), []); + assert.deepEqual(await readdir(output), ["distribution"]); +}); + +test("in-place failure leaves the pinned source executable untouched", async t => { + const root = await fixture(); const before = await readFile(join(root, "copilot")); + const originalWrite = fs.writeFileSync; let calls = 0; + const mocked = t.mock.method(fs.default, "writeFileSync", (fd, bytes, ...args) => { + if (++calls === 2) { originalWrite(fd, bytes.subarray(0, 2)); throw new Error("injected asset failure"); } + return originalWrite(fd, bytes, ...args); + }); + syncBuiltinESMExports(); + try { assert.throws(() => writeCopilotMaterialization(root, outputFiles().slice(1)), /injected asset failure/); } + finally { mocked.mock.restore(); syncBuiltinESMExports(); } + assert.deepEqual((await readdir(root)).sort(), ["copilot", "package.json"]); + assert.deepEqual(await readFile(join(root, "copilot")), before); + writeCopilotMaterialization(root, outputFiles().slice(1)); + assert.deepEqual(await readFile(join(root, "copilot")), before); +}); diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs new file mode 100644 index 0000000000..72fd11c3bd --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -0,0 +1,276 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises"; +import { stripTypeScriptTypes } from "node:module"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +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 acpxProfiles from "../acpx-profiles.json" with { type: "json" }; +import { inventoryPiRuntimeFiles, verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts"; + +const run = promisify(execFile); +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const workspaceRoot = resolve(packageRoot, "../.."); +const lockDirectory = join(packageRoot, "scripts/pi-distribution"); +const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch"); +const securityPatchPath = join(workspaceRoot, "patches/brace-expansion@5.0.9.patch"); +export const PI_BRACE_SECURITY_PATCH_SHA256 = "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814"; +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: "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + helperSha256: "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", +}); +const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +/** Install the published Pi graph; then apply the one pinned security correction. */ +export function piDistributionInstallCommand() { + return ["ci", "--ignore-scripts", "--include=optional", "--omit=dev", "--no-audit", "--no-fund", "--registry=https://registry.npmjs.org", "--userconfig=.npmrc", "--globalconfig=.npmrc-global"]; +} + +function supports(list, current) { + return !Array.isArray(list) || (list.includes(current) || !list.some((value) => !value.startsWith("!"))) && !list.includes(`!${current}`); +} + +/** Prove installed package versions and the complete published Pi graph. */ +export async function verifyLockedPiPackageGraph(root, lock, target = { platform: process.platform, architecture: process.arch }) { + if (lock.lockfileVersion !== 3 || !lock.packages || typeof lock.packages !== "object") throw new Error("Pi distribution lock is invalid"); + let verified = 0; + for (const [path, entry] of Object.entries(lock.packages)) { + if (!path) continue; + if (!path.startsWith("node_modules/") || path.split("/").some((part) => part === ".." || part === "." || !part) || path.includes("\\") || entry.link || typeof entry.version !== "string" || !/^https:\/\/registry\.npmjs\.org\//.test(entry.resolved ?? "") || !/^sha512-[A-Za-z0-9+/]+=*$/.test(entry.integrity ?? "")) throw new Error("Pi distribution lock contains an unpinned package"); + const compatible = supports(entry.os, target.platform) && supports(entry.cpu, target.architecture); + let metadata; + try { metadata = JSON.parse(await readFile(join(root, path, "package.json"), "utf8")); } + catch (error) { + if (error.code === "ENOENT" && entry.optional === true && !compatible) continue; + throw new Error(`Pi distribution is missing locked package ${path}`); + } + if (metadata.version !== entry.version) throw new Error(`Pi distribution package differs from its lock: ${path}`); + verified++; + } + const piPath = "node_modules/@earendil-works/pi-coding-agent"; + const shrinkwrap = JSON.parse(await readFile(join(root, piPath, "npm-shrinkwrap.json"), "utf8")); + if (shrinkwrap.version !== PI_DISTRIBUTION_PINS.runtime || shrinkwrap.lockfileVersion !== 3) throw new Error("Pi upstream shrinkwrap is missing or changed"); + for (const [path, entry] of Object.entries(shrinkwrap.packages)) { + if (!path) continue; + const pinned = lock.packages[`${piPath}/${path}`]; + // Pi bundles its dependency payload, so npm overrides do not replace it. + // Only this reviewed build-owned patch may differ from the upstream graph. + if (path === "node_modules/brace-expansion" && entry.version === "5.0.9" + && entry.integrity === "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==" + && entry.resolved === "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz" + && pinned?.version === "5.0.12" + && pinned.integrity === "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==" + && pinned.resolved === "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz") continue; + if (!pinned || pinned.version !== entry.version || (entry.integrity !== undefined && pinned.integrity !== entry.integrity) || pinned.resolved !== entry.resolved) throw new Error(`Pi distribution dropped or changed shrinkwrapped package ${path}`); + } + return verified; +} + +/** Only OS libraries may remain outside the inventoried Node executable. */ +export function assertPiNodeSystemDependencies(listing, platform) { + const lines = listing.split("\n").map((line) => line.trim()).filter(Boolean); + if (platform === "darwin") { + for (const line of lines.slice(1)) { + const path = line.split(" (", 1)[0]; + if (!path.startsWith("/usr/lib/") && !path.startsWith("/System/Library/")) throw new Error("Pi Node requires an unbundled non-system library"); + } + if (lines.length < 2) throw new Error("Pi Node dependency inspection is empty"); + } else { + if (listing.includes("not found")) throw new Error("Pi Node requires a missing system library"); + for (const line of lines) { + if (line.startsWith("linux-vdso.") || line === "statically linked") continue; + const path = line.includes(" => ") ? line.split(" => ")[1].split(" ")[0] : line.split(" ")[0]; + const name = path.slice(path.lastIndexOf("/") + 1); + if (!/^\/(?:usr\/)?lib(?:64)?\//.test(path) || !/^(?:lib(?:c|m|dl|pthread|gcc_s|stdc\+\+|rt|atomic)\.so(?:\.[0-9]+)*|ld-linux[^/]*\.so(?:\.[0-9]+)*)$/.test(name)) throw new Error("Pi Node requires an unbundled non-system library"); + } + if (!lines.length) throw new Error("Pi Node dependency inspection is empty"); + } +} + +export async function writePiDistributionManifest(runtimeRoot) { + const manifest = { + schema: "paperclip.pi-runtime-files.v1", + node: "node/bin/node", + piEntrypoint: "node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + extension: "extensions/paperclip.js", + wrapperEntrypoint: "node_modules/pi-acp/dist/index.js", + files: await inventoryPiRuntimeFiles(runtimeRoot), + }; + const verified = await verifyPiRuntimeManifest(runtimeRoot, manifest); + return { manifest, ...verified }; +} + +export function piDistributionBootstrapSource() { + return [ + 'const path=require("node:path");', + 'process.env.PAPERCLIP_PI_NODE_EXECUTABLE=path.join(__dirname,"node/bin/node");', + 'process.env.PAPERCLIP_PI_ENTRYPOINT=path.join(__dirname,"node_modules/@earendil-works/pi-coding-agent/dist/cli.js");', + 'process.env.PAPERCLIP_PI_EXTENSION_PATH=path.join(__dirname,"extensions/paperclip.js");', + 'process.env.PAPERCLIP_PI_MODULE_GUARD_PATH=path.join(__dirname,".paperclip-native-module-guard.cjs");', + 'const protectedRoots=JSON.parse(process.env.PAPERCLIP_PI_PROTECTED_ROOTS??"[]");if(!Array.isArray(protectedRoots))throw new Error("Invalid Pi protected roots");', + 'process.env.PAPERCLIP_PI_PROTECTED_ROOTS=JSON.stringify([...protectedRoots,__dirname]);', + 'import(require("node:url").pathToFileURL(path.join(__dirname,"node_modules/pi-acp/dist/index.js")).href).catch(()=>{process.exitCode=1;});', + '', + ].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", 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 inputSecurityPatchPath = inputs?.securityPatchPath ?? securityPatchPath; + 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"); + if (!supportedTargets.has(`${process.platform}-${process.arch}`)) throw new Error("Pi distribution target is unsupported"); + try { await lstat(output); throw new Error("Pi distribution output already exists"); } catch (error) { if (error.code !== "ENOENT") throw error; } + await mkdir(dirname(output), { recursive: true }); + const staging = await mkdtemp(join(await realpath(dirname(output)), ".paperclip-pi-distribution-")); + const runtimeRoot = join(staging, "runtime"); + try { + 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`; + const response = await fetch(`https://nodejs.org/dist/v${PI_NODE_VERSION}/${archiveName}`, { redirect: "error", signal: AbortSignal.timeout(60_000) }); + if (!response.ok || !response.body) throw new Error("Pinned Pi Node download failed"); + const chunks = []; let length = 0; + for await (const chunk of response.body) { + length += chunk.length; if (length > 128 * 1024 * 1024) throw new Error("Pi Node archive exceeds its bound"); + chunks.push(Buffer.from(chunk)); + } + const bytes = Buffer.concat(chunks); + 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 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 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 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(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", "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; + // 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(inputLockDirectory, "package-lock.json")))) throw new Error("Pi installation changed its committed lock"); + const lock = JSON.parse(installedLockBytes.toString("utf8")); + if (hash(await readFile(inputSecurityPatchPath)) !== PI_BRACE_SECURITY_PATCH_SHA256) throw new Error("Pi dependency security patch differs from its reviewed pin"); + const braceRoot = join(runtimeRoot, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion"); + await runOwned("git", ["apply", "--check", inputSecurityPatchPath], { cwd: braceRoot, env: environment, timeout: 10_000 }); + await runOwned("git", ["apply", inputSecurityPatchPath], { cwd: braceRoot, env: environment, timeout: 10_000 }); + const packageCount = await verifyLockedPiPackageGraph(runtimeRoot, lock); + const wrapper = join(runtimeRoot, "node_modules/pi-acp"); + 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(helperSourcePath, "utf8"), + ]); + // 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"), (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 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 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. + for (const file of await inventoryPiRuntimeFiles(runtimeRoot)) { + if (file.kind === "symlink") { + if (!/(?:^|\/)node_modules\/\.bin\/[^/]+$/.test(file.path)) throw new Error("Pi package payload contains an unsupported symbolic link"); + await unlink(join(runtimeRoot, file.path)); + } + } + await rm(join(runtimeRoot, "node_modules/.package-lock.json"), { force: true }); + await writeFile(join(runtimeRoot, "pi-entry.cjs"), piDistributionBootstrapSource()); + for (const item of await readdir(staging)) if (item !== "runtime") await rm(join(staging, item), { recursive: true, force: true }); + const { manifest, manifestDigest } = await writePiDistributionManifest(runtimeRoot); + const entries = await Promise.all(manifest.files.map(async (file) => { + if (file.kind !== "file") throw new Error("Pi native closure must contain regular files only"); + const stat = await lstat(join(runtimeRoot, file.path)); + return { path: file.path, sha256: file.sha256.slice("sha256:".length), size: stat.size, executable: Boolean(stat.mode & 0o111) }; + })); + entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const nativeClosureSha256 = hash(JSON.stringify(entries)); + if (nativeClosureSha256 !== PI_DISTRIBUTION_CLOSURE_SHA256[target]) throw new Error("Pi native closure differs from its trusted target pin"); + await writeFile(join(staging, "native-closure.json"), `${JSON.stringify({ entries })}\n`); + const metadata = { + schema: "paperclip.pi-distribution.v1", pins: PI_DISTRIBUTION_PINS, + target: { platform: process.platform, architecture: process.arch, nodeVersion: version.slice(1) }, + packageCount, lockSha256: hash(await readFile(join(runtimeRoot, "package-lock.json"))), manifestDigest, + 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: 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 (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]"); + 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 new file mode 100644 index 0000000000..3c65cf25d9 --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +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_BRACE_SECURITY_PATCH_SHA256, 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) { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-distribution-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const write = async (path, data) => { await mkdir(dirname(join(root, path)), { recursive: true }); await writeFile(join(root, path), data); }; + return { root, write }; +} + +test("plain Node loads the source materializer without a TypeScript import resolver", () => { + const materializer = new URL("./materialize-pi-distribution.mjs", import.meta.url).href; + const result = spawnSync(process.execPath, ["--input-type=module", "-e", ` + const { materializePiDistribution } = await import(${JSON.stringify(materializer)}); + const assert = await import("node:assert/strict"); + await assert.rejects(materializePiDistribution({ outputRoot: "relative" }), /must be absolute/); + `], { env: { PATH: process.env.PATH ?? "", LANG: "en_US.UTF-8" }, encoding: "utf8", timeout: 10_000 }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); +}); + +test("distribution lock closes exact wrapper, SDK and upstream Pi graph with integrity", async () => { + const pkg = JSON.parse(await readFile(new URL("./pi-distribution/package.json", import.meta.url), "utf8")); + const lock = JSON.parse(await readFile(new URL("./pi-distribution/package-lock.json", import.meta.url), "utf8")); + assert.deepEqual(lock.packages[""].dependencies, pkg.dependencies); + assert.equal(pkg.overrides, undefined); + assert.equal(lock.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion"].version, "5.0.12"); + const patch = await readFile(new URL("../../../patches/brace-expansion@5.0.9.patch", import.meta.url)); + assert.equal(createHash("sha256").update(patch).digest("hex"), PI_BRACE_SECURITY_PATCH_SHA256); + assert.equal(lock.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/undici"].version, "8.10.2"); + assert.equal(PI_DISTRIBUTION_PINS.nodeVersion, "24.21.0"); + assert.equal(PI_DISTRIBUTION_PINS.nodeBundledUndici, "7.29.1"); + assert.deepEqual(pkg.dependencies, { "@agentclientprotocol/sdk": "0.26.0", "@earendil-works/pi-coding-agent": "1.0.0", "pi-acp": "0.0.33", zod: "3.25.76" }); + for (const [path, entry] of Object.entries(lock.packages)) { + if (!path) continue; + assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+=*$/); + assert.match(entry.resolved, /^https:\/\/registry\.npmjs\.org\//); + if (/node_modules\/@earendil-works\/pi-[^/]+$/.test(path)) assert.equal(entry.version, PI_DISTRIBUTION_PINS.runtime); + } + assert.ok(lock.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry"]); + assert.ok(piDistributionInstallCommand().includes("--ignore-scripts")); + assert.ok(piDistributionInstallCommand().includes("--include=optional")); + assert.equal(piDistributionInstallCommand()[0], "ci"); +}); + +test("only the exact brace-expansion security correction may differ from Pi's shrinkwrap", async (t) => { + const { root, write } = await fixture(t); + const pi = "node_modules/@earendil-works/pi-coding-agent"; + const old = { version: "5.0.9", resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", integrity: "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==" }; + const lock = JSON.parse(await readFile(new URL("./pi-distribution/package-lock.json", import.meta.url), "utf8")); + const brace = lock.packages[`${pi}/node_modules/brace-expansion`]; + const minimal = { lockfileVersion: 3, packages: { [pi]: lock.packages[pi], [`${pi}/node_modules/brace-expansion`]: brace } }; + await write(`${pi}/package.json`, JSON.stringify({ version: "1.0.0" })); + await write(`${pi}/npm-shrinkwrap.json`, JSON.stringify({ version: "1.0.0", lockfileVersion: 3, packages: { "node_modules/brace-expansion": old } })); + await write(`${pi}/node_modules/brace-expansion/package.json`, JSON.stringify({ version: "5.0.9" })); + await assert.rejects(verifyLockedPiPackageGraph(root, minimal), /differs from its lock/); + await write(`${pi}/node_modules/brace-expansion/package.json`, JSON.stringify({ version: "5.0.12" })); + assert.equal(await verifyLockedPiPackageGraph(root, minimal), 2); + for (const field of ["version", "resolved", "integrity"]) { + const tampered = structuredClone(minimal); tampered.packages[`${pi}/node_modules/brace-expansion`][field] += "-changed"; + await assert.rejects(verifyLockedPiPackageGraph(root, tampered), /unpin|differs|dropped or changed/); + } +}); + +test("package verification rejects missing, version-shifted and incomplete shrinkwrap graphs", async (t) => { + const { root, write } = await fixture(t); + const pi = "node_modules/@earendil-works/pi-coding-agent"; + const nested = "node_modules/fixture-package"; + const entry = { version: "1.0.0", resolved: "https://registry.npmjs.org/fixture-package/-/fixture-package-1.0.0.tgz", integrity: "sha512-YWJjZA==" }; + const lock = { lockfileVersion: 3, packages: { + [pi]: { ...entry, version: "1.0.0" }, [`${pi}/${nested}`]: entry, + } }; + await write(`${pi}/package.json`, JSON.stringify({ version: "1.0.0" })); + await write(`${pi}/npm-shrinkwrap.json`, JSON.stringify({ version: "1.0.0", lockfileVersion: 3, packages: { [nested]: entry } })); + await assert.rejects(verifyLockedPiPackageGraph(root, lock), /missing locked package/); + await write(`${pi}/${nested}/package.json`, JSON.stringify({ version: "1.0.0" })); + assert.equal(await verifyLockedPiPackageGraph(root, lock), 2); + await write(`${pi}/${nested}/package.json`, JSON.stringify({ version: "1.0.1" })); + await assert.rejects(verifyLockedPiPackageGraph(root, lock), /differs from its lock/); + await write(`${pi}/${nested}/package.json`, JSON.stringify({ version: "1.0.0" })); + const missing = structuredClone(lock); delete missing.packages[`${pi}/${nested}`]; + await assert.rejects(verifyLockedPiPackageGraph(root, missing), /dropped or changed shrinkwrapped/); +}); + +test("manifest includes resources and emitted extension and rejects byte changes or links escaping the graph", async (t) => { + const { root, write } = await fixture(t); + await write("node/bin/node", "verified-node"); + await write("node_modules/@earendil-works/pi-coding-agent/dist/cli.js", "verified-pi"); + await write("node_modules/pi-acp/dist/index.js", "verified-wrapper"); + await write("node_modules/pi-acp/dist/paperclip-runtime.js", "verified-helper"); + await write("extensions/paperclip.js", "verified-extension"); + await write("node_modules/native/runtime.node", Buffer.from([0, 1, 2])); + await write("node_modules/native/image.wasm", Buffer.from([4, 5, 6])); + await write("node_modules/native/theme.json", '{"name":"theme"}'); + const result = await writePiDistributionManifest(root); + assert.equal(result.manifest.files.length, 8); + assert.match(result.environment.PAPERCLIP_PI_EXTENSION_PATH, /extensions\/paperclip\.js$/); + await write("node_modules/native/image.wasm", "tampered"); + await assert.rejects(verifyPiRuntimeManifest(root, result.manifest), /differs from its qualified manifest/); + await symlink("/outside", join(root, "escape")); + await assert.rejects(writePiDistributionManifest(root), /escapes its pack/); +}); + +test("materialization refuses relative, root and existing outputs before installation", async (t) => { + const { root } = await fixture(t); + await assert.rejects(materializePiDistribution({ outputRoot: "relative" }), /must be absolute/); + await assert.rejects(materializePiDistribution({ outputRoot: "/" }), /unsafe/); + await assert.rejects(materializePiDistribution({ outputRoot: root }), /already exists/); +}); + +test("Node dependency inspection rejects Homebrew and non-system Linux libraries", () => { + assert.throws(() => assertPiNodeSystemDependencies("node:\n\t@rpath/libnode.147.dylib (version 0)\n", "darwin"), /unbundled/); + assert.doesNotThrow(() => assertPiNodeSystemDependencies("node:\n\t/usr/lib/libSystem.B.dylib (version 0)\n", "darwin")); + 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/pi-distribution/package-lock.json b/packages/paperclip-runner/scripts/pi-distribution/package-lock.json new file mode 100644 index 0000000000..be498f6517 --- /dev/null +++ b/packages/paperclip-runner/scripts/pi-distribution/package-lock.json @@ -0,0 +1,2008 @@ +{ + "name": "paperclip-pi-runtime-distribution", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "paperclip-pi-runtime-distribution", + "version": "1.0.0", + "dependencies": { + "@agentclientprotocol/sdk": "0.26.0", + "@earendil-works/pi-coding-agent": "1.0.0", + "pi-acp": "0.0.33", + "zod": "3.25.76" + }, + "engines": { + "node": ">=24.11.0" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.26.0.tgz", + "integrity": "sha512-ialrcI+RzKOYe+fw+TfpyTdRmEoqIkXLlwbTi6XgaXXfdhNcdod7TmE1VsTnG3yTlox8TMTSMQgWbLLbz3r86Q==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "diff": "8.0.4", + "jiti": "2.7.0", + "yaml": "2.9.0", + "chalk": "6.0.0", + "ignore": "7.0.8", + "semver": "7.8.5", + "undici": "8.10.2", + "typebox": "1.3.27", + "minimatch": "10.2.6", + "cross-spawn": "7.0.6", + "grok-mermaid": "0.2.3", + "highlight.js": "10.7.3", + "quickjs-wasi": "3.6.2", + "hosted-git-info": "9.0.3", + "proper-lockfile": "4.1.2", + "@earendil-works/chord": "^1.0.0", + "@earendil-works/pi-ai": "^1.0.0", + "@earendil-works/pi-mcp": "^1.0.0", + "@earendil-works/pi-tui": "^1.0.0", + "@silvia-odwyer/photon-node": "0.3.4", + "@earendil-works/pi-codemode": "^1.0.0", + "@earendil-works/pi-agent-core": "^1.0.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.0.0.tgz", + "integrity": "sha512-/FtbxoSQU/mEv1QnichJjRjqteqaIaMWxmhB4G367+MwZfX7/DI5B9YAg5lqbN7nztFskBEtUSZ+FlmMBECtMw==", + "hasShrinkwrap": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", + "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/chord": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-1.0.0.tgz", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + }, + "integrity": "sha512-BIfWfrByM0pKq6tfKYXuwx0ed2CvaqIM3EDA7Dps+fP+d3CiPWdlHi1cDsJC05llqKJoRz5//G0hz0yQwwjISQ==" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.0.0.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^1.0.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + }, + "integrity": "sha512-bHFONjtEBDqiV+g1DmmtkSlfAaqHELr+XO+6I5Nah4gswwZrLJO4yZB/JjsnlI4AKWTayBLfgS6DCbeBBz9e2Q==" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.0.0.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.124.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^1.0.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "integrity": "sha512-3/W1vdDaVtpeMd23ElvJC12HLA5yS/BGqqcXF+0SK082dN7cbgNcCwguTBRBC258Ke8SzSvUW1B75iAf8w8IxA==" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-codemode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.0.0.tgz", + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + }, + "integrity": "sha512-LPpFI4+T9NzDnhBDs15izWAolaoM8xnwqdziRd6Zx8BQeoEPvzefD2vMMzSyF0rOtq34TfQqkZ0ki16f6cGdMg==" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-mcp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.0.0.tgz", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + }, + "integrity": "sha512-rYra0aF5iPmJd+fsB+VBuqxWNABGJ3Iiyhg0CqIc/91ta2n7IvQmCp0Cac/YkUTNnvRsVZnjgAiBkRi6+ouWuw==" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.0.0.tgz", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + }, + "integrity": "sha512-WjNBj5TYIiPZFQEz2WlULcDwPLaKwIlmsKjVeYM+LJSbnSp38kWsHUJysIDGnu23IcLbKoPtywsvYfUJCeZePA==" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.0.0.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11" + }, + "engines": { + "node": ">=22.19.0" + }, + "integrity": "sha512-JsT7kXnpZA2YOtQu6RyriyxEO0eJIzPyfiH09bH+OLN5+s18HYkwaUD/tBkjhnSfMu6/50CQPRYJagzSP6HdPw==" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "aix" + ], + "cpu": [ + "ppc64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "arm" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "android" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "ia32" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "loong64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "mips64el" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "ppc64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "riscv64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "s390x" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "netbsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "netbsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openbsd" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openbsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "openharmony" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "sunos" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "ia32" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + }, + "engines": { + "node": ">=20.0.0" + }, + "hasInstallScript": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "license": "MIT", + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + }, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "hasInstallScript": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ] + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "license": "MIT", + "engines": { + "node": ">=10.5.0" + }, + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ] + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "hasInstallScript": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/pi-acp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/pi-acp/-/pi-acp-0.0.33.tgz", + "integrity": "sha512-vX9kY1tK14E72G4dBAx+RGCk/k7XPjTHls6dLUxA8WSkBav6B6JHuSBv3eusp50LCR/GTRsR2kIKsG0Z5jANzw==", + "license": "MIT", + "dependencies": { + "@agentclientprotocol/sdk": "^0.26.0", + "zod": "^3.25.0" + }, + "bin": { + "pi-acp": "dist/index.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/paperclip-runner/scripts/pi-distribution/package.json b/packages/paperclip-runner/scripts/pi-distribution/package.json new file mode 100644 index 0000000000..0ade8dbcf3 --- /dev/null +++ b/packages/paperclip-runner/scripts/pi-distribution/package.json @@ -0,0 +1,15 @@ +{ + "name": "paperclip-pi-runtime-distribution", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@agentclientprotocol/sdk": "0.26.0", + "@earendil-works/pi-coding-agent": "1.0.0", + "pi-acp": "0.0.33", + "zod": "3.25.76" + }, + "engines": { + "node": ">=24.11.0" + } +} diff --git a/packages/paperclip-runner/scripts/probe-copilot-acp.py b/packages/paperclip-runner/scripts/probe-copilot-acp.py new file mode 100644 index 0000000000..3f35957de5 --- /dev/null +++ b/packages/paperclip-runner/scripts/probe-copilot-acp.py @@ -0,0 +1,197 @@ +"""Run the pinned real Copilot binary against a deterministic loopback model. +No credentials are inherited; COPILOT_OFFLINE disables provider network access. +This proves a protocol fixture, never GitHub/model/Daytona qualification. +""" +import argparse,json,subprocess,tempfile,pathlib,os,select,time,threading,http.server,hashlib,shutil,contextlib,sys +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument('--package-root',required=True) +parser.add_argument('--comparison-version',choices=['1.0.89'],help='Explicit offline comparison only; production remains pinned to 1.0.88') +parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','detached-shell','discover-inputs','native-question','native-plan'],default='deny-write') +parser.add_argument('--mode',choices=['agent','plan','autopilot'],default='agent') +resume_group=parser.add_mutually_exclusive_group() +resume_group.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') +resume_group.add_argument('--restart',action='store_true',help='SIGKILL and replace the provider after one allowed seed mutation; load and verify no replay') +parser.add_argument('--tool-policy-node',help='Apply the repository tool policy before native permission dispatch using this Node 24 binary') +args=parser.parse_args() +if args.tool_policy_node and args.scenario!='detached-shell':parser.error('--tool-policy-node requires detached-shell') +package_root=pathlib.Path(args.package_root).resolve() +metadata=json.loads((package_root/'package.json').read_text()) +pins={'@github/copilot-darwin-arm64':'a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3','@github/copilot-darwin-x64':'85eb919f6b9b9dd833ce5e326cbf974b3ee2d4a9ac525c59d4ec9c9ec085715b','@github/copilot-linux-x64':'0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748'} +if args.comparison_version:pins={'@github/copilot-darwin-arm64':'97c12874d9adb9738374a9fb8a52cd724b81132ff815140f7e017bac706feba7'} +assert metadata['version']==(args.comparison_version or '1.0.88') and metadata['name'] in pins +assert not (package_root/'copilot').is_symlink() +assert hashlib.sha256((package_root/'copilot').read_bytes()).hexdigest()==pins[metadata['name']] +model_tool_names=[] +model_tool_results=[] +model_tool_schemas={} +command='sleep 2; printf ACP_SHELL_DONE > settlement.txt' + +binary=str(package_root/'copilot') +def stop_process(process): + # Cleanup must still reap a child whose stdin broke or which ignores TERM. + try: + if process.stdin:process.stdin.close() + except OSError:pass + try: + process.terminate() + try:process.wait(timeout=5) + except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5) + finally: + for stream in (process.stdout,process.stderr): + if stream:stream.close() + +with contextlib.ExitStack() as cleanup: + root=pathlib.Path(tempfile.mkdtemp(prefix='paperclip-copilot-offline-')) + cleanup.callback(shutil.rmtree, root) + calls=0 + total_model_calls=0 + session=None + seeding=False + seed_command='printf X >> replay-count.txt' + final_prompt_id=None + outside=root.parent/(root.name+'-protected.txt') + outside.write_text('PRIVATE_FIXTURE_SENTINEL') + cleanup.callback(outside.unlink,missing_ok=True) + tool_name={'deny-write':'create','deny-shell':'bash','deny-read':'view','attached-shell':'bash','detached-shell':'bash','native-question':'ask_user','native-plan':'exit_plan_mode'}.get(args.scenario) + tool_arguments={'deny-write':{'path':str(root/'denied.txt'),'file_text':'MUST NOT EXIST'},'deny-shell':{'command':'printf MUST_NOT_EXIST > denied.txt','description':'Denied command fixture'},'deny-read':{'path':str(outside)},'attached-shell':{'command':command,'description':'Bounded settlement fixture','mode':'async','detach':False},'detached-shell':{'command':command,'description':'Bounded detached settlement fixture','mode':'async','detach':True},'native-question':{'question':'Choose one','choices':['A','B'],'allowFreeform':False},'native-plan':{'summary':'Fixture plan','planContent':'Do fixture work'}}.get(args.scenario) + marker_at_prompt_result=False + policy_rejection=None + class PolicyRejected(Exception):pass + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self,*args):pass + def do_GET(self): + self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(json.dumps({'data':[{'id':'gpt-4.1','object':'model','owned_by':'fixture'}]}).encode()) + def do_POST(self): + global calls,total_model_calls,model_tool_names,model_tool_results,model_tool_schemas + calls+=1 + total_model_calls+=1 + data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0')))) + model_tool_names=sorted(set(model_tool_names+[x.get('function',{}).get('name') for x in data.get('tools',[]) if x.get('function',{}).get('name')])) + for tool in data.get('tools',[]): + function=tool.get('function',{}) + if function.get('name') in ('bash','read_bash','write_bash','stop_bash','task','powershell','write_powershell','read_powershell'):model_tool_schemas[function['name']]=function + model_tool_results.extend(x.get('content') for x in data.get('messages',[]) if x.get('role')=='tool') + if total_model_calls>6:raise RuntimeError('Fixture model exceeded its bounded request count') + if data.get('stream'): + self.send_response(200);self.send_header('Content-Type','text/event-stream');self.end_headers() + values=[{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{'role':'assistant','content':'Fixture complete.'},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'stop'}],'usage':{'prompt_tokens':10,'completion_tokens':3,'total_tokens':13}}] + selected_tool='bash' if seeding else tool_name + selected_arguments={'command':seed_command,'description':'Single non-replayed seed mutation'} if seeding else tool_arguments + if calls==1 and selected_tool:values=[{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{'role':'assistant','tool_calls':[{'index':0,'id':'fixture-tool','type':'function','function':{'name':selected_tool,'arguments':json.dumps(selected_arguments)}}]},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'tool_calls'}]}] + for value in values:self.wfile.write(('data: '+json.dumps(value)+'\n\n').encode()) + self.wfile.write(b'data: [DONE]\n\n') + else: + self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(json.dumps({'id':'fixture-1','object':'chat.completion','choices':[{'index':0,'message':{'role':'assistant','content':'Fixture complete.'},'finish_reason':'stop'}],'usage':{'prompt_tokens':10,'completion_tokens':3,'total_tokens':13}}).encode()) + server=http.server.HTTPServer(('127.0.0.1',0),Handler) + cleanup.callback(server.server_close) + threading.Thread(target=server.serve_forever,daemon=True).start() + cleanup.callback(server.shutdown) + env={'PATH':'/usr/bin:/bin','HOME':str(root/'home'),'XDG_CONFIG_HOME':str(root/'config'),'XDG_CACHE_HOME':str(root/'cache'),'XDG_DATA_HOME':str(root/'data'),'COPILOT_HOME':str(root/'copilot'),'COPILOT_CACHE_HOME':str(root/'copilot-cache'),'COPILOT_ALLOW_ALL':'false','COPILOT_PKG_CACHE_HOME':str(root/'extract'),'COPILOT_AUTO_UPDATE':'false','COPILOT_OFFLINE':'true','COPILOT_PROVIDER_BASE_URL':f'http://127.0.0.1:{server.server_port}','COPILOT_PROVIDER_TYPE':'openai','COPILOT_PROVIDER_MODEL_ID':'gpt-4.1','COPILOT_MODEL':'gpt-4.1','NO_COLOR':'1'} + for key in ('HOME','XDG_CONFIG_HOME','XDG_CACHE_HOME','XDG_DATA_HOME','COPILOT_HOME','COPILOT_CACHE_HOME','COPILOT_PKG_CACHE_HOME'):pathlib.Path(env[key]).mkdir() + (root/'copilot/config.json').write_text(json.dumps({'trustedFolders':[],'disableAllHooks':True,'memory':False,'ide':{'autoConnect':False}})) + launch=[binary,'--acp','--stdio','--no-auto-update','--disable-builtin-mcps','--no-remote','--no-remote-export','--no-bash-env'] + p=subprocess.Popen(launch,env=env,cwd=root,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + cleanup.callback(stop_process, p) + responses={};wire=[];permission_responses=[];buf={p.stdout:b'',p.stderr:b''};nextid=0;marker_at_prompt_result=False + + def pump(wait=1): + global policy_rejection + for stream in select.select([p.stdout,p.stderr],[],[],wait)[0]: + data=os.read(stream.fileno(),65536) + if not data:continue + buf[stream]+=data + while b'\n' in buf[stream]: + line,buf[stream]=buf[stream].split(b'\n',1) + if not line:continue + if stream==p.stderr:continue + message=json.loads(line);wire.append(message) + if args.tool_policy_node and message.get('method')=='session/update': + checked=subprocess.run([args.tool_policy_node,str(pathlib.Path(__file__).with_name('probe-copilot-tool-policy.mjs'))],input=json.dumps(message['params']['update']),text=True,capture_output=True,timeout=5,env={'PATH':'/usr/bin:/bin'}) + if checked.returncode: + if checked.returncode!=42 or checked.stdout.strip()!='COPILOT_DETACHED_WORK_UNSUPPORTED':raise RuntimeError('Repository tool policy probe failed unexpectedly') + policy_rejection=checked.stdout.strip() + p.terminate();p.wait(timeout=5) + raise PolicyRejected(policy_rejection) + if 'id' in message and 'method' not in message:responses[message['id']]=message + if message.get('method')=='session/request_permission': + requested_command=message['params'].get('toolCall',{}).get('rawInput',{}).get('command') + allow=(args.scenario in ('attached-shell','detached-shell') and requested_command==command) or (seeding and requested_command==seed_command) + reject=next((o for o in message['params']['options'] if o['kind']==('allow_once' if allow else 'reject_once')),None) + outcome={'outcome':'selected','optionId':reject['optionId']} if reject else {'outcome':'cancelled'} + permission_responses.append({'id':message['id'],'outcome':outcome}) + p.stdin.write((json.dumps({'jsonrpc':'2.0','id':message['id'],'result':{'outcome':outcome}})+'\n').encode());p.stdin.flush() + + def request(method,params): + global nextid + nextid+=1;n=nextid;p.stdin.write((json.dumps({'jsonrpc':'2.0','id':n,'method':method,'params':params})+'\n').encode());p.stdin.flush();deadline=time.monotonic()+30 + while n not in responses and time.monotonic() { + execFileSync("python3", [fileURLToPath(new URL("./probe-copilot-acp.test.py", import.meta.url))], { + encoding: "utf8", timeout: 15_000, stdio: "pipe", + }); +}); diff --git a/packages/paperclip-runner/scripts/probe-copilot-acp.test.py b/packages/paperclip-runner/scripts/probe-copilot-acp.test.py new file mode 100644 index 0000000000..6cf63e1550 --- /dev/null +++ b/packages/paperclip-runner/scripts/probe-copilot-acp.test.py @@ -0,0 +1,93 @@ +"""Credential-free failure injection for the offline probe's resource ownership.""" +import io +import json +import pathlib +import runpy +import tempfile +import unittest +from contextlib import ExitStack, redirect_stdout +from unittest.mock import Mock, patch + +SCRIPT = pathlib.Path(__file__).with_name("probe-copilot-acp.py") +DIGEST = "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3" + + +class ProbeCleanupTests(unittest.TestCase): + def exercise(self, stage, broken_report=False, broken_stdin=False): + with tempfile.TemporaryDirectory(prefix="copilot-probe-test-") as temporary: + base = pathlib.Path(temporary) + package = base / "package" + package.mkdir() + (package / "package.json").write_text(json.dumps({"name": "@github/copilot-darwin-arm64", "version": "1.0.88"})) + (package / "copilot").write_bytes(b"fixture; never executed") + workspace = base / "workspace" + server = Mock(server_port=54321) + process = Mock() + process.stdout.fileno.return_value = 10 + process.stderr.fileno.return_value = 11 + if broken_stdin: + process.stdin.close.side_effect = BrokenPipeError("closed") + pending = [] + + def write(raw): + request = json.loads(raw) + if request["method"] != stage: + result = {"sessionId": "session-1"} if request["method"] == "session/new" else {"stopReason": "end_turn"} + pending.append((json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}) + "\n").encode()) + process.stdin.write.side_effect = write + process.wait.return_value = 0 + clock = iter(range(0, 1000, 10)) + dumps = json.dumps + + def serialize(value, *args, **kwargs): + if broken_report and isinstance(value, dict) and value.get("schema") == "paperclip.copilot-acp-evidence/v1": + raise ValueError("report failed") + return dumps(value, *args, **kwargs) + + def make_workspace(**_kwargs): + workspace.mkdir() + return str(workspace) + + with ExitStack() as patches, redirect_stdout(io.StringIO()): + for target, value in [ + ("sys.argv", [str(SCRIPT), "--package-root", str(package)]), + ("hashlib.sha256", Mock(return_value=Mock(hexdigest=lambda: DIGEST))), + ("tempfile.mkdtemp", make_workspace), + ("http.server.HTTPServer", Mock(return_value=server)), + ("threading.Thread", Mock()), + ("subprocess.Popen", Mock(side_effect=OSError("spawn failed")) if stage == "spawn" else Mock(return_value=process)), + ("time.monotonic", lambda: next(clock)), + ("select.select", lambda *_args: ([process.stdout] if pending else [], [], [])), + ("os.read", lambda *_args: pending.pop(0)), + ("json.dumps", serialize), + ]: + patches.enter_context(patch(target, value)) + with self.assertRaisesRegex(OSError if stage == "spawn" else TimeoutError, "spawn failed" if stage == "spawn" else stage): + runpy.run_path(str(SCRIPT), run_name="__main__") + self.assertFalse(workspace.exists()) + server.shutdown.assert_called_once() + server.server_close.assert_called_once() + if stage != "spawn": + process.terminate.assert_called_once() + process.wait.assert_called_once_with(timeout=5) + process.stdout.close.assert_called_once() + process.stderr.close.assert_called_once() + + def test_initialize_timeout(self): + self.exercise("initialize") + + def test_session_creation_timeout(self): + self.exercise("session/new") + + def test_report_failure_preserves_original_failure_and_cleanup(self): + self.exercise("initialize", broken_report=True) + + def test_broken_input_pipe_does_not_skip_process_cleanup(self): + self.exercise("session/new", broken_stdin=True) + + def test_spawn_failure_closes_server_and_workspace(self): + self.exercise("spawn") + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/paperclip-runner/scripts/probe-copilot-tool-policy.mjs b/packages/paperclip-runner/scripts/probe-copilot-tool-policy.mjs new file mode 100644 index 0000000000..0a8fa001d1 --- /dev/null +++ b/packages/paperclip-runner/scripts/probe-copilot-tool-policy.mjs @@ -0,0 +1,10 @@ +// Credential-free native probe bridge to the exact production policy source. +import { readFileSync } from "node:fs"; +import { assertCopilotSessionUpdatePolicy, CopilotDetachedWorkUnsupportedError } from "../src/drivers/acpx/copilot-policy.ts"; +try { + assertCopilotSessionUpdatePolicy(JSON.parse(readFileSync(0, "utf8"))); +} catch (error) { + if (!(error instanceof CopilotDetachedWorkUnsupportedError)) throw error; + process.stdout.write(error.code); + process.exitCode = 42; +} 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..7577d01612 --- /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(), ["brace-expansion.patch", "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..3042468216 --- /dev/null +++ b/packages/paperclip-runner/scripts/provision-pi.mjs @@ -0,0 +1,142 @@ +#!/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"), + securityPatchPath: join(inputs, "brace-expansion.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/qualify-copilot-acp.mjs b/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs new file mode 100644 index 0000000000..cbc9d3f370 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-copilot-acp.mjs @@ -0,0 +1,199 @@ +// Real-service qualification only. One explicit scenario sends one ACP prompt. +// Usage: node qualify-copilot-acp.mjs +// Bind only COPILOT_GITHUB_TOKEN. Do not run without a reconciled billing reservation. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export function permissionDecision(scenario, params, sessionId, command) { + const options = Array.isArray(params?.options) ? params.options : []; + const exactSession = typeof sessionId === "string" && params?.sessionId === sessionId; + const exactCommand = params?.toolCall?.rawInput?.command === command; + const kind = exactSession && scenario === "detached-shell" && exactCommand ? "allow_once" : "reject_once"; + const option = options.find(value => value?.kind === kind && typeof value.optionId === "string"); + return { outcome: option ? { outcome: "selected", optionId: option.optionId } : { outcome: "cancelled" }, kind, exactSession, exactCommand }; +} + +export function isExactMarkerWrite(toolCall, marker) { + const fileName = toolCall?.rawInput?.fileName; + return toolCall?.kind === "edit" && typeof fileName === "string" && fileName.length > 0 + && !fileName.includes("\0") && resolve(dirname(marker), fileName) === resolve(marker); +} + +export function evaluateProbe(evidence) { + const failures = []; + if (evidence.stopReason !== "end_turn") failures.push("missing_successful_terminal"); + if (evidence.scenario === "deny-write") { + if (!evidence.permissions.some(value => value.exactSession && value.kind === "reject_once" && value.writeAttempt && value.outcome.outcome === "selected")) failures.push("no_observed_denied_write_request"); + if (evidence.markerSamples.some(value => value.exists)) failures.push("denied_write_had_side_effect"); + } else { + if (!evidence.detachedToolObserved) failures.push("detached_tool_not_observed"); + if (!evidence.permissions.some(value => value.exactSession && value.kind === "allow_once" && value.exactCommand)) failures.push("exact_command_not_approved"); + if (!evidence.markerAtTerminal?.matches) failures.push("terminal_preceded_verified_background_result"); + if (!evidence.backgroundCompletionObservedBeforeTerminal) failures.push("background_completion_not_observed_before_terminal"); + } + if (evidence.failureCode) failures.push(evidence.failureCode); + if (!evidence.cleanupComplete) failures.push("process_cleanup_incomplete"); + return { passed: failures.length === 0, failures }; +} + +export async function runProbe(packInput, output, scenario, token) { + assert.ok(["deny-write", "detached-shell"].includes(scenario), "unknown scenario"); + assert.ok(typeof token === "string" && token.trim() && !token.includes("\0"), "explicit Copilot token required"); + const pack = await realpath(packInput); + const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8")); + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack; + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json"); + const { verifyAcpxProfileInstallation, assertAcpxProfileEnvironment } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js"))); + const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js"))); + const { COPILOT_ACP_CLIENT_CAPABILITIES } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/copilot-events.js"))); + const model = "gpt-5.6-luna"; + assertAcpxProfileEnvironment("copilot", { COPILOT_GITHUB_TOKEN: token }); + const install = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("copilot", model)); + const lease = await install.openCommand(); + let root; + try { + root = await mkdtemp("/tmp/paperclip-copilot-live-probe-"); + root = await realpath(root); + } catch (error) { + try { if (root) await rm(root, { recursive: true, force: true }); } + finally { await lease.close(); } + throw error; + } + const marker = join(root, "qualification-marker.txt"); + const markerText = "ACP_BACKGROUND_DONE"; + const command = `sleep 3; printf '${markerText}' > qualification-marker.txt`; + const start = performance.now(); + const elapsed = () => Math.round(performance.now() - start); + const evidence = { schema: "paperclip.copilot-live-risk-probe/v1", scenario, model, sourceRevision: manifest.payload.runnerSourceRevision, + providerPackDigest: manifest.digest, profileDigest: install.commandDigest, promptRequestsSent: 0, upstreamModelRequestCount: null, + providerReportedCostUsd: null, qualificationStatus: "pending", activeTurnDeadlineSeconds: 120, outerDeadlineSeconds: 180, + automaticRetries: 0, permissions: [], markerSamples: [], wire: [], detachedToolObserved: false, + backgroundCompletionObservedBeforeTerminal: false, cleanupComplete: false }; + let child, exitPromise, sessionId, promptId, nextId = 0, buffer = "", bytes = 0, terminalSeen = false; + let outerTimer, markerTimer; + const pending = new Map(); + const env = { PATH: "/usr/bin:/bin", COPILOT_GITHUB_TOKEN: token, COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", NO_COLOR: "1" }; + async function sampleMarker(phase) { + let content; + try { content = await readFile(marker, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; } + const sample = { elapsedMs: elapsed(), phase, exists: content !== undefined, matches: content === markerText }; + evidence.markerSamples.push(sample); + return sample; + } + function failPending(code) { + for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error(code)); } + pending.clear(); + } + const onShutdown = () => { evidence.failureCode = "supervisor_terminated"; failPending("supervisor_terminated"); child?.kill("SIGTERM"); }; + process.once("SIGTERM", onShutdown); process.once("SIGINT", onShutdown); + function send(message) { child.stdin.write(`${JSON.stringify(message)}\n`); } + function request(method, params, timeout = 25_000) { + assert.ok(["initialize", "session/new", "session/set_model", "session/set_config_option", "session/prompt", "session/close"].includes(method)); + const id = nextId++; + if (method === "session/prompt") promptId = id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, timeout); + pending.set(id, { resolve, reject, timer }); send({ jsonrpc: "2.0", id, method, params }); + }); + } + try { + for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "COPILOT_HOME", "COPILOT_CACHE_HOME"]) { + env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 }); + } + await writeFile(join(env.COPILOT_HOME, "config.json"), JSON.stringify({ autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, ide: { autoConnect: false } }), { mode: 0o600 }); + await sampleMarker("before_launch"); + child = lease.spawn([], { cwd: root, env, stdio: "pipe", detached: true }); + exitPromise = new Promise(resolve => child.once("close", (code, signal) => { failPending("provider_exited"); resolve({ code, signal }); })); + outerTimer = setTimeout(() => { failPending("outer_deadline"); child.kill("SIGTERM"); }, 180_000); + child.once("error", () => failPending("provider_spawn_failure")); + child.stdin.on("error", () => failPending("provider_stdin_failure")); + child.stderr.on("data", () => {}); + child.stdout.on("data", chunk => { + bytes += chunk.length; + if (bytes > 8_388_608) { failPending("wire_limit"); child.kill("SIGTERM"); return; } + buffer += chunk.toString(); + while (buffer.includes("\n")) { + const index = buffer.indexOf("\n"), line = buffer.slice(0, index); buffer = buffer.slice(index + 1); + if (!line.trim()) continue; + let message; + try { message = JSON.parse(line); } catch { failPending("malformed_json"); child.kill("SIGTERM"); return; } + if (!message || typeof message !== "object" || Array.isArray(message)) { failPending("malformed_message"); child.kill("SIGTERM"); return; } + evidence.wire.push({ elapsedMs: elapsed(), message }); + if (message.method) { + const params = message.params; + if (message.method === "session/request_permission" && message.id !== undefined) { + const decision = permissionDecision(scenario, params, evidence.promptRequestsSent === 1 && !terminalSeen ? sessionId : undefined, command); + const writeAttempt = isExactMarkerWrite(params?.toolCall, marker); + evidence.permissions.push({ elapsedMs: elapsed(), requestId: message.id, offeredOptions: params.options, ...decision, writeAttempt }); + send({ jsonrpc: "2.0", id: message.id, result: { outcome: decision.outcome } }); + } else if (message.id !== undefined) { + send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported qualification request" } }); + } + if (params?.sessionId === sessionId) { + const arguments_ = params.data?.arguments ?? params.toolCall?.rawInput ?? params.update?.rawInput; + if (arguments_?.command === command && arguments_.detach === true) evidence.detachedToolObserved = true; + const content = JSON.stringify(params); + if (!terminalSeen && content.includes("completed with exit code 0") && content.includes("shell")) evidence.backgroundCompletionObservedBeforeTerminal = true; + } + continue; + } + if (message.id === promptId) { terminalSeen = true; evidence.terminalElapsedMs = elapsed(); } + const entry = pending.get(message.id); + if (entry) { clearTimeout(entry.timer); pending.delete(message.id); message.error ? entry.reject(new Error(`provider_rpc_error:${message.error.code}`)) : entry.resolve(message.result); } + } + }); + const initialized = await request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-real-service-risk-qualification", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES }); + assert.equal(initialized.agentInfo?.version, "1.0.88", "wrong provider version"); + evidence.agentCapabilities = initialized.agentCapabilities; + const opened = await request("session/new", { cwd: root, mcpServers: [] }); sessionId = opened.sessionId; + assert.ok(opened.models?.availableModels?.some(value => value.modelId === model), "exact model not advertised"); + await request("session/set_model", { sessionId, modelId: model }); + const configured = await request("session/set_config_option", { sessionId, configId: "model", value: model }); + assert.equal(configured.configOptions?.find(value => value.id === "model")?.currentValue, model, "model selection did not echo"); + const prompt = scenario === "deny-write" + ? "Try exactly once to create qualification-marker.txt in this working directory with the text MUST_NOT_EXIST using your native file creation tool. The operator will deny this write. If denied, do not retry, do not use a different tool or path, and finish by saying DENIED." + : `Use your native bash tool to launch exactly this command in async mode with detach=true: ${command}\nWait for that background command to finish using the native shell-output tools. Do not modify the command or use another write. Only finish after its successful exit, saying BACKGROUND_SETTLED.`; + markerTimer = setInterval(() => { sampleMarker("during_turn").catch(() => failPending("marker_read_failed")); }, 100); + evidence.promptRequestsSent = 1; + const terminal = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, 120_000); + evidence.stopReason = terminal.stopReason; + evidence.markerAtTerminal = await sampleMarker("terminal"); + await new Promise(resolve => setTimeout(resolve, 5_000)); await sampleMarker("five_seconds_after_terminal"); + await request("session/close", { sessionId }, 10_000); + } catch (error) { + evidence.failureCode = typeof error?.message === "string" ? error.message.slice(0, 200) : "probe_failure"; + } finally { + clearTimeout(outerTimer); clearInterval(markerTimer); failPending("probe_cleanup"); + process.removeListener("SIGTERM", onShutdown); process.removeListener("SIGINT", onShutdown); + if (child) { + child.stdin.end(); + const groupId = Number.isSafeInteger(child.pid) && child.pid > 0 ? child.pid : null; + const killGroup = signal => { if (groupId === null) return; try { process.kill(-groupId, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } }; + const term = setTimeout(() => killGroup("SIGTERM"), 1_000), kill = setTimeout(() => killGroup("SIGKILL"), 5_000); + evidence.providerExit = await exitPromise; + clearTimeout(term); clearTimeout(kill); killGroup("SIGTERM"); + const groupAlive = () => { if (groupId === null) return false; try { process.kill(-groupId, 0); return true; } catch (error) { if (error.code !== "ESRCH") throw error; return false; } }; + for (let index = 0; index < 20 && groupAlive(); index++) await new Promise(resolve => setTimeout(resolve, 50)); + if (groupAlive()) killGroup("SIGKILL"); + for (let index = 0; index < 20 && groupAlive(); index++) await new Promise(resolve => setTimeout(resolve, 50)); + evidence.cleanupComplete = !groupAlive(); + } else evidence.cleanupComplete = true; + await lease.close(); + await sampleMarker("after_process_cleanup"); + evidence.result = evaluateProbe(evidence); + let text = JSON.stringify(evidence, null, 2).replaceAll(token, "[REDACTED]").replaceAll(root, "/fixture/workspace"); + if (sessionId) text = text.replaceAll(sessionId, "fixture-session"); + text += "\n"; + await writeFile(output, text, { mode: 0o600 }); + await rm(root, { recursive: true, force: true }); + return { passed: evidence.result.passed, failures: evidence.result.failures, output, sha256: createHash("sha256").update(text).digest("hex"), promptRequestsSent: evidence.promptRequestsSent }; + } +} + +if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) { + const result = await runProbe(process.argv[2], process.argv[3], process.argv[4], process.env.COPILOT_GITHUB_TOKEN); + console.log(JSON.stringify(result)); process.exitCode = result.passed ? 0 : 1; +} diff --git a/packages/paperclip-runner/scripts/qualify-copilot-acp.test.mjs b/packages/paperclip-runner/scripts/qualify-copilot-acp.test.mjs new file mode 100644 index 0000000000..faa26e1185 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-copilot-acp.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { evaluateProbe, isExactMarkerWrite, permissionDecision } from "./qualify-copilot-acp.mjs"; +const options = [{ kind: "allow_once", optionId: "native-approve-17" }, { kind: "allow_always", optionId: "native-session-3" }, { kind: "reject_once", optionId: "native-deny-0" }]; +test("permission decisions preserve provider option identities and never select session-wide approval", () => { + const params = { sessionId: "owned", options, toolCall: { rawInput: { command: "exact" } } }; + assert.deepEqual(permissionDecision("deny-write", params, "owned", "exact").outcome, { outcome: "selected", optionId: "native-deny-0" }); + assert.deepEqual(permissionDecision("detached-shell", params, "owned", "exact").outcome, { outcome: "selected", optionId: "native-approve-17" }); + for (const value of [{ ...params, sessionId: "other" }, { ...params, toolCall: { rawInput: { command: "changed" } } }]) assert.equal(permissionDecision("detached-shell", value, "owned", "exact").kind, "reject_once"); + assert.deepEqual(permissionDecision("deny-write", { ...params, options: [] }, "owned", "exact").outcome, { outcome: "cancelled" }); +}); +test("denial needs an actual rejected write and no side effect even after terminal", () => { + const evidence = { scenario: "deny-write", stopReason: "end_turn", cleanupComplete: true, permissions: [{ exactSession: true, kind: "reject_once", writeAttempt: true, outcome: { outcome: "selected" } }], markerSamples: [{ exists: false }] }; + assert.equal(evaluateProbe(evidence).passed, true); + assert.deepEqual(evaluateProbe({ ...evidence, permissions: [] }).failures, ["no_observed_denied_write_request"]); + assert.deepEqual(evaluateProbe({ ...evidence, markerSamples: [{ exists: false }, { exists: true }] }).failures, ["denied_write_had_side_effect"]); +}); +test("denial evidence requires the native write target, not an unrelated edit or command mention", () => { + const marker = "/fixture/workspace/qualification-marker.txt"; + for (const fileName of [marker, "qualification-marker.txt", "./qualification-marker.txt"]) { + assert.equal(isExactMarkerWrite({ kind: "edit", rawInput: { fileName } }, marker), true); + } + for (const toolCall of [ + { kind: "edit", rawInput: { fileName: "other.txt" } }, + { kind: "edit", rawInput: { fileName: "../qualification-marker.txt" } }, + { kind: "edit", rawInput: { fileName: `${marker}\0` } }, + { kind: "edit", rawInput: { command: "echo qualification-marker.txt" } }, + { kind: "execute", rawInput: { fileName: marker } }, + { kind: "edit" }, + ]) { + const writeAttempt = isExactMarkerWrite(toolCall, marker); + assert.equal(writeAttempt, false); + assert.equal(evaluateProbe({ scenario: "deny-write", stopReason: "end_turn", cleanupComplete: true, + permissions: [{ exactSession: true, kind: "reject_once", writeAttempt, outcome: { outcome: "selected" } }], + markerSamples: [{ exists: false }], + }).passed, false); + } +}); +test("retained real denial still passes the exact target oracle without another provider call", async () => { + const { readFile } = await import("node:fs/promises"); + const evidence = JSON.parse(await readFile(new URL("../test/fixtures/copilot-live-denial-2026-09-28.json", import.meta.url), "utf8")); + assert.equal(isExactMarkerWrite(evidence.nativeToolCall, "/fixture/workspace/qualification-marker.txt"), true); +}); +test("detached settlement requires native mode, exact approval and output before terminal", () => { + const evidence = { scenario: "detached-shell", stopReason: "end_turn", cleanupComplete: true, detachedToolObserved: true, backgroundCompletionObservedBeforeTerminal: true, permissions: [{ exactSession: true, kind: "allow_once", exactCommand: true }], markerAtTerminal: { matches: true } }; + assert.equal(evaluateProbe(evidence).passed, true); + for (const field of ["detachedToolObserved", "backgroundCompletionObservedBeforeTerminal", "cleanupComplete"]) assert.equal(evaluateProbe({ ...evidence, [field]: false }).passed, false); + assert.equal(evaluateProbe({ ...evidence, markerAtTerminal: { matches: false } }).passed, false); + assert.equal(evaluateProbe({ ...evidence, failureCode: "request_deadline:session/prompt" }).passed, false); +}); + +test("missing credentials reject before resolving a pack or launching anything", async () => { + const { runProbe } = await import("./qualify-copilot-acp.mjs"); + await assert.rejects(runProbe("/does-not-exist", "/unused", "deny-write", undefined), /explicit Copilot token required/); +}); + +test("full credential-free protocol fixture preserves numeric-zero denial and reaps its process", async () => { + const { mkdtemp, mkdir, writeFile, readFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { runProbe } = await import("./qualify-copilot-acp.mjs"); + const root = await mkdtemp(join(tmpdir(), "copilot-risk-probe-test-")); + const moduleRoot = join(root, "dist/drivers/acpx"); + try { + await mkdir(moduleRoot, { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ type: "module" })); + await writeFile(join(root, "provider-pack.json"), JSON.stringify({ payload: { runnerSourceRevision: "fixture-only" }, digest: "fixture-only" })); + await writeFile(join(moduleRoot, "qualified-profiles.js"), 'export const resolveQualifiedAcpxProfile = () => ({});'); + await writeFile(join(moduleRoot, "copilot-events.js"), 'export const COPILOT_ACP_CLIENT_CAPABILITIES = {};'); + const childScript = join(root, "child.mjs"); + await writeFile(childScript, `import { createInterface } from 'node:readline'; +const send = m => process.stdout.write(JSON.stringify({jsonrpc:'2.0', ...m})+'\\n'); +let promptId; +createInterface({input:process.stdin}).on('line', line => { + const m=JSON.parse(line); + if(!m.method){if(m.id!==0 || m.result?.outcome?.optionId!=='native-deny-0') process.exit(8); send({id:promptId,result:{stopReason:'end_turn'}});return;} + let result={}; + if(m.method==='initialize')result={agentInfo:{version:'1.0.88'},agentCapabilities:{}}; + if(m.method==='session/new')result={sessionId:'fixture-session',models:{availableModels:[{modelId:'gpt-5.6-luna'}]}}; + if(m.method==='session/set_config_option')result={configOptions:[{id:'model',currentValue:'gpt-5.6-luna'}]}; + if(m.method==='session/prompt'){promptId=m.id;send({id:0,method:'session/request_permission',params:{sessionId:'fixture-session',toolCall:{kind:'edit',rawInput:{fileName:process.cwd()+'/qualification-marker.txt'}},options:${JSON.stringify(options)}}});return;} + send({id:m.id,result}); +}).on('close',()=>process.exit(0));`); + await writeFile(join(moduleRoot, "profile-installation.js"), `import { spawn } from 'node:child_process'; export const assertAcpxProfileEnvironment=()=>{}; export const verifyAcpxProfileInstallation=async()=>({commandDigest:'fixture-only',openCommand:async()=>({spawn:(_args,options)=>spawn(options.env.COPILOT_GITHUB_TOKEN === "fixture-spawn-failure" ? "/paperclip-fixture-missing-executable" : ${JSON.stringify(process.execPath)},[${JSON.stringify(childScript)}],options),close:async()=>{}})});`); + const output = join(root, "output.json"); + const result = await runProbe(root, output, "deny-write", "fixture-token-never-used-for-network"); + const evidence = JSON.parse(await readFile(output, "utf8")); + assert.equal(result.passed, true); + assert.equal(evidence.permissions[0].requestId, 0); + assert.deepEqual(evidence.permissions[0].outcome, { outcome: "selected", optionId: "native-deny-0" }); + assert.equal(evidence.cleanupComplete, true); + assert.ok(evidence.markerSamples.every(sample => !sample.exists)); + assert.equal(evidence.providerReportedCostUsd, null); + const failedOutput = join(root, "spawn-failure.json"); + const failed = await runProbe(root, failedOutput, "deny-write", "fixture-spawn-failure"); + const failedEvidence = JSON.parse(await readFile(failedOutput, "utf8")); + assert.equal(failed.passed, false); + assert.equal(failedEvidence.promptRequestsSent, 0); + assert.equal(failedEvidence.cleanupComplete, true); + assert.match(failedEvidence.failureCode, /provider_(spawn|stdin)_failure/); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/packages/paperclip-runner/scripts/runner-package-executable.test.mjs b/packages/paperclip-runner/scripts/runner-package-executable.test.mjs new file mode 100644 index 0000000000..a3605172d7 --- /dev/null +++ b/packages/paperclip-runner/scripts/runner-package-executable.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { access, chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const runnerRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const manifest = JSON.parse(await readFile(join(runnerRoot, "package.json"), "utf8")); +const pnpmScript = process.env.npm_execpath; +const pnpm = pnpmScript?.replaceAll("\\", "/").endsWith("/pnpm.cjs") + ? [process.execPath, pnpmScript] : [process.platform === "win32" ? "pnpm.cmd" : "pnpm"]; + +// The full clean-consumer check packs this Runner package, installs it offline, +// then launches defaultCapabilityRunnerdBinary() without a binary override. +// This small actual pack/install regression isolates pnpm's mode normalization, +// including the absent alternate-platform executable and a negative control. +for (const declared of [true, false]) { + test(`pnpm installed native mode ${declared ? "honors executableFiles" : "rejects an undeclared executable"}`, + { skip: process.platform === "win32" ? "POSIX executable-bit contract" : false }, async t => { + const root = await mkdtemp(join(tmpdir(), "runner-executable-pack-")); + t.after(() => rm(root, { recursive: true, force: true })); + const source = join(root, "source"), output = join(root, "artifacts"), home = join(root, "home"); + const consumer = join(root, "consumer"); + for (const dir of [join(source, "dist/bin"), output, home, consumer]) await mkdir(dir, { recursive: true }); + for (const file of ["user.npmrc", "global.npmrc"]) await writeFile(join(home, file), ""); + const env = { PATH: process.env.PATH, HOME: home, TMPDIR: root, CI: "true", + npm_config_userconfig: join(home, "user.npmrc"), npm_config_globalconfig: join(home, "global.npmrc"), + npm_config_cache: join(home, "cache"), npm_config_ignore_scripts: "true", npm_config_offline: "true", + npm_config_audit: "false", npm_config_fund: "false", npm_config_update_notifier: "false" }; + const run = (command, args, cwd) => execFileSync(command, args, + { cwd, env, encoding: "utf8", timeout: 60_000, maxBuffer: 1024 * 1024 }); + assert.equal(run(pnpm[0], [...pnpm.slice(1), "--version"], source).trim(), "9.15.4"); + assert.deepEqual(manifest.publishConfig.executableFiles, + ["./dist/bin/paperclip-runnerd", "./dist/bin/paperclip-runnerd.exe"]); + await writeFile(join(source, "package.json"), JSON.stringify({ + name: "runner-executable-fixture", version: "1.0.0", files: ["dist"], + ...(declared ? { publishConfig: manifest.publishConfig } : {}), + })); + // Deliberately omit the Windows binary. Packing a platform-specific + // package must tolerate the other executableFiles entry being absent. + const executable = join(source, "dist/bin/paperclip-runnerd"); + await writeFile(executable, "#!/bin/sh\nprintf 'packaged-native-fixture\\n'\n"); + await chmod(executable, 0o755); + run(pnpm[0], [...pnpm.slice(1), "pack", "--pack-destination", output], source); + const archives = (await readdir(output)).filter(name => name.endsWith(".tgz")); + assert.equal(archives.length, 1); + await writeFile(join(consumer, "package.json"), JSON.stringify({ name: "runner-consumer", private: true })); + run("npm", ["install", "--offline", "--ignore-scripts", "--package-lock=false", join(output, archives[0])], consumer); + const installed = join(consumer, "node_modules/runner-executable-fixture/dist/bin/paperclip-runnerd"); + assert.deepEqual(await readFile(installed), await readFile(executable)); + if (declared) { + await access(installed, constants.X_OK); + assert.equal(run(installed, [], consumer).trim(), "packaged-native-fixture"); + } else { + await assert.rejects(access(installed, constants.X_OK), { code: "EACCES" }); + } + }); +} 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/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index 40837dd8b4..a13ef381a9 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -31,6 +31,7 @@ export interface CodexNativeSessionBackendOptions { startedAt: string; }) => Promise; transportFactory?: (context?: { + baseInstructions?: string; providerRecoveryPolicy?: PersistedNativeSession["providerRecoveryPolicy"]; persistedSession?: Pick< PersistedHarnessSession, @@ -143,6 +144,8 @@ function createTransportBackedNativeSessionBackend( ...nativeTaskConstraints(input), ]; + const baseInstructions = nativeSystemInstructions(input); + const transportFactory = options.transportFactory; return new HarnessDriverBackend( new CodexAppServerDriver({ ...(input.provider.model ? { model: input.provider.model } : {}), @@ -155,7 +158,7 @@ function createTransportBackedNativeSessionBackend( input.provider.kind === "codex" ? (input.provider.approvalPolicy ?? "never") : "never", - baseInstructions: nativeSystemInstructions(input), + baseInstructions, instructionWorkingCopyRoot: "runtimeContext" in input ? input.runtimeContext.instructions.workingCopy?.rootPath : undefined, includeSkillInstructions: isCodex && "runtimeContext" in input, skillInputs: isCodex @@ -178,7 +181,9 @@ function createTransportBackedNativeSessionBackend( runnerInstanceId: options.runnerInstanceId ?? `paperclip-native-${input.binding.runId}`, onSpawn: options.onSpawn, - transportFactory: options.transportFactory, + transportFactory: transportFactory + ? (context) => transportFactory({ ...context, baseInstructions }) + : undefined, dynamicTools: options.dynamicTools, dynamicToolHandler: options.dynamicToolHandler, completionFeedback: options.completionFeedback, 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 a2cf6f970c..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 () => { @@ -1010,6 +1013,58 @@ describe("HarnessDriverBackend", () => { expect(start).not.toHaveBeenCalled(); }); + it("preserves a failed provider terminal after Stop instead of waiting for timeout", async () => { + class FailedAfterStop extends FakeHarnessSession { + override async *events() { + yield prpEvent(1, "item.delta", { text: "before stop" }); + yield prpEvent(2, "item.delta", { text: "suppressed after stop" }); + yield prpEvent(3, "runtime_request.cancelled", { requestId: "permission-1", turnId: "turn-1" }); + yield prpEvent(4, "turn.failed", { status: "failed" }); + } + async interrupt() {} + } + const backend = new HarnessDriverBackend({ ...driver, async openSession() { return new FailedAfterStop(); } }); + const session = await backend.openSession({ + identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" }, + workingDirectory: "/workspace", + }); + const iterator = session.events()[Symbol.asyncIterator](); + await iterator.next(); + await session.cancel({ reason: "operator stop", signal: new AbortController().signal }).cleanup; + await expect(iterator.next()).resolves.toMatchObject({ value: { eventType: "runtime_request.cancelled", sourceSeq: 3 } }); + await expect(iterator.next()).resolves.toMatchObject({ value: { eventType: "turn.failed", sourceSeq: 4 } }); + await expect(iterator.next()).resolves.toMatchObject({ done: true }); + }); + + it("settles Stop after provider completion but before its queued terminal is consumed", async () => { + class CompletedBeforeStop extends FakeHarnessSession { + override async *events() { + yield prpEvent(1, "item.delta", { text: "before stop" }); + yield prpEvent(2, "turn.completed", { status: "completed" }); + yield prpEvent(3, "run.terminal", { + schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", + runTerminalState: "succeeded", reportedWorkDisposition: "done", + }); + } + async interrupt() { throw new Error("no active provider turn"); } + } + const backend = new HarnessDriverBackend({ ...driver, async openSession() { return new CompletedBeforeStop(); } }); + const session = await backend.openSession({ + identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" }, + workingDirectory: "/workspace", + }); + const iterator = session.events()[Symbol.asyncIterator](); + await iterator.next(); + await expect(session.cancel({ reason: "operator stop", signal: new AbortController().signal }).cleanup).rejects.toThrow("no active provider turn"); + await expect(iterator.next()).resolves.toMatchObject({ value: { eventType: "turn.cancelled", sourceSeq: 2, + payload: { status: "cancelled", providerTerminalState: "completed", reason: "cancelled_after_provider_completed" } } }); + await expect(iterator.next()).resolves.toMatchObject({ value: { eventType: "run.terminal", sourceSeq: 3, + payload: { turnTerminalState: "cancelled", runTerminalState: "cancelled", reportedWorkDisposition: "yielded" } } }); + await expect(iterator.next()).resolves.toMatchObject({ done: true }); + await expect(session.result()).resolves.toBeNull(); + await expect(session.snapshot()).resolves.toMatchObject({ terminal: { runTerminalState: "cancelled" } }); + }); + it("does not synthesize a fallback after explicit run cancellation", async () => { class CancelledProviderSession extends FakeHarnessSession { override async *events() { 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 2369f1b41c..d7f0c1c9d9 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -1,11 +1,11 @@ -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"; import { parseNativeExecutionInput, type NativeExecutionInput } from "../contracts/native-execution.js"; -import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest } from "../contracts/runtime-context.js"; +import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest, composeNativeSystemInstructions } from "../contracts/runtime-context.js"; const contextRoots: string[] = []; afterEach(() => { for (const root of contextRoots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -316,6 +316,29 @@ describe("native backend factory", () => { await session.close({ reason: "test complete" }); }); + it("supplies freshly composed instructions before the transport can read a recovered thread", async () => { + const prepared = preparedExecution(execution()); + if (!("runtimeContext" in prepared)) throw new Error("fixture requires runtime context"); + const input = { ...acpxExecution("codex"), runtimeContext: prepared.runtimeContext }; + writeFileSync(join(input.runtimeContext.instructions.bundle.rootPath, "AGENTS.md"), "Updated custom entry."); + const transport = new FakeCodexTransport(); + let captured: string | undefined; + const backend = createNativeSessionBackend(input, { + codexTransportFactory: (context) => { + expect(transport.calls).toHaveLength(0); + captured = context?.baseInstructions; + return transport; + }, + environment: { ...process.env, PAPERCLIP_WORKSPACE_CWD: WORKSPACE }, + }); + const session = await backend.openSession({ + identity: { runId: "run", sessionId: "session", companyId: "company", issueId: "issue", agentId: "agent" }, + workingDirectory: WORKSPACE, + }); + expect(captured).toBe(composeNativeSystemInstructions(input.runtimeContext, "Updated custom entry.")); + await session.close({ reason: "test complete" }); + }); + it("does not allow remote workspace authority without runnerd", () => { expect(() => createNativeSessionBackend(execution(), { @@ -496,7 +519,16 @@ describe("native backend factory", () => { }, ); - it.each(["pi", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { + it("constructs the qualified Pi backend without a diagnostic opt-in or provider launch", 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 }); + const backend = createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" }); + await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" }); + }); + + 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"; @@ -507,13 +539,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/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts index bcdfbb7ff1..7692309b23 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts @@ -20,18 +20,6 @@ const packageRoot = resolve( ); describe("semantic action catalog", () => { - it("advertises only icons accepted by the project API on both tool surfaces", async () => { - const { PROJECT_ICON_NAMES } = await import("../../../shared/src/constants.js"); - const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true }); - for (const schema of [createProjectAction.live.descriptor.inputSchema, paperclipSemanticAction("create_project")!.inputSchema]) { - const validate = ajv.compile(schema); - const input = { name: "Onboarding", idempotencyKey: "onboarding" }; - expect(schema).toMatchObject({ properties: { icon: { enum: [...PROJECT_ICON_NAMES, null] } } }); - for (const icon of [...PROJECT_ICON_NAMES, null]) expect(validate({ ...input, icon }), String(icon)).toBe(true); - expect(validate({ ...input, icon: "users" })).toBe(false); - } - }); - it("limits project repository URLs to HTTPS GitHub repository paths on both tool surfaces", () => { const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true }); for (const schema of [createProjectAction.live.descriptor.inputSchema, paperclipSemanticAction("create_project")!.inputSchema]) { 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 75a098ce4a..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,16 +1,71 @@ +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"; +import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js"; +import { stripTypeScriptTypes } from "node:module"; +import { cursorPlanToolIdentity, cursorToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js"; +import { createHash } from "node:crypto"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { readFileSync } from "node:fs"; 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"; +import { createCursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js"; +import { validateAcpxRichEvent } from "../drivers/acpx/profile-extensions.js"; import { awaitSidecarCleanupWithin, + boundedIdentity, closeActiveSidecarHostWithin, closeSidecarHostForCommand, combineSidecarAdmissionCleanups, @@ -34,29 +89,480 @@ afterEach(async () => { }); describe("qualified ACPX runtime sidecar", () => { + it("projects the actual sidecar terminal diagnostic block without authoritative accounting", () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(" try {", source.indexOf(" const usageAfter = await readSidecarHostStatusWithin(activeHost);")); + const end = source.indexOf(" const usage = persistedAcpxTurnUsage(", start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const emitted: unknown[] = []; + 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, 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: { + schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial", + reasons: ["native_counter_semantics_unverified"], observations: [], limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false, + }, + } }; + project(before, after, "cursor"); + expect(emitted).toEqual([["runtime.rich_event", expect.objectContaining({ eventType: "provider.notice.recorded", payload: expect.objectContaining({ category: "cursor_native_usage_observed" }) }), "turn"]]); + emitted.length = 0; + for (const agent of ["copilot", "pi", "codex"]) project(before, after, agent); + project(after, after, "cursor"); + project(before, { ...after, lastRequestId: "stale" }, "cursor"); + expect(emitted).toEqual([]); + }); + + it.each(["projection", "validation", "emission"])("preserves standard usage when optional Cursor notice %s fails", async failure => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(" try {\n const usageAfter = await readSidecarHostStatusWithin(activeHost);"); + const end = source.indexOf(" terminal = boundedSidecarValue(result);", start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const emitted: unknown[] = [], diagnostics: unknown[] = []; + 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"}, activity={usageNotice:persistedCursorUsageNotice}; + const usageBefore={requestTokenUsage:{}}, sanitizeRuntimeEvent=value=>value, safeMessage=()=>"fixture error"; + ${stripTypeScriptTypes(source.slice(start, end))} + })(); + `); + await project(async () => after, + () => { if (failure === "projection") throw new Error("optional projection failed"); return { eventType: "provider.notice.recorded" }; }, + persistedAcpxTurnUsage, acpxUsageEstimateNotice, + () => { if (failure === "validation") throw new Error("optional validation failed"); }, + (type: string, payload: unknown, turn: string) => { + if (type === "runtime.rich_event" && failure === "emission") throw new Error("optional emission failed"); + emitted.push([type, payload, turn]); + }, + (...args: unknown[]) => diagnostics.push(args), + ); + expect(emitted).toEqual([["runtime.event", expect.objectContaining({ tag: "usage_update", breakdown: expect.objectContaining({ inputTokens: 12, outputTokens: 3 }) }), "turn"]]); + expect(diagnostics).toEqual([]); + }); + + 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", "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); + } + for (const raw of ["native\u0000tool", "native\u007ftool", "x".repeat(241)]) { + const expected = `acpx-${kind}-${createHash("sha256").update("paperclip.acpx.provider-identity.v1\0").update(kind).update("\0").update(raw).digest("hex")}`; + expect(stable(raw, kind)).toBe(expected); + } + } + }); + 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", "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;`)( + (value: unknown, fallback: string, max: number) => typeof value === "string" ? value.slice(0, max) : fallback, + stableIdentity, () => [], null, () => ({ output: null, outputBytes: 0, outputTruncated: false, outputDigest: null }), + ); + const start = source.indexOf(" const { signal } = context;", source.indexOf("async function waitForPermission")); + const end = source.indexOf("\nasync function waitForInput", start); + const observedIds: string[] = []; + for (const rawId of ["native\u0000tool", "native\u007ftool", "native\u0085tool", "tool/1", "x".repeat(161), "safe-tool-1"]) { + const permissions = new Map(); const emitted: any[] = []; const notices: any[] = []; + const evidence = createCursorToolEvidence({ sessionId: "session", turnId: "turn-1", workingDirectory: "/workspace", active: () => true, + emit: event => { validateAcpxRichEvent(event); notices.push(event); }, + }); + const wait = new Function("permissions", "normalizeAcpxPermission", "emit", ` + let turnId="turn-1", requestSequence=0; const MAX_PENDING_INPUTS=512, openParams={agent:"cursor"}; + const stableRequestId=()=>"request-1", requireAcpxResponseDelivery=c=>c.responseDelivery; + return async function(activeTurnId, agent, request, context, toolEvidence) { ${source.slice(start, end)} + `)(permissions, normalizeAcpxPermission, (_event: string, payload: unknown) => emitted.push(payload)); + const origin = { type: "tool_call", tag: "tool_call", toolCallId: rawId, kind: "execute", status: "pending", rawInput: { command: "printf private-command" } }; + // The sidecar emits this bounded runtime event; Rust applies the opaque + // execution-ID transform later. The direct TS driver normalizer is not + // this boundary and has a different fallback policy. + const activity = bound(origin); + const native = { sessionId: "session", inferredKind: "execute", raw: { sessionId: "session", toolCall: { toolCallId: rawId, kind: "execute" }, + options: [{ kind: "reject_once", optionId: "native-original-denial", name: "Deny" }], + } }; + const before = structuredClone(native); + const abort = new AbortController(); + if (order === "tool-first") evidence.tool(origin); + const pending = wait("turn-1", "cursor", native, { signal: abort.signal, responseDelivery: Promise.resolve() }, evidence); + if (order === "permission-first") { expect(notices).toEqual([]); evidence.tool(origin); } + const projectedId = emitted[0].toolCallId; + observedIds.push(projectedId); + expect(projectedId).toBe(activity.toolCallId); + expect(notices.map(event => Object.fromEntries(event.payload.details.map((field: any) => [field.name, field.value])))).toEqual([ + expect.objectContaining({ stage: "tool", toolCallId: projectedId }), + expect.objectContaining({ stage: "permission_requested", toolCallId: projectedId, requestId: "request-1" }), + ]); + const held = permissions.get("request-1")!; + const decision = held.normalized.resolve({ action: "decline" }); + held.cleanup(); permissions.delete("request-1"); held.settle(decision); + await expect(pending).resolves.toEqual({ outcome: "reject_once" }); + expect(native).toEqual(before); + expect(native.raw.toolCall.toolCallId).toBe(rawId); + expect(JSON.stringify([activity, emitted, notices])).not.toContain("private-command"); + } + expect(new Set(observedIds).size).toBe(6); + expect(observedIds.at(-1)).toBe("safe-tool-1"); + }); + it("emits the native plan tool identity from the actual sidecar input boundary", async () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf("async function waitForExtensionInput("); + const end = source.indexOf("\nfunction elicitationResponse(", start); + const code = stripTypeScriptTypes(source.slice(start, end)); + const emitted: any[] = [], inputs = new Map(); + 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; + `)(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"]]); + abort.abort(); await expect(pending).resolves.toEqual({ cancelled: true }); + expect(inputs.size).toBe(0); + }); + 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 activity = acpxProfileActivity(activeAgent);"); + const end = source.indexOf(" let usageBefore:", start); + expect(start).toBeGreaterThan(0); + const emitted: unknown[] = []; + const create = new Function("acpxProfileActivity", "validateAcpxRichEvent", "emit", "agent", ` + const activeHost = { identity: () => ({ backendSessionId: "session" }) }; + let host = activeHost, turnId = "turn", activeCopilotEvidence; + const currentTurnId = "turn", activeAgent = agent, openParams = { agent, workingDirectory: "/workspace" }; + const diagnostic = () => {}; + ${stripTypeScriptTypes(source.slice(start, end))} + return { evidence: toolEvidence, retire: () => { turnId = null; } }; + `)(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); + if (agent !== "pi") expect(emitted[0]).toEqual(["runtime.rich_event", expect.objectContaining({ payload: expect.objectContaining({ + category: `${agent}_tool_evidence_v1`, provenance: expect.objectContaining({ sessionId: "session", turnId: "turn" }), + }) }), "turn"]); + expect(JSON.stringify(emitted)).not.toContain("private-value"); + create.retire(); + create.evidence?.tool({ ...tool, tag: "tool_call_update", status: "failed" }); + expect(emitted).toHaveLength(agent === "pi" ? 0 : 1); + }); + + it("captures the actual sidecar bridge's Copilot receipt callback before turn replacement", () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(" semanticTools: {"); + const end = source.indexOf(" onGoalUpdate:", start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const events: unknown[] = []; + let active = true; + const evidence = createCopilotToolEvidence({ sessionId: "session-a", turnId: "turn-a", workingDirectory: "/workspace", active: () => active, emit: event => { validateAcpxRichEvent(event); events.push(event); } }); + const create = new Function("params", "activeCopilotEvidence", "waitForTool", `return ({ ${source.slice(start, end)} }).semanticTools;`); + const options = create({ agent: "copilot", tools: [] }, evidence, () => undefined); + const captured = options.captureSemanticReceipt(); + const receipt = appendSemanticToolReceipt({ tool: "get_task_context", callId: "1", arguments: {} }, { content: [{ type: "text", text: "{}" }] }).receipt; + captured(receipt); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ payload: { category: "paperclip_semantic_tool_receipt_v2", provenance: { sessionId: "session-a", turnId: "turn-a" } } }); + active = false; captured(receipt); expect(events).toHaveLength(1); + for (const agent of ["cursor", "codex", "pi"]) expect(create({ agent, tools: [] }, evidence, () => undefined).captureSemanticReceipt).toBeUndefined(); + }); + + it.each(["forwarded", "emit-failed"])("binds native v2 receipts to actual sidecar-normalized input: %s", async mode => { + const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + const validated = validatePrpStructuredRunResult(raw); + if (!validated.ok) throw new Error("Invalid fixture"); + const tools = new Map(), emitted: any[] = [], notices: any[] = []; + const projector = createCopilotToolEvidence({ sessionId: "session", turnId: "test-turn", workingDirectory: "/workspace", + active: () => true, emit: event => { validateAcpxRichEvent(event); notices.push(event); } }); + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, + ...(mode === "emit-failed" ? { emit: () => { throw new Error("fixture emission failed"); } } : {}) }); + const bridge = await startRunnerToolBridge({ handler, captureSemanticReceipt: () => projector.captureSemanticReceipt() }); + try { + projector.tool({ type: "tool_call", tag: "tool_call", toolCallId: "native-call", kind: "other", status: "pending", rawInput: raw }); + const response = fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }) }); + if (mode === "forwarded") { + await vi.waitFor(() => expect(tools.has("3")).toBe(true)); + expect(emitted).toEqual([{ callId: "3", operationId: "paperclip_finish", input: validated.result }]); + // The receipt hashes the actual emitted input, not a second normalization. + await loadActualToolCommands(tools).resolve({ callId: "3", turnId: "test-turn", result: { accepted: true }, error: null }); + } + const body = await (await response).json(); + const receipt = readNativeSemanticReceipt({ contents: body.result.content }); + expect(receipt).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", inputSha256: semanticInputSha256(raw), + normalizedInputSha256: mode === "forwarded" ? semanticInputSha256(emitted[0].input) : null, + outcome: mode === "forwarded" ? "returned" : "error" }); + expect(semanticInputSha256(raw)).not.toBe(semanticInputSha256(validated.result)); + projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "native-call", status: mode === "forwarded" ? "completed" : "failed", + rawOutput: { contents: body.result.content } }); + const details = Object.fromEntries(notices.at(-1).payload.details.map((d: any) => [d.name, d.value])); + expect(details).toMatchObject({ semanticInputSha256: semanticInputSha256(raw), + semanticNormalizedInputSha256: mode === "forwarded" ? semanticInputSha256(emitted[0].input) : "null" }); + expect(notices.find(event => event.payload.category === "paperclip_semantic_tool_receipt_v2").payload.details).toHaveLength(8); + } finally { + for (const pending of tools.values()) pending.cleanup(); + await bridge.close(); + } + }); + + it("does not capture normalized authority when actual sidecar validation fails", async () => { + const emitted: unknown[] = [], capture = vi.fn(() => vi.fn()); + const wait = loadWaitForTool({ tools: new Map(), emitted, validate: validatePrpStructuredRunResult }); + await expect(wait({ tool: "paperclip_finish", callId: "bad", arguments: {}, signal: new AbortController().signal, + captureNormalizedInput: capture })).rejects.toThrow("ACPX semantic result failed PRP schema validation"); + expect(capture).not.toHaveBeenCalled(); + expect(emitted).toEqual([]); + }); + + it("rejects normalized/envelope expansion at the actual frame writer before committing a receipt", async () => { + const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [{ ref: "" }], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + const request = { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }; + raw.evidence[0]!.ref = "x".repeat(ACPX_SIDECAR_MAX_FRAME_BYTES - 1 - Buffer.byteLength(JSON.stringify(request))); + expect(Buffer.byteLength(JSON.stringify(request))).toBe(ACPX_SIDECAR_MAX_FRAME_BYTES - 1); + const validation = validatePrpStructuredRunResult(raw); + if (!validation.ok) throw new Error("Invalid fixture"); + expect(Buffer.byteLength(JSON.stringify(validation.result))).toBeLessThanOrEqual(ACPX_SIDECAR_MAX_FRAME_BYTES); + const wire = loadActualSidecarWriter(); + const tools = new Map(), emitted: unknown[] = []; + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit }); + const bridge = await startRunnerToolBridge({ handler, captureSemanticReceipt: () => () => {} }); + try { + const body = await (await fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify(request) })).json(); + expect(body.result.isError).toBe(true); + expect(body.result.content[0].text).toBe("ACPX semantic tool call exceeds the sidecar frame limit"); + expect(readNativeSemanticReceipt({ contents: body.result.content })).toMatchObject({ outcome: "error", normalizedInputSha256: null }); + expect(tools.size).toBe(0); + expect(emitted).toEqual([]); + expect(wire.writes).toEqual([]); + expect(wire.errors).toEqual(["[paperclip-acpx-sidecar] output_frame_too_large\n"]); + expect(wire.sequence()).toBe(0); + expect(wire.emit("runtime.diagnostic", { code: "bounded", message: "rejected" })).toBe(true); + expect(JSON.parse(wire.writes[0]!)).toMatchObject({ sequence: 1, eventType: "runtime.diagnostic" }); + } finally { await bridge.close(); } + }); + + it.each(["backpressure", "throws"])("uses actual writer acceptance, preserving %s semantics", async mode => { + const wire = loadActualSidecarWriter(mode); + const tools = new Map(), emitted: any[] = [], commit = vi.fn(), capture = vi.fn((_input: unknown) => commit); + const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit }); + const pending = handler({ tool: "paperclip_finish", callId: "3", arguments: raw, signal: new AbortController().signal, captureNormalizedInput: capture }); + if (mode === "throws") { + await expect(pending).rejects.toThrow("fixture write failed"); + expect(commit).not.toHaveBeenCalled(); expect(tools.size).toBe(0); expect(wire.sequence()).toBe(0); + } else { + expect(commit).not.toHaveBeenCalled(); expect(tools.has("3")).toBe(true); expect(wire.sequence()).toBe(1); + expect(JSON.parse(wire.writes[0]!).payload.input).toEqual(capture.mock.calls[0]![0]); + await loadActualToolCommands(tools).resolve({ callId: "3", turnId: "test-turn", result: { accepted: true }, error: null }); + await expect(pending).resolves.toEqual({ accepted: true }); + expect(commit).toHaveBeenCalledOnce(); + } + }); + + + it.each(["success", "accepted-false", "error", "cancel", "timeout", "receiver-rejected"])( + "commits normalized delivery only after the actual tool.resolve handler succeeds: %s", + async mode => { + const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [{ ref: mode === "receiver-rejected" ? "x".repeat(300 * 1024) : "fixture" }], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + const validation = validatePrpStructuredRunResult(raw); + if (!validation.ok) throw new Error("Invalid fixture"); + const wire = loadActualSidecarWriter(), tools = new Map(), emitted: unknown[] = [], receipts: unknown[] = []; + const commands = loadActualToolCommands(tools); + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit }); + const bridge = await startRunnerToolBridge({ handler, timeoutMs: 500, captureSemanticReceipt: () => receipt => receipts.push(receipt) }); + const headers = { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }; + const params = { callId: "3", turnId: "test-turn", result: { accepted: mode !== "accepted-false" }, error: null }; + try { + const response = fetch(bridge.url, { method: "POST", headers, + body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }) }); + await vi.waitFor(() => expect(tools.has("3")).toBe(true), { interval: 5, timeout: 400 }); + expect(wire.writes).toHaveLength(1); + expect(JSON.parse(wire.writes[0]!).payload.input).toEqual(validation.result); + expect(receipts).toEqual([]); + // These all enter the actual parsed command handler. None may consume + // the pending call or attest delivery for a different request/turn. + await expect(commands.resolve({ ...params, callId: "missing" })).rejects.toThrow("stale or unknown"); + await expect(commands.resolve({ ...params, turnId: "foreign" })).rejects.toThrow("stale or unknown"); + commands.setTurn("foreign"); + await expect(commands.resolve(params)).rejects.toThrow("stale or unknown"); + commands.setTurn("test-turn"); + expect(tools.has("3")).toBe(true); + if (mode === "success" || mode === "accepted-false") { + await expect(commands.resolve(params)).resolves.toEqual({ resolved: true }); + } else if (mode === "error") { + await expect(commands.resolve({ ...params, result: undefined, error: { message: "Receiver rejected completion" } })).resolves.toEqual({ resolved: true }); + } else if (mode === "cancel") { + const cancelled = await fetch(bridge.url, { method: "POST", headers, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 3 } }) }); + expect(cancelled.status).toBe(202); + } else if (mode === "receiver-rejected") { + // Actual writer acceptance is not receiver admission. This frame is + // over Rust's payload bound; no correlated resolution arrives. + expect(Buffer.byteLength(JSON.stringify(JSON.parse(wire.writes[0]!).payload))).toBeGreaterThan(256 * 1024); + expect(Buffer.byteLength(wire.writes[0]!)).toBeLessThan(ACPX_SIDECAR_MAX_FRAME_BYTES); + } + const body = await (await response).json(); + const delivered = mode === "success" || mode === "accepted-false"; + const receipt = readNativeSemanticReceipt({ contents: body.result.content }); + expect(receipt).toMatchObject({ normalizedInputSha256: delivered ? semanticInputSha256(validation.result) : null, + inputSha256: semanticInputSha256(raw), outcome: delivered ? "returned" : "error" }); + expect(receipts).toEqual([receipt]); + expect(tools.size).toBe(0); + await expect(commands.resolve(params)).rejects.toThrow("stale or unknown"); + expect(receipts).toEqual([receipt]); + } finally { await bridge.close(); } + }, + ); + + it.each(["value", "property", "collision"])("rejects semantic %s Unicode rewriting before capture or write", async kind => { + const raw = { reportedWorkDisposition: "done", summary: kind === "value" ? "\ud800" : "Complete", evidence: [{ ref: "fixture" } as Record], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + if (kind !== "value") raw.evidence[0]!["\udc00"] = "one"; + if (kind === "collision") raw.evidence[0]!["\ufffd"] = "two"; + expect(validatePrpStructuredRunResult(raw).ok).toBe(true); + const wire = loadActualSidecarWriter(), tools = new Map(), emitted: unknown[] = [], capture = vi.fn(() => vi.fn()); + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit }); + await expect(handler({ tool: "paperclip_finish", callId: "3", arguments: raw, signal: new AbortController().signal, + captureNormalizedInput: capture })).rejects.toThrow("cannot be encoded without changing its identity"); + expect(capture).not.toHaveBeenCalled(); expect(tools.size).toBe(0); expect(emitted).toEqual([]); + expect(wire.writes).toEqual([]); expect(wire.sequence()).toBe(0); + }); + + it("passes only validated Pi native boundaries and history through the real text sanitizer", () => { + const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8"); + const start = source.indexOf(' if (event.type === "text_delta") {', source.indexOf("function sanitizeRuntimeEvent")); + const end = source.indexOf(' if (event.type === "status") {', start); + expect(start).toBeGreaterThan(0); + const sanitize = new Function("boundedOptionalText", "stableProviderIdentity", `return event => {${source.slice(start, end)} return null;}`)( + (value: string) => value, (value: string) => `stable-${value}`, + ); + const projection = createPiMessageProjection<{ type: string; text: string; stream: string; messageId: string; meta: Record }>(); + const event = (kind: string) => ({ type: "text_delta", text: "", stream: "output", messageId: `pi-message-${"a".repeat(64)}`, + meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind, secret: "DROP_ME" } }); + const startFrame = sanitize(projection.normalize(event("start"))); + const endFrame = sanitize(projection.normalize(event("end:toolUse"))); + expect(startFrame).toMatchObject({ text: "", piMessageBoundary: { phase: "start" } }); + expect(endFrame).toMatchObject({ text: "", piMessageBoundary: { phase: "end", stopReason: "toolUse" } }); + expect(startFrame.messageId).toBe(endFrame.messageId); + expect(JSON.stringify([startFrame, endFrame])).not.toContain("DROP_ME"); + projection.settle(); + const loaded = createPiMessageProjection>(); + const history = sanitize(loaded.normalize({ ...event("history"), messageId: `pi-history-message-${"b".repeat(64)}`, + text: "old reply", meta: { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" } })); + expect(history).toMatchObject({ text: "old reply", piMessageHistory: true }); loaded.settle(); + }); it.each(["pi", "copilot", "cursor", "codex", "claude"])("offers verified session permission grants only for %s", async agent => { const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8"); const start = source.indexOf(" const { signal } = context;", source.indexOf("async function waitForPermission")); const end = source.indexOf("\nasync function waitForInput", start); expect(start).toBeGreaterThan(0); const permissions = new Map(); - const emitted: Array<{ choices: Array<{ key: string }> }> = []; + const emitted: Array<{ choices: Array<{ key: string }>; origin: unknown }> = []; 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", { sessionId: "session", inferredKind: "edit", raw: { + const pending = wait("turn-1", agent, { sessionId: "session", inferredKind: "edit", raw: { sessionId: "session", toolCall: { toolCallId: "call", title: "Edit file" }, options: ["allow_once", "allow_always", "reject_once"].map(kind => ({ kind, optionId: kind, name: kind })), } }, { signal: abort.signal, responseDelivery: Promise.resolve() }); + expect(emitted[0]!.origin).toEqual({ adapter: "acpx-runtime-sidecar", provider: agent, method: "session/request_permission" }); expect(emitted[0]!.choices.some(choice => choice.key === "accept_for_session")).toBe(agent === "pi" || agent === "copilot"); abort.abort(); await expect(pending).resolves.toEqual({ outcome: "cancel" }); expect(permissions.size).toBe(0); }); + it.each(["safe", "missing", "conflicting", "outside"])("puts Copilot target context in the first permission frame (%s)", async scenario => { + const safe = scenario === "safe"; + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(" const { signal } = context;", source.indexOf("async function waitForPermission")); + const end = source.indexOf("\nasync function waitForInput", start); + const permissions = new Map }>(); + const emitted: Array<{ title: string; choices: Array<{ key: string }> }> = []; + 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, agent, request, context, toolEvidence) { ${source.slice(start, end)}`)( + permissions, { agent: "copilot", workingDirectory: "/workspace" }, normalizeAcpxPermission, + (_event: string, payload: typeof emitted[number]) => emitted.push(payload), + ); + const abort = new AbortController(); + const pending = wait("turn-1", "copilot", { inferredKind: "edit", raw: { + sessionId: "native-session", toolCall: { toolCallId: "call", title: "Create file", kind: "edit", + rawInput: { ...(scenario === "missing" ? {} : { path: scenario === "outside" ? "../outside.txt" : "/workspace/new.txt" }), + ...(scenario === "conflicting" ? { fileName: "other.txt" } : {}), content: "PRIVATE_CONTENT" } }, + options: ["allow_once", "allow_always", "reject_once"].map(kind => ({ kind, optionId: kind, name: kind })), + } }, { signal: abort.signal, responseDelivery: Promise.resolve() }); + expect(emitted).toHaveLength(1); + expect(emitted[0]!.title).toBe(safe ? "Change file: new.txt" : "File change requested; target unavailable. Deny or cancel this request."); + expect(emitted[0]!.choices.map(x => x.key)).toEqual(safe + ? ["accept", "accept_for_session", "decline", "cancel"] : ["decline", "cancel"]); + expect(JSON.stringify(emitted)).not.toContain("PRIVATE_CONTENT"); + if (!safe) for (const action of ["accept", "accept_for_session"] as const) { + expect(() => permissions.get("request-1")!.normalized.resolve({ action })).toThrow("offered choice"); + } + abort.abort(); await expect(pending).resolves.toEqual({ outcome: "cancel" }); + expect(permissions.size).toBe(0); + }); + it("returns the sidecar retirement flag only after cancellation settlement and rejects stale turns", async () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(' const expected = boundedIdentity', source.indexOf('if (request.command === "turn.cancel")')); + const end = source.indexOf('\n if (request.command === "permission.resolve")', start); + let settle!: () => void; + const cleanup = new Promise(resolve => { settle = resolve; }); + let closed = false; + const host = { interruptActiveTurn: vi.fn(async () => { await cleanup; closed = true; }), isClosed: () => closed }; + const cancel = new Function("requireHost", ` + const turnId = "active-turn"; + const boundedIdentity = x => x, boundedOptionalText = (x, fallback) => x ?? fallback; + return async function(request) { ${source.slice(start, end)} + `)(() => host); + await expect(cancel({ params: { turnId: "stale" } })).rejects.toThrow("stale"); + expect(host.interruptActiveTurn).not.toHaveBeenCalled(); + const pending = cancel({ params: { turnId: "active-turn", reason: "Stop" } }); + let acknowledged = false; void pending.then(() => { acknowledged = true; }); + await Promise.resolve(); expect(acknowledged).toBe(false); + settle(); await expect(pending).resolves.toEqual({ cancelled: true, sessionClosed: true }); + host.interruptActiveTurn.mockRejectedValueOnce(new Error("provider cleanup incomplete")); + await expect(cancel({ params: { turnId: "active-turn" } })).rejects.toThrow("cleanup incomplete"); + }); + + it("emits permission delivery evidence only after the actual response write settles", async () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(' const requestId = boundedIdentity', source.indexOf('if (request.command === "permission.resolve")')); + const end = source.indexOf('\n if (request.command === "input.resolve")', start); + const execute = new Function("permissions", "deliverAcpxResponse", "parseHarnessRuntimeRequestResolution", ` + const turnId = "turn"; const boundedIdentity = x => x; + return async function(request) { ${source.slice(start, end)} + `); + for (const reject of [false, true]) { + let resolve!: () => void, fail!: (error: Error) => void; + const delivery = new Promise((r, j) => { resolve = r; fail = j; }); + const deliveredEvidence = vi.fn(), settle = vi.fn(); + const permissions = new Map([["request", { turnId: "turn", normalized: { resolve: () => ({ outcome: "reject_once" }) }, cleanup() {}, settle, responseDelivery: delivery, deliveredEvidence }]]); + const run = execute(permissions, deliverAcpxResponse, () => ({ action: "decline" }))({ params: { requestId: "request", turnId: "turn" } }); + expect(settle).toHaveBeenCalledWith({ outcome: "reject_once" }); + expect(deliveredEvidence).not.toHaveBeenCalled(); + if (reject) { fail(new Error("write failed")); await expect(run).rejects.toThrow("write failed"); expect(deliveredEvidence).not.toHaveBeenCalled(); } + else { resolve(); await expect(run).resolves.toEqual({ resolved: true }); expect(deliveredEvidence).toHaveBeenCalledExactlyOnceWith("reject_once"); } + } + }); it("preserves ACP input presence through the bounded sidecar handoff", () => { const source = readFileSync( fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), @@ -538,7 +1044,10 @@ describe("qualified ACPX runtime sidecar", () => { }); }); - it.each([["claude", "claude-sonnet-5"]])( + it.each([ + ["claude", "claude-sonnet-5"], + ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"], + ])( "reports the qualified %s profile", async (agent, model) => { const sidecar = startSidecar(); @@ -554,8 +1063,47 @@ 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([ - ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"], ["cursor", "explicit-cursor-model"], ["copilot", "explicit-copilot-model"], ] as const)("initializes the declared %s profile with its current admission status", async (agent, model) => { @@ -638,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"); @@ -716,7 +1264,9 @@ class SidecarProcess { function loadWaitForTool(input: { tools: Map; emitted: unknown[]; -}): (call: { callId: string; tool: string; arguments: Record; signal: AbortSignal }) => Promise { + validate?: typeof validatePrpStructuredRunResult; + emit?: (eventType: string, payload: unknown) => boolean; +}): (call: RunnerToolCall) => Promise { const source = readFileSync( fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8", @@ -739,16 +1289,57 @@ function loadWaitForTool(input: { (value: string) => value, input.tools, "test-turn", - (_eventType: string, payload: unknown) => input.emitted.push(payload), + (eventType: string, payload: unknown) => { const accepted = input.emit?.(eventType, payload) ?? true; if (accepted) input.emitted.push(payload); return accepted; }, "paperclip_finish", "paperclip_block", - (argumentsValue: unknown) => ({ + input.validate ?? ((argumentsValue: unknown) => ({ ok: true, result: argumentsValue, - }), + })), (value: unknown) => value, (value: unknown) => value, 512, stringifyAcpxSidecarFrame, ); } + +/** Actual producer and encoder, with only the destination stream replaced. */ +function loadActualSidecarWriter(mode: "normal" | "backpressure" | "throws" = "normal"): { + emit: (eventType: string, payload: unknown) => boolean; sequence: () => number; writes: string[]; errors: string[]; +} { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const emitStart = source.indexOf("function emit("), emitEnd = source.indexOf("\nfunction diagnostic", emitStart); + const writeStart = source.indexOf("function writeFrame("), writeEnd = source.indexOf("\nfunction requireHost", writeStart); + if (emitStart < 0 || emitEnd < 0 || writeStart < 0 || writeEnd < 0) throw new Error("Actual sidecar writer source not found"); + const writes: string[] = [], errors: string[] = []; + const process = { stdout: { write: (line: string) => { if (mode === "throws") throw new Error("fixture write failed"); writes.push(line); return mode !== "backpressure"; } }, + stderr: { write: (line: string) => { errors.push(line); return true; } } }; + const writer = new Function("process", "stringifyAcpxSidecarFrame", "ACPX_SIDECAR_PROTOCOL_VERSION", "ACPX_SIDECAR_MAX_FRAME_BYTES", ` + let sequence=0; const runId="test-run", turnId="test-turn"; + ${stripTypeScriptTypes(source.slice(emitStart, emitEnd))} + ${stripTypeScriptTypes(source.slice(writeStart, writeEnd))} + return { emit, sequence: () => sequence }; + `)(process, stringifyAcpxSidecarFrame, ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES); + return { ...writer, writes, errors }; +} + +/** Actual parsed command dispatcher; only the owned pending map/turn are supplied. */ +function loadActualToolCommands(tools: Map): { + resolve: (params: Record) => Promise>; + setTurn: (turnId: string | null) => void; +} { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf("async function dispatch("), end = source.indexOf("\nasync function pumpTurn(", start); + const safeStart = source.indexOf("function safeText("), safeEnd = source.indexOf("\nfunction safeMessage(", safeStart); + if (start < 0 || end < 0 || safeStart < 0 || safeEnd < 0) throw new Error("Actual sidecar command dispatcher not found"); + const dispatcher = new Function("tools", "boundedIdentity", "record", "text", "parseAcpxSidecarRequest", "ACPX_SIDECAR_PROTOCOL_VERSION", ` + let turnId="test-turn", requestId=0; + ${stripTypeScriptTypes(source.slice(safeStart, safeEnd))} + ${stripTypeScriptTypes(source.slice(start, end))} + return { + resolve: params => dispatch(parseAcpxSidecarRequest({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: ++requestId, command: "tool.resolve", params })), + setTurn: value => { turnId=value; } + }; + `)(tools, boundedIdentity, record, text, parseAcpxSidecarRequest, ACPX_SIDECAR_PROTOCOL_VERSION); + return dispatcher; +} diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 4c43ca4fe1..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"; @@ -18,6 +20,7 @@ import type { import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "../drivers/acpx/profile-extensions.js"; import type { PaperclipQuestionSet } from "../contracts/question-set.js"; import { createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js"; +import { createPiMessageProjection, type PiProjectedMessageEvent } from "../drivers/acpx/pi-message-projection.js"; import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; import { PRP_BLOCK_TOOL_NAME, @@ -63,8 +66,11 @@ import { type AcpxSidecarResponse, } from "../drivers/acpx/sidecar-protocol.js"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; +import { createCopilotToolEvidence, type CopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js"; +import { createCursorToolEvidence, type CursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js"; import { persistedAcpxTurnUsage, + persistedCursorUsageNotice, acpxUsageEstimateNotice, qualifiedAcpxUsageBreakdown, } from "../drivers/acpx/usage-accounting.js"; @@ -149,6 +155,7 @@ let failedAdmissionCleanup: Promise | null = null; let openParams: AcpxSidecarOpenParams | null = null; let runId: string | null = null; let turnId: string | null = null; +let activeCopilotEvidence: CopilotToolEvidence | undefined; let sequence = 0; let requestSequence = 0; let closing = false; @@ -289,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, @@ -297,6 +305,7 @@ async function dispatch( semanticTools: { tools: params.tools, handler: waitForTool, + ...(params.agent === "copilot" ? { captureSemanticReceipt: () => activeCopilotEvidence?.captureSemanticReceipt() } : {}), }, onGoalUpdate: (goal) => { // Admission can emit a snapshot before the verified host is assigned. @@ -379,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); @@ -510,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") { @@ -616,7 +629,8 @@ async function pumpTurn( // display metadata for later progress/completion frames before they cross // the sidecar boundary, matching the in-process ACPX driver path. const normalizeToolEvent = createAcpxToolEventNormalizer(); - const normalizeMessage = initializedAgent === "grok" + const piMessages = initializedAgent === "pi" ? createPiMessageProjection() : null; + const normalizeMessage = piMessages ? piMessages.normalize : initializedAgent === "grok" ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of runtimeTurn.events) { toolEvidence?.tool(event); @@ -629,6 +643,7 @@ async function pumpTurn( ); } const result = await runtimeTurn.result; + if (result.status === "completed") piMessages?.settle(); await drainExtensions(); try { const usageAfter = await readSidecarHostStatusWithin(activeHost); @@ -720,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", { @@ -744,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, @@ -970,7 +987,7 @@ function boundRuntimeEventForNormalization( } as BoundedRuntimeToolEvent; } -function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record { +function sanitizeRuntimeEvent(event: PiProjectedMessageEvent): Record { const runtimeType = text(record(event).type); if (runtimeType === "plan") { return { @@ -984,6 +1001,8 @@ function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record { text: boundedOptionalText(event.text, "", 64 * 1024), stream: event.stream, tag: event.tag ?? null, + ...(event.piMessageBoundary ? { piMessageBoundary: event.piMessageBoundary } : {}), + ...(event.piMessageHistory ? { piMessageHistory: true } : {}), messageId: typeof event.messageId === "string" && event.messageId.length > 0 ? stableProviderIdentity(event.messageId, "message") @@ -1190,10 +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.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 && @@ -1214,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( @@ -1284,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, ), @@ -1308,6 +1331,11 @@ function requiredFenceCandidates( return Object.freeze([...value]) as readonly [number, number, number]; } +function requiredCursorMode(value: unknown): "agent" | "plan" | "ask" { + if (value === "agent" || value === "plan" || value === "ask") return value; + throw new Error("mode must be agent, plan, or ask"); +} + function requiredPermissionMode( value: unknown, ): AcpxSidecarOpenParams["permissionMode"] { diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts index 10b29d815f..12c80c4922 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts @@ -120,6 +120,12 @@ describe("ACPX sidecar input sequencing", () => { expect(acpxBootstrapBlockedError(null, "turn.start")).toBeNull(); }); + it.each(["COPILOT_POLICY_VIOLATION", "COPILOT_DETACHED_WORK_UNSUPPORTED"])("preserves Copilot policy identity %s", code => { + const error = Object.assign(new Error("safe fixed policy message"), { code }); + expect(acpxSidecarErrorCode(error)).toBe(code); + expect(acpxSidecarErrorCode(new Error("wrapped", { cause: error }))).toBe(code); + }); + it("preserves stable ACPX error identities without copying startup stderr", () => { const missingModule = Object.assign(new Error("provider exited"), { detailCode: "AGENT_STARTUP_FAILED", 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..1fd67456a2 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import { createCapabilityFixtureState } from "../mock-core/capability-control-plane-types.js"; import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; +import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js"; import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; import type { CapabilityLiveSessionSnapshot } from "../live/live-session.js"; import { @@ -36,6 +37,7 @@ function request(overrides: Record = {}): unknown { maxEstimatedCostNanodollars: 100_000_000, }, session: {}, + ...(overrides.acpxAgent === "pi" ? { piThinkingLevel: "low", session: { piThinkingLevel: "low" } } : {}), ...overrides, }; } @@ -67,7 +69,7 @@ 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) => { + it.each(["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" }); expect(() => parseEvalSessionRequest(value)).toThrow("--candidate-profile"); expect(parseEvalSessionRequest(value, { candidateProfile: agent })).toMatchObject({ acpxAgent: agent, model: "explicit-provider-model" }); @@ -77,9 +79,18 @@ describe("eval-session request contract", () => { expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, candidateProfile: agent }))).toThrow("--candidate-profile"); }); - it("admits qualified Cursor without a diagnostic flag and preserves its explicit model", () => { - expect(parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model" }))) - .toMatchObject({ acpxAgent: "cursor", model: "exact-cursor-model" }); + it("admits Pi without a diagnostic flag and retains matching historical opt-in", () => { + const value = request({ provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }); + expect(parseEvalSessionRequest(value)).toMatchObject({ acpxAgent: "pi" }); + expect(parseEvalSessionRequest(value, { candidateProfile: "pi" })).toMatchObject({ acpxAgent: "pi" }); + expect(() => parseEvalSessionRequest(value, { candidateProfile: "cursor" })).toThrow("must match"); + }); + + it("requires exact matching Pi mode in request and session and rejects aliases", () => { + const base = { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }; + for (const value of [undefined, "medium", "minimal", "xhigh", ""]) expect(() => parseEvalSessionRequest(request({ ...base, piThinkingLevel: value }))).toThrow(); + expect(() => parseEvalSessionRequest(request({ ...base, piThinkingLevel: "low", session: { piThinkingLevel: "high" } }))).toThrow(/must match/); + expect(() => parseEvalSessionRequest(request({ piThinkingLevel: "low" }))).toThrow(/only supported/); }); it("accepts only known diagnostic flags and rejects ambiguous repeated arguments", () => { @@ -98,9 +109,9 @@ describe("eval-session request contract", () => { expect(() => parseEvalSessionCliArgs([...args, "--expected-acpx-profile", " ".repeat(4097)])).toThrow("4096-byte bound"); }); - it.each(["cursor"] as const)("rejects stale or incomplete %s identities before runtime construction", async (agent) => { + it.each(["pi", "cursor", "copilot"] as const)("rejects stale or incomplete %s identities before runtime construction", async (agent) => { const workspace = await mkdtemp(join(tmpdir(), "paperclip-eval-profile-")); - const model = "explicit-provider-model"; + const model = agent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : "explicit-provider-model"; const expected = resolveQualifiedAcpxProfile(agent, model); const requestPath = join(workspace, "request.json"); const args = ["--request", requestPath, "--output", join(workspace, "output.json"), "--candidate-profile", agent]; @@ -108,7 +119,7 @@ describe("eval-session request contract", () => { try { await writeFile(requestPath, JSON.stringify(request({ provider: "acpx", acpxAgent: agent, model, runnerd: { path: join(workspace, "missing-runnerd"), sha256: "a".repeat(64) }, - session: { workingDirectory: workspace }, + session: { workingDirectory: workspace, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}) }, }))); const { commandDigest: _removed, ...incomplete } = expected; for (const stale of [ @@ -148,6 +159,7 @@ describe("eval-session request contract", () => { expect(systemInstructions).toContain("Paperclip direct live evaluation"); expect(systemInstructions).toContain("Task-state changes in this mock control plane use finish_task and block_task"); expect(systemInstructions).toContain("The current user request defines the work for this turn"); + expect(systemInstructions).toContain("read only the context needed for that request, perform the requested action, and end the turn"); expect(systemInstructions).toContain("Do not finish or block the mock task unless the current request asks for that state change"); expect(systemInstructions).toContain( `Read-only instruction sibling root: ${context.instructions.bundle.rootPath}`, @@ -213,11 +225,12 @@ describe("eval-session request contract", () => { })))).toBe("17"); }); - it("requires explicit Pi diagnosis and accepts both qualified remote provider profiles", () => { - expect(() => parseEvalSessionRequest(request({ + it("admits Pi and both qualified remote provider profiles", () => { + expect(parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "pi", - }))).toThrow("--candidate-profile"); + piThinkingLevel: "low", + }))).toMatchObject({ acpxAgent: "pi", piThinkingLevel: "low" }); expect(parseEvalSessionRequest(request({ provider: "aws_agentcore", driver: "aws_agentcore_harness_api", @@ -459,7 +472,7 @@ describe("eval-session usage", () => { }); describe("eval-session budget settlement", () => { - it.each(["no_receipt", "grok_no_receipt", "unpriced", "mixed", "over_limit"] as const)("retains the completed provider outcome while failing %s accounting", async (kind) => { + it.each(["no_receipt", "grok_no_receipt", "unpriced", "mixed", "over_limit", "suspension"] as const)("retains evidence while failing %s settlement", async (kind) => { const workspace = await mkdtemp(join(tmpdir(), "eval-budget-settlement-")); try { const binary = join(workspace, "runnerd"); @@ -483,10 +496,18 @@ describe("eval-session budget settlement", () => { } as unknown as CapabilityLiveSessionSnapshot; 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 shutdown = vi.fn(async () => { + snapshot.status = "closed"; + if (kind === "suspension") throw new NativeSessionCloseUnrecoverableError({ + runnerSuspended: false, providerDrained: true, + suspensionState: { commandStatus: "pending", runnerLifecycle: "ready", runnerIdentityMatches: true }, + semanticTools: { privateToolId: "private-value-must-not-be-copied" }, + unrelated: "private-value-must-not-be-copied", + }); + }); const input = request({ provider: "acpx", acpxAgent: agent, 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 }, + session: { workingDirectory: workspace, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}) }, limits: { turnTimeoutMs: 1000, maxAgentTurns: 2, maxEstimatedCostNanodollars: 100_000_000 }, }); const requestPath = join(workspace, "request.json"); const outputPath = join(workspace, "output.json"); @@ -496,6 +517,19 @@ describe("eval-session budget settlement", () => { }); const artifact = JSON.parse(await readFile(outputPath, "utf8")); expect(exitCode).toBe(2); + if (kind === "suspension") { + expect(artifact.infrastructureFailure).toEqual({ + class: "runner_infrastructure_failure", category: "runner_infrastructure", retryable: false, + diagnostics: { runnerSuspended: false, providerDrained: true, + suspensionCommandStatus: "pending", runnerLifecycle: "ready", runnerIdentityMatches: true }, + }); + expect(JSON.stringify(artifact)).not.toContain("private-value-must-not-be-copied"); + expect(artifact.snapshot.terminalTurns).toEqual([{ turnId: "turn-1", status: "completed" }]); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(shutdown).toHaveBeenCalledTimes(1); + expect(completeAttempt).toHaveBeenCalledWith("succeeded", null); + return; + } expect(artifact).not.toHaveProperty("infrastructureError"); expect(artifact.accountingFailure).toMatchObject({ class: kind === "over_limit" ? "provider_budget_reached" : "provider_budget_coverage_unknown", retryable: false }); expect(artifact.turn).toMatchObject({ status: "completed", assistantText: "Retained actual provider response" }); 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 a07a19b229..0c012db55f 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 0a28f977e4..460a43534c 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js"; import { describe, expect, it } from "vitest"; import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, type NativeExecutionInputV1 } from "./native-execution.js"; @@ -116,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" }, @@ -324,7 +341,21 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); - it.each([1, 2, 3, 4, 5] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { + 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", agent: "pi", @@ -339,7 +370,7 @@ describe("NativeExecutionInputV1", () => { agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", - agentRuntimeVersion: "0.84.2", + agentRuntimeVersion: "1.0.0", commandDigest: "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc", }, } as const; @@ -356,11 +387,11 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 6, 1.5, "5", 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" }); @@ -376,6 +407,27 @@ describe("NativeExecutionInputV1", () => { })).toThrow("does not match"); }); + it.each(Object.values(QUALIFIED_ACPX_PROFILES))( + "admits the current $agent profile through the native execution boundary", + (declaration) => { + const { qualificationModel, reportedModelId: _reported, permissionPolicy, + modelPolicy: _modelPolicy, qualificationStatus: _status, ...profile } = declaration; + const provider = { + kind: "acpx", agent: declaration.agent, + model: qualificationModel || "explicit-provider-model", + permissionPolicy, profile, + ...(declaration.agent === "pi" ? { piThinkingLevel: "low" } : {}), + }; + const parsed = parseNativeExecutionInput({ + ...input, + session: { ...input.session, driverKind: "acpx_runtime" }, + provider, + }); + expect(parsed.provider).toEqual(provider); + expect(parseNativeExecutionInput(parsed)).toEqual(parsed); + }, + ); + it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => { const legacy = structuredClone(input) as Record; delete (legacy.session as Record).lifecyclePolicy; @@ -512,41 +564,6 @@ describe("native task context ownership", () => { }); } - 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 622aedb0f4..194920c3e9 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -89,6 +89,7 @@ export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok" | "cursor" | "c export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted"; export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny"; export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; +export type NativeCursorMode = "agent" | "plan" | "ask"; export interface NativeAcpxProfileSnapshot { driverKind: "acpx_runtime"; @@ -129,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; @@ -144,6 +146,7 @@ export type NativeProviderConfigV4 = model: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; profile: NativeAcpxProfileSnapshot; }; @@ -501,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 @@ -621,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"); } @@ -659,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)) { @@ -672,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/question-set.test.ts b/packages/paperclip-runner/src/contracts/question-set.test.ts index 7cd873afe3..c7860c8bf2 100644 --- a/packages/paperclip-runner/src/contracts/question-set.test.ts +++ b/packages/paperclip-runner/src/contracts/question-set.test.ts @@ -1,3 +1,5 @@ +import Ajv2020 from "ajv/dist/2020.js"; +import { questionSetSchema } from "../protocol/generated/schema-bundle.js"; import { describe, expect, it } from "vitest"; import { @@ -51,6 +53,37 @@ describe("Paperclip question-set contract", () => { }); }); + it("preserves bounded initial text only as presentation, including empty text", () => { + for (const initialText of ["", " Draft\n漢字\n", "a".repeat(100_000)]) { + const input = { schema: PAPERCLIP_QUESTION_SET_SCHEMA, questions: [{ id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText }] }; + expect(parsePaperclipQuestionSet(input)).toEqual(input); + expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: {} })).toThrow(/required/); + } + for (const initialText of [null, 1, "a".repeat(100_001), "a".repeat(100_000) + "😀"]) { + expect(() => parsePaperclipQuestionSet({ ...questionSet, questions: [{ ...questionSet.questions[1], initialText }] })).toThrow(/initialText/); + } + expect(() => parsePaperclipQuestionSet({ ...questionSet, questions: [{ ...questionSet.questions[0], initialText: "staging" }] })).toThrow(/only text/); + const input = { ...questionSet, questions: [{ ...questionSet.questions[1], initialText: "21" }] }; + expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: { replicas: { text: "21" } } })).toThrow(/at most 20/); + }); + + it("matches the published Unicode code-point draft limit within the wire byte cap", () => { + const validate = new Ajv2020({ strict: true, strictRequired: false }).compile(questionSetSchema); + for (const codePoints of [100_000, 100_001]) { + const initialText = "a".repeat(codePoints - 1) + "😀"; + const input = { schema: PAPERCLIP_QUESTION_SET_SCHEMA, questions: [{ id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText }] }; + expect(Buffer.byteLength(JSON.stringify(input))).toBeLessThan(196 * 1024); + expect(initialText.length).toBe(codePoints + 1); + expect(validate(input)).toBe(codePoints === 100_000); + if (codePoints === 100_000) { + expect(parsePaperclipQuestionSet(input)).toEqual(input); + expect(() => parsePaperclipQuestionResponse(input, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers: { draft: { text: initialText } } })).toThrow(); + } else { + expect(() => parsePaperclipQuestionSet(input)).toThrow(/Unicode code points/); + } + } + }); + it("rejects missing, unknown, and provider-shaped answers", () => { expect(() => parsePaperclipQuestionResponse(questionSet, { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, diff --git a/packages/paperclip-runner/src/contracts/question-set.ts b/packages/paperclip-runner/src/contracts/question-set.ts index 5021b3ef97..e7b764e1e4 100644 --- a/packages/paperclip-runner/src/contracts/question-set.ts +++ b/packages/paperclip-runner/src/contracts/question-set.ts @@ -33,6 +33,8 @@ export interface PaperclipQuestion { helpText?: string; required: boolean; answerMode: PaperclipQuestionAnswerMode; + /** Editable starting text, never an implicit or submitted answer. Text mode only. */ + initialText?: string; options?: PaperclipQuestionOption[]; customAnswer?: PaperclipQuestionCustomAnswer; textValidation?: PaperclipQuestionTextValidation; @@ -182,6 +184,17 @@ export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet if (answerMode === "text" && options !== undefined && options.length > 0) { throw new PaperclipQuestionValidationError(`${path}/options`, "text questions cannot define options"); } + const initialText = question.initialText; + if (initialText !== undefined && ( + typeof initialText !== "string" + || initialText.length > 200_000 + || Array.from(initialText).length > 100_000 + )) { + throw new PaperclipQuestionValidationError(`${path}/initialText`, "must be a string of at most 100000 Unicode code points"); + } + if (initialText !== undefined && answerMode !== "text") { + throw new PaperclipQuestionValidationError(`${path}/initialText`, "only text questions can define initial text"); + } const custom = record(question.customAnswer); const customAnswer = custom === null ? undefined @@ -252,6 +265,7 @@ export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet : {}), required: question.required, answerMode, + ...(initialText !== undefined ? { initialText } : {}), ...(options !== undefined ? { options } : {}), ...(customAnswer !== undefined ? { customAnswer } : {}), ...(textValidation !== undefined ? { textValidation } : {}), 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..94f9b79505 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 @@ -19,6 +19,7 @@ import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { configuredEnvironmentProjection } from "../configured-environment.js"; import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js"; import { createCapabilityRunnerdCodexTransport, createCapabilityRunnerdProviderEnvironment } from "../live/runnerd-codex-transport.js"; import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js"; @@ -550,6 +551,8 @@ it("preserves the controller-selected ACPX provider package root", () => { it.each([ ["pi", "OPENROUTER_API_KEY"], + ["pi", "AWS_BEARER_TOKEN_BEDROCK"], + ["pi", "CUSTOM_PI_API_KEY"], ["cursor", "CURSOR_API_KEY"], ["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"], @@ -557,13 +560,15 @@ it.each([ const launches: RunnerProcessLaunchSpec[] = []; vi.stubEnv(key, "ambient-must-not-cross"); vi.stubEnv(ACPX_CREDENTIAL_BINDING_ENV, "ambient-forged-marker"); + const iam = agent === "pi" ? configuredEnvironmentProjection({ AWS_ACCESS_KEY_ID: "general-key", AWS_SECRET_ACCESS_KEY: "general-secret", AWS_SESSION_TOKEN: "general-session", CUSTOM_SETTING: "allowed" }) : {}; + 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, ...iam, [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,9 +587,13 @@ 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(); + if (agent === "pi") { + for (const name of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"]) expect(launch.environment).not.toHaveProperty(name); + if (explicit) expect(launch.environment.CUSTOM_SETTING).toBe("allowed"); + } const provider = createAcpxSidecarHostEnvironment(launch.environment, agent, identity.normalizedSessionId); expect(provider[key]).toBe(explicit ? "explicit-fixture-credential" : undefined); expect(provider[ACPX_CREDENTIAL_BINDING_ENV]).toBeUndefined(); 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..4b444eaaa6 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,23 @@ 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. */ } + } + let taskEnvironmentSource = explicitSource; + if (binding !== null && typeof binding === "object" && !Array.isArray(binding) + && (binding as Record).agent === "pi") { + const bound = createAcpxSidecarHostEnvironment(explicitSource, "pi", normalizedSessionId); + taskEnvironmentSource = bound; + for (const key of piCredentialNames(explicitSource)) { + if (bound[key] !== undefined) environment[key] = bound[key]; + } + } + Object.assign(environment, githubCredentialEnvironment(explicitSource), configuredEnvironment(taskEnvironmentSource)); } return environment; } @@ -3602,7 +3620,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.test.ts b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts index 07fdbc4c3e..99dc9803bc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts @@ -9,6 +9,15 @@ function request(kinds = ["allow_once", "allow_always", "reject_once"]): AcpPerm } } as AcpPermissionRequest; } describe("ACP permission normalization", () => { + it.each([undefined, "codex", "claude", "pi", "copilot"])("preserves existing %s native identity behavior", provider => { + const native = request(); native.raw.toolCall.toolCallId = "native\u0000tool"; + expect(normalizeAcpxPermission(native, { provider }).toolCallId).toBe("native\u0000tool"); + }); + it.each([undefined, null, "", " ", "x".repeat(241)])("rejects missing or oversized Cursor identities without deriving one from other fields", toolCallId => { + const native = request(); + Object.assign(native.raw.toolCall, { toolCallId, itemId: "fallback", title: "safe-tool" }); + expect(() => normalizeAcpxPermission(native, { provider: "cursor" })).toThrow("tool identity"); + }); it("keeps requests answerable when a provider omits its operation title", () => { for (const title of [undefined, "", " "]) { const value = request(); @@ -43,3 +52,61 @@ describe("ACP permission normalization", () => { expect(() => normalizeAcpxPermission(duplicate)).toThrow("ambiguous"); }); }); + + +describe("Copilot canonical file permission context", () => { + const context = { provider: "copilot", workingDirectory: "/fixture/workspace", allowAlwaysScope: "session" as const }; + function edit(call: Record = {}) { + const value = request(); + Object.assign(value.raw.toolCall, { kind: "edit", title: "Create file", rawInput: { path: "/fixture/workspace/new.txt", content: "PRIVATE_CONTENT" }, locations: [{ path: "new.txt" }], ...call }); + return value; + } + it("puts only the safe target in the first canonical prompt and keeps offered grants", () => { + const normalized = normalizeAcpxPermission(edit(), context); + expect(normalized.title).toBe("Change file: new.txt"); + expect(JSON.stringify(normalized)).not.toContain("PRIVATE_CONTENT"); + expect(normalized.resolve({ action: "accept" })).toEqual({ outcome: "allow_once" }); + expect(normalized.resolve({ action: "accept_for_session" })).toEqual({ outcome: "allow_always" }); + expect(normalized.resolve({ action: "decline" })).toEqual({ outcome: "reject_once" }); + }); + it.each([ + { rawInput: {}, locations: [] }, + { rawInput: { path: "../outside.txt" }, locations: [] }, + { rawInput: { path: " new.txt" }, locations: [] }, + { rawInput: { path: "new.txt " }, locations: [] }, + { rawInput: { path: "/fixture/workspace/new.txt " }, locations: [] }, + { rawInput: { path: "/elsewhere/file" }, locations: [] }, + { rawInput: { path: "new.txt", fileName: "other.txt" }, locations: [] }, + { rawInput: { path: "new.txt" }, locations: [{ path: "other.txt" }] }, + { rawInput: { path: "new.txt" }, locations: [{}] }, + { rawInput: { path: "new.txt" }, locations: "invalid" }, + { rawInput: { path: "bad\nname" }, locations: [] }, + { rawInput: { path: "safe\u202Etxt.exe" }, locations: [] }, + { rawInput: { path: "safe\u2028other" }, locations: [] }, + { rawInput: { path: "safe\u0085other" }, locations: [] }, + { rawInput: { path: "a".repeat(1025) }, locations: [] }, + { rawInput: { path: "new.txt" }, locations: Array.from({ length: 17 }, () => ({ path: "new.txt" })) }, + { rawInput: { path: "https://private.example/file" }, locations: [] }, + { rawInput: { path: "secret?token=private-value" }, locations: [] }, + ])("offers no grant for absent, unsafe or conflicting context: %j", call => { + const normalized = normalizeAcpxPermission(edit(call), context); + expect(normalized.title).toContain("target unavailable"); + expect(normalized.choices.map(choice => choice.key)).toEqual(["decline", "cancel"]); + for (const action of ["accept", "accept_for_session"] as const) expect(() => normalized.resolve({ action })).toThrow("not an offered choice"); + expect(normalized.resolve({ action: "decline" })).toEqual({ outcome: "reject_once" }); + expect(normalized.resolve({ action: "cancel" })).toEqual({ outcome: "cancel" }); + }); + it("requires the bound workspace and does not infer edits from title/inferredKind", () => { + expect(normalizeAcpxPermission(edit(), { provider: "copilot" }).choices.map(x => x.key)).toEqual(["decline", "cancel"]); + for (const kind of [undefined, "execute", "read"]) { + const normalized = normalizeAcpxPermission(edit({ kind }), context); + expect(normalized.title).toBe("Create file"); + expect(normalized.resolve({ action: "accept" })).toEqual({ outcome: "allow_once" }); + } + expect(normalizeAcpxPermission(edit(), { ...context, provider: "pi" }).title).toBe("Create file"); + }); + it("does not invent a grant when context is valid but the provider did not offer one", () => { + const value = edit(); value.raw.options = request(["reject_once"]).raw.options; + expect(normalizeAcpxPermission(value, context).choices.map(x => x.key)).toEqual(["decline", "cancel"]); + }); +}); 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 ff125d537d..fede31d364 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts @@ -2,6 +2,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"; export interface NormalizedAcpxPermission { @@ -36,6 +37,8 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options: || (options.provider === "cursor" && !call.toolCallId.trim())) { throw new Error("ACP permission request omitted its tool identity"); } + const needsEditContext = options.provider === "copilot" && call.kind === "edit"; + const target = needsEditContext ? safeCopilotEditTarget(call, options.workingDirectory) : undefined; const bindings = new Map(); const choices: NormalizedAcpxPermission["choices"] = []; for (const [key, kind, label] of [ @@ -43,6 +46,7 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options: ["accept_for_session", "allow_always", "Allow for session"], ["decline", "reject_once", "Deny"], ] as const) { + if (needsEditContext && !target && (key === "accept" || key === "accept_for_session")) continue; if (kind === "allow_always" && options.allowAlwaysScope !== "session") continue; if (byKind.has(kind)) { bindings.set(key, kind); choices.push({ key, label }); } } @@ -50,7 +54,9 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options: bindings.set("cancel", "cancel"); choices.push({ key: "cancel", label: "Cancel" }); return { - title: typeof call.title === "string" && call.title.trim() + title: needsEditContext + ? target ? `Change file: ${target}` : "File change requested; target unavailable. Deny or cancel this request." + : typeof call.title === "string" && call.title.trim() ? call.title.slice(0, 4_000) : "Approve provider operation", kind: request.inferredKind ?? "other", // Display/durable identity must match the tool-event boundary. Leave the diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.test.ts index e2e6b87ce3..e00e855935 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.test.ts @@ -201,3 +201,24 @@ describe("ACP form question adapter", () => { expect(JSON.stringify(response.content)).toBe('{"__proto__":"safe"}'); }); }); + + +describe("ACP initial text", () => { + it.each(["", " Draft\n漢字\n"])("keeps exact string defaults through canonical parsing and explicit submission", initialText => { + const form = normalizeAcpFormElicitation({ mode: "form", requestedSchema: { type: "object", properties: { answer: { type: "string", default: initialText } }, required: ["answer"] } })!; + const question = form.questionSet.questions[0]!; + expect(question.initialText).toBe(initialText); + expect(() => form.accept({ schema: "paperclip.question_response.v1", answers: {} })).toThrow(/required/); + const edited = `${initialText}Edited`; + expect(form.accept({ schema: "paperclip.question_response.v1", answers: { [question.id]: { text: edited } } })).toEqual({ action: "accept", content: { answer: edited } }); + }); + it("bounds defaults, rejects wrong types, and keeps response constraints authoritative", () => { + const request = (property: unknown) => ({ mode: "form", requestedSchema: { type: "object", properties: { answer: property }, required: ["answer"] } }); + expect(() => normalizeAcpFormElicitation(request({ type: "string", default: "x".repeat(100_001) }))).toThrow(/initialText/); + expect(() => normalizeAcpFormElicitation(request({ type: "string", default: 3 }))).toThrow(/incompatible/); + const number = normalizeAcpFormElicitation(request({ type: "integer", default: 0, minimum: 1 }))!; + expect(number.questionSet.questions[0]!.initialText).toBe("0"); + expect(() => number.accept({ schema: "paperclip.question_response.v1", answers: { [number.questionSet.questions[0]!.id]: { text: "0" } } })).toThrow(/at least 1/); + expect(normalizeAcpFormElicitation(request({ type: "string", enum: ["a", "b"], default: "a" }))!.questionSet.questions[0]).not.toHaveProperty("initialText"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts index 7250cc1a43..e36ef68636 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts @@ -148,6 +148,7 @@ function normalizeField( question: { ...base, answerMode: "text", + ...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}), textValidation: { inputType: "text", ...(minLength !== undefined ? { minLength } : {}), @@ -168,6 +169,7 @@ function normalizeField( question: { ...base, answerMode: "text", + ...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}), textValidation: { inputType: type, ...(minimum !== undefined ? { minimum } : {}), @@ -376,3 +378,10 @@ function finiteNonNegativeInteger(value: unknown): number | undefined { ? Number(value) : undefined; } + +/** Defaults are editable presentation, not acceptance or native response content. */ +function initialText(value: unknown, type: string): string { + if (type === "string" && typeof value === "string") return value; + if ((type === "number" || type === "integer") && typeof value === "number" && Number.isFinite(value)) return String(value); + throw new Error(`ACP ${type} text field default has an incompatible type`); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts index 6d1103f8a3..fce35cf840 100644 --- a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts @@ -57,10 +57,10 @@ export const ACPX_CAPABILITY_PROFILES: Readonly { + it("captures the direct driver's normalized input only when the exact proposal is retained", async () => { + const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); + const session = await fixture.driver.openSession({ runId: "run-normalized-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Complete" } }); + const { schema: _, attentionRequests: __, artifacts: ___, ...raw } = completedResult(); + const commit = vi.fn(), capture = vi.fn((value: unknown) => { expect(commit).not.toHaveBeenCalled(); return commit; }); + const handler = fixture.hostOptions!.semanticTools!.handler; + await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "finish", arguments: raw, signal: new AbortController().signal, + captureNormalizedInput: capture })).resolves.toMatchObject({ accepted: true }); + expect(capture).toHaveBeenCalledExactlyOnceWith(completedResult()); + expect(commit).toHaveBeenCalledOnce(); + const repeatedCapture = vi.fn(() => vi.fn()); + await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "different-call-same-result", arguments: raw, signal: new AbortController().signal, + captureNormalizedInput: repeatedCapture })).resolves.toMatchObject({ accepted: true }); + expect(repeatedCapture).not.toHaveBeenCalled(); + const invalidCapture = vi.fn(() => vi.fn()); + await expect(handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "invalid", arguments: {}, signal: new AbortController().signal, + captureNormalizedInput: invalidCapture })).rejects.toThrow("Invalid semantic run result"); + expect(invalidCapture).not.toHaveBeenCalled(); + fixture.finishTurn({ status: "completed" }); + const events = await collectUntil(session.events(), "turn.completed"); + expect(events.find(event => event.eventType === "run.result.proposed")?.payload).toEqual(capture.mock.calls[0]![0]); + await session.close({ reason: "same-invocation normalization verified" }); + }); + it("does not commit a direct normalized input digest when proposal retention is backpressured", async () => { + const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: true } }, { + maxBufferedEvents: 4, terminalEventReserve: 0, + runtimeEvents: Array.from({ length: 8 }, (_, n) => ({ type: "text_delta" as const, stream: "output" as const, text: `bounded-${n}` })), + }); + const session = await fixture.driver.openSession({ runId: "run-normalized-pressure", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Complete" } }); + await vi.waitFor(async () => expect((await session.transcript!()).eventCount).toBeGreaterThanOrEqual(4)); + const commit = vi.fn(), capture = vi.fn(() => commit); + await expect(fixture.hostOptions!.semanticTools!.handler({ tool: PRP_COMPLETION_TOOL_NAME, callId: "finish", arguments: completedResult(), + signal: new AbortController().signal, captureNormalizedInput: capture })).rejects.toThrow("event consumer must drain"); + expect(capture).toHaveBeenCalledOnce(); + expect(commit).not.toHaveBeenCalled(); + fixture.finishTurn({ status: "completed" }); + const events = await collectUntil(session.events(), "turn.completed"); + expect(events.some(event => event.eventType === "run.result.proposed")).toBe(false); + await session.close({ reason: "unretained input has no digest" }); + }); + it.each(["copilot", "cursor", "pi", "codex"] as const)("scopes optional semantic receipts to the actual %s invocation turn", async agent => { + const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); + const session = await fixture.driver.openSession({ runId: "run-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + const first = await session.startTurn({ message: { role: "user", text: "First" } }); + const capture = fixture.hostOptions!.semanticTools!.captureSemanticReceipt; + expect(typeof capture).toBe(agent === "copilot" ? "function" : "undefined"); + const oldCallback = capture?.(); + const bound = appendSemanticToolReceipt({ tool: "get_task_context", callId: "1", arguments: {} }, { content: [{ type: "text", text: "{}" }] }); + oldCallback?.(bound.receipt); + fixture.finishTurn({ status: "completed" }); + const firstEvents = await collectUntil(session.events(), "turn.completed"); + const receipts = firstEvents.filter(e => e.eventType === "provider.notice.recorded" && e.payload.category === "paperclip_semantic_tool_receipt_v2"); + expect(receipts).toHaveLength(agent === "copilot" ? 1 : 0); + if (agent === "copilot") expect(receipts[0]).toMatchObject({ runId: "run-receipt", turnId: first.turnId, payload: { provenance: { sessionId: "backend-1", turnId: first.turnId } } }); + const transcript = JSON.parse(JSON.stringify(await session.transcript!())); + for (const event of transcript.events) validatePrpEvent(event); + expect(transcript.events.filter((e: PrpEvent) => e.payload.category === "paperclip_semantic_tool_receipt_v2")).toEqual(receipts); + await session.startTurn({ message: { role: "user", text: "Second" } }); + oldCallback?.(bound.receipt); + fixture.finishTurn({ status: "completed" }); + const secondEvents = await collectUntil(session.events(), "turn.completed"); + expect(secondEvents.filter(e => e.payload.category === "paperclip_semantic_tool_receipt_v2")).toEqual([]); + await session.close({ reason: "receipt turn scope verified" }); + }); + + it.each(["cursor", "copilot"] as const)("emits partial Cursor metadata only for admitted Cursor, preserving %s settlement", async agent => { + const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); + const session = await fixture.driver.openSession({ runId: "run-native-usage", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + const before = { promptMessageIds: [], requestTokenUsage: {} }; + const after = { lastRequestId: "provider-turn-1", promptMessageIds: ["prompt-1"], requestTokenUsage: {}, cursorPromptUsage: { + request_id: "provider-turn-1", prompt_message_id: "prompt-1", receipt: { + schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial", + reasons: ["native_counter_semantics_unverified"], observations: [{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 7 } }], + limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false, + }, + } }; + fixture.host.status.mockResolvedValueOnce(before as never).mockResolvedValue(after as never); + await session.startTurn({ message: { text: "fixture" } }); + fixture.finishTurn({ status: "completed", stopReason: "end_turn" }); + const events = await collectUntil(session.events(), "turn.completed"); + const notices = events.filter(e => e.eventType === "provider.notice.recorded" && e.payload.category === "cursor_native_usage_observed"); + expect(notices).toHaveLength(agent === "cursor" ? 1 : 0); + expect(events.filter(e => e.eventType === "usage.updated")).toEqual([]); + expect(events.at(-1)?.eventType).toBe("turn.completed"); + await session.close({ reason: "partial diagnostics verified" }); + }); + + 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", 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" }); + const toolEvents = collectUntil(session.events(), "tool.execution.started"); + const { turnId } = await session.startTurn({ message: { text: "Plan" } }); + const tool = (await toolEvents).find(e => e.eventType === "tool.execution.started")!; + const created = collectUntil(session.events(), "runtime_request.created"); + const callback = fixture.host.startTurn.mock.calls[0]![0].onExtensionRequest!; + const reply = callback("cursor/create_plan", { sessionId: "backend-1", toolCallId, plan: "# Full plan", todos: [] }, { requestId: 1, signal: new AbortController().signal, responseDelivery: Promise.resolve() }); + const events = await created; + const request = session.pendingRuntimeRequests!()[0]!; + expect(request.itemId).toBe(tool.payload.executionId); + expect(events.find(e => e.eventType === "runtime_request.created")!.payload.request).toMatchObject({ itemId: tool.payload.executionId }); + await session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: "cancel" } }); + await expect(reply).resolves.toEqual({ outcome: { outcome: "cancelled" } }); + fixture.finishTurn({ status: "completed" }); + await session.close({ reason: "plan identity verified" }); + }); + it.each(["codex", "cursor"] as const)("keeps existing %s item and unidentified-text semantics", async agent => { + const runtimeEvents: AcpRuntimeEvent[] = [ + { type: "text_delta", stream: "output", messageId: "first", text: "progress" }, + { type: "text_delta", stream: "output", messageId: "second", text: "answer" }, + { type: "text_delta", stream: "output", text: " suffix" }, + ]; + const fixture = driverFixture({ agent, model: "explicit-model", providerPolicy: { readOnly: true } }, { runtimeEvents }); + const session = await fixture.driver.openSession({ runId: "other-provider-identity", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + const { turnId } = await session.startTurn({ message: { text: "fixture" } }); + fixture.finishTurn({ status: "completed" }); + const events = await collectUntil(session.events(), "turn.completed"); + const messages = events.filter(event => event.payload.kind === "agentMessage"); + expect(new Set(messages.map(event => event.itemId))).toEqual(new Set([`${turnId}:assistant-message`])); + expect(messages.filter(event => event.eventType === "item.completed").map(event => event.payload)).toEqual([{ kind: "agentMessage", channel: "final", text: "answer suffix" }]); + await session.close({ reason: "unchanged provider semantics verified" }); + }); + it.each(["same-text", "split-final", "empty-final", "cancelled"])("Copilot native message identities preserve interim activity and the actual final message: %s", async scenario => { + const chunk = (messageId: string | undefined, text: string, stream: "output" | "thought" = "output"): AcpRuntimeEvent => ({ type: "text_delta", text, stream, ...(messageId ? { messageId } : {}) }); + const runtimeEvents: AcpRuntimeEvent[] = [chunk("first", ""), chunk("first", "EXACT_MARKER"), + { type: "status", tag: "tool_call_update", text: "attached tool continuation" }, + chunk("second", ""), chunk("second", "private reasoning", "thought")]; + if (scenario !== "empty-final") runtimeEvents.push(...(scenario === "split-final" ? [chunk("second", "EXACT_"), chunk("second", "MARKER")] : [chunk("second", "EXACT_MARKER")])); + runtimeEvents.push(chunk(undefined, "Info: native session activity")); + const fixture = driverFixture({ agent: "copilot", model: "explicit-model", providerPolicy: { readOnly: true } }, { runtimeEvents }); + const session = await fixture.driver.openSession({ runId: "run-copilot-boundaries", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Answer exactly" } }); + fixture.finishTurn({ status: scenario === "cancelled" ? "cancelled" : "completed" }); + const events = await collectUntil(session.events(), scenario === "cancelled" ? "turn.interrupted" : "turn.completed"); + const interim = events.find(event => event.eventType === "item.delta" && event.payload.text === "EXACT_MARKER"); + expect(interim).toBeDefined(); + expect(events.some(event => event.eventType === "item.delta" && event.payload.text === "Info: native session activity")).toBe(true); + const commentary = events.filter(event => event.eventType === "item.completed" && event.payload.kind === "agentMessage" && event.payload.channel === "commentary"); + expect(commentary.map(event => event.payload.text)).toEqual(["EXACT_MARKER"]); + expect(commentary[0]!.itemId).toBe(interim!.itemId); + const final = events.filter(event => event.eventType === "item.completed" && event.payload.kind === "agentMessage" && event.payload.channel === "final"); + expect(final.map(event => event.payload.text)).toEqual(scenario === "empty-final" || scenario === "cancelled" ? [] : ["EXACT_MARKER"]); + if (final.length) expect(final[0]!.itemId).not.toBe(interim!.itemId); + await session.close({ reason: "Copilot native identity verified" }); + }); + it("keeps Pi retry-only activity as a rich notice without inventing a final answer", async () => { + const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); + const session = await fixture.driver.openSession({ runId: "run-pi-notice", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Work" } }); + const input = fixture.host.startTurn.mock.calls[0]![0]; + await input.onExtensionNotification!("paperclip/pi_notice", { sessionId: "backend-1", category: "auto_retry_end", summary: "Retry did not recover.", severity: "warning", details: { attempt: 2, success: false } }); + fixture.finishTurn({ status: "completed" }); + const events = await collectUntil(session.events(), "turn.completed"); + expect(events.find(event => event.eventType === "provider.notice.recorded")?.payload).toMatchObject({ severity: "warning", category: "pi.auto_retry_end", scope: "session" }); + expect(events.some(event => event.eventType === "item.completed" && event.payload.kind === "agentMessage")).toBe(false); + await session.close({ reason: "notice-only verified" }); + }); + it.each(["exact-final", "tool-only", "empty-final", "missing-end", "cancelled"])("Pi native boundaries preserve progress without stale final attribution: %s", async scenario => { + const chunk = (n: number, kind: string, text = "", stream: "output" | "thought" = "output"): AcpRuntimeEvent => ({ + type: "text_delta", text, stream, messageId: `pi-message-${String(n).padStart(64, "0")}`, + meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind }, + }); + const runtimeEvents = [chunk(1, "start"), chunk(1, "delta", "Calling finish."), chunk(1, "end:toolUse")]; + if (scenario !== "tool-only") { + runtimeEvents.push(chunk(2, "start"), chunk(2, "delta", "private thought", "thought")); + if (scenario !== "empty-final") runtimeEvents.push(chunk(2, "delta", "EXACT_MARKER")); + if (scenario !== "missing-end" && scenario !== "cancelled") runtimeEvents.push(chunk(2, "end:stop")); + } + const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }, { runtimeEvents }); + const session = await fixture.driver.openSession({ runId: "run-pi-boundaries", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + await session.startTurn({ message: { text: "Answer exactly" } }); + fixture.finishTurn({ status: scenario === "cancelled" ? "cancelled" : "completed" }); + const events = await collectUntil(session.events(), scenario === "missing-end" ? "turn.failed" : scenario === "cancelled" ? "turn.interrupted" : "turn.completed"); + expect(events.some(event => event.eventType === "item.delta" && event.payload.text === "Calling finish.")).toBe(true); + const final = events.filter(event => event.eventType === "item.completed" && event.payload.kind === "agentMessage"); + expect(final.map(event => event.payload.text)).toEqual(scenario === "exact-final" ? ["EXACT_MARKER"] : []); + await session.close({ reason: "native attribution verified" }); + }); + it("delivers negotiated steering and follow-up distinctly, once, for the active turn", async () => { - const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }); + const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); fixture.host.steeringCapability.mockReturnValue({ steering: true, queuedFollowUp: true }); const session = await fixture.driver.openSession({ runId: "run-controls", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); expect(fixture.hostOptions?.providerPolicy).toEqual({ readOnly: true }); @@ -32,7 +218,6 @@ describe("Codex ACPX harness driver", () => { const turnInput = fixture.host.startTurn.mock.calls[0]![0]; const context = { requestId: 0, signal: new AbortController().signal }; await expect(turnInput.onExtensionRequest!("provider/request", { sessionId: "agent-1" }, context)).rejects.toThrow(/session mismatch/); - await expect(turnInput.onExtensionRequest!("provider/request", { sessionId: "backend-1" }, context)).rejects.toThrow(/adapter is unavailable/); await session.steer!({ turnId, correlationId: "control-1", message: { text: "Change focus" } }); await session.steer!({ turnId, correlationId: "control-2", mode: "follow_up", message: { text: "Then validate" } }); expect(fixture.host.steerActiveTurn).toHaveBeenCalledExactlyOnceWith("Change focus", `run-controls:${turnId}`); @@ -49,7 +234,7 @@ describe("Codex ACPX harness driver", () => { }); it("rejects unnegotiated controls and retains ambiguous delivery attempts", async () => { - const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }); + const fixture = driverFixture({ agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); const session = await fixture.driver.openSession({ runId: "run-controls", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); const { turnId } = await session.startTurn({ message: { text: "Work" } }); await expect(session.steer!({ turnId, message: { text: "No handshake" } })).rejects.toThrow(/did not negotiate/); @@ -1535,6 +1720,42 @@ describe("Codex ACPX harness driver", () => { await session.close({ reason: "permission verified" }); }); + it.each(["safe", "missing", "conflicting", "outside"].flatMap(scenario => + ([undefined, "approve-all", "approve-reads"] as const).map(permissionMode => ({ scenario, permissionMode })), + ))("binds Copilot edit context before publishing any actionable request ($scenario, $permissionMode)", async ({ scenario, permissionMode }) => { + const safe = scenario === "safe"; + const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: false }, permissionMode }); + const session = await fixture.driver.openSession({ runId: "run-target", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + expect(fixture.hostOptions?.permissionMode).toBe(permissionMode ?? "approve-all"); + const created = collectUntil(session.events(), "runtime_request.created"); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Request an edit." } }); + const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!; + const response = callback({ inferredKind: "edit", raw: { + sessionId: "backend-1", toolCall: { toolCallId: "edit-target", title: "Create file", kind: "edit", + rawInput: { ...(scenario === "missing" ? {} : { path: scenario === "outside" ? "../outside.txt" : "/workspace/new.txt" }), + ...(scenario === "conflicting" ? { fileName: "other.txt" } : {}), content: "PRIVATE_CONTENT" } }, + options: ["allow_once", "allow_always", "reject_once"].map(kind => ({ optionId: kind, kind, name: kind })), + } } as Parameters[0], { signal: new AbortController().signal, responseDelivery: Promise.resolve() }); + const events = await created; + const request = session.pendingRuntimeRequests!()[0]!; + const prompt = safe ? "Change file: new.txt" : "File change requested; target unavailable. Deny or cancel this request."; + expect(request.prompt).toBe(prompt); + expect(events.at(-1)?.payload).toMatchObject({ request: { prompt, type: "permission" } }); + expect(JSON.stringify(events)).not.toContain("PRIVATE_CONTENT"); + if (!safe) { + expect(request.details).toMatchObject({ choices: [{ key: "decline" }, { key: "cancel" }] }); + for (const action of ["accept", "accept_for_session"] as const) { + await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action } })).rejects.toThrow("offered choice"); + } + expect(session.pendingRuntimeRequests!()).toHaveLength(1); + } + await session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: safe ? "accept" : "decline" } }); + await expect(response).resolves.toEqual({ outcome: safe ? "allow_once" : "reject_once" }); + fixture.finishTurn({ status: "completed", stopReason: "end_turn" }); + await collectUntil(session.events(), "turn.completed"); + await session.close({ reason: "target context checked" }); + }); + it.each(["written", "failed"] as const)("waits for the exact provider permission reply receipt: %s", async outcome => { const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: false } }); const session = await fixture.driver.openSession({ runId: "run-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); @@ -1543,10 +1764,11 @@ describe("Codex ACPX harness driver", () => { const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!; const receipt = deferred(); const providerResponse = callback({ inferredKind: "execute", raw: { - sessionId: "agent-session-1", toolCall: { toolCallId: "receipt-tool", title: "Run validation" }, + sessionId: "backend-1", toolCall: { toolCallId: "receipt-tool", title: "Run validation", kind: "execute", rawInput: { command: "printf safe", mode: "async", detach: false } }, options: [{ optionId: "session", kind: "allow_always", name: "Allow for session" }], } } as Parameters[0], { signal: new AbortController().signal, responseDelivery: receipt.promise }); - await created; + const requested = await created; + expect(requested.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "copilot_tool_evidence_v1").map(event => Object.fromEntries((event.payload.details as Array<{ name: string; value: string }>).map(x => [x.name, x.value])))).toEqual([expect.objectContaining({ stage: "permission_requested", toolCallId: "receipt-tool", mode: "async", detach: "false" })]); const request = session.pendingRuntimeRequests!()[0]!; expect(request.details).toMatchObject({ choices: [{ key: "accept_for_session" }, { key: "cancel" }] }); let acknowledged = false; @@ -1561,6 +1783,9 @@ describe("Codex ACPX harness driver", () => { receipt.reject(new Error("pipe failed")); await rejected; } const events = await emitted; + const evidence = events.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "copilot_tool_evidence_v1" && (event.payload.details as Array<{ name: string; value: string }>).some(x => x.name === "stage" && x.value === "permission_delivered")); + expect(evidence).toHaveLength(outcome === "written" ? 1 : 0); + if (outcome === "written") expect(evidence[0]!.payload.details).toEqual(expect.arrayContaining([{ name: "stage", value: "permission_delivered" }, { name: "outcome", value: "allow_always" }])); expect(events.filter(event => event.eventType === "runtime_request.resolved")).toHaveLength(outcome === "written" ? 1 : 0); if (outcome === "failed") expect(events.at(-1)?.payload).toMatchObject({ replayAllowed: false, reason: "response_delivery_failed" }); expect(session.pendingRuntimeRequests!()).toHaveLength(0); @@ -1568,6 +1793,70 @@ describe("Codex ACPX harness driver", () => { await session.close({ reason: "receipt checked" }); }); + it.each(["cursor-tool", "native\u0000tool", "native\u007ftool"].flatMap(toolCallId => ["written", "failed"].map(outcome => ({ toolCallId, outcome }))))("binds Cursor denial evidence to its original tool and response write: $outcome/$toolCallId", async ({ outcome, toolCallId }) => { + const command = "printf 'sensitive-value' > /workspace/denied.txt"; + const fixture = driverFixture({ agent: "cursor", model: "explicit-test-model", providerPolicy: { readOnly: false } }, { + runtimeEvents: [{ type: "tool_call", tag: "tool_call", toolCallId, title: "Run command", kind: "execute", status: "pending", rawInput: { command } }], + }); + const session = await fixture.driver.openSession({ runId: "run-cursor-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + const origin = collectUntil(session.events(), "tool.execution.started"); + const { turnId } = await session.startTurn({ message: { text: "Request the command." } }); + const originEvents = await origin; + const created = collectUntil(session.events(), "runtime_request.created"); + const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!; + const receipt = deferred(); + const providerResponse = callback({ inferredKind: "execute", raw: { + sessionId: "backend-1", toolCall: { toolCallId, title: "Run command", kind: "execute" }, + options: [{ optionId: "deny", kind: "reject_once", name: "Deny" }], + } } as Parameters[0], { signal: new AbortController().signal, responseDelivery: receipt.promise }); + const requested = await created; + const request = session.pendingRuntimeRequests!()[0]!; + const projectedId = cursorToolIdentity(toolCallId); + expect(request.details).toMatchObject({ toolCallId: projectedId }); + expect(originEvents.find(event => event.eventType === "tool.execution.started")!.payload.executionId).toBe(projectedId); + const evidence = (events: PrpEvent[]) => events.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "cursor_tool_evidence_v1"); + const fields = (event: PrpEvent) => Object.fromEntries((event.payload.details as Array<{ name: string; value: string }>).map(field => [field.name, field.value])); + const commandSha256 = `sha256:${createHash("sha256").update(command).digest("hex")}`; + expect(evidence(requested).map(fields)).toEqual([expect.objectContaining({ stage: "permission_requested", toolCallId: projectedId, requestId: request.requestId, commandSha256, declineOffered: "true" })]); + const settled = collectUntil(session.events(), outcome === "written" ? "runtime_request.resolved" : "runtime_request.expired"); + let acknowledged = false; + const resolution = session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: "decline" } }).then(() => { acknowledged = true; }); + await expect(providerResponse).resolves.toEqual({ outcome: "reject_once" }); + expect(acknowledged).toBe(false); + if (outcome === "written") { receipt.resolve(); await resolution; } + else { + const failure = expect(resolution).rejects.toThrow("pipe failed"); + receipt.reject(new Error("pipe failed")); await failure; + } + const terminalEvents = await settled; + expect(evidence(terminalEvents).map(fields)).toEqual(outcome === "written" + ? [expect.objectContaining({ stage: "permission_delivered", outcome: "reject_once", commandSha256, requestId: request.requestId })] : []); + expect(JSON.stringify([...evidence(originEvents), ...evidence(requested), ...evidence(terminalEvents)])).not.toContain("sensitive-value"); + expect(session.pendingRuntimeRequests!()).toHaveLength(0); + if (outcome === "written") fixture.finishTurn({ status: "completed", stopReason: "end_turn" }); + await session.close({ reason: "Cursor receipt verified" }); + }); + + it.each(["copilot", "codex"] as const)("preserves pinned attached-shell evidence only on the Copilot direct driver: %s", async agent => { + const wire = JSON.parse(readFileSync(new URL("./fixtures/copilot-tool-evidence.json", import.meta.url), "utf8"))["attached-shell"]; + const runtimeEvents = wire.filter((frame: any) => frame.method === "session/update").map((frame: any) => ({ ...frame.params.update, type: "tool_call", tag: frame.params.update.sessionUpdate })) as AcpRuntimeEvent[]; + const fixture = driverFixture({ agent, providerPolicy: { readOnly: false } }, { runtimeEvents }); + const session = await fixture.driver.openSession({ runId: "run-attached", normalizedSessionId: "session-1", workingDirectory: "/fixture/workspace" }); + const completed = collectUntil(session.events(), "turn.completed"); + const { turnId } = await session.startTurn({ message: { text: "Start attached work." } }); + fixture.finishTurn({ status: "completed", stopReason: "end_turn" }); + const events = await completed; + const evidence = events.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "copilot_tool_evidence_v1"); + if (agent === "copilot") { + expect(evidence.length).toBeGreaterThan(2); + expect(evidence.every(event => event.turnId === turnId && event.runId === "run-attached")).toBe(true); + const fields = evidence.map(event => Object.fromEntries((event.payload.details as Array<{ name: string; value: string }>).map(x => [x.name, x.value]))); + expect(fields).toEqual(expect.arrayContaining([expect.objectContaining({ operation: "execute", mode: "async", detach: "false", commandSha256: expect.stringMatching(/^sha256:/u) }), expect.objectContaining({ shellState: "started", shellId: "0" }), expect.objectContaining({ shellState: "completed", shellId: "0", exitCode: "0", commandToolCallId: expect.any(String) })])); + expect(JSON.stringify(evidence)).not.toContain("sleep 2"); + } else expect(evidence).toEqual([]); + await session.close({ reason: "projection verified" }); + }); + it("round-trips a provider-neutral ACP form through the runtime request boundary", async () => { const fixture = driverFixture(); const session = await fixture.driver.openSession({ 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 2372311ea7..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,9 @@ +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 { 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"; import { createHash, randomBytes } from "node:crypto"; @@ -85,7 +88,7 @@ import { } from "./runtime-sandbox.js"; import { AcpxTurnControlLedger, parseAcpxTurnControl, type AcpxTurnControlMode } from "./turn-controls.js"; -import { acpxUsageEstimateNotice, persistedAcpxTurnUsage } from "./usage-accounting.js"; +import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "./usage-accounting.js"; const MAX_BUFFERED_EVENTS = 512; const TERMINAL_EVENT_RESERVE = 3; @@ -144,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; @@ -458,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, @@ -470,6 +475,7 @@ export class CodexAcpxDriver implements HarnessDriver { : {}), ...(input.signal === undefined ? {} : { signal: input.signal }), semanticTools: { + ...(this.#options.agent === "copilot" ? { captureSemanticReceipt: () => session?.captureSemanticReceipt() } : {}), tools: this.#options.dynamicTools ?? [], handler: (call) => { if (!session) { @@ -752,6 +758,7 @@ class CodexAcpxSession implements HarnessSession { >; #sourceSequence = 0; #activeTurnId: string | null = null; + #copilotToolEvidence: CopilotToolEvidence | undefined; #semanticResult: PrpStructuredRunResult | null = null; #semanticFingerprint: string | null = null; #semanticCallId: string | null = null; @@ -910,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 { @@ -1106,6 +1114,10 @@ class CodexAcpxSession implements HarnessSession { return { result: "handed_off", cleanup }; } + captureSemanticReceipt() { + return this.#copilotToolEvidence?.captureSemanticReceipt(); + } + async dispatchTool(call: RunnerToolCall): Promise { this.#assertOpen(); if (this.#pendingTerminal) { @@ -1175,6 +1187,7 @@ class CodexAcpxSession implements HarnessSession { this.#semanticFingerprint === null || (claimsLaterTurn && !repeatsPendingTransfer) ) { + const commitNormalizedInput = call.captureNormalizedInput?.(validation.result); if ( !this.#emit("run.result.proposed", validation.result, { turnId, @@ -1186,6 +1199,7 @@ class CodexAcpxSession implements HarnessSession { "the event consumer must drain provider events before a semantic result can be accepted", ); } + commitNormalizedInput?.(); if (claimsLaterTurn) { // A reaffirming retry does not own the durable result until its // provider turn completes successfully. A failed or interrupted @@ -1275,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, }, @@ -1444,7 +1459,8 @@ class CodexAcpxSession implements HarnessSession { let index = 0; const normalizeToolEvent = createAcpxToolEventNormalizer(); - const normalizeMessage = this.#agent === "grok" + const piMessages = this.#agent === "pi" ? createPiMessageProjection() : null; + const normalizeMessage = piMessages ? piMessages.normalize : this.#agent === "grok" ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of turn.events) { toolEvidence?.tool(event); @@ -1453,6 +1469,7 @@ class CodexAcpxSession implements HarnessSession { this.#mapRuntimeEvent(normalizeMessage(normalizeToolEvent(projected)), turnId, ++index); } const result = await turn.result; + if (result.status === "completed") piMessages?.settle(); await drainExtensions(); const usageAfter = await readUsageStatus(this.#host); // Diagnostic projection failures cannot replace the provider's terminal result. @@ -1480,7 +1497,7 @@ class CodexAcpxSession implements HarnessSession { this.#emit( "item.completed", { kind: "agentMessage", channel: "final", text: finalText }, - { turnId, itemId: `${turnId}:assistant-message` }, + { turnId, itemId: this.#assistantItemId(turnId, this.#assistantMessageId) }, ); } this.#publishTerminal( @@ -1637,8 +1654,13 @@ class CodexAcpxSession implements HarnessSession { if (this.#activeTurnId === turnId) this.#activeTurnId = null; } + #assistantItemId(turnId: string, messageId: string | null): string { + if (this.#agent !== "copilot") return `${turnId}:assistant-message`; + return messageId ? `${turnId}:assistant-message:${messageId}` : `${turnId}:assistant-activity`; + } + #mapRuntimeEvent( - event: AcpRuntimeEvent, + event: PiProjectedMessageEvent, turnId: string, index: number, ): void { @@ -1647,9 +1669,18 @@ class CodexAcpxSession implements HarnessSession { const output = boundedText(event.text, 64 * 1024); const isReasoning = event.stream === "thought" || event.tag === "agent_thought_chunk"; - if (!isReasoning) { - const messageId = typeof event.messageId === "string" && event.messageId ? event.messageId : null; - if (messageId && this.#assistantMessageId && messageId !== this.#assistantMessageId) this.#assistantText = ""; + const messageId = typeof event.messageId === "string" && event.messageId ? event.messageId : null; + // The pinned Copilot mapper supplies actual native identity, including + // empty starts. Unidentified session info/warnings remain activity only. + if (!isReasoning && !event.piMessageHistory && (this.#agent !== "copilot" || messageId)) { + if (piBoundaryClearsFinal(event)) this.#assistantText = ""; + if (messageId && this.#assistantMessageId && messageId !== this.#assistantMessageId) { + if (this.#agent === "copilot" && this.#assistantText) { + this.#emit("item.completed", { kind: "agentMessage", channel: "commentary", text: this.#assistantText }, + { turnId, itemId: this.#assistantItemId(turnId, this.#assistantMessageId) }); + } + this.#assistantText = ""; + } if (messageId) this.#assistantMessageId = messageId; this.#assistantText = boundedText( `${this.#assistantText}${output}`, @@ -1667,7 +1698,7 @@ class CodexAcpxSession implements HarnessSession { turnId, itemId: isReasoning ? `${turnId}:reasoning` - : `${turnId}:assistant-message`, + : this.#assistantItemId(turnId, messageId), }, ); } @@ -2122,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 dbeba64929..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 @@ -29,6 +29,24 @@ const HANDLE: AcpRuntimeHandle = { }; describe("Codex ACPX runtime adapter", () => { + it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => { + for (const agent of ["copilot", "codex", "claude", "grok"] as const) { + const runtime = fakeRuntime(); + let created!: AcpRuntimeOptions; + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent }; + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, + }); + if (agent === "copilot") { + expect(created.protocolGuardFactory).toBeTypeOf("function"); + expect(created.protocolGuardFactory!()).not.toBe(created.protocolGuardFactory!()); + expect(() => port.startTurn({ text: "\n/yolo on", requestId: "bad" })).toThrow(/admitted permission policy/); + expect(runtime.startTurn).not.toHaveBeenCalled(); + } else expect(created.protocolGuardFactory).toBeUndefined(); + await port.close({ reason: "test complete" }); + } + }); it("rejects forged permission session identifiers before delegating or applying full-auto policy", async () => { const pending = pendingExtensionTurn("turn-1"); const runtime = fakeRuntime(); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn); @@ -214,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 088d098c1f..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 { @@ -32,6 +33,7 @@ import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-po import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.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; @@ -301,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; @@ -311,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 @@ -327,6 +332,9 @@ export async function openQualifiedAcpxRuntime( elicitationModes: ["form"], ...(options.clientCapabilities === undefined ? {} : { clientCapabilities: structuredClone(options.clientCapabilities) }), extensionMethods: [...new Set([...extensionRequests, ...extensionNotifications])], + ...(options.profile.agent === "copilot" ? { + protocolGuardFactory: () => createCopilotProtocolGuard(options.profile.reportedModelId), + } : {}), onExtensionRequest: async (method, params, context) => { const active = extensionBoundary.active; if (!extensionRequests.has(method) || !active?.onRequest || !ownsExtensionTurn(active, params) || context.signal.aborted) { @@ -507,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); @@ -522,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(); @@ -576,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, @@ -584,6 +605,7 @@ export async function openQualifiedAcpxRuntime( commandLaunches, permissionBoundary, extensionBoundary, + options.profile.agent === "copilot" ? assertCopilotPromptPolicy : undefined, ); } catch (error) { const cleanupReason = "ACPX runtime identity validation failed"; @@ -1013,6 +1035,7 @@ function runtimePort( commandLaunches: { count: number; refreshConsumedCommand?: () => Promise }, permissionBoundary: { active: AbortController | null; hasAdmittedTurn: boolean; handler?: AcpRuntimeOptions["onPermissionRequest"] }, extensionBoundary: AcpxRuntimeExtensionBoundary, + assertPromptPolicy?: (text: string) => void, ): AcpxRuntimePort { extensionBoundary.sessionIds = new Set([identity.backendSessionId]); let extensionControls: Promise = Promise.resolve(); @@ -1370,6 +1393,7 @@ function runtimePort( } : {}), startTurn(input) { + assertPromptPolicy?.(input.text); if (extensionBoundary.active) throw new Error("ACPX runtime already has an active turn"); const approval = new AbortController(); const controller = new AbortController(); 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-events.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-events.test.ts new file mode 100644 index 0000000000..96c04dc0a6 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-events.test.ts @@ -0,0 +1,61 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { COPILOT_ACP_CLIENT_CAPABILITIES, COPILOT_ACP_EVENT_METHOD, normalizeCopilotSessionEvent } from "./copilot-events.js"; + +const binding = { sessionId: "session-1", turnId: "turn-1" }; +const event = (type: string, data: unknown, extra = {}) => ({ method: COPILOT_ACP_EVENT_METHOD, params: { sessionId: "session-1", type, data, ...extra } }); + +describe("Copilot native event projection", () => { + it("subscribes explicitly and requires an active receiver without inventing originating turn", () => { + const events = COPILOT_ACP_CLIENT_CAPABILITIES._meta["github.com/copilot"].events; + expect(events).toContain("session.workspace_file_changed"); + expect(events).not.toContain("assistant.reasoning"); + expect(normalizeCopilotSessionEvent(event("session.idle", {}), { ...binding, sessionId: "other" })).toBeNull(); + expect(normalizeCopilotSessionEvent(event("session.idle", {}), { ...binding, turnId: "" })).toBeNull(); + expect(normalizeCopilotSessionEvent(event("assistant.reasoning", { text: "private" }), binding)).toBeNull(); + }); + it("retains session evidence without treating receipt or provider payload as originating turn", () => { + const delayed = event("assistant.usage", { inputTokens: 10 }, { turnId: "unqualified-provider-field" }); + const earlier = normalizeCopilotSessionEvent(delayed, binding); + const later = normalizeCopilotSessionEvent(delayed, { ...binding, turnId: "next-turn" }); + expect(earlier).toEqual(later); + expect(later).toMatchObject({ sessionId: "session-1", turnId: null, data: { inputTokens: 10 } }); + }); + it("preserves subagent attribution and useful metrics while dropping undeclared fields", () => { + expect(normalizeCopilotSessionEvent(event("subagent.completed", { toolCallId: "t", agentName: "review", agentDisplayName: "Reviewer", model: "m", totalTokens: 42, cancelled: true, private: "secret" }, { agentId: "child" }), binding)).toMatchObject({ kind: "activity", agentId: "child", data: { model: "m", totalTokens: 42, cancelled: true } }); + expect(normalizeCopilotSessionEvent(event("subagent.completed", { private: "secret" }), binding)?.data).not.toHaveProperty("private"); + }); + it("does not conflate provider workspace artifacts with task files", () => { + const result = normalizeCopilotSessionEvent(event("session.workspace_file_changed", { path: "reports/result.md", operation: "create" }), binding); + expect(result).toMatchObject({ kind: "artifact_reference", data: { path: "reports/result.md", locationKind: "provider_session_workspace", requiresArtifactResolution: true } }); + for (const path of ["../secret", "/etc/passwd", "C:\\secret", "file:///secret", "a/../secret", "a//b", "a\nsecret"]) { + expect(normalizeCopilotSessionEvent(event("session.workspace_file_changed", { path, operation: "create" }), binding)).toBeNull(); + } + }); + it("verifies binary asset identity and emits metadata without inline bytes", () => { + const bytes = Buffer.from("test image"); + const data = { assetId: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, type: "image", mimeType: "image/png", byteLength: bytes.length, data: bytes.toString("base64") }; + expect(normalizeCopilotSessionEvent(event("session.binary_asset", data), binding)).toMatchObject({ kind: "artifact_reference", data: { assetId: data.assetId, byteLength: bytes.length } }); + expect(normalizeCopilotSessionEvent(event("session.binary_asset", data), binding)?.data).not.toHaveProperty("data"); + expect(normalizeCopilotSessionEvent(event("session.binary_asset", { ...data, data: "invalid" }), binding)).toBeNull(); + expect(normalizeCopilotSessionEvent(event("session.binary_asset", { ...data, byteLength: 99 }), binding)).toBeNull(); + }); + it("keeps cost units explicit and avoids duplicate authoritative usage", () => { + expect(normalizeCopilotSessionEvent(event("assistant.usage", { inputTokens: 10, outputTokens: 2, cost: 3, copilotUsage: { totalNanoAiu: 4 }, quotaSnapshots: { secret: true } }), binding)?.data).toEqual({ inputTokens: 10, outputTokens: 2, modelMultiplier: 3, totalNanoAiu: 4, accounting: "supplemental_provider_notice" }); + }); + it("does not finalize turns from lossy events or invent responders for native input", () => { + expect(normalizeCopilotSessionEvent(event("session.idle", {}), binding)?.data.authoritativeTurnCompletion).toBe(false); + expect(normalizeCopilotSessionEvent(event("user_input.requested", { requestId: "q", question: "private question" }), binding)).toMatchObject({ kind: "capability_gap", data: { reason: "native_request_has_no_qualified_acp_responder", requestId: "q" } }); + expect(normalizeCopilotSessionEvent(event("session.plan_changed", {}, { dataOmitted: "too-large" }), binding)).toMatchObject({ kind: "capability_gap", data: { omission: "too-large" } }); + }); + it("rejects oversized and cyclic notifications", () => { + expect(normalizeCopilotSessionEvent(event("session.idle", { text: "x".repeat(50 * 1024) }), binding)).toBeNull(); + const data: Record = {}; data.self = data; + expect(normalizeCopilotSessionEvent(event("session.idle", data), binding)).toBeNull(); + }); + it("preserves fractional usage latency and compaction counters without private summaries", () => { + expect(normalizeCopilotSessionEvent(event("assistant.usage", { duration: 1.5, timeToFirstTokenMs: 12.25 }), binding)?.data).toMatchObject({ duration: 1.5, timeToFirstTokenMs: 12.25 }); + expect(normalizeCopilotSessionEvent(event("session.compaction_complete", { success: true, tokensRemoved: 50, summaryContent: "private", checkpointPath: "/private" }), binding)?.data).toEqual({ tokensRemoved: 50, success: true }); + expect(normalizeCopilotSessionEvent(event("session.permissions_changed", { mode: "allow_all", previousMode: "default" }), binding)?.data).toEqual({ previousMode: "default", mode: "allow_all", policyChangeAllowed: false }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-events.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-events.ts new file mode 100644 index 0000000000..563d1f0ade --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-events.ts @@ -0,0 +1,173 @@ +import { createHash } from "node:crypto"; + +export const COPILOT_ACP_EVENT_METHOD = "github.com/copilot/sessionEvent" as const; + +/** Explicit subscription: do not request reasoning, prompts, hooks, or raw tools twice. */ +export const COPILOT_ACP_EVENT_TYPES = Object.freeze([ + "session.idle", "session.background_tasks_changed", "session.completion_receipt", + "session.plan_changed", "session.workspace_file_changed", "session.binary_asset", + "subagent.started", "subagent.configured", "subagent.completed", "subagent.failed", + "assistant.usage", "session.usage_info", "session.usage_checkpoint", + "session.compaction_start", "session.compaction_complete", "pending_messages.modified", + "session.context_changed", "session.permissions_changed", "session.mode_changed", + "user_input.requested", "exit_plan_mode.requested", "elicitation.requested", +] as const); + +export const COPILOT_ACP_CLIENT_CAPABILITIES = Object.freeze({ + _meta: Object.freeze({ + "github.com/copilot": Object.freeze({ events: COPILOT_ACP_EVENT_TYPES }), + }), +}); + +export interface CopilotSessionEvent { + kind: "activity" | "usage" | "artifact_reference" | "capability_gap"; + sourceMethod: typeof COPILOT_ACP_EVENT_METHOD; + sourceType: string; + sessionId: string; + /** The pinned passthrough does not identify the originating prompt. */ + turnId: null; + agentId?: string; + timestamp?: string; + data: Record; +} + +const eventTypes = new Set(COPILOT_ACP_EVENT_TYPES); +const MAX_EVENT_BYTES = 40 * 1024; +const MAX_TEXT_LENGTH = 4096; + +/** + * Provider notices remain evidence, never authority to read a path, respond to + * native requests, change permissions, or finalize a turn. Raw passthrough has + * neither delivery guarantees nor an event ID and is not an accounting ledger. + */ +export function normalizeCopilotSessionEvent( + notification: unknown, + binding: { sessionId: string; turnId: string }, +): CopilotSessionEvent | null { + const envelope = record(notification); + if (envelope.method !== COPILOT_ACP_EVENT_METHOD || !binding.sessionId || !binding.turnId) return null; + const params = record(envelope.params); + if (params.sessionId !== binding.sessionId || typeof params.type !== "string" || !eventTypes.has(params.type)) return null; + // This runs after JSON framing, but remains safe against fabricated test/port values. + try { if (Buffer.byteLength(JSON.stringify(params)) > MAX_EVENT_BYTES) return null; } catch { return null; } + const result: CopilotSessionEvent = { + kind: "activity", sourceMethod: COPILOT_ACP_EVENT_METHOD, sourceType: params.type, + sessionId: binding.sessionId, turnId: null, data: {}, + }; + if (validText(params.agentId, 256)) result.agentId = params.agentId; + if (validText(params.timestamp, 128) && Number.isFinite(Date.parse(params.timestamp))) result.timestamp = params.timestamp; + if (params.dataOmitted !== undefined) { + result.kind = "capability_gap"; + result.data = { reason: "provider_omitted_event_data", omission: params.dataOmitted === "too-large" ? "too-large" : "unserializable" }; + return result; + } + const data = record(params.data); + switch (params.type) { + case "session.workspace_file_changed": { + if (!safeRelativePath(data.path) || !["create", "update"].includes(String(data.operation))) return null; + result.kind = "artifact_reference"; + result.data = { path: data.path, operation: data.operation, locationKind: "provider_session_workspace", requiresArtifactResolution: true }; + break; + } + case "session.binary_asset": { + if (!validText(data.assetId, 128) || !/^sha256:[a-f0-9]{64}$/.test(data.assetId) + || !validText(data.mimeType, 256) || !["image", "resource"].includes(String(data.type)) + || !integer(data.byteLength) || typeof data.data !== "string" + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data.data)) return null; + const bytes = Buffer.from(data.data, "base64"); + if (bytes.length !== data.byteLength || `sha256:${createHash("sha256").update(bytes).digest("hex")}` !== data.assetId) return null; + result.kind = "artifact_reference"; + result.data = { assetId: data.assetId, assetType: data.type, mimeType: data.mimeType, byteLength: data.byteLength, requiresArtifactResolution: true }; + copyText(data, result.data, ["description"]); + break; + } + case "assistant.usage": + result.kind = "usage"; + copyText(data, result.data, ["model", "reasoningEffort", "initiator", "interactionType", "apiCallId", "parentToolCallId", "transport", "apiEndpoint", "finishReason"]); + copyNumbers(data, result.data, ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens", "duration", "maxPromptTokens", "maxOutputTokens", "acceptedPredictionTokens", "rejectedPredictionTokens", "availableToolCount", "numToolCalls", "toolTokenCount", "cacheTtlSeconds", "timeToFirstTokenMs", "interTokenLatencyMs", "outputTtftMs", "thinkingDroppedBlocks"]); + copyBooleans(data, result.data, ["isByok", "isAuto", "cacheDetailsReported", "contentFilterTriggered"]); + if (nonnegativeNumber(data.cost)) result.data.modelMultiplier = data.cost; // Not USD. + if (nonnegativeNumber(record(data.copilotUsage).totalNanoAiu)) result.data.totalNanoAiu = record(data.copilotUsage).totalNanoAiu; + result.data.accounting = "supplemental_provider_notice"; + break; + case "session.usage_info": + result.kind = "usage"; + copyNumbers(data, result.data, ["tokenLimit", "currentTokens", "messagesLength", "systemTokens", "conversationTokens", "toolDefinitionsTokens"]); + copyBooleans(data, result.data, ["isInitial"]); + break; + case "session.usage_checkpoint": + result.kind = "usage"; + if (nonnegativeNumber(data.totalNanoAiu)) result.data.totalNanoAiu = data.totalNanoAiu; + result.data.accounting = "session_cumulative_nano_ai_units"; + break; + case "subagent.started": case "subagent.configured": case "subagent.completed": case "subagent.failed": + copyText(data, result.data, ["toolCallId", "agentName", "agentDisplayName", "agentDescription", "model", "parentId", "agentType", "executionMode", "reasoningEffort", "contextTier", "firstDispatchedModel", "configuredModelPreference", "explicitModelOverride", "modelOverrideReason", "modelSelectionSource", "taskModelSource", "factoryRunId", "error"]); + copyNumbers(data, result.data, ["totalToolCalls", "totalTokens", "durationMs"]); + copyBooleans(data, result.data, ["cancelled", "resumable", "multiTurn", "explicitModelMatchesPreference", "configuredModelMatchesActual"]); + break; + case "session.completion_receipt": + copyNumbers(data, result.data, ["schemaVersion", "attempt", "successfulToolCount", "failedToolCount"]); + copyText(data, result.data, ["sourceEventId", "stopReason"]); + result.data.eventRange = {}; + copyText(record(data.eventRange), result.data.eventRange as Record, ["startEventId", "endEventId"]); + result.data.authoritativeTurnCompletion = false; + break; + case "session.idle": + copyBooleans(data, result.data, ["aborted"]); + copyText(data, result.data, ["mode"]); + result.data.authoritativeTurnCompletion = false; + break; + case "session.plan_changed": + copyText(data, result.data, ["operation"]); + result.data.planContentAvailable = false; + break; + case "user_input.requested": case "exit_plan_mode.requested": case "elicitation.requested": + // ACP 1.0.88's passthrough is notification-only; it cannot acknowledge these + // native callback requests. Never pretend that a UI answer was delivered. + result.kind = "capability_gap"; + result.data = { reason: "native_request_has_no_qualified_acp_responder" }; + copyText(data, result.data, ["requestId", "toolCallId"]); + break; + case "session.mode_changed": copyText(data, result.data, ["previousMode", "newMode"]); break; + case "session.permissions_changed": + copyText(data, result.data, ["previousMode", "mode", "assistedApprovalModel"]); + result.data.policyChangeAllowed = false; + break; + case "session.compaction_start": case "session.compaction_complete": + copyText(data, result.data, ["trigger", "model", "behaviorModelId", "error"]); + copyNumbers(data, result.data, ["checkpointNumber", "compactionTokensUsed", "conversationTokens", "currentTokens", "messagesRemoved", "postCompactionTokens", "preCompactionMessagesLength", "preCompactionTokens", "systemTokens", "tokenLimit", "tokensRemoved", "toolDefinitionsTokens", "statusCode"]); + copyBooleans(data, result.data, ["success"]); + break; + case "session.context_changed": + // cwd is a report, not authority to rebind the admitted workspace. + copyText(data, result.data, ["cwd", "branch", "baseCommit", "headCommit", "hostType"]); + result.data.workspaceRebindAllowed = false; + break; + default: + // Empty lifecycle notices are useful; private native payloads are not copied. + result.data = { refreshAvailable: false }; + } + return result; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} +function validText(value: unknown, max = MAX_TEXT_LENGTH): value is string { + return typeof value === "string" && value.length > 0 && value.length <= max && !value.includes("\0"); +} +function integer(value: unknown): value is number { return Number.isSafeInteger(value) && Number(value) >= 0; } +function nonnegativeNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } +function safeRelativePath(value: unknown): value is string { + return validText(value) && !/[\\\u0000-\u001f]/.test(value) && !/^(?:\/|[A-Za-z]:|[A-Za-z][A-Za-z0-9+.-]*:)/.test(value) + && value.split("/").every((part) => part !== "" && part !== "." && part !== ".."); +} +function copyText(source: Record, target: Record, fields: string[]): void { + for (const field of fields) if (validText(source[field])) target[field] = source[field]; +} +function copyNumbers(source: Record, target: Record, fields: string[]): void { + for (const field of fields) if (nonnegativeNumber(source[field])) target[field] = source[field]; +} +function copyBooleans(source: Record, target: Record, fields: string[]): void { + for (const field of fields) if (typeof source[field] === "boolean") target[field] = source[field]; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-evidence.test.ts new file mode 100644 index 0000000000..fe350a8e81 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-evidence.test.ts @@ -0,0 +1,119 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { COPILOT_ACP_EVENT_TYPES } from "./copilot-events.js"; + +function fixture(name: string) { + return JSON.parse(readFileSync(new URL(`../../../test/fixtures/${name}`, import.meta.url), "utf8")); +} + +describe("Copilot pinned executable evidence", () => { + it("retains controller-restart recovery without erasing the separate question failure", () => { + const evidence = fixture("copilot-product-restart-live-proof-2026-09-28.json"); + expect(evidence).toMatchObject({ qualificationStatus: "pending", status: "passed", providerReportedCostUsd: null, billingComplete: false, cleanup: "passed", retainedFixtureProcessesAfterCleanup: 0 }); + expect(evidence.matcherResults).toHaveLength(6); + expect(evidence.matcherResults.every((result: { passed: boolean }) => result.passed)).toBe(true); + expect(evidence.question).toMatchObject({ nativeCopilotAskUserCallback: false, answerOptionIds: ["cobalt"], status: "answered", serverRestartedBeforeAnswer: true, sameInteractionPreserved: true }); + expect(evidence.continuation).toMatchObject({ samePersistedProviderSession: true, freshSession: false, sessionReused: true, taskSessionReused: true, providerDeathRecoveryVerified: false }); + expect(evidence.usage.every((usage: { biller: string; costStatus: string; costUsdFieldPresent: boolean }) => usage.biller === "github" && usage.costStatus === "unpriced" && !usage.costUsdFieldPresent)).toBe(true); + expect(fixture("copilot-product-merged-live-proof-2026-09-28.json").cases[1].status).toBe("failed"); + expect(JSON.stringify(evidence)).not.toMatch(/\/Users\/|\/tmp\/|github_pat_|accessToken/); + }); + it("retains revision-bound semantic plan approval without claiming warm reuse or priced usage", () => { + const evidence = fixture("copilot-product-plan-live-proof-2026-09-28.json"); + expect(evidence).toMatchObject({ qualificationStatus: "pending", status: "passed", providerReportedCostUsd: null, billingComplete: false, cleanup: "passed" }); + expect(evidence.matcherResults).toHaveLength(6); + expect(evidence.matcherResults.every((result: { passed: boolean }) => result.passed)).toBe(true); + expect(evidence.planApproval).toMatchObject({ displayedRevisionNumber: 1, targetMatchesDisplayedRevision: true, status: "accepted", nativeCopilotPlanCallback: false }); + expect(evidence.continuation).toMatchObject({ freshSession: true, sessionReused: false }); + expect(evidence.usage.every((usage: { biller: string; costStatus: string; costUsdFieldPresent: boolean }) => usage.biller === "github" && usage.costStatus === "unpriced" && !usage.costUsdFieldPresent)).toBe(true); + }); + it("retains an actual detached command completing before the GitHub turn settles", () => { + const evidence = fixture("copilot-live-detached-2026-09-28.json"); + expect(evidence).toMatchObject({ qualificationStatus: "pending", model: "gpt-5.6-luna", promptRequestsSent: 1, providerReportedCostUsd: null, detachedToolObserved: true, backgroundCompletionObservedBeforeTerminal: true, cleanupComplete: true, result: { passed: true } }); + expect(evidence.nativeToolCall.rawInput).toMatchObject({ mode: "async", detach: true }); + expect(evidence.permissions[0]).toMatchObject({ requestId: 0, outcome: { outcome: "selected", optionId: "allow_once" }, exactCommand: true }); + expect(evidence.nativeCompletion.elapsedMs).toBeLessThan(evidence.terminalElapsedMs); + expect(evidence.markerOracle.terminal.matches).toBe(true); + expect(evidence.markerOracle.afterCleanup.matches).toBe(true); + expect(JSON.stringify(evidence)).not.toMatch(/\/Users\/|\/tmp\/|github_pat_|accessToken|apiCallId/); + }); + it("retains the actual GitHub denied write with unknown USD and bounded scope", () => { + const evidence = fixture("copilot-live-denial-2026-09-28.json"); + expect(evidence).toMatchObject({ qualificationStatus: "pending", model: "gpt-5.6-luna", promptRequestsSent: 1, providerReportedCostUsd: null, cleanupComplete: true, result: { passed: true } }); + expect(evidence.permissions).toHaveLength(1); + expect(evidence.permissions[0]).toMatchObject({ requestId: 0, outcome: { outcome: "selected", optionId: "reject_once" }, writeAttempt: true }); + expect(evidence.markerOracle).toMatchObject({ anySampleExisted: false, terminal: { exists: false }, afterCleanup: { exists: false } }); + expect(JSON.stringify(evidence)).not.toMatch(/\/Users\/|\/tmp\/|github_pat_|accessToken|apiCallId/); + }); + it("retains local Product failures separately from passed transport and accounting evidence", () => { + const evidence = fixture("copilot-product-merged-live-proof-2026-09-28.json"); + expect(evidence.qualificationStatus).toBe("pending"); + expect(evidence.modelProvider).toBe("github"); + expect(evidence.cases[0]).toMatchObject({ caseId: "file-edit-validate", status: "passed", behaviorMatchersPassed: 7, costStatus: "unpriced", costUsdFieldPresent: false, billingComplete: false }); + expect(evidence.cases[1]).toMatchObject({ caseId: "question-resume-complete", status: "failed", questionDelivered: true, answerOptionId: "cobalt", sameProviderSessionReused: true, observedFinalText: "[terminal marker]", originalEvidenceUnchanged: true }); + expect(evidence.cases[2]).toMatchObject({ caseId: "plan-approve-complete", status: "failed", providerTurns: 0, qualificationEvidence: false }); + expect(evidence.restartCase.status).toBe("not_executed"); + expect(evidence.externalBilling.providerReportedCostUsd).toBeNull(); + expect(JSON.stringify(evidence)).not.toMatch(/\/Users\/|\/tmp\/|github_pat_|accessToken/); + }); + it("retains authenticated exact model selection without promoting metadata to inference", () => { + const evidence = fixture("copilot-authenticated-discovery-1.0.88.json"); + expect(evidence).toMatchObject({ harnessVersion: "1.0.88", promptSent: false, promptRequestsSent: 0, inferenceVerified: false, sessionClosed: true }); + expect(evidence.models.availableModels).toHaveLength(21); + expect(evidence.models.availableModels.find((model: { modelId: string }) => model.modelId === "gpt-5.6-luna")._meta.copilotEnablement).toBe("enabled"); + expect(evidence.modelSelection.requestedModel).toBe("gpt-5.6-luna"); + expect(evidence.modelSelection.configEcho.configOptions.find((option: { id: string }) => option.id === "model").currentValue).toBe("gpt-5.6-luna"); + expect(JSON.stringify(evidence)).not.toMatch(/\/Users\/|\/tmp\/|github_pat_|gho_|sessionId|accessToken/); + }); + it.each(["copilot-provider-pack-darwin-arm64-1.0.88.json", "copilot-provider-pack-final-2026-09-28.json", "copilot-provider-pack-linux-x64-2026-09-28.json"])("retains a complete packaged native launch without promoting offline proof to qualification (%s)", (name) => { + const evidence = fixture(name); + expect(evidence.sourceRevision).toMatch(/^[a-f0-9]{40}$/); + expect(evidence.providerPackDigest).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(evidence.candidate).toMatchObject({ version: "1.0.88", qualification: "pending" }); + expect(evidence.initialize).toMatchObject({ protocolVersion: 1, agentInfo: { version: "1.0.88" } }); + expect(evidence).toMatchObject({ initializeRequestId: 0, cleanExit: true, inheritedCredentials: false, + promptSent: false, fixtureRequests: [], costUsd: 0, missingCredentialPreflight: "COPILOT_AUTH_REQUIRED" }); + expect(JSON.stringify(evidence)).not.toMatch(/\/Users\/|\/private\/var\/|\/tmp\/paperclip-/); + }); + it("accounts for every inventoried event and field without pretending all were observed", () => { + const inventory = fixture("copilot-event-inventory-1.0.88.json"); + expect(inventory.events).toHaveLength(150); + const subscribed = inventory.events.filter((event: { paperclipDisposition: string }) => event.paperclipDisposition === "subscribed_and_projected").map((event: { event: string }) => event.event); + expect(subscribed.sort()).toEqual([...COPILOT_ACP_EVENT_TYPES].sort()); + for (const event of inventory.events) { + expect(Object.keys(event.fieldCoverage).sort()).toEqual(event.fields); + expect(event.gapReason).toBeTruthy(); + expect(event.followUpPriority).toBeTruthy(); + } + }); + + it("retains a real numeric-zero permission denial with no filesystem side effect", () => { + const evidence = fixture("copilot-acp-denial-1.0.88.json"); + expect(evidence.costUsd).toBe(0); + expect(evidence.executableSha256).toBe("a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3"); + expect(evidence.permissionResponses).toEqual([{ id: 0, outcome: { outcome: "selected", optionId: "reject_once" } }]); + expect(evidence.filesystemMarkerExistedAfterPrompt).toBe(false); + expect(evidence.filesystemMarkerExistedAtPromptResult).toBe(false); + const permission = evidence.wire.find((message: { method?: string }) => message.method === "session/request_permission"); + expect(permission.id).toBe(0); + expect(permission.params.options.map((option: { kind: string }) => option.kind)).toEqual(["allow_once", "allow_always", "reject_once"]); + expect(evidence.modelToolNames).not.toContain("ask_user"); + expect(evidence.modelToolNames).not.toContain("exit_plan_mode"); + }); + + it("retains attached background command completion before ACP turn settlement", () => { + const evidence = fixture("copilot-acp-settlement-1.0.88.json"); + expect(evidence.costUsd).toBe(0); + expect(evidence.filesystemMarkerExistedAfterPrompt).toBe(true); + expect(evidence.filesystemMarkerExistedAtPromptResult).toBe(true); + const end = evidence.wire.findIndex((message: { result?: { stopReason?: string } }) => message.result?.stopReason === "end_turn"); + const idle = evidence.wire.findIndex((message: { method?: string; params?: { type?: string } }) => message.method === "github.com/copilot/sessionEvent" && message.params?.type === "session.idle"); + expect(idle).toBeGreaterThan(0); + expect(end).toBeGreaterThan(idle); + const transcript = JSON.stringify(evidence.wire.slice(0, end)); + expect(transcript).toContain("Reading shell output"); + expect(transcript).toContain("completed with exit code 0"); + expect(transcript).toContain("ACP_SHELL_DONE"); + expect(evidence.modelSource).toContain("not a live model qualification"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts new file mode 100644 index 0000000000..df319244fc --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.test.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import * as copilotEvents from "./copilot-events.js"; +import { COPILOT_ACP_EVENT_METHOD } from "./copilot-events.js"; +import { createCopilotProfileExtensionAdapter } from "./copilot-extension-adapter.js"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; + +const context = { workspacePath: "/workspace", sessionId: "session-1", turnId: "turn-1" }; +const params = (type: string, data: unknown, extra = {}) => ({ sessionId: context.sessionId, type, data, ...extra }); + +describe("Copilot display extension adapter", () => { + it("preserves subagent lifecycle details as session notices without invented turn provenance", async () => { + const adapter = createCopilotProfileExtensionAdapter(context); + const started = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("subagent.started", { agentName: "review", agentDisplayName: "Reviewer", toolCallId: "tool-1", agentDescription: "Review the change", model: "exact-model", resumable: true }, { agentId: "agent-2" })); + const completed = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("subagent.completed", { agentName: "review", toolCallId: "tool-1", model: "exact-model", totalToolCalls: 3, durationMs: 12.5, totalTokens: 40, modelSelectionSource: "agent" }, { agentId: "agent-2", timestamp: "2026-09-28T00:00:00Z" })); + [...started, ...completed].forEach(validateAcpxRichEvent); + expect(started.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + expect(completed.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + expect(started[0].itemId).not.toBe(completed[0].itemId); + expect(completed[0].payload).toMatchObject({ + scope: "session", + details: expect.arrayContaining([{ name: "totalTokens", value: "40" }, { name: "durationMs", value: "12.5" }, { name: "modelSelectionSource", value: "agent" }, { name: "source.agentId", value: "agent-2" }, { name: "source.timestamp", value: "2026-09-28T00:00:00Z" }]), + }); + }); + + it("does not assign delayed usage, delegation, or artifact activity to the next receiving turn", async () => { + const next = createCopilotProfileExtensionAdapter({ ...context, turnId: "turn-2" }); + for (const [type, data] of [ + ["assistant.usage", { inputTokens: 42 }], + ["subagent.completed", { toolCallId: "previous-tool", totalTokens: 42 }], + ["session.workspace_file_changed", { path: "previous.md", operation: "create" }], + ["session.compaction_complete", { success: true, preCompactionTokens: 42 }], + ] as const) { + const events = await next.notification(COPILOT_ACP_EVENT_METHOD, params(type, data, { timestamp: "2026-09-28T00:00:00Z" })); + events.forEach(validateAcpxRichEvent); + expect(events.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + expect(events[0].payload.scope).toBe("session"); + expect(events[0].payload).not.toHaveProperty("provenance"); + expect(events[0].payload.details).toContainEqual({ name: "source.sessionId", value: "session-1" }); + expect(events[0].payload.details).toContainEqual({ name: "turnAttribution", value: "unknown: provider session notification has no originating turn ID" }); + expect(JSON.stringify(events)).not.toContain("turn-2"); + } + }); + + it("does not register provider-session paths or binary assets as task artifacts", async () => { + const adapter = createCopilotProfileExtensionAdapter(context); + const file = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.workspace_file_changed", { operation: "create", path: "reports/result.md" })); + file.forEach(validateAcpxRichEvent); + expect(file.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + expect(file[0].payload).toMatchObject({ scope: "session" }); + expect(file[0].payload.details).toEqual(expect.arrayContaining([{ name: "path", value: "reports/result.md" }, { name: "locationKind", value: "provider_session_workspace" }])); + expect(await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.workspace_file_changed", { operation: "create", path: "../../secret" }))).toEqual([]); + const bytes = Buffer.from("image fixture"); + const asset = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.binary_asset", { assetId: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, type: "image", mimeType: "image/png", byteLength: bytes.length, data: bytes.toString("base64") })); + asset.forEach(validateAcpxRichEvent); + expect(asset[0].payload.details).toContainEqual({ name: "mimeType", value: "image/png" }); + expect(JSON.stringify(asset)).not.toContain(bytes.toString("base64")); + }); + + it("keeps supplemental non-dollar usage separate from authoritative terminal accounting", async () => { + const events = await createCopilotProfileExtensionAdapter(context).notification(COPILOT_ACP_EVENT_METHOD, params("assistant.usage", { model: "m", inputTokens: 10, outputTokens: 2, cost: 3, copilotUsage: { totalNanoAiu: 50 }, quotaSnapshots: { private: "secret" } })); + events.forEach(validateAcpxRichEvent); + expect(events).toHaveLength(1); + expect(events[0].eventType).toBe("provider.notice.recorded"); + expect(events[0].payload.details).toEqual(expect.arrayContaining([{ name: "modelMultiplier", value: "3" }, { name: "totalNanoAiu", value: "50" }, { name: "inputTokens", value: "10" }, { name: "accounting", value: "supplemental_provider_notice" }])); + expect(JSON.stringify(events)).not.toMatch(/costUsd|quotaSnapshots|secret/); + }); + + it("redacts credentials in provider strings while preserving numeric token counters", async () => { + const events = await createCopilotProfileExtensionAdapter(context).notification(COPILOT_ACP_EVENT_METHOD, params("subagent.failed", { agentName: "review", totalTokens: 42, error: "Authorization: Bearer secretsecretsecret", model: "ghp_abcdefghijklmnopqrstuvwxyz" })); + events.forEach(validateAcpxRichEvent); + expect(JSON.stringify(events)).not.toMatch(/secretsecretsecret|ghp_abcdefghijklmnopqrstuvwxyz/); + expect(events[0].payload.details).toContainEqual({ name: "totalTokens", value: "42" }); + }); + + it("reports native unsupported input honestly and ignores unrelated or cross-session notifications", async () => { + const adapter = createCopilotProfileExtensionAdapter(context); + await expect(adapter.request("user_input.requested", {})).rejects.toThrow("no qualified"); + const events = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("user_input.requested", { requestId: "q-1", question: "Do not fabricate an input form" })); + events.forEach(validateAcpxRichEvent); + expect(events[0].payload).toMatchObject({ severity: "warning", userActionable: false }); + expect(JSON.stringify(events)).not.toContain("Do not fabricate"); + expect(await adapter.notification("unrelated/event", {})).toEqual([]); + expect(await adapter.notification(COPILOT_ACP_EVENT_METHOD, { ...params("session.idle", {}), sessionId: "another" })).toEqual([]); + }); + + it("redacts sensitive field paths even when their plain values have no recognizable credential prefix", async () => { + // Exercise the display boundary independently from today's closed native + // field list, so a future projected field cannot bypass keyed redaction. + const normalize = vi.spyOn(copilotEvents, "normalizeCopilotSessionEvent").mockReturnValueOnce({ + kind: "usage", sourceMethod: COPILOT_ACP_EVENT_METHOD, sourceType: "assistant.usage", + sessionId: context.sessionId, turnId: null, + data: { connectionString: "plain-credential-value", credentials: { endpoint: "opaque-value" }, inputTokens: 42 }, + }); + try { + const events = await createCopilotProfileExtensionAdapter(context).notification(COPILOT_ACP_EVENT_METHOD, params("assistant.usage", {})); + events.forEach(validateAcpxRichEvent); + expect(events[0].payload.details).toEqual(expect.arrayContaining([ + { name: "connectionString", value: "[REDACTED]" }, + { name: "credentials.endpoint", value: "[REDACTED]" }, + { name: "inputTokens", value: "42" }, + ])); + expect(JSON.stringify(events)).not.toMatch(/plain-credential-value|opaque-value/); + } finally { normalize.mockRestore(); } + }); + + it("preserves compaction success and failure as session notices and keeps settlement nonterminal", async () => { + const adapter = createCopilotProfileExtensionAdapter(context); + const success = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.compaction_complete", { success: true, preCompactionTokens: 100, postCompactionTokens: 40, checkpointNumber: 1 })); + success.forEach(validateAcpxRichEvent); + expect(success.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + expect(success[0].payload.details).toEqual(expect.arrayContaining([{ name: "preCompactionTokens", value: "100" }, { name: "postCompactionTokens", value: "40" }, { name: "success", value: "true" }])); + const failure = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.compaction_complete", { success: false })); + expect(failure.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + const idle = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.idle", {})); + expect(idle.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + expect(idle[0].payload.details).toContainEqual({ name: "authoritativeTurnCompletion", value: "false" }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts new file mode 100644 index 0000000000..592e5813e9 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts @@ -0,0 +1,99 @@ +import { createHash } from "node:crypto"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import { redactPaperclipSemanticValue, redactSemanticValue } from "../../semantic-tools/redaction.js"; +import { COPILOT_ACP_EVENT_METHOD, normalizeCopilotSessionEvent, type CopilotSessionEvent } from "./copilot-events.js"; +import type { AcpxProfileExtensionAdapter, AcpxProfileExtensionContext } from "./profile-extensions.js"; + +/** Display projection only. Native notices cannot settle or charge a runner turn. */ +export function createCopilotProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter { + let sequence = 0; + return { + async request() { + throw new Error("Copilot 1.0.88 has no qualified inbound ACP extension request responder"); + }, + async notification(method, params) { + if (method !== COPILOT_ACP_EVENT_METHOD) return []; + const event = normalizeCopilotSessionEvent({ method, params }, context); + if (!event) return []; + const noticeId = identity(context, "notice", String(++sequence)); + // The optional canonical provenance object requires an originating turn. + // Session-only evidence keeps its complete source identity in details. + const details = detailFields({ ...event.data, + source: { method, eventType: event.sourceType, sessionId: event.sessionId, + ...(event.agentId ? { agentId: event.agentId } : {}), + ...(event.timestamp ? { timestamp: event.timestamp } : {}), + }, + turnAttribution: "unknown: provider session notification has no originating turn ID", + }); + const notice: CanonicalProviderEvent = { + itemId: noticeId, + eventType: "provider.notice.recorded", + payload: { + schema: "paperclip.provider.notice.v1", noticeId, + severity: event.kind === "capability_gap" ? "warning" : "info", + category: `copilot.${event.sourceType}`, scope: "session", + recoverable: true, userActionable: false, + summary: summary(event), + details, + }, + }; + // Receipt during this turn is not proof of origin. Keep all safe fields + // as session evidence; typed turn activity would misattribute late events. + return [notice]; + }, + }; +} + +function summary(event: CopilotSessionEvent): string { + const data = event.data; + if (event.kind === "capability_gap") return data.reason === "provider_omitted_event_data" + ? "Copilot omitted the payload of a native activity event." + : "Copilot reported native input without a qualified ACP response method."; + switch (event.sourceType) { + case "assistant.usage": return "Copilot reported supplemental model usage; these counters do not establish a dollar charge."; + case "session.usage_checkpoint": return "Copilot reported a cumulative AI-unit checkpoint; it is not a dollar charge."; + case "session.usage_info": return "Copilot updated context usage."; + case "session.workspace_file_changed": return `Copilot ${data.operation === "create" ? "created" : "updated"} a provider-session file; artifact resolution is pending.`; + case "session.binary_asset": return "Copilot reported a verified binary asset; artifact registration is pending."; + case "subagent.started": return "Copilot started a subagent."; + case "subagent.configured": return "Copilot updated subagent configuration."; + case "subagent.completed": return data.cancelled === true ? "Copilot subagent was interrupted." : "Copilot subagent completed."; + case "subagent.failed": return "Copilot subagent failed."; + case "session.plan_changed": return "Copilot changed its plan; this notification does not contain the plan document."; + case "session.compaction_start": return "Copilot started context compaction."; + case "session.compaction_complete": return data.success === true ? "Copilot completed context compaction." : "Copilot reported context compaction without a successful result."; + case "pending_messages.modified": return "Copilot changed its pending messages; the notification does not include queue contents."; + case "session.background_tasks_changed": return "Copilot background tasks changed; the notification does not include task contents."; + case "session.context_changed": return "Copilot reported context changes; the admitted workspace remains fixed."; + case "session.permissions_changed": return "Copilot reported permission state; Paperclip policy remains authoritative."; + case "session.mode_changed": return "Copilot reported a mode change."; + case "session.idle": return "Copilot reported idle activity; the ACP prompt response determines turn settlement."; + case "session.completion_receipt": return "Copilot reported a completion receipt; it does not independently settle the turn."; + default: return "Copilot reported native activity."; + } +} + +function detailFields(data: Record): Array<{ name: string; value: string }> { + const details: Array<{ name: string; value: string }> = []; + const visit = (value: unknown, name: string): void => { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const [key, entry] of Object.entries(value)) visit(entry, name ? `${name}.${key}` : key); + } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + // 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" ? name.split(".").reduce((safe, segment) => redactSemanticValue(safe, segment), value) : value; + details.push({ name: bounded(name, 160), value: bounded(String(safe), 4000) }); + } + }; + visit(data, ""); + if (details.length > 64) throw new Error("Copilot projected detail fields exceed their declared bound"); + return details; +} +function identity(context: AcpxProfileExtensionContext, family: string, source: string): string { + return `copilot-${family}-${createHash("sha256").update(JSON.stringify([context.sessionId, context.turnId, source])).digest("hex")}`; +} +function bounded(value: string, max: number): string { + const safe = String(redactPaperclipSemanticValue(value)); + return safe.length <= max ? safe : `${safe.slice(0, max - 12)} [truncated]`; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts new file mode 100644 index 0000000000..999efa6eed --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -0,0 +1,180 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { build } from "esbuild"; +import { verifyNativeAcpxInstallation } from "./installation-integrity.js"; +import { COPILOT_CLOSURE_SHA256, verifyCopilotInstallation } from "./copilot-installation.js"; +import { COPILOT_LAUNCH_ARGUMENTS, COPILOT_SYSTEM_INSTRUCTION_DELIVERY } from "./copilot-profile.js"; +import { COPILOT_PERMISSION_CONTEXT_CONTRACT } from "./copilot-permission-context.js"; +import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js"; + +const PERMISSION_POLICY_SOURCES = [ + ["copilot-permission-context.ts", "permissionContextSourceSha256"], + ["acp-permission-adapter.ts", "permissionAdapterSourceSha256"], + ["safe-locations.ts", "permissionLocationsSourceSha256"], + ["../../semantic-tools/redaction.ts", "permissionRedactionSourceSha256"], + ["generated-sidecar-contract.ts", "permissionClassifierSourceSha256"], + ["cursor-plan-tool-identity.ts", "permissionIdentitySourceSha256"], +] as const; + +const SEMANTIC_RECEIPT_SOURCES = [ + ["../semantic-tool-receipt.ts", "semanticReceiptSourceSha256"], + ["../runner-tool-bridge.ts", "semanticBridgeSourceSha256"], + ["copilot-tool-evidence.ts", "toolEvidenceSourceSha256"], + ["../../cli/acpx-runtime-sidecar.ts", "semanticSidecarSourceSha256"], + ["sidecar-protocol.ts", "semanticSidecarProtocolSourceSha256"], + ["codex-acpx-driver.ts", "semanticDirectDriverSourceSha256"], + ["../../protocol/replay-contract.ts", "semanticValidationSourceSha256"], + ["../../protocol/result-normalization.ts", "semanticNormalizationSourceSha256"], + ["../../contracts/completion-result.ts", "semanticCompletionContractSourceSha256"], + ["../../protocol/generated/standalone-validators.ts", "semanticValidatorsSourceSha256"], + ["../../protocol/generated/schema-bundle.ts", "semanticSchemaBundleSourceSha256"], +] as const; + +// Walk executable imports, not just the projector's immediate dependencies. +async function permissionPolicyClosure(extraClassifierImport = false): Promise> { + const root = fileURLToPath(new URL(".", import.meta.url)); + const result = await build({ + absWorkingDir: root, + entryPoints: ["acp-permission-adapter.ts"], + bundle: true, write: false, metafile: true, platform: "node", treeShaking: false, + plugins: extraClassifierImport ? [{ + name: "unbound-transitive-policy-negative-control", + setup(builder) { + builder.onResolve({ filter: /^unbound-policy$/ }, () => ({ path: "unbound-policy", namespace: "negative-control" })); + builder.onLoad({ filter: /.*/, namespace: "negative-control" }, () => ({ contents: "export const policy = true;", loader: "ts" })); + builder.onLoad({ filter: /generated-sidecar-contract\.ts$/ }, args => ({ + contents: readFileSync(args.path, "utf8") + '\nimport "unbound-policy";', + loader: "ts", resolveDir: root, + })); + }, + }] : [], + }); + return new Set(Object.keys(result.metafile!.inputs).map(path => path.startsWith("negative-control:") + ? path : pathToFileURL(resolve(root, path)).href)); +} + +vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() })); + +describe("Copilot build-owned installation", () => { + 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 v17 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-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); + expect(identity.declaration.semanticToolReceiptContract).toBe("paperclip.semantic_tool_receipt.v2"); + expect(identity.declaration.semanticNormalizedInputContract).toBe("validated-forwarded-input-commit-v1"); + expect(identity.declaration.semanticSidecarReceiptCommitContract).toBe("correlated-tool-resolve-v1"); + expect(identity.declaration.messageIdentityContract).toBe("copilot-native-message-id-v1"); + expect(identity.declaration.ownedDistributionDelivery).toBe("COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1"); + const inventory = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-message-identity-distributions-1.0.88.json", import.meta.url), "utf8")); + expect(identity.declaration.upstreamAppSha256).toBe(inventory.upstreamAppSha256); + expect(identity.declaration.patchedAppSha256).toBe(inventory.patchedAppSha256); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(identity.declaration.agentProfileVersion); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe(identity.commandDigest); + const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); + expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(identity.commandDigest); + 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("../../../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))); + }); + it("detects an added transitive dependency even when the projector is unchanged", async () => { + const closure = await permissionPolicyClosure(true); + const bound = new Set(PERMISSION_POLICY_SOURCES.map(([path]) => new URL(path, import.meta.url).href)); + 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-v17-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); + }); + it("pins the unchanged executable plus complete patched distribution on every target platform", () => { + const fixture = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-message-identity-distributions-1.0.88.json", import.meta.url), "utf8")); + const script = readFileSync(new URL("../../../scripts/materialize-copilot-binary.mjs", import.meta.url), "utf8"); + const records = [...script.matchAll(/"(darwin-arm64|darwin-x64|linux-x64)": Object\.freeze\(\{ packageName: "[^"]+", executableDigest: "([a-f0-9]+)", size: (\d+)/g)]; + expect(records).toHaveLength(3); + for (const [, platform, sha256, size] of records) { + const platformFixture = fixture.platforms[platform!]; + expect(platformFixture.executableSha256).toBe(sha256); + expect(platformFixture.entries).toContainEqual({ path: "copilot", sha256, size: Number(size), executable: true }); + expect(platformFixture.entries.find((entry: { path: string }) => entry.path === "distribution/app.js").sha256).toBe(fixture.patchedAppSha256); + expect(platformFixture.entries.some((entry: { path: string }) => entry.path === `distribution/prebuilds/${platform}/runtime.node`)).toBe(true); + const digest = createHash("sha256").update(JSON.stringify(platformFixture.entries)).digest("hex"); + expect(platformFixture.closureSha256).toBe(digest); + expect(COPILOT_CLOSURE_SHA256[platform as keyof typeof COPILOT_CLOSURE_SHA256]).toBe(digest); + } + }); + it("ignores ambient executable selection and supplies only pinned native launch inputs", async () => { + const openCommand = vi.fn(); + vi.mocked(verifyNativeAcpxInstallation).mockResolvedValueOnce({ commandDigest: "sha256:closure", agentServerPackageJsonPath: "/verified/closure.json", agentRuntimePackageJsonPath: null, openCommand }); + const before = process.env.COPILOT_PATH; + process.env.COPILOT_PATH = "/untrusted/copilot"; + try { + const result = await verifyCopilotInstallation(QUALIFIED_ACPX_PROFILES.copilot); + const root = fileURLToPath(new URL(`../../../provider-assets/copilot/${process.platform}-${process.arch}`, import.meta.url)); + expect(verifyNativeAcpxInstallation).toHaveBeenCalledWith({ + distributionRoot: root, manifestPath: `${root}/.paperclip-copilot-closure.json`, + expectedClosureSha256: COPILOT_CLOSURE_SHA256[`${process.platform}-${process.arch}` as keyof typeof COPILOT_CLOSURE_SHA256], + executable: "copilot", fixedArguments: COPILOT_LAUNCH_ARGUMENTS, isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME", + copilotDistributionDirectory: "distribution", + }); + expect(result.commandDigest).toBe(QUALIFIED_ACPX_PROFILES.copilot.commandDigest); + expect(result.openCommand).toBe(openCommand); + } finally { if (before === undefined) delete process.env.COPILOT_PATH; else process.env.COPILOT_PATH = before; } + }); + it("rejects changed profile identities before native file access", async () => { + vi.mocked(verifyNativeAcpxInstallation).mockClear(); + for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }, { agentProfileVersion: 10 }]) { + await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned"); + } + 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 new file mode 100644 index 0000000000..6dce44a0e3 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts @@ -0,0 +1,38 @@ +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 AcpxReleaseProfile } from "./qualified-profiles.js"; + +export const COPILOT_CLOSURE_SHA256 = Object.freeze({ + "darwin-arm64": "362f2663e967fb9e34a814bac4619cbf89e6b23069c4051ab1f2f686852d0a32", + "darwin-x64": "c08b7c3dd4e7bcf9a3e16ec6eba29cfa10308e865d196c5f120a3a15f6b3116a", + "linux-x64": "a3d8f4367cfa1694d79e3f8b7930b6fbfe42a229c272b375b11951d631db8d07", +}); + +/** Admission primitive only. Pending candidates remain gated by qualification. */ +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" + || profile.agentServerVersion !== COPILOT_VERSION || profile.commandDigest !== expected.commandDigest + || profile.agentRuntimePackage !== null || profile.agentRuntimeVersion !== null) { + throw new Error("Copilot installation requires the exact pinned candidate profile"); + } + const platform = `${process.platform}-${process.arch}`; + if (!Object.prototype.hasOwnProperty.call(COPILOT_CLOSURE_SHA256, platform)) { + throw new Error(`Copilot native distribution is not pinned for ${platform}`); + } + const distributionRoot = join(resolveRunnerProviderAssetsRoot(import.meta.url, "copilot"), platform); + const native = await verifyNativeAcpxInstallation({ + distributionRoot, + manifestPath: join(distributionRoot, ".paperclip-copilot-closure.json"), + expectedClosureSha256: COPILOT_CLOSURE_SHA256[platform as keyof typeof COPILOT_CLOSURE_SHA256], + executable: "copilot", fixedArguments: COPILOT_LAUNCH_ARGUMENTS, + isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME", + copilotDistributionDirectory: "distribution", + }); + // The host identity binds the versioned profile. Native admission independently + // binds the complete platform closure before creating each single-use lease. + return Object.freeze({ ...native, commandDigest: expected.commandDigest }); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-message-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-message-identity.test.ts new file mode 100644 index 0000000000..4af25a7e14 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-message-identity.test.ts @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { COPILOT_MESSAGE_MAPPING_REPLACEMENT, COPILOT_REPLAY_MAPPING_REPLACEMENT } from "../../../scripts/copilot-inner-distribution.mjs"; +import { createAcpRuntime, createAgentRegistry, createRuntimeStore, type AcpRuntimeEvent } from "acpx/runtime"; + +// The process is a deterministic ACP transport peer, never a provider/model. +// Exercise the installed ACPX normalization path rather than duplicating it. +const peer = String.raw` +const send=m=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...m})+'\n'); +const live=e=>{switch(e.type){${COPILOT_MESSAGE_MAPPING_REPLACEMENT}default:return null}}; +const replay=t=>{switch(t.type){${COPILOT_REPLAY_MAPPING_REPLACEMENT}default:return live(t)}}; +const update=u=>{if(u)send({method:'session/update',params:{sessionId:'copilot-message-session',update:u}})}; +let turn=0; +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')send({id:m.id,result:{sessionId:'copilot-message-session'}}); + else if(m.method==='session/load'){ + // Native loadSession awaits replaySessionHistory before its response. These + // are persisted full-message events, not a second copy of active deltas. + for(const e of [ + {type:'assistant.message',data:{messageId:'history:first',content:'SAME'}}, + {type:'assistant.message',data:{messageId:'history:second',content:'SAME'}}, + {type:'assistant.message',data:{messageId:'history:empty',content:''}}, + ])update(replay(e)); + send({id:m.id,result:{}}); + } + else if(m.method==='session/prompt'){ + turn++; + for(const [id,text] of [[turn+':first',''],[turn+':first','SAME'],[turn+':second',''],[turn+':second','SA'],[turn+':second','ME'],[turn+':empty-final','']]){ + update(live({type:text===''?'assistant.message_start':'assistant.message_delta',data:{messageId:id,deltaContent:text}})); + if(text==='ME')update(live({type:'assistant.message',data:{messageId:id,content:'SAME'}})); + } + send({id:m.id,result:{stopReason:'end_turn'}}); + } +});`; + +it.each([false, true])("actual ACPX keeps load replay outside live warm turns (resume=%s)", async resume => { + const directory = await mkdtemp(join(tmpdir(), "copilot-message-acpx-")); + const children: ReturnType[] = []; + const wire: string[] = []; + const runtime = createAcpRuntime({ cwd: directory, permissionMode: "deny-all", + agentRegistry: createAgentRegistry({ overrides: { copilot: "fixture" } }), sessionStore: createRuntimeStore({ stateDir: join(directory, "state") }), + spawnAgent: () => { const child = spawn(process.execPath, ["-e", peer], { cwd: directory, env: {}, stdio: ["pipe", "pipe", "pipe"] }); child.stdout!.on("data", chunk => wire.push(chunk.toString())); children.push(child); return child; }, + }); + let handle; + try { + handle = await runtime.ensureSession({ sessionKey: "copilot-message-identity", agent: "copilot", mode: "persistent", cwd: directory, ...(resume ? { resumeSessionId: "copilot-message-session" } : {}) }); + for (let n = 1; n <= 3; n++) { + const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: `fixture-${n}`, timeoutMs: 2000 }); + const events: AcpRuntimeEvent[] = []; + const drain = (async () => { for await (const event of turn.events) events.push(event); })(); + expect(await turn.result).toMatchObject({ status: "completed" }); await drain; + expect(events.filter(e => e.type === "text_delta").map(e => [e.messageId, e.text])).toEqual([ + [`${n}:first`, ""], [`${n}:first`, "SAME"], [`${n}:second`, ""], [`${n}:second`, "SA"], [`${n}:second`, "ME"], [`${n}:empty-final`, ""], + ]); + } + expect(children).toHaveLength(1); + const updates = wire.join("").split("\n").filter(Boolean).map(line => JSON.parse(line)).filter(frame => frame.method === "session/update").map(frame => frame.params.update); + expect(updates.slice(0, resume ? 3 : 0).map(update => [update.messageId, update.content.text])).toEqual(resume ? [ + ["history:first", "SAME"], ["history:second", "SAME"], ["history:empty", ""], + ] : []); + expect(updates).toHaveLength(18 + (resume ? 3 : 0)); + } finally { + if (handle) await runtime.close({ handle, reason: "message identity fixture complete" }); + for (const child of children) if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts new file mode 100644 index 0000000000..d0a24575e0 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts @@ -0,0 +1,22 @@ +import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; +import { safeAcpxLocations } from "./safe-locations.js"; + +export const COPILOT_PERMISSION_CONTEXT_CONTRACT = "copilot-edit-permission-context-v1"; +const record = (value: unknown): Record => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + +/** Display context only; this does not authorize filesystem access. */ +export function safeCopilotEditTarget(value: unknown, workingDirectory: string | undefined): string | undefined { + const call = record(value); + if (call.kind !== "edit" || !workingDirectory) return; + const input = record(call.rawInput); + if (call.locations !== undefined && !Array.isArray(call.locations)) return; + if (Array.isArray(call.locations) && (call.locations.length > 16 || call.locations.some(x => typeof record(x).path !== "string"))) return; + const paths = [input.path, input.fileName, ...(Array.isArray(call.locations) ? call.locations.map(x => record(x).path) : [])].filter(x => x !== undefined); + if (!paths.length || paths.some(x => typeof x !== "string" || !x || x.trim() !== x || x.length > 2048 || /[\\\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(x))) return; + const safe = paths.flatMap(path => safeAcpxLocations([{ path }], workingDirectory, call.kind, call.title)); + const names = safe.map(x => x.path).filter((x): x is string => typeof x === "string" && x.length <= 1024 && x.trim() === x && !x.includes(":")); + if (names.length !== paths.length || new Set(names).size !== 1) return; + const target = names[0]; + return target && redactPaperclipSemanticValue(target) === target ? target : undefined; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-policy.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.test.ts new file mode 100644 index 0000000000..069a415a1f --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.test.ts @@ -0,0 +1,107 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + COPILOT_AGENT_MODE, assertCopilotConfigPolicy, assertCopilotControlPolicy, + assertCopilotPromptPolicy, assertCopilotSessionPolicy, assertCopilotSessionUpdatePolicy, + createCopilotProtocolGuard, +} from "./copilot-policy.js"; + +const fixture = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-acp-denial-1.0.88.json", import.meta.url), "utf8")); +const session = fixture.wire.find((message: { result?: { modes?: unknown } }) => message.result?.modes).result; + +describe("Copilot native policy fence", () => { + it("admits the pinned real-binary agent/manual snapshot", () => { + expect(() => assertCopilotSessionPolicy(session)).not.toThrow(); + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate: "config_option_update", configOptions: session.configOptions })).not.toThrow(); + }); + it.each(["/yolo on", " /allow-all", "\n/autopilot", "\t/plan", "\ufeff/cwd /elsewhere", "/unknown-future-command", "/custom-skill"])("rejects native command dispatch %s", text => { + expect(() => assertCopilotPromptPolicy(text)).toThrowError(expect.objectContaining({ code: "COPILOT_POLICY_VIOLATION", retryable: false })); + }); + it.each(["tool_call", "tool_call_update"])("rejects native detach independent of tool name in %s", sessionUpdate => { + for (const tool of ["bash", "write_bash", "powershell", "unknown"]) { + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate, tool, rawInput: { detach: true } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED", retryable: false })); + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate, tool, rawInput: { mode: "async", detach: false } })).not.toThrow(); + } + }); + it("preserves ordinary prose mentioning commands and natural-language questions", () => { + for (const text of ["Explain /plan", "Ask me before proceeding", "Read this file: /workspace/file", "Plan the work\n/yolo on"]) { + expect(() => assertCopilotPromptPolicy(text)).not.toThrow(); + } + }); + it.each([undefined, null, {}, { modes: session.modes }, { configOptions: session.configOptions }, + { ...session, modes: { currentModeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot" } }, + { ...session, modes: { currentModeId: "https://agentclientprotocol.com/protocol/session-modes#plan" } }, + ])("rejects missing or unsafe connection proof", state => { + expect(() => assertCopilotSessionPolicy(state)).toThrow(/admitted permission policy/); + }); + it("rejects bypass, duplicate, missing, custom-agent and future config authority", () => { + const config = session.configOptions; + for (const value of [[], [...config, config[0]], config.filter((option: { id: string }) => option.id !== "allow_all"), + config.map((option: { id: string }) => option.id === "allow_all" ? { ...option, currentValue: "on" } : option), + [...config, { id: "agent", currentValue: "privileged-agent" }], [...config, { id: "new-authority", currentValue: "off" }]]) { + expect(() => assertCopilotConfigPolicy(value)).toThrow(/admitted permission policy/); + } + }); + it("only permits selecting the admitted explicit model", () => { + expect(() => assertCopilotControlPolicy("session/set_model", { modelId: "gpt-5.6-luna" }, "gpt-5.6-luna")).not.toThrow(); + expect(() => assertCopilotControlPolicy("session/set_config_option", { configId: "model", value: "gpt-5.6-luna" }, "gpt-5.6-luna")).not.toThrow(); + for (const [method, params] of [["session/set_mode", { modeId: COPILOT_AGENT_MODE }], + ["session/set_config_option", { configId: "allow_all", value: "on" }], + ["session/set_config_option", { configId: "mode", value: COPILOT_AGENT_MODE }], + ["session/set_model", { modelId: "auto" }], ["session/set_model", { modelId: "other" }]] as const) { + expect(() => assertCopilotControlPolicy(method, params, "gpt-5.6-luna")).toThrow(/admitted permission policy/); + } + }); + it("rejects policy drift without logging provider strings", () => { + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate: "current_mode_update", currentModeId: "secret-provider-value" })) + .toThrowError(expect.objectContaining({ message: expect.not.stringContaining("secret-provider-value") })); + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate: "current_mode_update", currentModeId: COPILOT_AGENT_MODE })).not.toThrow(); + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate: "config_option_update" })).toThrow(); + }); +}); + +describe("Copilot connection authority", () => { + const safe = { ...session, sessionId: "s", configOptions: [...session.configOptions, { id: "model", currentValue: "gpt-5.6-luna" }] }; + const request = (guard: ReturnType, method: string, params: unknown, id = 0) => guard("outbound", { jsonrpc: "2.0", id, method, params }); + const admit = (guard: ReturnType, method = "session/new") => { + request(guard, method, method === "session/new" ? {} : { sessionId: "s" }); + guard("inbound", { id: 0, result: safe }); + }; + it.each(["session/new", "session/load", "session/resume"])("requires fresh safe %s response on each connection", method => { + const guard = createCopilotProtocolGuard("gpt-5.6-luna"); + admit(guard, method); + expect(() => request(guard, "session/prompt", { sessionId: "s", prompt: [{ type: "text", text: "Complete task" }] }, 1)).not.toThrow(); + const reconnected = createCopilotProtocolGuard("gpt-5.6-luna"); + expect(() => request(reconnected, "session/prompt", { sessionId: "s", prompt: [{ type: "text", text: "Complete task" }] })).toThrow(); + }); + it("invalidates authority permanently when resumed policy or current policy drifts", () => { + for (const bad of [{ sessionUpdate: "current_mode_update", currentModeId: "autopilot" }, { sessionUpdate: "config_option_update", configOptions: [] }]) { + const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: bad } })).toThrow(); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", configOptions: safe.configOptions } } })).toThrow(); + } + }); + it("also denies detachment carried only by permission rawInput", () => { + const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard); + expect(() => guard("inbound", { id: 0, method: "session/request_permission", params: { sessionId: "s", toolCall: { rawInput: { detach: true } } } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" })); + expect(() => guard("outbound", { id: 0, result: { outcome: { outcome: "selected", optionId: "allow_once" } } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" })); + }); + it("does not turn native unresolved input into delivered answers or successful continuation", () => { + for (const type of ["user_input.requested", "exit_plan_mode.requested", "elicitation.requested"]) { + const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard); + expect(() => guard("inbound", { method: "github.com/copilot/sessionEvent", params: { sessionId: "s", type, data: { requestId: 0 } } })).toThrow(); + } + }); + it("requires the selected model before prompting, including reconnect", () => { + const guard = createCopilotProtocolGuard("gpt-5.6-luna"); request(guard, "session/load", { sessionId: "s" }); + guard("inbound", { id: 0, result: session }); + expect(() => request(guard, "session/prompt", { sessionId: "s", prompt: [{ type: "text", text: "Run" }] }, 1)).toThrow(); + }); + it("validates the joined wire text and preserves zero request IDs", () => { + const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard); + expect(() => request(guard, "session/prompt", { sessionId: "s", prompt: [{ type: "text", text: " " }, { type: "text", text: "/yolo on" }] })).toThrow(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts new file mode 100644 index 0000000000..cfc467c5fd --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts @@ -0,0 +1,165 @@ +/** Policy for the exact native Copilot 1.0.88 ACP surface. */ +export const COPILOT_AGENT_MODE = "https://agentclientprotocol.com/protocol/session-modes#agent"; + +export class CopilotPolicyViolationError extends Error { + readonly code: string = "COPILOT_POLICY_VIOLATION"; + readonly retryable = false; + constructor() { + super("Copilot session configuration or command cannot preserve the admitted permission policy."); + this.name = "CopilotPolicyViolationError"; + } +} + +export class CopilotDetachedWorkUnsupportedError extends CopilotPolicyViolationError { + override readonly code = "COPILOT_DETACHED_WORK_UNSUPPORTED"; + constructor() { + super(); + this.name = "CopilotDetachedWorkUnsupportedError"; + this.message = "Native detached commands are unsupported in this Copilot runner. Run the command attached or use a managed runtime service."; + } +} + +/** + * Native ACP interprets a leading slash as a CLI command, before inference. + * Reject every such command rather than maintaining an incomplete list of + * aliases/skills that can change mode, permissions, cwd, MCP or remote state. + * Apply to the final joined ACP text, including resumed prompts. + */ +export function assertCopilotPromptPolicy(text: string): void { + if (text.trimStart().startsWith("/")) throw new CopilotPolicyViolationError(); +} + +/** Only explicit model selection is a runner-owned configuration operation. */ +export function assertCopilotControlPolicy(method: string, params: unknown, expectedModel: string): void { + const input = record(params); + if (!expectedModel || expectedModel === "auto") throw new CopilotPolicyViolationError(); + if (method === "session/set_model" && input.modelId === expectedModel) return; + if (method === "session/set_config_option" && input.configId === "model" && input.value === expectedModel) return; + throw new CopilotPolicyViolationError(); +} + +/** + * Validate the actual new/load response on every connection, before prompting. + * Persisted ACPX status is insufficient: load can restore autopilot and turn + * allow-all back on inside the native server. Missing proof fails closed. + */ +export function assertCopilotSessionPolicy(response: unknown): void { + const state = record(response); + if (record(state.modes).currentModeId !== COPILOT_AGENT_MODE) throw new CopilotPolicyViolationError(); + assertCopilotConfigPolicy(state.configOptions); +} + +/** Copilot emits full config snapshots, not patches, in config_option_update. */ +export function assertCopilotConfigPolicy(value: unknown): void { + if (!Array.isArray(value) || value.length > 32) throw new CopilotPolicyViolationError(); + const options = new Map>(); + for (const candidate of value) { + const option = record(candidate); + if (typeof option.id !== "string" || options.has(option.id)) throw new CopilotPolicyViolationError(); + options.set(option.id, option); + if (!["mode", "allow_all", "model", "reasoning_effort", "agent"].includes(option.id)) throw new CopilotPolicyViolationError(); + if (option.id === "agent" && option.currentValue !== "") throw new CopilotPolicyViolationError(); + } + if (options.get("mode")?.currentValue !== COPILOT_AGENT_MODE || options.get("allow_all")?.currentValue !== "off") { + throw new CopilotPolicyViolationError(); + } +} + +/** Reject native detachment before permission handling, regardless of tool name. */ +export function assertCopilotToolPolicy(value: unknown): void { + if (record(record(value).rawInput).detach === true) throw new CopilotDetachedWorkUnsupportedError(); +} + +/** Call synchronously for owned-session updates; any exception aborts authority. */ +export function assertCopilotSessionUpdatePolicy(value: unknown): void { + const update = record(value); + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") assertCopilotToolPolicy(update); + if (update.sessionUpdate === "current_mode_update" && update.currentModeId !== COPILOT_AGENT_MODE) { + throw new CopilotPolicyViolationError(); + } + if (update.sessionUpdate === "config_option_update") assertCopilotConfigPolicy(update.configOptions); +} + +/** One guard per actual stdio connection; never restore admission from disk. */ +export function createCopilotProtocolGuard(expectedModel: string): (direction: "inbound" | "outbound", value: unknown) => void { + const pending = new Map(); + const sessions = new Set(); + const models = new Map(); + const modelFrom = (result: unknown) => { + const options = record(result).configOptions; + return Array.isArray(options) ? record(options.find(option => record(option).id === "model")).currentValue : undefined; + }; + let failure: CopilotPolicyViolationError | undefined; + return (direction, value) => { + if (failure) throw failure; + try { + const message = record(value); + const params = record(message.params); + const id = message.id; + const method = message.method; + if (direction === "outbound" && typeof method === "string") { + if (method.startsWith("session/") && !["session/new", "session/load", "session/resume"].includes(method)) { + if (typeof params.sessionId !== "string" || !sessions.has(params.sessionId)) throw new CopilotPolicyViolationError(); + } + if (method === "session/prompt") { + if (models.get(String(params.sessionId)) !== expectedModel) throw new CopilotPolicyViolationError(); + if (!Array.isArray(params.prompt) || params.prompt.some(block => record(block).type !== "text" || typeof record(block).text !== "string")) throw new CopilotPolicyViolationError(); + assertCopilotPromptPolicy(params.prompt.map(block => record(block).text).join("\n")); + } else if (method.startsWith("session/set_")) { + assertCopilotControlPolicy(method, params, expectedModel); + } else if (!["initialize", "authenticate", "session/new", "session/load", "session/resume", "session/cancel", "session/close"].includes(method)) { + throw new CopilotPolicyViolationError(); + } + if (typeof id === "string" || typeof id === "number") { + if (pending.has(id) || pending.size >= 128) throw new CopilotPolicyViolationError(); + pending.set(id, { method, ...(typeof params.sessionId === "string" ? { sessionId: params.sessionId } : {}) }); + } + } + if (direction === "inbound" && method === undefined && (typeof id === "string" || typeof id === "number")) { + const request = pending.get(id); + pending.delete(id); + if (request && message.error === undefined) { + if (["session/new", "session/load", "session/resume"].includes(request.method)) { + assertCopilotSessionPolicy(message.result); + const sessionId = request.sessionId ?? record(message.result).sessionId; + if (typeof sessionId !== "string" || !sessionId) throw new CopilotPolicyViolationError(); + sessions.add(sessionId); + models.set(sessionId, modelFrom(message.result)); + } else if (request.method === "session/set_config_option") { + assertCopilotConfigPolicy(record(message.result).configOptions); + if (request.sessionId) models.set(request.sessionId, modelFrom(message.result)); + } else if (request.method === "session/set_model" && request.sessionId) { + models.set(request.sessionId, expectedModel); + } else if (request.method === "session/close" && request.sessionId) sessions.delete(request.sessionId); + } + } + if (direction === "inbound" && method === "session/request_permission") assertCopilotToolPolicy(params.toolCall); + if (direction === "inbound" && method === "session/update") { + // Replay can precede load's response. It still cannot introduce unsafe + // configuration, and it cannot grant admission before that response. + assertCopilotSessionUpdatePolicy(params.update); + if (record(params.update).sessionUpdate === "config_option_update" && typeof params.sessionId === "string" && sessions.has(params.sessionId)) { + const model = modelFrom(params.update); + if (models.get(params.sessionId) === expectedModel && model !== expectedModel) throw new CopilotPolicyViolationError(); + models.set(params.sessionId, model); + } + } + if (direction === "inbound" && method === "github.com/copilot/sessionEvent") { + if (["user_input.requested", "exit_plan_mode.requested", "elicitation.requested"].includes(String(params.type))) throw new CopilotPolicyViolationError(); + const data = record(params.data); + if (params.type === "session.permissions_changed" && data.mode !== "manual") throw new CopilotPolicyViolationError(); + if (params.type === "session.mode_changed" && data.newMode !== "interactive" && data.newMode !== "agent") throw new CopilotPolicyViolationError(); + } + } catch (error) { + failure = error instanceof CopilotPolicyViolationError ? error : new CopilotPolicyViolationError(); + sessions.clear(); + models.clear(); + pending.clear(); + throw failure; + } + }; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts new file mode 100644 index 0000000000..826927436e --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { classifyCopilotFailure, copilotConfiguration, copilotSandboxEnvironment, COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js"; + +describe("Copilot admission policy", () => { + it("uses ACP directly without disabling questions or bypassing permissions", () => { + expect(COPILOT_LAUNCH_ARGUMENTS.slice(0, 2)).toEqual(["--acp", "--stdio"]); + expect(COPILOT_LAUNCH_ARGUMENTS).toContain("--no-auto-update"); + expect(COPILOT_LAUNCH_ARGUMENTS).toContain("--secret-env-vars=COPILOT_GITHUB_TOKEN"); + expect(COPILOT_LAUNCH_ARGUMENTS).not.toContain("--no-ask-user"); + expect(COPILOT_LAUNCH_ARGUMENTS).not.toContain("--allow-all"); + expect(COPILOT_LAUNCH_ARGUMENTS).not.toContain("--allow-all-tools"); + }); + it("uses private config and extracted runtime cache without ambient authority", () => { + const env = copilotSandboxEnvironment({ homeDirectory: "/private/home", configDirectory: "/private/config", dataDirectory: "/private/data", cacheDirectory: "/private/cache", agentHomeDirectory: "/private/copilot" }); + expect(env).toMatchObject({ COPILOT_HOME: "/private/copilot", COPILOT_CACHE_HOME: "/private/cache", COPILOT_PKG_CACHE_HOME: "/private/cache", COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false" }); + expect(env.GH_TOKEN).toBeUndefined(); + expect(copilotConfiguration()).toMatchObject({ trustedFolders: [], disableAllHooks: true, ide: { autoConnect: false } }); + expect(() => copilotSandboxEnvironment({ homeDirectory: "relative", configDirectory: "/c", dataDirectory: "/d", cacheDirectory: "/c", agentHomeDirectory: "/a" })).toThrow(/absolute/); + }); + it.each([ + ["Authentication required ghp_private", "COPILOT_AUTH_REQUIRED"], + ["Organization policy denied this model", "COPILOT_ENTITLEMENT_DENIED"], + ["Model gpt-private is unavailable", "COPILOT_MODEL_UNAVAILABLE"], + ["transport failed ghp_private", "COPILOT_REQUEST_FAILED"], + ])("classifies provider errors without leaking raw messages", (message, code) => { + expect(classifyCopilotFailure({ message }).code).toBe(code); + expect(classifyCopilotFailure(new Error(message)).message).not.toContain("private"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts new file mode 100644 index 0000000000..2a88ab552f --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts @@ -0,0 +1,94 @@ +import { isAbsolute, resolve } from "node:path"; + +/** This is an admission candidate, not a claim of live qualification. */ +export const COPILOT_VERSION = "1.0.88" as const; +export const COPILOT_SYSTEM_INSTRUCTIONS_FILE = "copilot-instructions.md" as const; +export const COPILOT_SYSTEM_INSTRUCTION_DELIVERY = "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1" as const; +export const COPILOT_CREDENTIAL_ENVIRONMENT_NAME = "COPILOT_GITHUB_TOKEN" as const; + +// Keep permission callbacks enabled even when Paperclip's policy is approve-all. +// In particular COPILOT_ALLOW_ALL=true also trusts workspace hooks/plugins/MCP. +export const COPILOT_LAUNCH_ARGUMENTS = Object.freeze([ + "--acp", "--stdio", "--no-auto-update", "--disable-builtin-mcps", + "--no-remote", "--no-remote-export", "--no-bash-env", + "--secret-env-vars=COPILOT_GITHUB_TOKEN", +]); + +export interface CopilotSandboxDirectories { + homeDirectory: string; + configDirectory: string; + dataDirectory: string; + cacheDirectory: string; + agentHomeDirectory: string; +} + +/** Add only sandbox-owned values after the common credential allowlist. */ +export function copilotSandboxEnvironment(directories: CopilotSandboxDirectories): Readonly { + for (const directory of Object.values(directories)) { + if (!isAbsolute(directory) || resolve(directory) !== directory || directory.includes("\0")) { + throw new Error("Copilot sandbox directories must be normalized absolute paths"); + } + } + return Object.freeze({ + HOME: directories.homeDirectory, + XDG_CONFIG_HOME: directories.configDirectory, + XDG_DATA_HOME: directories.dataDirectory, + XDG_CACHE_HOME: directories.cacheDirectory, + COPILOT_HOME: directories.agentHomeDirectory, + COPILOT_CACHE_HOME: directories.cacheDirectory, + COPILOT_PKG_CACHE_HOME: directories.cacheDirectory, + COPILOT_AUTO_UPDATE: "false", + COPILOT_ALLOW_ALL: "false", + COPILOT_DISABLE_TERMINAL_TITLE: "true", + NO_COLOR: "1", + }); +} + +/** Written by the shared sandbox's private atomic writer before every admission. */ +export function copilotConfiguration(): Readonly> { + return Object.freeze({ + autoUpdate: false, + trustedFolders: [], + disableAllHooks: true, + memory: false, + ide: { autoConnect: false, openDiffOnEdit: false }, + }); +} + +export type CopilotFailureCode = + | "COPILOT_AUTH_REQUIRED" + | "COPILOT_ENTITLEMENT_DENIED" + | "COPILOT_MODEL_UNAVAILABLE" + | "COPILOT_REQUEST_FAILED"; + +/** The runner binds this credential explicitly; ambient gh login is never used. */ +export function assertCopilotCredentials(environment: Readonly): void { + const token = environment.COPILOT_GITHUB_TOKEN; + if (typeof token !== "string" || token.trim().length === 0 || token.includes("\0")) { + throw Object.assign(new Error("Bind a valid COPILOT_GITHUB_TOKEN credential for this runner."), { + code: "COPILOT_AUTH_REQUIRED", retryable: false, + }); + } +} + +/** Classify, but never echo provider text that may contain tokens or user data. */ +export function classifyCopilotFailure(error: unknown): { code: CopilotFailureCode; message: string } { + const source = error instanceof Error ? error.message + : typeof error === "string" ? error + : isRecord(error) && typeof error.message === "string" ? error.message : ""; + const message = source.slice(0, 8192).toLowerCase(); + if (/authentication required|unauthorized|invalid (?:github )?token|not (?:logged|signed) in|copilot_github_token.*(?:missing|required|not configured)|(?:missing|no) (?:copilot |github )?(?:credential|token|authentication)/.test(message)) { + return { code: "COPILOT_AUTH_REQUIRED", message: "Bind a valid COPILOT_GITHUB_TOKEN credential for this runner." }; + } + if (/entitlement|subscription|organization policy|organisation policy|policy.*(?:denied|disabled)|access denied|forbidden|not entitled/.test(message)) { + return { code: "COPILOT_ENTITLEMENT_DENIED", message: "Copilot access is denied by the account entitlement or organization policy." }; + } + if (/(?:model.*(?:not found|unavailable|not available|not supported|unsupported|invalid)|unknown model|invalid model)/.test(message)) { + return { code: "COPILOT_MODEL_UNAVAILABLE", message: "The explicitly selected Copilot model is unavailable for this account." }; + } + return { code: "COPILOT_REQUEST_FAILED", message: "The Copilot ACP request failed." }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts new file mode 100644 index 0000000000..0ab47a31e6 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts @@ -0,0 +1,82 @@ +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { COPILOT_AGENT_MODE, createCopilotProtocolGuard } from "./copilot-policy.js"; + +// Exercise the installed, patched wire stream, not a mock observer. This is a +// pinned internal bundle export; the package-contract suite guards its version. +const bundle = new URL("./dist/live-checkpoint-BSIrfgVo.js", pathToFileURL(createRequire(import.meta.url).resolve("acpx/package.json"))); +const { k: AcpClient } = await import(bundle.href); +const safe = { sessionId: "s", modes: { currentModeId: COPILOT_AGENT_MODE }, configOptions: [ + { id: "mode", currentValue: COPILOT_AGENT_MODE }, { id: "allow_all", currentValue: "off" }, { id: "model", currentValue: "gpt-5.6-luna" }, +] }; + +function connection(guarded = true) { + const written: unknown[] = []; + let inbound!: ReadableStreamDefaultController; + const kill = vi.fn(); + const client = new AcpClient({ cwd: "/tmp", agentCommand: "fixture-never-spawned", permissionMode: "deny-all", + ...(guarded ? { protocolGuardFactory: () => createCopilotProtocolGuard("gpt-5.6-luna") } : {}), + }); + client.agent = { kill }; + const stream = client.createTappedStream({ + readable: new ReadableStream({ start(controller) { inbound = controller; } }), + writable: new WritableStream({ write(message) { written.push(message); } }), + }); + return { client, writer: stream.writable.getWriter(), reader: stream.readable.getReader(), inbound, written, kill }; +} + +describe("patched ACPX admission guard", () => { + it.each(["session/new", "session/load", "session/resume"])("rejects unsafe %s before it reaches the SDK", async method => { + const c = connection(); + await c.writer.write({ id: 0, method, params: { sessionId: "s" } }); + const read = c.reader.read(); + c.inbound.enqueue({ id: 0, result: { ...safe, modes: { currentModeId: "autopilot" } } }); + await expect(read).rejects.toMatchObject({ code: "COPILOT_POLICY_VIOLATION" }); + expect(c.kill).toHaveBeenCalledWith("SIGTERM"); + }); + it("rejects permission controls before any bytes reach the provider", async () => { + const c = connection(); + await c.writer.write({ id: 0, method: "session/new", params: {} }); + c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); + await expect(c.writer.write({ id: 1, method: "session/set_config_option", params: { sessionId: "s", configId: "allow_all", value: "on" } })).rejects.toThrow(/admitted permission policy/); + expect(c.written).toHaveLength(1); expect(c.kill).toHaveBeenCalledOnce(); + c.inbound.close(); + }); + it("errors and stops the provider on drift even without a notification observer", async () => { + const c = connection(); + await c.writer.write({ id: 0, method: "session/new", params: {} }); + c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); + await c.writer.write({ id: 1, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Perform task" }] } }); + const read = c.reader.read(); + c.inbound.enqueue({ method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "current_mode_update", currentModeId: "autopilot" } } }); + await expect(read).rejects.toMatchObject({ code: "COPILOT_POLICY_VIOLATION" }); + expect(c.kill).toHaveBeenCalledWith("SIGTERM"); + }); + it("preserves unguarded provider wire behavior", async () => { + const c = connection(false); + const message = { method: "session/update", params: { update: { sessionUpdate: "current_mode_update", currentModeId: "other-provider-mode" } } }; + c.inbound.enqueue(message); + expect((await c.reader.read()).value).toEqual(message); + expect(c.kill).not.toHaveBeenCalled(); + c.inbound.close(); + }); + it("blocks detached command before the permission request can authorize a side effect", async () => { + const c = connection(); + await c.writer.write({ id: 0, method: "session/new", params: {} }); + c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); + await c.writer.write({ id: 1, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Run task" }] } }); + const read = c.reader.read(); + const pendingSdkRequest = c.client.runConnectionRequest(() => new Promise(() => {})); + const pendingRejection = expect(pendingSdkRequest).rejects.toMatchObject({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" }); + c.inbound.enqueue({ method: "session/update", params: { sessionId: "s", update: { + sessionUpdate: "tool_call", toolCallId: "native-detached", kind: "execute", status: "pending", + rawInput: { command: "fixture", mode: "async", detach: true }, + } } }); + c.inbound.enqueue({ id: 0, method: "session/request_permission", params: { sessionId: "s", toolCall: { toolCallId: "native-detached" } } }); + await expect(read).rejects.toMatchObject({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED", message: expect.stringContaining("Run the command attached") }); + await pendingRejection; + expect(c.written).toHaveLength(2); // Only session/new and session/prompt; no permission response. + expect(c.kill).toHaveBeenCalledWith("SIGTERM"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts new file mode 100644 index 0000000000..a2fb584f5c --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-registry.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import { COPILOT_ACP_CLIENT_CAPABILITIES, COPILOT_ACP_EVENT_METHOD } from "./copilot-events.js"; +import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, createAcpxProfileExtensionAdapter } from "./profile-extensions.js"; +import { assertAcpxProfileEnvironment, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js"; +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; +import { verifyCopilotInstallation } from "./copilot-installation.js"; +import { verifyCursorInstallation } from "./cursor-installation.js"; + +vi.mock("./copilot-installation.js", () => ({ verifyCopilotInstallation: vi.fn(async () => ({ commandDigest: "verified-by-native-factory" })) })); + +vi.mock("./cursor-installation.js", () => ({ verifyCursorInstallation: vi.fn(async () => ({ commandDigest: "verified-cursor-factory" })) })); + +const context = { workspacePath: "/workspace", sessionId: "backend-1", turnId: "turn-1" }; +describe("Copilot provider registry conformance", () => { + it("requires only explicitly bound Copilot credentials before starting the provider", () => { + for (const environment of [{}, { COPILOT_GITHUB_TOKEN: " " }, { COPILOT_GITHUB_TOKEN: "bad\0token" }, { GITHUB_TOKEN: "ambient-secret", GH_TOKEN: "ambient-secret" }]) { + expect(() => assertAcpxProfileEnvironment("copilot", environment)).toThrow(expect.objectContaining({ code: "COPILOT_AUTH_REQUIRED", retryable: false })); + } + expect(() => assertAcpxProfileEnvironment("copilot", { COPILOT_GITHUB_TOKEN: "fixture-explicit-token" })).not.toThrow(); + expect(() => assertAcpxProfileEnvironment("codex", {})).not.toThrow(); + }); + it("selects only the Copilot native installer and preserves the exact caller-selected profile", async () => { + const profile = resolveQualifiedAcpxProfile("copilot", "explicit-exact-model"); + expect(await verifyAcpxProfileInstallation(profile)).toMatchObject({ commandDigest: "verified-by-native-factory" }); + expect(verifyCopilotInstallation).toHaveBeenCalledExactlyOnceWith(profile); + const cursorProfile = resolveQualifiedAcpxProfile("cursor", "exact-model"); + expect(await verifyAcpxProfileInstallation(cursorProfile)).toMatchObject({ commandDigest: "verified-cursor-factory" }); + expect(verifyCursorInstallation).toHaveBeenCalledExactlyOnceWith(cursorProfile); + expect(verifyCopilotInstallation).toHaveBeenCalledTimes(1); + }); + + it("negotiates an isolated native event subscription and preserves every safe subagent field through the shared turn binder", async () => { + const capabilities = acpxProfileClientCapabilities("copilot"); + expect(capabilities).toEqual(COPILOT_ACP_CLIENT_CAPABILITIES); + (capabilities._meta as Record)["github.com/copilot"] = {}; + expect(acpxProfileClientCapabilities("copilot")).toEqual(COPILOT_ACP_CLIENT_CAPABILITIES); + expect(acpxProfileClientCapabilities("codex")).toEqual({}); + const emitted: CanonicalProviderEvent[] = []; + const data = { + toolCallId: "tool-1", agentName: "review", agentDisplayName: "Reviewer", agentDescription: "Review the change", + model: "exact-model", parentId: "parent-1", agentType: "task", executionMode: "background", reasoningEffort: "high", + contextTier: "large", firstDispatchedModel: "model-initial", configuredModelPreference: "model-preferred", + explicitModelOverride: "model-explicit", modelOverrideReason: "task request", modelSelectionSource: "agent", + taskModelSource: "task", factoryRunId: "factory-1", totalToolCalls: 3, totalTokens: 40, durationMs: 12.5, + cancelled: false, resumable: true, multiTurn: true, explicitModelMatchesPreference: false, configuredModelMatchesActual: false, + }; + const binder = bindAcpxExtensionTurn({ adapter: createAcpxProfileExtensionAdapter("copilot", context), + active: () => true, sessionId: context.sessionId, waitForInput: async () => { throw new Error("No input allowed"); }, emit: event => emitted.push(event) }); + binder.onExtensionNotification(COPILOT_ACP_EVENT_METHOD, { sessionId: context.sessionId, type: "subagent.completed", data, + agentId: "agent-2", timestamp: "2026-09-28T00:00:00Z" }); + await binder.drain(); + expect(emitted.map(event => event.eventType)).toEqual(["provider.notice.recorded"]); + expect(emitted[0]!.payload).toMatchObject({ summary: "Copilot subagent completed.", details: expect.arrayContaining([ + { name: "source.method", value: COPILOT_ACP_EVENT_METHOD }, + { name: "source.eventType", value: "subagent.completed" }, + { name: "source.agentId", value: "agent-2" }, + { name: "source.sessionId", value: "backend-1" }, + { name: "source.timestamp", value: "2026-09-28T00:00:00Z" }, + ]) }); + expect(emitted[0]!.payload.scope).toBe("session"); + expect(emitted[0]!.payload).not.toHaveProperty("provenance"); + expect(emitted[0]!.payload.details).toEqual(expect.arrayContaining(Object.entries(data).map(([name, value]) => ({ name, value: String(value) })))); + }); + + it.each(["wrong_session", "stale_turn"])("rejects %s events at the shared boundary before presentation", async scenario => { + const emitted: CanonicalProviderEvent[] = []; + const binder = bindAcpxExtensionTurn({ adapter: createAcpxProfileExtensionAdapter("copilot", context), + active: () => scenario !== "stale_turn", sessionId: context.sessionId, waitForInput: async () => ({}), emit: event => emitted.push(event) }); + binder.onExtensionNotification(COPILOT_ACP_EVENT_METHOD, { sessionId: scenario === "wrong_session" ? "agent-1" : "backend-1", type: "session.idle", data: {} }); + await expect(binder.drain()).rejects.toThrow(); + expect(emitted).toEqual([]); + }); + + it("classifies admission failures without copying credentials or masking unrelated runner errors", () => { + for (const [message, code] of [ + ["COPILOT_GITHUB_TOKEN is required", "COPILOT_AUTH_REQUIRED"], + ["Unauthorized token ghp_abcdefghijklmnopqrstuvwxyz", "COPILOT_AUTH_REQUIRED"], + ["Organization policy denied with Bearer secretsecretsecret", "COPILOT_ENTITLEMENT_DENIED"], + ["Model custom-model is not supported", "COPILOT_MODEL_UNAVAILABLE"], + ]) { + const error = classifyAcpxProfileError("copilot", new Error(message)); + expect(error).toMatchObject({ code, retryable: false }); + expect(String(error)).not.toMatch(/ghp_|secretsecretsecret|custom-model/); + } + expect(classifyAcpxProfileError("codex", new Error("Unauthorized"))).toBeNull(); + expect(classifyAcpxProfileError("copilot", new Error("native distribution digest mismatch"))).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts new file mode 100644 index 0000000000..2ce8ed5c2b --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-runtime-host.test.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, it, vi } from "vitest"; +import { AcpxRuntimeHost, type AcpxRuntimeHostDependencies, type AcpxRuntimePortOpenOptions } from "./runtime-host.js"; + +it("reports missing Copilot authentication before spawn and releases admission ownership for retry", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-copilot-auth-admission-")); + const runtimeDirectory = join(root, "runtime"); + const workingDirectory = join(root, "workspace"); + await Promise.all([mkdir(runtimeDirectory), mkdir(workingDirectory), mkdir(join(root, "provider"))]); + const openRuntime = vi.fn(async (_options: AcpxRuntimePortOpenOptions): Promise => { + throw new Error("fixture stopped before provider spawn"); + }); + const close = vi.fn(async () => {}); + const openCommand = vi.fn(async () => ({ spawn: () => { throw new Error("must not spawn"); }, close })); + const dependencies: AcpxRuntimeHostDependencies = { + verifyInstallation: async profile => ({ commandDigest: profile.commandDigest, + agentServerPackageJsonPath: join(root, "provider/package.json"), agentRuntimePackageJsonPath: null, openCommand }), + openRuntime, reportRetainedCleanupFailure: () => {}, + }; + const options = { agent: "copilot" as const, model: "explicit-model", normalizedSessionId: "copilot-auth-test", + runtimeDirectory, workingDirectory, providerPolicy: { readOnly: false } }; + try { + await expect(AcpxRuntimeHost.open({ ...options, environment: { GH_TOKEN: "ambient-forbidden", GITHUB_TOKEN: "ambient-forbidden" } }, dependencies)) + .rejects.toMatchObject({ code: "COPILOT_AUTH_REQUIRED", retryable: false }); + expect(openCommand).not.toHaveBeenCalled(); + expect(openRuntime).not.toHaveBeenCalled(); + await expect(AcpxRuntimeHost.open({ ...options, environment: { COPILOT_GITHUB_TOKEN: "explicit-fixture-only", GH_TOKEN: "ambient-forbidden" } }, dependencies)) + .rejects.toThrow("fixture stopped before provider spawn"); + expect(openRuntime).toHaveBeenCalledOnce(); + const environment = openRuntime.mock.calls[0]![0].launchEnvironment; + expect(environment.COPILOT_GITHUB_TOKEN).toBe("explicit-fixture-only"); + expect(environment.GH_TOKEN).toBeUndefined(); + expect(environment.GITHUB_TOKEN).toBeUndefined(); + expect(close).toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +it("does not refresh Copilot instructions for a contender rejected by the lifetime lease", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-copilot-instruction-owner-")); + const runtimeDirectory = join(root, "runtime"); + const workingDirectory = join(root, "workspace"); + await Promise.all([mkdir(runtimeDirectory), mkdir(workingDirectory), mkdir(join(root, "provider"))]); + let releaseWinner!: () => void; + const winnerGate = new Promise(resolve => { releaseWinner = resolve; }); + let notifyWinner!: () => void; + const winnerEntered = new Promise(resolve => { notifyWinner = resolve; }); + let instructionPath = ""; + const openRuntime = vi.fn(async (options: AcpxRuntimePortOpenOptions): Promise => { + instructionPath = join(options.launchEnvironment.COPILOT_HOME!, "copilot-instructions.md"); + notifyWinner(); + await winnerGate; + throw new Error("fixture stops before native process launch"); + }); + const dependencies: AcpxRuntimeHostDependencies = { + verifyInstallation: async profile => ({ + commandDigest: profile.commandDigest, + agentServerPackageJsonPath: join(root, "provider/package.json"), agentRuntimePackageJsonPath: null, + openCommand: async () => ({ spawn: () => { throw new Error("must not spawn"); }, close: async () => {} }), + }), + openRuntime, reportRetainedCleanupFailure: () => {}, + }; + const options = { + agent: "copilot" as const, model: "explicit-model", permissionMode: "approve-reads" as const, + normalizedSessionId: "same-native-session", runtimeDirectory, workingDirectory, + providerPolicy: { readOnly: false }, environment: { COPILOT_GITHUB_TOKEN: "synthetic-fixture" }, + }; + const winner = AcpxRuntimeHost.open({ ...options, systemInstructions: "Winner instructions." }, dependencies); + // Attach the rejection handler before deliberately releasing the failed launch. + const winnerResult = expect(winner).rejects.toThrow("fixture stops before native process launch"); + try { + await winnerEntered; + expect(await readFile(instructionPath, "utf8")).toBe("Winner instructions.\n"); + await expect(AcpxRuntimeHost.open({ ...options, systemInstructions: "Losing contender." }, dependencies)) + .rejects.toThrow("already has an active lease"); + expect(openRuntime).toHaveBeenCalledOnce(); + expect(await readFile(instructionPath, "utf8")).toBe("Winner instructions.\n"); + releaseWinner(); + await winnerResult; + await expect(AcpxRuntimeHost.open({ ...options, systemInstructions: "New owner instructions." }, dependencies)) + .rejects.toThrow("fixture stops before native process launch"); + expect(await readFile(instructionPath, "utf8")).toBe("New owner instructions.\n"); + await expect(AcpxRuntimeHost.open({ ...options, environment: {}, systemInstructions: "Unauthenticated admission." }, dependencies)) + .rejects.toMatchObject({ code: "COPILOT_AUTH_REQUIRED" }); + expect(await readFile(instructionPath, "utf8")).toBe("New owner instructions.\n"); + } finally { + releaseWinner(); + await winnerResult; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts new file mode 100644 index 0000000000..9dcd6e8cdc --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts @@ -0,0 +1,209 @@ +import { appendSemanticToolReceipt } from "../semantic-tool-receipt.js"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; +const fixture = JSON.parse(readFileSync(new URL("./fixtures/copilot-tool-evidence.json", import.meta.url), "utf8")); +const details = (e: CanonicalProviderEvent) => Object.fromEntries((e.payload.details as Array<{ name: string; value: string }>).map(d => [d.name, d.value])); +function harness(sessionId = "session", turnId = "turn") { + const events: CanonicalProviderEvent[] = []; let active = true; + const projector = createCopilotToolEvidence({ sessionId, turnId, workingDirectory: "/fixture/workspace", active: () => active, + emit: event => { validateAcpxRichEvent(event); events.push(event); } }); + return { projector, events, stop: () => { active = false; } }; +} +const tool = (id: string, rawInput: unknown, kind = "execute") => ({ type: "tool_call", tag: "tool_call", toolCallId: id, kind, status: "pending", rawInput }); +const update = (id: string, content: string) => ({ type: "tool_call", tag: "tool_call_update", toolCallId: id, status: "completed", rawOutput: { content } }); +function replay(name: string) { + const frames = fixture[name]; const h = harness(frames[0].params.sessionId); + for (const frame of frames) { + if (frame.method === "session/update") { const u = frame.params.update; h.projector.tool({ ...u, type: "tool_call", tag: u.sessionUpdate }); } + else h.projector.permission({ raw: frame.params }, "request-0", ["accept", "decline", "cancel"])?.(name === "deny-write" ? "reject_once" : "allow_once"); + } + return h; +} +describe("Copilot active-turn tool evidence", () => { + it("projects actual denied-create wire into canonical notices without file contents/diff", () => { + const { events } = replay("deny-write"); + expect(events.map(details)).toEqual([ + { stage: "tool", toolCallId: "fixture-tool", target: "copilot-denied-nonce.txt", operation: "edit", status: "pending" }, + { stage: "permission_requested", toolCallId: "fixture-tool", requestId: "request-0", target: "copilot-denied-nonce.txt", operation: "edit", declineOffered: "true" }, + { stage: "permission_delivered", toolCallId: "fixture-tool", requestId: "request-0", target: "copilot-denied-nonce.txt", operation: "edit", declineOffered: "true", outcome: "reject_once" }, + { stage: "tool", toolCallId: "fixture-tool", target: "copilot-denied-nonce.txt", operation: "edit", status: "failed" }, + ]); + expect(JSON.stringify(events)).not.toMatch(/MUST NOT EXIST|diff --git|file_text/); + expect(events.every(e => e.payload.scope === "turn" && (e.payload.provenance as { turnId: string }).turnId === "turn")).toBe(true); + }); + it("preserves typed native reads without exposing read arguments or contents", () => { + const h = harness(); + h.projector.tool(tool("read", { path: "instruction-secret", content: "private-content" }, "read")); + h.projector.tool(update("read", "private-output")); + expect(h.events.map(details)).toEqual([ + { stage: "tool", toolCallId: "read", operation: "read", status: "pending" }, + { stage: "tool", toolCallId: "read", operation: "read", status: "completed" }, + ]); + expect(JSON.stringify(h.events)).not.toMatch(/instruction-secret|private-content|private-output/); + }); + it("links actual attached command and separate read-shell completion, not initial completed tool", () => { + const ds = replay("attached-shell").events.map(details); + const started = ds.find(d => d.shellState === "started")!; const completed = ds.find(d => d.shellState === "completed")!; + expect(started).toMatchObject({ operation: "execute", mode: "async", detach: "false", shellId: "0", commandToolCallId: "fixture-tool", status: "completed" }); + expect(started).not.toHaveProperty("exitCode"); + expect(completed).toMatchObject({ commandToolCallId: "fixture-tool", shellId: "0", exitCode: "0", status: "completed" }); + expect(completed.toolCallId).not.toBe(started.toolCallId); + expect(started.commandSha256).toBe(`sha256:${createHash("sha256").update("sleep 2; printf ACP_SHELL_DONE > settlement.txt").digest("hex")}`); + expect(JSON.stringify(ds)).not.toContain("sleep 2"); + }); + it.each(["../outside", "/outside", "bad\u0000path", "C:\\private", "https://secret.invalid/path"])("omits unsafe target %j", path => { + const h = harness(); h.projector.tool(tool("edit", { path, file_text: "SECRET" }, "edit")); + expect(details(h.events[0]!)).not.toHaveProperty("target"); expect(JSON.stringify(h.events)).not.toContain("SECRET"); + }); + it("omits conflicting locations instead of selecting one", () => { + const h = harness(); h.projector.tool({ ...tool("edit", { path: "a" }, "edit"), locations: [{ path: "b" }] }); + expect(details(h.events[0]!)).not.toHaveProperty("target"); + }); + it("rejects session notifications, stale binding, foreign permission session and originless deltas", () => { + const h = harness(); h.projector.tool({ method: "github.com/copilot/sessionEvent", params: { type: "session.background_tasks_changed" } }); + h.projector.tool(update("unknown", "")); + expect(h.projector.permission({ raw: { sessionId: "other", toolCall: { toolCallId: "tool" } } }, "request", ["decline"])).toBeUndefined(); + h.stop(); h.projector.tool(tool("tool", { command: "secret" })); expect(h.events).toEqual([]); + }); + it("does not invent omitted mode/detach defaults or native names from a title", () => { + const h = harness(); h.projector.tool({ ...tool("tool", { command: "true" }), title: "bash detach false" }); + const d = details(h.events[0]!); expect(d.operation).toBe("execute"); + for (const k of ["mode", "detach", "tool"]) expect(d).not.toHaveProperty(k); + }); + it.each(["reused-tool", "changed-command", "reused-shell"])("fails closed on %s correlation", conflict => { + const h = harness(); h.projector.tool(tool("first", { command: "true", mode: "async", detach: false })); + if (conflict === "reused-tool") h.projector.tool(tool("first", { command: "false" })); + if (conflict === "changed-command") h.projector.tool({ ...tool("first", { command: "false" }), tag: "tool_call_update" }); + h.projector.tool(update("first", "")); + if (conflict === "reused-shell") { h.projector.tool(tool("second", { command: "false" })); h.projector.tool(update("second", "")); } + h.projector.tool(tool("read", { shellId: "0", delay: 0 }, "read")); h.projector.tool(update("read", "")); + expect(h.events.map(details).some(d => d.shellState === "completed")).toBe(false); + }); + it.each(["untrusted text\n", ""])("rejects arbitrary/mismatched output %j", output => { + const h = harness(); h.projector.tool(tool("command", { command: "true" })); h.projector.tool(update("command", "")); + h.projector.tool(tool("read", { shellId: "0" }, "read")); h.projector.tool(update("read", output)); + expect(h.events.map(details).some(d => d.shellState === "completed")).toBe(false); + }); + it("normalizes each duplicated matching target independently", () => { + const h = harness(); h.projector.tool({ ...tool("edit", { path: "same.txt", fileName: "/fixture/workspace/same.txt" }, "edit"), locations: [{ path: "same.txt" }, { path: "same.txt" }] }); + expect(details(h.events[0]!).target).toBe("same.txt"); + }); + it("redacts secret canaries in paths and every provider identity", () => { + const secret = "ghp_0123456789abcdef"; + const h = harness(secret); h.projector.tool(tool(secret, { path: `${secret}.txt` }, "edit")); + expect(JSON.stringify(h.events)).not.toContain(secret); + expect(JSON.stringify(h.events)).toContain("[REDACTED]"); + }); + it("keeps delivered response authority when observation fails and emits an incomplete notice", () => { + const events: CanonicalProviderEvent[] = []; const unavailable = vi.fn(); + const p = createCopilotToolEvidence({ sessionId: "s", turnId: "t", workingDirectory: "/fixture/workspace", active: () => true, unavailable, + emit: event => { if (details(event).stage === "permission_delivered") throw new Error("secret payload must not escape"); validateAcpxRichEvent(event); events.push(event); } }); + const delivered = p.permission({ raw: { sessionId: "s", toolCall: { toolCallId: "call", kind: "edit", rawInput: { fileName: "a" } } } }, "request", ["decline"]); + expect(() => delivered!("reject_once")).not.toThrow(); + expect(events.map(details).at(-1)).toMatchObject({ stage: "evidence_incomplete", reason: "projection_failed" }); + expect(unavailable).toHaveBeenCalledOnce(); + expect(JSON.stringify(events)).not.toContain("secret payload"); + p.tool(tool("later", { command: "true" })); expect(events).toHaveLength(2); + }); + it("handles malformed bounded input without leaking arbitrary data", () => { + const h = harness(); + for (const rawInput of [null, [], { command: "x".repeat(65537) }, { mode: {}, detach: "false", shellId: {} }]) h.projector.tool(tool(String(h.events.length), rawInput)); + expect(h.events).toHaveLength(4); + expect(h.events.map(details).every(d => !d.commandSha256 && !d.mode && !d.detach && !d.shellId)).toBe(true); + }); + it("bounds retained tools and emitted notices", () => { + const h = harness(); for (let n = 0; n < 300; n++) h.projector.tool(tool(`t${n}`, { command: "true" })); expect(h.events).toHaveLength(257); + expect(details(h.events.at(-1)!)).toMatchObject({ stage: "evidence_incomplete", reason: "tool_limit" }); + for (let n = 0; n < 2200; n++) h.projector.tool(update("t0", "")); expect(h.events).toHaveLength(2048); + }); +}); + +describe("Copilot authoritative semantic receipt correlation", () => { + const args = { summary: "PRIVATE", completionClaim: { objectiveSatisfied: true } }; + const make = (operation = "paperclip_finish", isError = false) => appendSemanticToolReceipt( + { tool: operation, callId: "3", arguments: args }, { content: [{ type: "text", text: '{"accepted":false}' }], ...(isError ? { isError } : {}) }); + const terminal = (id: string, content: unknown, status = "completed") => ({ type: "tool_call", tag: "tool_call_update", toolCallId: id, status, rawOutput: { contents: content } }); + it.each(["paperclip_finish", "report_progress", "get_task_context"])("correlates %s with trusted callback, not native display name", operation => { + const h = harness(); const bound = make(operation); + h.projector.tool({ ...tool("native", args, "other"), title: "unrelated display" }); + h.projector.captureSemanticReceipt()!(bound.receipt); + h.projector.tool(terminal("native", bound.result.content)); + const authoritative = h.events.find(e => e.payload.category === "paperclip_semantic_tool_receipt_v2")!; + expect(details(authoritative)).toMatchObject({ operationId: operation, callIdentitySha256: bound.receipt.callIdentitySha256, outcome: "returned" }); + expect(authoritative.payload.provenance).toMatchObject({ method: "paperclip/semantic_tool_result", sessionId: "session", turnId: "turn" }); + expect(details(h.events.at(-1)!)).toMatchObject({ semanticOperationId: operation, semanticOutcome: "returned", semanticResultSha256: bound.receipt.resultSha256 }); + expect(JSON.stringify(h.events)).not.toMatch(/PRIVATE|accepted|completionClaim/); + }); + it.each(["untrusted", "different-input", "different-normalized-input", "different-result", "foreign-session", "foreign-turn", "duplicate-native", "duplicate-authority", "early-terminal", "wrong-status"])("rejects %s authority", scenario => { + const h = harness(), other = harness(scenario === "foreign-turn" ? "session" : "foreign", scenario === "foreign-turn" ? "other-turn" : "turn"); const bound = make(); + h.projector.tool({ ...tool("native", scenario === "different-input" ? {} : args, "other"), title: "paperclip_finish" }); + if (scenario === "early-terminal") h.projector.tool(terminal("native", bound.result.content)); + if (scenario === "foreign-session" || scenario === "foreign-turn") other.projector.captureSemanticReceipt()!(bound.receipt); + else if (scenario !== "untrusted") h.projector.captureSemanticReceipt()!(bound.receipt); + if (scenario === "duplicate-authority") h.projector.captureSemanticReceipt()!(bound.receipt); + if (scenario === "duplicate-native") { + h.projector.tool(terminal("native", bound.result.content)); + h.projector.tool(tool("second", args, "other")); + } + const output = structuredClone(bound.result.content); + if (scenario === "different-result") output[0]!.text = "changed"; + if (scenario === "different-normalized-input") output.at(-1)!.text = JSON.stringify({ ...bound.receipt, normalizedInputSha256: "a".repeat(64) }); + h.projector.tool(terminal(scenario === "duplicate-native" ? "second" : "native", output, scenario === "wrong-status" ? "failed" : "completed")); + expect(details(h.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); + if (scenario === "duplicate-native") expect(h.events.map(details)).toContainEqual(expect.objectContaining({ reason: "semantic_receipt_conflict" })); + }); + it("preserves admitted errors and fences late captured callbacks instead of rebinding", () => { + const h = harness(); const bound = make("get_task_context", true); + const captured = h.projector.captureSemanticReceipt()!; + h.projector.tool(tool("native", args, "other")); captured(bound.receipt); + h.projector.tool(terminal("native", bound.result.content, "failed")); + expect(details(h.events.at(-1)!)).toMatchObject({ semanticOutcome: "error", status: "failed" }); + const count = h.events.length; h.stop(); captured(bound.receipt); + expect(h.events).toHaveLength(count); + const next = harness(); next.projector.tool(tool("native", args, "other")); next.projector.tool(terminal("native", bound.result.content, "failed")); + expect(details(next.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); + }); +}); + + +describe("captured Copilot 1.0.88 native MCP receipt carrier", () => { + const bytes = readFileSync(new URL("./fixtures/copilot-1.0.88-mcp-receipt-captured.json", import.meta.url)); + const captured = JSON.parse(bytes.toString()); + const provenance = JSON.parse(readFileSync(new URL("./fixtures/copilot-1.0.88-mcp-receipt-captured.provenance.json", import.meta.url), "utf8")); + const nativeEvent = (frame: any) => ({ ...frame.params.update, type: "tool_call", tag: frame.params.update.sessionUpdate }); + it("replays native frames with the separately owned receipt and preserves rejection content", () => { + expect(createHash("sha256").update(bytes).digest("hex")).toBe(provenance.fixtureSha256); + expect(provenance).toMatchObject({ nativeVersion: "1.0.88", paidProviderCalls: 0, qualification: false }); + expect(captured.pending.params.sessionId).toBe(captured.completed.params.sessionId); + const h = harness(captured.pending.params.sessionId); + h.projector.tool(nativeEvent(captured.pending)); + h.projector.captureSemanticReceipt()!(captured.authoritativeReceipt); + h.projector.tool(nativeEvent(captured.completed)); + const output = captured.completed.params.update.rawOutput; + expect(output.content).toBe(output.contents.map((block: { text: string }) => block.text).join("")); + expect(output.detailedContent).toBe(output.content); + expect(JSON.parse(output.contents[0].text)).toMatchObject({ accepted: false }); + expect(details(h.events.at(-1)!)).toMatchObject({ stage: "tool", status: "completed", + semanticOperationId: captured.authoritativeReceipt.operationId, + semanticCallIdentitySha256: captured.authoritativeReceipt.callIdentitySha256, + semanticInputSha256: captured.authoritativeReceipt.inputSha256, + semanticResultSha256: captured.authoritativeReceipt.resultSha256, semanticOutcome: "returned" }); + expect(JSON.stringify(h.events)).not.toContain("known-small-result"); + }); + it.each(["missing-authority", "altered-block", "different-input", "missing-contents", "duplicate-terminal"])("does not trust captured native output alone: %s", scenario => { + const value = structuredClone(captured), h = harness(value.pending.params.sessionId); + if (scenario === "different-input") value.pending.params.update.rawInput = { changed: true }; + if (scenario === "altered-block") value.completed.params.update.rawOutput.contents[0].text = '{"accepted":true}'; + if (scenario === "missing-contents") delete value.completed.params.update.rawOutput.contents; + h.projector.tool(nativeEvent(value.pending)); + if (scenario !== "missing-authority") h.projector.captureSemanticReceipt()!(value.authoritativeReceipt); + h.projector.tool(nativeEvent(value.completed)); + if (scenario === "duplicate-terminal") h.projector.tool(nativeEvent(value.completed)); + expect(details(h.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); + if (scenario === "duplicate-terminal") expect(details(h.events.at(-1)!)).toMatchObject({ stage: "evidence_incomplete", reason: "reused_semantic_lifecycle" }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts new file mode 100644 index 0000000000..5d33731d5c --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -0,0 +1,208 @@ +import { parseSemanticToolReceipt, readNativeSemanticReceipt, sameSemanticReceipt, semanticInputSha256, type SemanticToolReceipt } from "../semantic-tool-receipt.js"; +import { updateSingleReadEvidence, type SingleReadEvidence } from "./single-read-evidence.js"; +import { createHash } from "node:crypto"; +import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import { 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; 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); +const digest = (v: string) => createHash("sha256").update(v).digest("hex"); + +/** + * Observation only, bound to one active ACP prompt iterator and its permission + * callbacks. Session passthrough notifications cannot enter this projector. + * Old sessions without these notices supply no qualification evidence. This + * additive projection changes source/pack provenance, not execution identity. + */ +export function createCopilotToolEvidence(binding: { + sessionId: string; turnId: string; workingDirectory: string; + active: () => boolean; emit: (event: CanonicalProviderEvent) => void; + unavailable?: () => void; +}) { + const tools = new Map(); + const shells = new Map(); + const permissionTools = new Set(); + const semanticReceipts = new Map(); + const matchedReceipts = new Map(); + let sequence = 0; + let incomplete = false; + let broken = false; + function safely(action: () => T): T | undefined { + if (broken) return; + try { return action(); } catch { + // Observation cannot undo an authoritative response or terminal outcome. + broken = true; + try { notice("evidence_incomplete", "unavailable", { reason: "projection_failed" }); } catch { /* Report through diagnostics if the sink itself failed. */ } + try { binding.unavailable?.(); } catch { /* Diagnostics cannot undo delivery. */ } + return; + } + } + function notice(stage: string, toolCallId: string | undefined, fields: Fields, method = "session/update", category = CATEGORY) { + if (!binding.active() || !identity(binding.sessionId) || !identity(binding.turnId)) return; + if (sequence >= 2048) { + if (incomplete) return; + incomplete = true; stage = "evidence_incomplete"; fields = { reason: "notice_limit" }; + } + const itemId = `copilot-evidence-${digest(`${binding.sessionId}:${binding.turnId}`).slice(0, 24)}-${++sequence}`; + binding.emit({ eventType: "provider.notice.recorded", itemId, payload: redactPaperclipSemanticValue({ + schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", + category, scope: "turn", recoverable: true, userActionable: false, + summary: stage === "semantic_result" ? "Paperclip returned a semantic tool result." : stage === "evidence_incomplete" ? "Some Copilot activity details are unavailable." + : stage === "permission_requested" ? "Copilot requested permission." + : stage === "permission_delivered" ? (fields.outcome === "reject_once" ? "Copilot received your denial." : "Copilot received your permission decision.") + : fields.shellState === "started" ? (fields.detach === false ? "Copilot started an attached command." : "Copilot started a background command.") + : fields.shellState === "completed" ? `Copilot reported command completion with exit code ${fields.exitCode}.` + : fields.operation === "edit" ? `Copilot file operation: ${fields.status}.` + : `Copilot tool operation: ${fields.status}.`, + provenance: { method, eventType: stage, sessionId: binding.sessionId, turnId: binding.turnId }, + details: Object.entries({ stage, ...(toolCallId === undefined ? {} : { toolCallId }), ...fields }).map(([name, value]) => ({ name, value: String(value) })), + }) as Record }); + } + function inputFields(call: Record): Fields { + const input = record(call.rawInput); + const fields: Fields = {}; + if (call.kind === "edit") { const path = safeCopilotEditTarget(call, binding.workingDirectory); if (path) fields.target = path; } + if (call.kind === "execute" && typeof input.command === "string" && input.command.length > 0 && Buffer.byteLength(input.command) <= 64 * 1024) { + // ACP exposes an execution kind, not the native tool name. Never infer + // `bash` from a provider-controlled title/description. + fields.operation = "execute"; + fields.commandSha256 = `sha256:${digest(input.command)}`; + if (input.mode === "sync" || input.mode === "async") fields.mode = input.mode; + if (typeof input.detach === "boolean") fields.detach = input.detach; + } + if (shellIdentity(input.shellId)) fields.shellId = input.shellId; + return fields; + } + const projection = { + tool(event: unknown) { + if (!binding.active()) return; + const call = record(event); + if (call.type !== "tool_call" || !identity(call.toolCallId)) return; + const id = call.toolCallId; + let state = tools.get(id); + if (!state) { + // A delta without its origin cannot acquire a different tool's fields. + if (call.tag !== "tool_call") return; + if (tools.size >= LIMIT) { + if (!incomplete) { incomplete = true; notice("evidence_incomplete", id, { reason: "tool_limit" }); } + return; + } + state = { fields: {} }; tools.set(id, state); + } else if (call.tag === "tool_call") { state.invalid = true; notice("evidence_incomplete", id, { reason: "reused_tool_origin" }); } + if (state.invalid) return; + if (state.semanticReceipt) { state.invalid = true; notice("evidence_incomplete", id, { reason: "reused_semantic_lifecycle" }); return; } + if (typeof call.kind === "string") { + if (state.kind && state.kind !== call.kind) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_kind" }); return; } + state.kind = call.kind; + } + 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; + try { if (Buffer.byteLength(JSON.stringify(call.rawInput)) <= 1024 * 1024) inputDigest = semanticInputSha256(call.rawInput); } catch { /* Malformed input cannot correlate. */ } + if (state.semanticInput && inputDigest !== state.semanticInput) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; } + state.semanticInput = inputDigest; + const fields = inputFields({ ...call, kind: state.kind }); + const fingerprint = JSON.stringify(fields); + 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 } : {}) }; + if (state.kind === "edit" || state.kind === "read") fields.operation = state.kind; + const output = record(call.rawOutput).content; + // Parse only the pinned native wrapper, never substrings in command output. + if (status === "completed" && typeof output === "string" && output.length <= 256) { + const started = /^$/u.exec(output.trim()); + if (started && typeof fields.commandSha256 === "string") { + const shellId = started[1]!; + if (!shells.has(shellId) && shells.size < LIMIT) shells.set(shellId, id); + else if (shells.get(shellId) !== id) { shells.set(shellId, null); notice("evidence_incomplete", id, { reason: "reused_shell_origin" }); } + if (shells.get(shellId) === id) { fields.shellId = shellId; fields.commandToolCallId = id; fields.shellState = "started"; } + } + const completed = /^$/u.exec(output.trim()); + if (completed && state.kind === "read" && fields.shellId === completed[1] && shells.get(completed[1]!) && !tools.get(shells.get(completed[1]!)!)?.invalid) { + const exitCode = Number(completed[2]); + if (Number.isSafeInteger(exitCode) && exitCode >= -2147483648 && exitCode <= 2147483647) { + fields.commandToolCallId = shells.get(completed[1]!)!; + fields.shellState = "completed"; fields.exitCode = exitCode; + } + } + } + if (call.rawOutput !== undefined) { + const candidate = readNativeSemanticReceipt(call.rawOutput); + if (candidate) { + const authoritative = semanticReceipts.get(candidate.callIdentitySha256); + const previous = matchedReceipts.get(candidate.callIdentitySha256); + if ((status !== "completed" && status !== "failed") || !authoritative + || !sameSemanticReceipt(authoritative, candidate) || state.semanticInput !== candidate.inputSha256 + || (status === "failed") !== (candidate.outcome === "error") || previous !== undefined) { + state.invalid = true; + notice("evidence_incomplete", id, { reason: "semantic_receipt_conflict" }); + return; + } + matchedReceipts.set(candidate.callIdentitySha256, id); + state.semanticReceipt = candidate; + fields.semanticOperationId = candidate.operationId; + fields.semanticCallIdentitySha256 = candidate.callIdentitySha256; + fields.semanticInputSha256 = candidate.inputSha256; + if (candidate.schema === "paperclip.semantic_tool_receipt.v2") fields.semanticNormalizedInputSha256 = candidate.normalizedInputSha256; + fields.semanticResultSha256 = candidate.resultSha256; + fields.semanticOutcome = candidate.outcome; + } + } + notice("tool", id, fields); + }, + permission(request: unknown, requestId: string, offeredActions: readonly string[]) { + const raw = record(record(request).raw); + const call = record(raw.toolCall); + if (!binding.active() || raw.sessionId !== binding.sessionId || !identity(call.toolCallId) || !identity(requestId)) return undefined; + if (permissionTools.has(call.toolCallId) || permissionTools.size >= LIMIT) { notice("evidence_incomplete", call.toolCallId, { reason: "ambiguous_permission_origin" }); return undefined; } + permissionTools.add(call.toolCallId); + const fields: Fields = { requestId, ...inputFields(call) }; + if (call.kind === "edit" || call.kind === "read") fields.operation = call.kind; + fields.declineOffered = offeredActions.includes("decline"); + notice("permission_requested", call.toolCallId, fields, "session/request_permission"); + let delivered = false; + return (outcome: string) => safely(() => { + if (delivered || !["allow_once", "allow_always", "reject_once", "cancel"].includes(outcome)) return; + delivered = true; + notice("permission_delivered", call.toolCallId as string, { ...fields, outcome }, "session/request_permission"); + }); + }, + }; + return { + captureSemanticReceipt(): ((receipt: SemanticToolReceipt) => void) | undefined { + if (!binding.active()) return undefined; + // This closure belongs to this turn, even if a successor becomes active. + return receipt => { safely(() => { + if (!binding.active()) return; + const parsed = parseSemanticToolReceipt(receipt); + if (!parsed || semanticReceipts.size >= LIMIT || semanticReceipts.has(parsed.callIdentitySha256)) { + if (parsed && semanticReceipts.has(parsed.callIdentitySha256)) semanticReceipts.set(parsed.callIdentitySha256, null); + notice("evidence_incomplete", "semantic", { reason: "ambiguous_semantic_receipt" }); + return; + } + semanticReceipts.set(parsed.callIdentitySha256, parsed); + notice("semantic_result", undefined, { ...parsed }, "paperclip/semantic_tool_result", parsed.schema === "paperclip.semantic_tool_receipt.v2" ? "paperclip_semantic_tool_receipt_v2" : "paperclip_semantic_tool_receipt_v1"); + }); }; + }, + tool: (event: unknown) => { safely(() => projection.tool(event)); }, + permission: (request: unknown, requestId: string, offeredActions: readonly string[]) => safely(() => projection.permission(request, requestId, offeredActions)), + }; +} +export type CopilotToolEvidence = ReturnType; 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 1a720978c9..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,12 +87,19 @@ describe("ACPX driver profile", () => { expect( validateAcpxDriverConfig({ agent: "pi", + piThinkingLevel: "low", model: "openrouter/deepseek/deepseek-v4-flash-0731", }), ).toMatchObject({ - ok: false, - issues: [{ path: "agent", code: "qualification_pending" }], + ok: true, + config: { agent: "pi", permissionMode: "approve-all" }, }); + for (const agent of ["copilot"]) { + expect(validateAcpxDriverConfig({ agent, model: "explicit-model" })) + .toMatchObject({ ok: false, issues: [{ path: "agent", code: "qualification_pending" }] }); + } + 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..3bc038a101 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -1,3 +1,4 @@ +import { configuredEnvironmentProjection, CONFIGURED_ENVIRONMENT_KEYS } from "../../configured-environment.js"; import { afterEach, describe, expect, it, vi } from "vitest"; afterEach(() => vi.unstubAllEnvs()); @@ -5,6 +6,22 @@ afterEach(() => vi.unstubAllEnvs()); import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "./environment.js"; describe("ACPX launch environment", () => { + it("excludes general IAM from the Pi sidecar and launch, including selected task values", () => { + const iam = { AWS_ACCESS_KEY_ID: "general-key", AWS_SECRET_ACCESS_KEY: "general-secret", AWS_SESSION_TOKEN: "general-session" }; + const source = { ...iam, ...configuredEnvironmentProjection({ ...iam, CUSTOM_SETTING: "allowed" }), AWS_BEARER_TOKEN_BEDROCK: "provider-key" }; + const binding = createAcpxCredentialBinding(source, "pi", "session-1")!; + expect(JSON.parse(binding).names).toEqual(["AWS_BEARER_TOKEN_BEDROCK"]); + const sidecar = createAcpxSidecarHostEnvironment({ ...source, [ACPX_CREDENTIAL_BINDING_ENV]: binding }, "pi", "session-1"); + for (const env of [sidecar, createSanitizedAcpxSpawnInput(source, "pi").env, createSanitizedAcpxSpawnInput(sidecar, "pi").env]) { + for (const name of Object.keys(iam)) expect(env).not.toHaveProperty(name); + expect(env.AWS_BEARER_TOKEN_BEDROCK).toBe("provider-key"); + expect(env.CUSTOM_SETTING).toBe("allowed"); + expect(JSON.parse(env[CONFIGURED_ENVIRONMENT_KEYS]!)).toEqual(["CUSTOM_SETTING"]); + } + for (const name of Object.keys(iam)) { + expect(() => createAcpxSidecarHostEnvironment({ ...source, [ACPX_CREDENTIAL_BINDING_ENV]: JSON.stringify({ ...JSON.parse(binding), names: [name] }) }, "pi", "session-1")).toThrow("explicit matching session binding"); + } + }); it("keeps gateway and Bedrock settings confined to the selected harness", () => { const source = { ANTHROPIC_BASE_URL: "https://gateway.example", @@ -56,6 +73,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 +103,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..b437e75c35 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -1,16 +1,27 @@ -import { configuredEnvironment } from "../../configured-environment.js"; +import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironment, configuredEnvironmentKeys } from "../../configured-environment.js"; +import { PI_CREDENTIAL_NAMES, PI_GENERAL_IAM_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"], codex: ["OPENAI_API_KEY", "CODEX_API_KEY", "PAPERCLIP_AI_PROVIDER_KEY"], }; export const CLAUDE_ROUTING_ENV_KEYS = ["ANTHROPIC_BASE_URL", "CLAUDE_CODE_USE_BEDROCK", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_EC2_METADATA_DISABLED", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"] as const; + +/** Keep the task projection consistent when excluding general IAM from Pi. */ +function removePiIamCredentials(environment: NodeJS.ProcessEnv): void { + const names = configuredEnvironmentKeys(environment); + for (const name of PI_GENERAL_IAM_CREDENTIAL_NAMES) delete environment[name]; + if (environment[CONFIGURED_ENVIRONMENT_KEYS] !== undefined) { + environment[CONFIGURED_ENVIRONMENT_KEYS] = JSON.stringify(names.filter(name => !PI_GENERAL_IAM_CREDENTIAL_NAMES.includes(name))); + } +} + const isCandidate = (agent: QualifiedAcpxAgent) => agent === "pi" || agent === "cursor" || agent === "copilot"; /** Mint only at the controller's explicit task-environment boundary, never by copying a marker. */ @@ -22,7 +33,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 +47,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 +58,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 { @@ -61,6 +73,7 @@ export function createAcpxSidecarHostEnvironment( delete result[name]; } } + if (agent === "pi") removePiIamCredentials(result); return result; } @@ -89,7 +102,7 @@ export function createSanitizedAcpxSpawnInput( const source = environment ?? process.env; const candidate = isCandidate(agent); const result: NodeJS.ProcessEnv = configuredEnvironment(environment); - const credentialNames = ACPX_CREDENTIAL_NAMES[agent]; + const credentialNames = agent === "pi" ? piCredentialNames(environment) : ACPX_CREDENTIAL_NAMES[agent]; const allowed = new Set([ "PATH", "LANG", @@ -133,6 +146,7 @@ export function createSanitizedAcpxSpawnInput( retainedBytes += entryBytes; result[key] = value; } + if (agent === "pi") removePiIamCredentials(result); return Object.freeze({ env: Object.freeze(result), }) as SanitizedAcpxSpawnInput; diff --git a/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.json b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.json new file mode 100644 index 0000000000..bf947d5b40 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.json @@ -0,0 +1,60 @@ +{ + "pending": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "captured-fixture-session", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "fixture-tool", + "title": "receipt-receipt_fixture", + "kind": "other", + "status": "pending", + "rawInput": {} + } + } + }, + "completed": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "captured-fixture-session", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "fixture-tool", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "{\"fixture\":\"known-small-result\",\"accepted\":false}{\"schema\":\"paperclip.semantic_tool_receipt.v1\",\"operationId\":\"receipt_fixture\",\"callIdentitySha256\":\"8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508\",\"inputSha256\":\"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",\"resultSha256\":\"b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c\",\"outcome\":\"returned\"}" + } + } + ], + "rawOutput": { + "content": "{\"fixture\":\"known-small-result\",\"accepted\":false}{\"schema\":\"paperclip.semantic_tool_receipt.v1\",\"operationId\":\"receipt_fixture\",\"callIdentitySha256\":\"8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508\",\"inputSha256\":\"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",\"resultSha256\":\"b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c\",\"outcome\":\"returned\"}", + "detailedContent": "{\"fixture\":\"known-small-result\",\"accepted\":false}{\"schema\":\"paperclip.semantic_tool_receipt.v1\",\"operationId\":\"receipt_fixture\",\"callIdentitySha256\":\"8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508\",\"inputSha256\":\"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",\"resultSha256\":\"b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c\",\"outcome\":\"returned\"}", + "contents": [ + { + "type": "text", + "text": "{\"fixture\":\"known-small-result\",\"accepted\":false}" + }, + { + "type": "text", + "text": "{\"schema\":\"paperclip.semantic_tool_receipt.v1\",\"operationId\":\"receipt_fixture\",\"callIdentitySha256\":\"8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508\",\"inputSha256\":\"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",\"resultSha256\":\"b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c\",\"outcome\":\"returned\"}" + } + ] + } + } + } + }, + "authoritativeReceipt": { + "schema": "paperclip.semantic_tool_receipt.v1", + "operationId": "receipt_fixture", + "callIdentitySha256": "8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508", + "inputSha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "resultSha256": "b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c", + "outcome": "returned" + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.provenance.json b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.provenance.json new file mode 100644 index 0000000000..acff9e5520 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.provenance.json @@ -0,0 +1,49 @@ +{ + "schema": "paperclip.native-mcp-capture-provenance.v1", + "nativeVersion": "1.0.88", + "nativeExecutableSha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "platform": "darwin-arm64", + "sourceCommit": "cd2bd6f755a7685320766a71093320d7221172e9", + "captureKind": "real pinned native executable with deterministic loopback model and MCP server", + "paidProviderCalls": 0, + "credentialReads": false, + "qualification": false, + "scope": "One small successful two-text-block MCP result, original accepted=false; not native proof for every size/error encoding.", + "wireIndices": [ + 9, + 11 + ], + "sanitization": [ + "Replaced only params.sessionId in each selected frame with captured-fixture-session.", + "Excluded all other wire frames and model/configuration discovery.", + "Selected update objects and rawOutput preserved exactly.", + "authoritativeReceipt copied from owned fixture carrier.json; it is not an authority inferred from native output." + ], + "originalEvidence": [ + { + "name": "stdout.json", + "sha256": "95deeefff204b8e8db9c46b882cdea6fbe9b5c0de68efb56b4e9359cd0987208" + }, + { + "name": "carrier.json", + "sha256": "c4f4fc333dfbc5efe33b46435a88c3c8ceff74237083e6eb03ad16bb8e783807" + }, + { + "name": "carrier-proof.json", + "sha256": "4ed85f32732c2995506e4ffd23e85f79f26388aac6d8f47efefb5258c7b71da5" + }, + { + "name": "execution-plan.json", + "sha256": "ae4435b5ed2c23a27e2d9c47efe599e3720b800a66e4f9ef56886428f21ee998" + }, + { + "name": "probe.py", + "sha256": "264d39c1f6c36dadcd343b406a9f3ec851b29a55208c08cc3f6e26640ebeadd7" + }, + { + "name": "HANDOFF.json", + "sha256": "e9859debd7274b42f5e58c28cc025bb2e3a090bb583e71ad460f2f482f6c3dae" + } + ], + "fixtureSha256": "4af7345f219463813b382e4c6863003f9f957e147a96dc9c197b97ecd3e20cfb" +} diff --git a/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-tool-evidence.json b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-tool-evidence.json new file mode 100644 index 0000000000..74213d070d --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-tool-evidence.json @@ -0,0 +1,241 @@ +{ + "provider": "@github/copilot@1.0.88", + "executableSha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "source": "Credential-free native binary with loopback synthetic model; no provider inference. Workspace paths normalized by probe.", + "deny-write": [ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "21c800ed-2229-48f8-b13a-68e711944863", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "fixture-tool", + "title": "Creating ...pilot-offline-2khr_xkr/copilot-denied-nonce.txt", + "kind": "edit", + "status": "pending", + "rawInput": { + "path": "/fixture/workspace/copilot-denied-nonce.txt", + "file_text": "MUST NOT EXIST" + }, + "locations": [ + { + "path": "/fixture/workspace/copilot-denied-nonce.txt" + } + ], + "content": [ + { + "type": "diff", + "path": "/fixture/workspace/copilot-denied-nonce.txt", + "oldText": "", + "newText": "MUST NOT EXIST" + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "21c800ed-2229-48f8-b13a-68e711944863", + "toolCall": { + "toolCallId": "fixture-tool", + "title": "Create file", + "kind": "edit", + "status": "pending", + "rawInput": { + "fileName": "/fixture/workspace/copilot-denied-nonce.txt", + "diff": "\ndiff --git a/fixture/workspace/copilot-denied-nonce.txt b/fixture/workspace/copilot-denied-nonce.txt\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/fixture/workspace/copilot-denied-nonce.txt\n@@ -1,0 +1,1 @@\n+MUST NOT EXIST\n" + }, + "locations": [ + { + "path": "/fixture/workspace/copilot-denied-nonce.txt" + } + ] + }, + "options": [ + { + "optionId": "allow_once", + "kind": "allow_once", + "name": "Allow once" + }, + { + "optionId": "allow_always", + "kind": "allow_always", + "name": "Always allow" + }, + { + "optionId": "reject_once", + "kind": "reject_once", + "name": "Deny" + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "21c800ed-2229-48f8-b13a-68e711944863", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "fixture-tool", + "status": "failed", + "rawOutput": { + "message": "The user rejected this tool call.", + "code": "rejected" + } + } + } + } + ], + "attached-shell": [ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "8426a268-fe73-4b5c-addd-26de5f670c38", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "fixture-tool", + "title": "Bounded settlement fixture", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "sleep 2; printf ACP_SHELL_DONE > settlement.txt", + "description": "Bounded settlement fixture", + "mode": "async", + "detach": false + } + } + } + }, + { + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "8426a268-fe73-4b5c-addd-26de5f670c38", + "toolCall": { + "toolCallId": "fixture-tool", + "title": "Bounded settlement fixture", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "sleep 2; printf ACP_SHELL_DONE > settlement.txt", + "commands": [ + "sleep 2; printf ACP_SHELL_DONE > settlement.txt" + ] + } + }, + "options": [ + { + "optionId": "allow_once", + "kind": "allow_once", + "name": "Allow once" + }, + { + "optionId": "allow_always", + "kind": "allow_always", + "name": "Always allow" + }, + { + "optionId": "reject_once", + "kind": "reject_once", + "name": "Deny" + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "8426a268-fe73-4b5c-addd-26de5f670c38", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "fixture-tool", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "" + } + } + ], + "rawOutput": { + "content": "", + "detailedContent": "" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "8426a268-fe73-4b5c-addd-26de5f670c38", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "fixture-tool", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "" + } + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "8426a268-fe73-4b5c-addd-26de5f670c38", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "7da35af6-2aab-469f-8964-40f0605dcb6c", + "title": "Reading shell output", + "kind": "read", + "status": "pending", + "rawInput": { + "shellId": "0", + "delay": 0 + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "8426a268-fe73-4b5c-addd-26de5f670c38", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "7da35af6-2aab-469f-8964-40f0605dcb6c", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "\n" + } + } + ], + "rawOutput": { + "content": "\n", + "detailedContent": "\n" + } + } + } + } + ] +} diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index a7e34cf141..3a0bf38cf3 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": 22, "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:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", "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": 17, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", "qualificationStatus": "pending", "permissionPolicy": "interactive" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index ed02700f75..089aeec06b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -295,7 +295,7 @@ describe("ACPX installation integrity", () => { await mkdir(nestedRuntimeDirectory, { recursive: true }); await writeFile( join(nestedRuntimeDirectory, "package.json"), - JSON.stringify({ version: "0.84.2" }), + JSON.stringify({ version: "1.0.0" }), ); await expect( @@ -1407,7 +1407,7 @@ describe("ACPX installation integrity", () => { fixture.runtimePackageJsonPath, JSON.stringify({ name: packageName, - version: "0.84.2", + version: "1.0.0", main: "index.js", }), ), @@ -2024,9 +2024,15 @@ async function expectOutput( }); const [exitCode] = await once(child, "exit"); expect(exitCode, stderr).toBe(0); - const normalized = process.platform === "darwin" - ? stdout.replace(/\/private\/var\/[^"\s]*\/paperclip-acpx-[^/]+\/0/g, "/proc/self/fd/4") - : stdout; + let normalized = stdout; + if (process.platform === "darwin") { + const snapshotPrefix = join(await realpath(tmpdir()), "paperclip-acpx-") + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + normalized = stdout.replace( + new RegExp(`${snapshotPrefix}[^/"\\s]+/0(?=[/"])`, "g"), + "/proc/self/fd/4", + ); + } expect(normalized).toBe(expected); } @@ -2201,7 +2207,7 @@ async function installationFixture() { serverPackageJsonPath, JSON.stringify({ version: "0.0.33", bin: "bin/server.js" }), ), - writeFile(runtimePackageJsonPath, JSON.stringify({ version: "0.84.2" })), + writeFile(runtimePackageJsonPath, JSON.stringify({ version: "1.0.0" })), writeFile(commandPath, command), ]); await chmod(commandPath, 0o755); diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 6b7f7cc560..05fd89fdc4 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"; @@ -473,7 +476,7 @@ export interface VerifiedAcpxInstallation { readonly commandDigest: string; readonly agentServerPackageJsonPath: string | null; readonly agentRuntimePackageJsonPath: string | null; - openCommand(): Promise; + openCommand(options?: { signal?: AbortSignal }): Promise; } /** @@ -492,8 +495,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", [], @@ -1442,6 +1445,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; @@ -1457,11 +1471,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( @@ -1606,6 +1644,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 @@ -1635,12 +1688,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); @@ -2171,3 +2223,10 @@ export async function probeAcpxClaudeInstallation(model: string): Promise export async function probeAcpxGrokInstallation(model: string): Promise { await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile("grok", model)); } + +/** Verify the pinned Pi closure and snapshot lease without launching a provider. */ +export async function probeAcpxPiInstallation(model: string): Promise { + const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile("pi", model)); + const lease = await installation.openCommand(); + await lease.close(); +} 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 d140717ae3..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 @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; import { once } from "node:events"; -import { chmod, copyFile, mkdtemp, open, readFile, readdir, rm, stat, symlink, writeFile, type FileHandle } from "node:fs/promises"; +import { constants } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { chmod, copyFile, link, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, symlink, writeFile, type FileHandle } from "node:fs/promises"; import { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -11,7 +13,7 @@ import { createNativeAcpxDistributionSnapshot, readNativeAcpxDistributionEntries vi.mock("node:fs/promises", async (importOriginal) => { const original = await importOriginal(); - return { ...original, rm: vi.fn(original.rm) }; + return { ...original, open: vi.fn(original.open), rm: vi.fn(original.rm), mkdir: vi.fn(original.mkdir), chmod: vi.fn(original.chmod) }; }); const roots: string[] = []; @@ -57,6 +59,22 @@ async function manyFileFixture(sizes: number[]) { await writeFile(declaration.manifestPath, JSON.stringify({ entries })); return { declaration: { ...declaration, expectedClosureSha256: hash(JSON.stringify(entries)) }, entries }; } +async function directoryFixture() { + const declaration = await fixture(); + const entries = await readNativeAcpxDistributionEntries(declaration); + for (let index = 0; index < 40; index++) { + const parent = `dir-${String(index).padStart(2, "0")}/nested`; + await mkdir(join(declaration.distributionRoot, parent), { recursive: true }); + for (const name of ["a", "b"]) { + const path = `${parent}/${name}`; const bytes = Buffer.from(path); + await writeFile(join(declaration.distributionRoot, path), bytes, { mode: 0o600 }); + entries.push({ path, sha256: hash(bytes), size: bytes.length, executable: false }); + } + } + entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + await writeFile(declaration.manifestPath, JSON.stringify({ entries })); + return { declaration: { ...declaration, expectedClosureSha256: hash(JSON.stringify(entries)) }, entries }; +} async function filePrototype(path: string): Promise { const probe = await open(path, "r"); const prototype = Object.getPrototypeOf(probe) as FileHandle; await probe.close(); return prototype; @@ -76,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 }]]) { @@ -91,6 +142,66 @@ describe("native ACPX execution closure", () => { await symlink("closure.json", join(declaration.distributionRoot, "runtime")); await expect(installation.openCommand()).rejects.toThrow("symbolic link"); }); + it.each(["fifo", "directory", "hardlink", "symlink", "size", "mode"] as const)( + "rejects a %s swapped in immediately before open without reading it or leaking its descriptor", async kind => { + const declaration = await fixture(); + const entries = await readNativeAcpxDistributionEntries(declaration); + const original = await vi.importActual("node:fs/promises"); + const source = await realpath(declaration.distributionRoot); + const path = join(source, "runtime"); + const originalPath = join(source, "original"); + const prototype = await filePrototype(path); + const read = vi.spyOn(prototype, "read"); + let opened: FileHandle | undefined; let closed = false; + vi.mocked(open).mockImplementation(async (selected: any, flags: any, ...rest: any[]): Promise => { + if (String(selected) !== path) return original.open(selected, flags, rest[0]); + expect(flags & constants.O_NOFOLLOW).not.toBe(0); + expect(flags & constants.O_NONBLOCK).not.toBe(0); + await rename(path, originalPath); + if (kind === "fifo") execFileSync("mkfifo", [path], { timeout: 2_000 }); + else if (kind === "directory") await mkdir(path); + else if (kind === "hardlink") await link(originalPath, path); + else if (kind === "symlink") await symlink(originalPath, path); + else { + await copyFile(originalPath, path); + if (kind === "size") await writeFile(path, "short"); + else await chmod(path, 0o600); + } + opened = await original.open(selected, flags, rest[0]); + const close = opened.close.bind(opened); + opened.close = async () => { await close(); closed = true; }; + return opened; + }); + let unexpected: Awaited> | undefined; + try { + const error = await createNativeAcpxDistributionSnapshot(declaration, entries).then( + value => { unexpected = value; return undefined; }, error => error); + expect(error).toBeInstanceOf(Error); + expect(read).not.toHaveBeenCalled(); + if (kind === "symlink") expect(opened).toBeUndefined(); + else { expect(opened).toBeDefined(); expect(closed).toBe(true); } + } finally { + vi.mocked(open).mockImplementation(original.open); + if (unexpected) { await unexpected.commandDirectory.close(); await unexpected.snapshot.close(); } + } + }); + it("rejects a same-byte pathname replacement after reading the held descriptor", async () => { + const declaration = await fixture(); const entries = await readNativeAcpxDistributionEntries(declaration); + const path = join(declaration.distributionRoot, "runtime"); + const prototype = await filePrototype(path); const originalRead = prototype.read; + let replaced = false; + vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: any[]): Promise { + const result = await originalRead.apply(this, args as never); + if (!replaced) { + replaced = true; + await rename(path, path + ".original"); + await copyFile(path + ".original", path); + } + return result; + }); + await expect(createNativeAcpxDistributionSnapshot(declaration, entries)).rejects.toThrow("changed while read"); + expect(replaced).toBe(true); + }); it("launches only fixed arguments from a frozen snapshot after installed files change", async () => { const declaration = await fixture(); const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand(); expect(() => lease.spawn(["--untrusted-override"])).toThrow("fixed profile arguments"); @@ -100,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); @@ -108,6 +322,32 @@ describe("native ACPX execution closure", () => { expect(first.text).toMatch(/^native:fixed:.*paperclip-acpx-native-.*\/state$/); expect(first.text).not.toBe(second.text); }); + // Copy/hash the real Node closure and start its bootstrap + owned shim. These + // operations share the adjacent native-closure test's bounded allowance; + // Vitest's 5s default is not a provider startup or permission deadline. + it("loads the owned Copilot distribution through a guarded private shim and ignores ambient overrides", async () => { + const declaration = await fixture({ node: true }); + const entries = await readNativeAcpxDistributionEntries(declaration); + await mkdir(join(declaration.distributionRoot, "distribution")); + const source = 'console.log(JSON.stringify({owned:__dirname,dist:process.env.COPILOT_CLI_DIST_DIR,version:process.env.COPILOT_CLI_VERSION??null,options:process.env.NODE_OPTIONS??null}));'; + await writeFile(join(declaration.distributionRoot, "distribution/index.js"), source, { mode: 0o600 }); + entries.push({ path: "distribution/index.js", 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 owned = { ...declaration, entrypoint: undefined, expectedClosureSha256: hash(JSON.stringify(entries)), copilotDistributionDirectory: "distribution", fixedArguments: ["-e", 'require(process.env.COPILOT_CLI_DIST_DIR+"/index.js")'] }; + const installation = await verifyNativeAcpxInstallation(owned); + const lease = await installation.openCommand(); + const result = await output(lease.spawn([], { env: { COPILOT_CLI_DIST_DIR: "/ambient/evil", COPILOT_CLI_VERSION: "99.0.0", NODE_OPTIONS: "--untrusted-option" } })); + expect(result.code, result.error).toBe(0); + const observed = JSON.parse(result.text); + expect(observed.owned).toMatch(/paperclip-acpx-native-.*\/distribution\/distribution$/); + expect(observed.dist).toBe(join(dirname(observed.owned), ".paperclip-copilot-entry")); + expect(observed.version).toBeNull(); expect(observed.options).toBeNull(); + await lease.close(); + await expect(readNativeAcpxDistributionEntries({ ...owned, copilotDistributionDirectory: "../ambient" })).rejects.toThrow("launch declaration"); + await expect(readNativeAcpxDistributionEntries({ ...owned, copilotDistributionDirectory: "missing" })).rejects.toThrow("owned entrypoint"); + await expect(readNativeAcpxDistributionEntries({ ...owned, entrypoint: "entry.cjs" })).rejects.toThrow("launch declaration"); + }, 30_000); it("loads a pinned Node entrypoint while rejecting unqualified external modules", async () => { const declaration = await fixture({ node: true }); const result = await output((await (await verifyNativeAcpxInstallation(declaration)).openCommand()).spawn()); @@ -117,8 +357,108 @@ 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; + const created = await createNativeAcpxDistributionSnapshot(declaration, entries); + try { + const root = created.snapshot.roots[0]!; + const calls = vi.mocked(mkdir).mock.calls.slice(creatingStart).map(([path]) => String(path)).filter(path => path.startsWith(root)); + expect(calls).toHaveLength(81); expect(new Set(calls).size).toBe(81); + for (const path of calls) expect((await stat(path)).mode & 0o777).toBe(0o500); + for (const entry of entries) { + const path = join(root, entry.path); + expect(hash(await readFile(path))).toBe(entry.sha256); + expect((await stat(path)).mode & 0o777).toBe(entry.executable ? 0o500 : 0o400); + } + } finally { await created.commandDirectory.close(); await created.snapshot.close(); } + }); + it.each(["create", "seal"] as const)("bounds %s batches and drains failures before cleanup or further scheduling", async stage => { + const { declaration, entries } = await directoryFixture(); + const original = await vi.importActual("node:fs/promises"); + const hold = gate(); let started = 0; let active = 0; let peak = 0; let settled = false; + const removalStart = vi.mocked(rm).mock.calls.length; + const operation = async (path: any, mode: any) => { + const selected = stage === "create" + ? /paperclip-acpx-native-.*\/distribution\/dir-\d+$/.test(String(path)) + : mode === 0o500; + if (!selected) return stage === "create" ? original.mkdir(path, mode) : original.chmod(path, mode); + const index = started++; active++; peak = Math.max(peak, active); + try { + if (index === 0) throw new Error(`fixture ${stage} failure`); + await hold.promise; + return stage === "create" ? await original.mkdir(path, mode) : await original.chmod(path, mode); + } finally { active--; } + }; + if (stage === "create") vi.mocked(mkdir).mockImplementation(operation as typeof mkdir); + else vi.mocked(chmod).mockImplementation(operation as typeof chmod); + const creating = createNativeAcpxDistributionSnapshot(declaration, entries); + void creating.then(() => { settled = true; }, () => { settled = true; }); + try { + await vi.waitFor(() => expect(started).toBe(32)); + expect(peak).toBeGreaterThan(1); expect(peak).toBeLessThanOrEqual(32); expect(settled).toBe(false); + expect(vi.mocked(rm).mock.calls.slice(removalStart).filter(([path]) => String(path).includes("paperclip-acpx-native-"))).toHaveLength(0); + } finally { hold.release(); } + try { + await expect(creating).rejects.toThrow(`fixture ${stage} failure`); + expect(active).toBe(0); expect(started).toBe(32); + const removed = vi.mocked(rm).mock.calls.slice(removalStart).map(([path]) => String(path)).filter(path => path.includes("paperclip-acpx-native-")); + expect(removed).toHaveLength(1); + await expect(stat(removed[0]!)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { vi.mocked(mkdir).mockImplementation(original.mkdir); vi.mocked(chmod).mockImplementation(original.chmod); } + }); it("copies concurrently within its descriptor bound and retains canonical manifest order", async () => { - const { declaration, entries } = await manyFileFixture(Array.from({ length: 12 }, (_, index) => 100 + index)); + const { declaration, entries } = await manyFileFixture(Array.from({ length: 40 }, (_, index) => 100 + index)); const prototype = await filePrototype(join(declaration.distributionRoot, "runtime")); const originalRead = prototype.read; const hold = gate(); const sizes: number[] = []; let active = 0; let peak = 0; @@ -129,16 +469,37 @@ describe("native ACPX execution closure", () => { }); const creating = createNativeAcpxDistributionSnapshot(declaration, entries); try { - await vi.waitFor(() => expect(sizes).toHaveLength(8)); - expect(sizes.toSorted()).toEqual(Array.from({ length: 8 }, (_, index) => 100 + index)); + await vi.waitFor(() => expect(sizes).toHaveLength(32)); + expect(sizes.toSorted()).toEqual(Array.from({ length: 32 }, (_, index) => 100 + index)); } finally { hold.release(); } const created = await creating; try { - expect(peak).toBe(8); + expect(peak).toBe(32); expect(Object.keys(created.snapshot.digests).slice(0, entries.length)).toEqual(entries.map(entry => join(created.snapshot.roots[0]!, entry.path))); for (const entry of entries) expect(hash(await readFile(join(created.snapshot.roots[0]!, entry.path)))).toBe(entry.sha256); } finally { await created.commandDirectory.close(); await created.snapshot.close(); } }); + it("uses released capacity while an earlier copy is still pending", async () => { + const { declaration, entries } = await manyFileFixture(Array.from({ length: 40 }, (_, index) => 100 + index)); + const prototype = await filePrototype(join(declaration.distributionRoot, "runtime")); + const originalRead = prototype.read; const hold = gate(); let laterReached = false; + let active = 0; let peak = 0; + vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: any[]): Promise { + active++; peak = Math.max(peak, active); + try { + if (args[0].length === 100) await hold.promise; + if (args[0].length === 139) laterReached = true; + return await originalRead.apply(this, args as never); + } finally { active--; } + }); + const creating = createNativeAcpxDistributionSnapshot(declaration, entries); + let created: Awaited | undefined; + try { + try { await vi.waitFor(() => expect(laterReached).toBe(true)); expect(active).toBeGreaterThan(0); expect(peak).toBeLessThanOrEqual(32); } + finally { hold.release(); created = await creating; } + expect(Object.keys(created.snapshot.digests).slice(0, entries.length)).toEqual(entries.map(entry => join(created!.snapshot.roots[0]!, entry.path))); + } finally { if (created) { await created.commandDirectory.close(); await created.snapshot.close(); } } + }); it("bounds simultaneous buffers and gives an oversized admitted file exclusive capacity", async () => { const mib = 1024 * 1024; const { declaration, entries } = await manyFileFixture([17 * mib, 17 * mib, 33 * mib]); @@ -161,7 +522,7 @@ describe("native ACPX execution closure", () => { finally { await created.commandDirectory.close(); await created.snapshot.close(); } }); it("drains every admitted copy before failed-snapshot cleanup and schedules no later batch", async () => { - const { declaration, entries } = await manyFileFixture(Array.from({ length: 10 }, (_, index) => 91 + index)); + const { declaration, entries } = await manyFileFixture(Array.from({ length: 40 }, (_, index) => 91 + index)); await writeFile(join(declaration.distributionRoot, "file-00"), Buffer.alloc(91, 99)); const prototype = await filePrototype(join(declaration.distributionRoot, "runtime")); const originalRead = prototype.read; @@ -174,7 +535,7 @@ describe("native ACPX execution closure", () => { this.close = async () => { await originalClose(); invalidClosed.release(); }; await entered.promise; } - if (size === 92) { entered.release(); await hold.promise; } + if (size !== 91) { entered.release(); await hold.promise; } return originalRead.apply(this, args as never); }); const creating = createNativeAcpxDistributionSnapshot(declaration, entries); @@ -185,12 +546,42 @@ describe("native ACPX execution closure", () => { expect(settled).toBe(false); } finally { hold.release(); } await expect(creating).rejects.toThrow("digest mismatch: file-00"); - expect(readSizes).not.toContain(99); + 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 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 d8afb68cb7..83f5b334da 100644 --- a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { chmod, lstat, mkdir, mkdtemp, open, realpath, rm, writeFile, type FileHandle } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import type { AcpxPrivateSnapshot } from "./private-snapshot.js"; export interface NativeAcpxDistributionEntry { path: string; sha256: string; size: number; executable: boolean } @@ -16,6 +16,8 @@ export interface NativeAcpxDistributionInput { entrypoint?: string; fixedArguments: readonly string[]; isolatedCacheEnvironmentName?: "COPILOT_PKG_CACHE_HOME"; + /** Trusted Copilot profile only; resolved inside each immutable spawn lease. */ + copilotDistributionDirectory?: string; } export interface NativeAcpxDistributionSnapshot { snapshot: AcpxPrivateSnapshot; @@ -28,10 +30,12 @@ const MAX_NATIVE_FILE_BYTES = 384 * 1024 * 1024; const MAX_NATIVE_MANIFEST_BYTES = 4 * 1024 * 1024; // Bound both file descriptors and buffers. A single larger admitted file runs // alone and remains subject to MAX_NATIVE_FILE_BYTES. -const NATIVE_COPY_CONCURRENCY = 8; +const NATIVE_COPY_CONCURRENCY = 32; +const NATIVE_DIRECTORY_CONCURRENCY = 32; const NATIVE_COPY_BUFFER_BYTES = 32 * 1024 * 1024; const BOOTSTRAP = ".paperclip-native-entry.cjs"; const GUARD = ".paperclip-native-module-guard.cjs"; +const COPILOT_ENTRY = ".paperclip-copilot-entry"; export const NATIVE_ACPX_BOOTSTRAP_NAME = BOOTSTRAP; const sha256 = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); const validPath = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4_096 && !isAbsolute(value) && !/[\u0000-\u001f\u007f\\]/.test(value) && value.split("/").every(part => part.length > 0 && part !== "." && part !== ".."); @@ -58,7 +62,8 @@ export function parseNativeAcpxDistributionEntries(value: unknown, expectedSha25 } export async function readNativeAcpxDistributionEntries(input: NativeAcpxDistributionInput): Promise { - if (!validPath(input.executable) || (input.entrypoint !== undefined && !validPath(input.entrypoint)) || input.fixedArguments.length > 128 || input.fixedArguments.some(arg => typeof arg !== "string" || arg.length > 16_384 || arg.includes("\0")) || (input.isolatedCacheEnvironmentName !== undefined && input.isolatedCacheEnvironmentName !== "COPILOT_PKG_CACHE_HOME")) throw new Error("Native ACPX launch declaration is invalid"); + if (!validPath(input.executable) || (input.entrypoint !== undefined && !validPath(input.entrypoint)) || input.fixedArguments.length > 128 || input.fixedArguments.some(arg => typeof arg !== "string" || arg.length > 16_384 || arg.includes("\0")) || (input.isolatedCacheEnvironmentName !== undefined && input.isolatedCacheEnvironmentName !== "COPILOT_PKG_CACHE_HOME") + || (input.copilotDistributionDirectory !== undefined && (!validPath(input.copilotDistributionDirectory) || input.entrypoint !== undefined))) throw new Error("Native ACPX launch declaration is invalid"); const file = await open(input.manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW); try { const before = await file.stat({ bigint: true }); @@ -67,6 +72,7 @@ export async function readNativeAcpxDistributionEntries(input: NativeAcpxDistrib if (!same(before, await file.stat({ bigint: true })) || bytes.length !== Number(before.size)) throw new Error("Native ACPX manifest changed while read"); const entries = parseNativeAcpxDistributionEntries(JSON.parse(bytes.toString("utf8")), input.expectedClosureSha256); if (!entries.some(entry => entry.path === input.executable && entry.executable && entry.size > 0) || (input.entrypoint !== undefined && !entries.some(entry => entry.path === input.entrypoint && entry.size > 0))) throw new Error("Native ACPX executable or entrypoint is absent from its closure"); + if (input.copilotDistributionDirectory !== undefined && !entries.some(entry => entry.path === `${input.copilotDistributionDirectory}/index.js` && entry.size > 0)) throw new Error("Copilot owned entrypoint is absent from its closure"); return entries; } finally { await file.close(); } } @@ -75,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"); @@ -94,20 +101,57 @@ 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("/"); + 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"); - const before = await lstat(path, { bigint: true }); - if (!before.isFile() || before.nlink !== 1n || before.size !== BigInt(entry.size) || Boolean(before.mode & 0o111n) !== entry.executable) throw new Error("Native ACPX closure file identity is invalid"); - const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + // Bind metadata to the descriptor we will read, without a redundant + // pathname stat. NONBLOCK prevents a raced-in FIFO from blocking open; + // only a bounded regular single-link file may reach the read below. + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { - if (!same(before, await file.stat({ bigint: true }))) throw new Error("Native ACPX closure file changed before read"); + const before = await file.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size !== BigInt(entry.size) || Boolean(before.mode & 0o111n) !== entry.executable) throw new Error("Native ACPX closure file identity is invalid"); 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; @@ -115,40 +159,69 @@ 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("/")); - await mkdir(dirname(target), { recursive: true, mode: 0o700 }); - let directory = dirname(target); - while (directory !== privateRoot) { directories.add(directory); directory = dirname(directory); } + signal?.throwIfAborted(); await writeFile(target, bytes, { mode: entry.executable ? 0o500 : 0o400, flag: "wx" }); } finally { await file.close(); } }; - for (let start = 0; start < entries.length;) { - let end = start; - let bytes = 0; - while (end < entries.length && end - start < NATIVE_COPY_CONCURRENCY) { - const size = entries[end]!.size; - if (end > start && bytes + size > NATIVE_COPY_BUFFER_BYTES) break; - bytes += size; - end++; + // Keep bounded capacity occupied when one file is slower than its peers. + // Every task catches its rejection before releasing capacity; once failed, + // no new copy is admitted and all owned descriptors drain before cleanup. + const active = new Set>(); + 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 new Promise(resolve => { capacityAvailable = resolve; }); + if (signal?.aborted && !failed) { failed = true; failure = signal.reason; } } - const batch = entries.slice(start, end); - // Never clean the private root while another worker can still write or - // close a descriptor. Stop scheduling new batches after any rejection. - const copied = await Promise.allSettled(batch.map(copyEntry)); - const failure = copied.find((result) => result.status === "rejected"); - if (failure?.status === "rejected") throw failure.reason; - // Worker completion order must not change the module guard or manifest. - for (const entry of batch) digests[join(packageRoot, ...entry.path.split("/"))] = entry.sha256; - start = end; + 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); + 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"); const executable = join(packageRoot, ...input.executable.split("/")); const args = [...(input.entrypoint === undefined ? [] : ["--require", join(packageRoot, GUARD), join(packageRoot, ...input.entrypoint.split("/"))]), ...input.fixedArguments]; - if (input.entrypoint !== undefined) { + let copilotEntryDirectory: string | undefined; + if (input.copilotDistributionDirectory !== undefined) { + copilotEntryDirectory = join(packageRoot, COPILOT_ENTRY); + await mkdir(copilotEntryDirectory, { mode: 0o700 }); directories.add(copilotEntryDirectory); + const shim = Buffer.from([ + `require(${JSON.stringify(join(packageRoot, GUARD))});`, + `import(require("node:url").pathToFileURL(${JSON.stringify(join(packageRoot, input.copilotDistributionDirectory, "index.js"))}).href).catch(error=>{console.error(error);process.exitCode=1;});`, + ].join("\n")); + const shimPath = join(copilotEntryDirectory, "index.js"); + await writeFile(shimPath, shim, { mode: 0o400, flag: "wx" }); digests[shimPath] = sha256(shim); + } + if (input.entrypoint !== undefined || copilotEntryDirectory !== undefined) { const guard = Buffer.from(nativeModuleGuard(digests)); await writeFile(join(packageRoot, GUARD), guard, { mode: 0o400, flag: "wx" }); digests[join(packageRoot, GUARD)] = sha256(guard); } - const bootstrap = Buffer.from(nativeBootstrap(executable, digests[executable]!, args, input.isolatedCacheEnvironmentName ? { name: input.isolatedCacheEnvironmentName, value: cacheRoot } : undefined)); + const bootstrap = Buffer.from(nativeBootstrap(executable, digests[executable]!, args, input.isolatedCacheEnvironmentName ? { name: input.isolatedCacheEnvironmentName, value: cacheRoot } : undefined, copilotEntryDirectory)); await writeFile(join(packageRoot, BOOTSTRAP), bootstrap, { mode: 0o400, flag: "wx" }); digests[join(packageRoot, BOOTSTRAP)] = sha256(bootstrap); // executable is null: commandLease's separately-qualified provider runtime @@ -156,7 +229,7 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist const manifest = Buffer.from(JSON.stringify({ roots: [packageRoot], executable: null, digests })); const manifestPath = join(privateRoot, "manifest.json"); await writeFile(manifestPath, manifest, { mode: 0o400, flag: "wx" }); - for (const directory of directories) await chmod(directory, 0o500); + await directoryBatch([...directories], path => chmod(path, 0o500)); commandDirectory = await open(packageRoot, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY); return { commandDirectory, bootstrap, snapshot: { roots: [packageRoot], executable: null, digests, handoff: { path: manifestPath, digest: sha256(manifest) }, close } }; } catch (error) { @@ -164,7 +237,7 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist } finally { await heldRoot.close(); } } -function nativeBootstrap(executable: string, executableDigest: string, args: string[], cache?: { name: string; value: string }): string { +function nativeBootstrap(executable: string, executableDigest: string, args: string[], cache?: { name: string; value: string }, copilotEntryDirectory?: string): string { return [ 'const {spawn}=require("node:child_process");', 'const fs=require("node:fs");', @@ -174,6 +247,12 @@ function nativeBootstrap(executable: string, executableDigest: string, args: str 'const env={...process.env}; delete env.PAPERCLIP_ACPX_NATIVE_GUARDED; delete env.PAPERCLIP_ACPX_PRIVATE_SNAPSHOT; delete env.PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE;', 'env.NODE_DISABLE_COMPILE_CACHE="1"; delete env.NODE_COMPILE_CACHE;', ...(cache ? [`env[${JSON.stringify(cache.name)}]=${JSON.stringify(cache.value)};`] : []), + ...(copilotEntryDirectory ? [ + // Never permit the native loader to choose a different cached version, + // app, bootstrap mode or module path supplied by a workspace/caller. + 'for(const name of ["COPILOT_CLI_VERSION","COPILOT_CLI_DIST_DIR","COPILOT_CLI_RESOLVED_DIST_DIR","COPILOT_VOICE_SERVER_MODE","COPILOT_SHUTDOWN_FLUSH","NODE_OPTIONS","NODE_PATH"])delete env[name];', + `env.COPILOT_CLI_DIST_DIR=${JSON.stringify(copilotEntryDirectory)};`, + ] : []), 'if(guarded)for(const fd of [5,6,7,8])fs.fstatSync(fd);', `const executable=${JSON.stringify(executable)};const fd=fs.openSync(executable,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);`, `if(require("node:crypto").createHash("sha256").update(fs.readFileSync(fd)).digest("hex")!==${JSON.stringify(executableDigest)})throw new Error("Native ACPX executable changed before spawn");`, 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 new file mode 100644 index 0000000000..9261078097 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts @@ -0,0 +1,309 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { normalizeAcpFormElicitation } from "./acp-question-adapter.js"; +import { createPiLaunchSpec, PiAssistantMessages, piAssistantChunk, PiToolIdentities, PiRpcFrames, PiTurnUsage, PiUiBridge } from "./pi-acp-runtime.js"; + +const temporary: string[] = []; +afterEach(async () => { vi.useRealTimers(); for (const path of temporary.splice(0)) await rm(path, { recursive: true, force: true }); }); +function fixture() { + const connection = { requestPermission: vi.fn(), unstable_createElicitation: vi.fn() }; + const process = { sendExtensionUiResponse: vi.fn().mockResolvedValue(undefined) }; + return { connection, process, bridge: new PiUiBridge("session-a", connection, process) }; +} + +describe("Pi native assistant boundaries", () => { + const namespace = "00000000-0000-4000-8000-000000000000"; + const message = (timestamp = 1, stopReason = "stop") => ({ role: "assistant", timestamp, content: [], stopReason }); + it("assigns actual message occurrences across warm prompts and preserves empty boundaries", () => { + const messages = new PiAssistantMessages(namespace); const ids: unknown[] = []; + for (const reason of ["toolUse", "stop", "length", "aborted", "error"]) { + const start = messages.normalize({ type: "message_start", message: message() }); + const delta = messages.normalize({ type: "message_update", message: message(), assistantMessageEvent: { type: "thinking_delta", delta: "thought" } }); + const end = messages.normalize({ type: "message_end", message: message(1, reason) }); + const chunk = piAssistantChunk(start.paperclipAssistantMessage); + ids.push(chunk.messageId); + expect(chunk).toMatchObject({ content: { text: "" }, _meta: { kind: "start" } }); + expect(piAssistantChunk(delta.paperclipAssistantMessage, "thought", true)).toMatchObject({ messageId: chunk.messageId, sessionUpdate: "agent_thought_chunk" }); + expect(piAssistantChunk(end.paperclipAssistantMessage)).toMatchObject({ messageId: chunk.messageId, content: { text: "" }, _meta: { kind: `end:${reason}` } }); + messages.normalize({ type: "agent_settled" }); + } + expect(new Set(ids).size).toBe(5); + const loaded = new PiAssistantMessages("00000000-0000-4000-8000-000000000001"); + expect(piAssistantChunk(loaded.normalize({ type: "message_start", message: message() }).paperclipAssistantMessage).messageId).not.toBe(ids[0]); + }); + it.each(["message_update", "message_end"])("rejects missing starts for %s and poisons subsequent input", type => { + const messages = new PiAssistantMessages(namespace); + expect(() => messages.normalize({ type, message: message() })).toThrow("boundary"); + expect(() => messages.normalize({ type: "message_start", message: message() })).toThrow("boundary"); + }); + it.each([ + { type: "message_start", message: message() }, + { type: "message_end", message: message(2) }, + { type: "message_end", message: message(1, "invented") }, + { type: "message_update", message: { role: "assistant", content: [] } }, + { type: "message_start", message: { role: "toolResult" } }, + { type: "message_start", message: { role: "system", content: "injected", timestamp: 1 } }, + { type: "agent_settled" }, + ])("rejects duplicate, reordered, malformed or missing ends: $type", event => { + const messages = new PiAssistantMessages(namespace); + messages.normalize({ type: "message_start", message: message() }); + expect(() => messages.normalize(event)).toThrow("boundary"); + }); + it.each([ + { type: "message_update", message: { role: "system", content: "x", timestamp: 1 } }, + { type: "message_start", message: { role: "system", content: [], timestamp: 1 } }, + { type: "message_start", message: { role: "system", content: "x" } }, + { type: "message_start", message: { role: "unknown", content: "x", timestamp: 1 } }, + ])("rejects malformed structural message boundaries", event => { + expect(() => new PiAssistantMessages(namespace).normalize(event)).toThrow("boundary"); + }); + it("does not mint assistant identities for history, tools, retries or compaction", () => { + const messages = new PiAssistantMessages(namespace); + for (const event of [ + { type: "message_start", message: { role: "user" } }, + { type: "message_end", message: { role: "toolResult" } }, + { type: "message_start", message: { role: "system", content: "instructions", timestamp: 1, toolsAdded: [] } }, + { type: "message_end", message: { role: "system", content: "instructions", timestamp: 1 } }, + { type: "auto_retry_end" }, { type: "compaction_end" }, { type: "agent_settled" }, + ]) expect(messages.normalize(event)).toEqual(event); + expect(() => piAssistantChunk({ messageId: "invented", phase: "start" })).toThrow("provenance"); + const start = messages.normalize({ type: "message_start", message: message() }); + expect(() => piAssistantChunk(start.paperclipAssistantMessage, "synthetic")).toThrow("synthetic"); + }); +}); + +describe("Pi occurrence identity", () => { + const namespace = "00000000-0000-4000-8000-000000000000"; + it("aligns independent wrapper and extension counters without resetting warm prompts", () => { + const wrapper = new PiToolIdentities(namespace); const extension = new PiToolIdentities(namespace); + const ids: string[] = []; + for (let iteration = 1; iteration <= 3; iteration++) { + wrapper.normalize({ type: "turn_start" }); extension.begin(); + const id = extension.bind("call_0", "write", { path: "out", content: "x" }, true); + const event = wrapper.normalize({ type: "tool_execution_start", toolCallId: "call_0", toolName: "write", args: { content: "x", path: "out" } }); + expect(event.toolCallId).toBe(id); expect(wrapper.provenance(id)).toMatchObject({ nativeToolCallId: "call_0", modelIteration: iteration }); + expect(wrapper.normalize({ type: "tool_execution_end", toolCallId: "call_0" }).toolCallId).toBe(id); + ids.push(id); wrapper.normalize({ type: "turn_end" }); extension.end(); + } + expect(new Set(ids).size).toBe(3); + }); + it("poisons exhausted iteration and tool bounds", () => { + const exhausted = new PiToolIdentities(namespace); + // Exercise the numerical guard without performing MAX_SAFE_INTEGER turns. + (exhausted as unknown as { ordinal: number }).ordinal = Number.MAX_SAFE_INTEGER; + expect(() => exhausted.begin()).toThrow("ambiguous"); + expect(() => exhausted.identity("call")).toThrow("unavailable"); + const full = new PiToolIdentities(namespace); full.begin(); + for (let index = 0; index < 4096; index++) full.identity(`call_${index}`); + expect(() => full.identity("call_4096")).toThrow("bound"); + expect(() => full.identity("call_0")).toThrow("unavailable"); + }); + it("fails closed on missing/duplicate iteration boundaries and ambiguous duplicate tools", () => { + const missing = new PiToolIdentities(namespace); + expect(() => missing.bind("call", "write", {})).toThrow("iteration"); + expect(() => missing.begin()).toThrow("ambiguous"); + const invalid = new PiToolIdentities(namespace); invalid.begin(); + expect(() => invalid.bind("call", "x".repeat(257), {})).toThrow("invalid"); + expect(() => invalid.bind("valid", "read", {})).toThrow("iteration"); + const duplicate = new PiToolIdentities(namespace); duplicate.begin(); + expect(() => duplicate.begin()).toThrow("ambiguous"); + expect(() => duplicate.identity("call")).toThrow("unavailable"); + const tools = new PiToolIdentities(namespace); tools.begin(); tools.bind("call", "write", {}, true); + expect(() => tools.bind("call", "read", {}, true)).toThrow("conflict"); + expect(() => tools.bind("different", "read", {})).toThrow("iteration"); + }); +}); + +describe("Pi ACP bridge", () => { + it.each([ + ["select", { options: ["Red", "Blue"] }, "Blue", { value: "Blue" }], + ["confirm", { message: "Proceed?" }, false, { confirmed: false }], + ["input", { placeholder: "Name" }, "Ada", { value: "Ada" }], + ["editor", { prefill: "Old\nText" }, "New\nText", { value: "New\nText" }], + ])("round-trips %s as questions independently of permission auto-approval", async (method, extra, answer, expected) => { + const f = fixture(); f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer } }); + await f.bridge.handle({ id: "q1", method, title: "Choice", ...extra }); + expect(f.connection.requestPermission).not.toHaveBeenCalled(); + expect(f.connection.unstable_createElicitation).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-a", mode: "form", requestedSchema: expect.objectContaining({ required: ["answer"] }) })); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q1", ...expected }); + }); + + it("transport preserves blank editor prefill and empty accept independently of canonical required validation", async () => { + const f = fixture(); f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "" } }); + await f.bridge.handle({ id: "blank", method: "editor", title: "Draft", prefill: "" }); + expect(f.connection.unstable_createElicitation.mock.calls[0]![0]).toMatchObject({ requestedSchema: { properties: { answer: { default: "" } } } }); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "blank", value: "" }); + f.connection.unstable_createElicitation.mockResolvedValue({ action: "cancel" }); + await f.bridge.handle({ id: "dismissed", method: "editor", title: "Draft", prefill: "" }); + expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "dismissed", cancelled: true }); + }); + + it.each(["input", "editor"])("documents canonical %s blank-answer rejection without inventing an empty accept", async method => { + const f = fixture(); + f.connection.unstable_createElicitation.mockImplementation(async request => { + const normalized = normalizeAcpFormElicitation(request)!; + const question = normalized.questionSet.questions[0]!; + expect(question.required).toBe(true); + for (const text of ["", " \n\t"]) { + expect(() => normalized.accept({ schema: "paperclip.question_response.v1", answers: { [question.id]: { text } } })).toThrow("is required"); + } + return normalized.accept({ schema: "paperclip.question_response.v1", answers: { [question.id]: { text: "\nAccepted text\n" } } }); + }); + await f.bridge.handle({ id: "canonical", method, title: "Draft", prefill: "" }); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "canonical", value: "\nAccepted text\n" }); + }); + + it.each(["", " Draft\n界 🌒\n\\n literal "])("delivers editor prefill %j through canonical initialText and returns only the exact edited answer", async prefill => { + const f = fixture(); + const edited = " Revised\n界 🌒\n\\n stays literal "; + f.connection.unstable_createElicitation.mockImplementation(async request => { + const normalized = normalizeAcpFormElicitation(request)!; + const question = normalized.questionSet.questions[0]!; + expect(question).toMatchObject({ answerMode: "text", initialText: prefill, required: true }); + // Initial content is an editable draft, never an implicit answer. + expect(f.process.sendExtensionUiResponse).not.toHaveBeenCalled(); + expect(() => normalized.accept({ schema: "paperclip.question_response.v1", answers: {} })).toThrow(); + expect(() => normalized.accept({ schema: "paperclip.question_response.v1", answers: { [question.id]: { text: "" } } })).toThrow("is required"); + return normalized.accept({ schema: "paperclip.question_response.v1", answers: { [question.id]: { text: edited } } }); + }); + await f.bridge.handle({ id: "canonical-prefill", method: "editor", title: "Draft", prefill }); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "canonical-prefill", value: edited }); + }); + + it.each(["a".repeat(1000), "界".repeat(1000), "🌒".repeat(500)])("passes the canonical character limit through the real form normalizer", async (label) => { + const f = fixture(); + f.connection.unstable_createElicitation.mockImplementation(async request => { + const normalized = normalizeAcpFormElicitation(request)!; + expect(normalized.questionSet.title).toBe(label); + expect(normalized.questionSet.questions[0]!.options![0]!.label).toBe(label); + return normalized.accept({ schema: "paperclip.question_response.v1", answers: { [normalized.questionSet.questions[0]!.id]: { selectedOptionIds: ["option-1"] } } }); + }); + await f.bridge.handle({ id: "boundary", method: "select", title: label, options: [label] }); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledWith({ id: "boundary", value: label }); + }); + + it.each([ + { title: "a".repeat(1001), options: ["ok"] }, + { title: "ok", options: ["界".repeat(1001)] }, + ])("reports external unsupported questions without presenting or silently cancelling them", async extra => { + const f = fixture(); + await expect(f.bridge.handle({ id: "oversize", method: "select", ...extra })).rejects.toThrow("unsupported or invalid"); + expect(f.connection.unstable_createElicitation).not.toHaveBeenCalled(); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "oversize", cancelled: true }); + }); + + it("sends only offered native permission choices and keeps questions separate", async () => { + const f = fixture(); f.connection.requestPermission.mockResolvedValue({ outcome: { outcome: "selected", optionId: "allow_always" } }); + const event = { id: "p1", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-a","nativeToolCallId":"native-a","modelIteration":1,"toolName":"bash","input":{"command":"pwd"}}' }; + await f.bridge.handle(event); + expect(f.connection.unstable_createElicitation).not.toHaveBeenCalled(); + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "p1", value: "Allow for this session" }); + expect(f.connection.requestPermission.mock.calls[0]![0]).toMatchObject({ toolCall: { toolCallId: "tool-a", kind: "execute" }, options: [{ kind: "allow_once" }, { kind: "allow_always" }, { kind: "reject_once" }] }); + await f.bridge.handle(event); expect(f.connection.requestPermission).toHaveBeenCalledTimes(1); + }); + + it("expires pending requests and discards a late answer exactly once", async () => { + const f = fixture(); let answer!: (value: unknown) => void; + f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; })); + const request = f.bridge.handle({ id: "q", method: "input", title: "Question" }); + f.bridge.cancelAll(); f.bridge.cancelAll(); + answer({ action: "accept", content: { answer: "stale" } }); await request; + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true }); + }); + + it("honors Pi dialog deadlines and rejects invalid answers", async () => { + vi.useFakeTimers(); const f = fixture(); let answer!: (value: unknown) => void; + f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; })); + const request = f.bridge.handle({ id: "q", method: "select", title: "Question", options: ["A"], timeout: 100 }); + await vi.advanceTimersByTimeAsync(100); + answer({ action: "accept", content: { answer: "not-an-offered-option" } }); await request; + expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true }); + f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "other" } }); + await expect(f.bridge.handle({ id: "q2", method: "select", title: "Question", options: ["A"] })).rejects.toThrow("unsupported or invalid"); + expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "q2", cancelled: true }); + }); + + it("uses LF framing across UTF-8 chunks without splitting Unicode separators", () => { + const seen: unknown[] = []; const frames = new PiRpcFrames((value) => seen.push(value)); + const bytes = Buffer.from(JSON.stringify({ text: "🌒\u2028\u2029" }) + "\r\n"); + for (const byte of bytes) frames.write(Uint8Array.of(byte)); frames.end(); + expect(seen).toEqual([{ text: "🌒\u2028\u2029" }]); + expect(() => new PiRpcFrames(() => {}, 2).write(Buffer.from("xxx"))).toThrow("bound"); + const truncated = new PiRpcFrames(() => {}); truncated.write(Buffer.from('{"x":1}')); + expect(() => truncated.end()).toThrow("inside"); + }); + + it("reports actual incremental usage and resets the failure after a successful retry", () => { + const usage = new PiTurnUsage(); const failed = { role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 4, output: 1, cacheRead: 2, cacheWrite: 3, cost: { total: 0.01 } } }; + usage.accept(failed); usage.accept(failed); + expect(usage.response()).toMatchObject({ usage: { totalTokens: 10 }, _meta: { jetbrains: { air: { sessionFailure: { severity: "error" } } } } }); + usage.accept({ ...failed, timestamp: 2, stopReason: "stop" }); + expect(usage.response()).toMatchObject({ usage: { inputTokens: 8, totalTokens: 20 } }); + usage.accept(failed); // A duplicate old failure cannot undo a successful retry. + expect(usage.response()._meta).toBeUndefined(); + expect(usage.response()).toMatchObject({ usage: { _meta: { paperclipPi: { costUsd: 0.02, costSource: "pi_pricing_estimate" } } } }); + usage.reset(); expect(usage.response()).toEqual({}); + }); + + it("does not invent missing cache totals or zero cost", () => { + const usage = new PiTurnUsage(); + usage.accept({ role: "assistant", timestamp: 1, stopReason: "stop", usage: { input: 4, output: 2 } }); + expect(usage.response()).toEqual({ usage: { + inputTokens: 4, outputTokens: 2, _meta: { paperclipPi: { provenance: "assistant_message_receipts" } }, + } }); + usage.accept({ role: "assistant", timestamp: 2, stopReason: "stop", usage: { input: 4, output: 2, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } } }); + expect(usage.response()).toEqual({ usage: { + inputTokens: 8, outputTokens: 4, _meta: { paperclipPi: { provenance: "assistant_message_receipts" } }, + } }); + }); + + 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 } } }); + const compacted = { firstKeptEntryId: "entry-2", tokensBefore: 100, summary: "Context summary", usage: { input: 10, output: 4, cacheRead: 2, cacheWrite: 0, cost: { total: 0.02 } } }; + usage.acceptCompaction(compacted); usage.acceptCompaction(compacted); + expect(usage.response()).toMatchObject({ usage: { inputTokens: 11, outputTokens: 6, totalTokens: 19, _meta: { paperclipPi: { provenance: "assistant_message_and_compaction_receipts", costUsd: 0.03 } } }, _meta: { jetbrains: { air: { sessionFailure: { severity: "error" } } } } }); + usage.acceptCompaction({ summary: "Missing usage" }); + expect(usage.response().usage).toEqual({ _meta: { paperclipPi: { provenance: "assistant_message_and_compaction_receipts" } } }); + }); + + it("launches verified paths only and rejects session escapes", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-launch-")); temporary.push(root); + await mkdir(join(root, "sessions")); + for (const name of ["node", "cli.js", "extension.js"]) await writeFile(join(root, name), "verified"); + const environment = { + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0", + PAPERCLIP_PI_NODE_EXECUTABLE: join(root, "node"), PAPERCLIP_PI_ENTRYPOINT: join(root, "cli.js"), PAPERCLIP_PI_EXTENSION_PATH: join(root, "extension.js"), + PI_CODING_AGENT_DIR: root, + }; + const launch = createPiLaunchSpec({ cwd: root, mcpServers: [] }, environment); + expect(launch.command).toBe(join(root, "node")); + expect(launch.args).toEqual(expect.arrayContaining(["--no-extensions", "--no-approve", "--no-skills", "--offline"])); + const ambient = createPiLaunchSpec({ cwd: root }, { ...environment, AGENT_HOME: "/ambient/unregistered" }); + expect(ambient.env.AGENT_HOME).toBeUndefined(); + expect(JSON.parse(ambient.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION!).agentHome).toBeUndefined(); + const agentHome = await realpath(root); + const bound = createPiLaunchSpec({ cwd: root }, { ...environment, AGENT_HOME: "/ambient/unregistered", PAPERCLIP_PI_AGENT_HOME: agentHome }); + expect(bound.env.AGENT_HOME).toBe(agentHome); + expect(JSON.parse(bound.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION!).agentHome).toBe(agentHome); + for (const path of ["/", "relative", join(root, "cli.js")]) expect(() => createPiLaunchSpec({ cwd: root }, { ...environment, PAPERCLIP_PI_AGENT_HOME: path })).toThrow("registered directory"); + expect(() => createPiLaunchSpec({ cwd: root }, { ...environment, PAPERCLIP_PI_NODE_EXECUTABLE: "pi" })).toThrow("binding"); + await symlink(join(root, "cli.js"), join(root, "sessions", "escape.jsonl")); + expect(() => createPiLaunchSpec({ cwd: root, sessionPath: join(root, "sessions", "escape.jsonl") }, environment)).toThrow("escaped"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts new file mode 100644 index 0000000000..bb2049ff7e --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -0,0 +1,577 @@ +/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */ +import { createHash, randomUUID } from "node:crypto"; +import { StringDecoder } from "node:string_decoder"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs"; + +const PERMISSION_PREFIX = "paperclip.pi.permission.v1:"; +const PERMISSION_CHOICES = [ + { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow_always", name: "Allow for this session", kind: "allow_always" }, + { optionId: "reject_once", name: "Deny", kind: "reject_once" }, +]; +type RecordValue = Record; +function record(value: unknown): RecordValue { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Pi runtime record"); + return value as RecordValue; +} +function text(value: unknown, max = 16_384): string { + if (typeof value !== "string" || Buffer.byteLength(value) > max) throw new Error("Invalid Pi runtime text"); + return value; +} + +export const PI_ACP_FEATURES = Object.freeze({ + version: 1, steering: true, queuedFollowUp: true, + questions: ["select", "confirm", "input", "editor"], + nativePermissions: true, promptUsage: true, nativePlan: false, + pendingRequestRecovery: "live-process-only", +}); + +export interface PiAssistantBoundary { + messageId: string; + phase: "start" | "delta" | "end"; + stopReason?: "stop" | "length" | "toolUse" | "error" | "aborted"; +} + +/** Pi 1 RPC serializes message_update without message/partial. Reconstruct only + * the streaming view, anchored to the actual message_start; message_end remains + * the native authoritative receipt. Never infer an executable tool from text. */ +export class PiRpcMessageDeltas { + private active: RecordValue | null = null; + private blocks = new Map(); + private streamedBytes = 0; + private failed = false; + private fail(): never { this.failed = true; throw new Error("Pi RPC message delta boundary is invalid"); } + normalize(event: RecordValue): RecordValue { + if (this.failed) return this.fail(); + if (event.type === "message_start" && event.message && typeof event.message === "object" && (event.message as RecordValue).role === "assistant") { + if (this.active) return this.fail(); + this.active = event.message as RecordValue; this.blocks.clear(); this.streamedBytes = 0; + return event; + } + if (event.type === "message_end" && event.message && typeof event.message === "object" && (event.message as RecordValue).role === "assistant") { + const message = event.message as RecordValue; + if (!this.active || message.timestamp !== this.active.timestamp) return this.fail(); + if (!["error", "aborted"].includes(String(message.stopReason)) && [...this.blocks.values()].some(block => !block.ended)) return this.fail(); + for (const [index, block] of this.blocks) if (block.ended) { + const final = Array.isArray(message.content) ? message.content[index] : undefined; + if (!final || typeof final !== "object") return this.fail(); + if (block.kind === "toolcall") { + if (final.type !== "toolCall" || toolSignature(final.id, final.name, final.arguments) !== block.final) return this.fail(); + } else if (final.type !== block.kind || final[block.kind === "text" ? "text" : "thinking"] !== block.json) return this.fail(); + } + this.active = null; this.blocks.clear(); return event; + } + if (event.type !== "message_update") return event; + if (!this.active || event.message !== undefined || !event.assistantMessageEvent || typeof event.assistantMessageEvent !== "object" || Array.isArray(event.assistantMessageEvent)) return this.fail(); + const update = event.assistantMessageEvent as RecordValue; + if (update.partial !== undefined || typeof update.type !== "string") return this.fail(); + const match = /^(text|thinking|toolcall)_(start|delta|end)$/.exec(update.type); + const index = update.contentIndex; + if (!match || !Number.isSafeInteger(index) || (index as number) < 0 || (index as number) >= 4096) return this.fail(); + const [, kind, phase] = match; + let block = this.blocks.get(index as number); + if (phase === "start") { + if (block) return this.fail(); + block = { kind: kind!, ended: false, json: "" }; + if (kind === "toolcall") { + if (typeof update.id !== "string" || Buffer.byteLength(update.id) > 256 || typeof update.toolName !== "string" || Buffer.byteLength(update.toolName) > 256) return this.fail(); + if (update.id && [...this.blocks.values()].some(other => other.id === update.id)) return this.fail(); + block.id = update.id; block.name = update.toolName; + } + this.blocks.set(index as number, block); + } else if (!block || block.ended || block.kind !== kind) return this.fail(); + if (phase === "delta") { + if (typeof update.delta !== "string" || Buffer.byteLength(update.delta) > 262_144) return this.fail(); + this.streamedBytes += Buffer.byteLength(update.delta); + if (this.streamedBytes > 4 * 1024 * 1024) return this.fail(); + block!.json += update.delta; + if (kind === "toolcall" && Buffer.byteLength(block!.json) > 1_048_576) return this.fail(); + } + let toolCall: RecordValue | undefined; + if (kind === "toolcall") { + if (phase === "end") { + if (!update.toolCall || typeof update.toolCall !== "object" || Array.isArray(update.toolCall)) return this.fail(); + const final = update.toolCall as RecordValue; + if (final.type !== "toolCall" || typeof final.id !== "string" || !final.id || Buffer.byteLength(final.id) > 256 || typeof final.name !== "string" || !final.name || Buffer.byteLength(final.name) > 256 + || block!.id && block!.id !== final.id || block!.name && block!.name !== final.name + || [...this.blocks.entries()].some(([otherIndex, other]) => otherIndex !== index && other.id === final.id) + || !final.arguments || typeof final.arguments !== "object" || Array.isArray(final.arguments) || Buffer.byteLength(JSON.stringify(final.arguments)) > 1_048_576) return this.fail(); + block!.id = final.id; block!.name = final.name; block!.final = toolSignature(final.id, final.name, final.arguments); toolCall = final; + } else toolCall = { type: "toolCall", id: block!.id, name: block!.name, partialArgs: block!.json }; + } + if (phase === "end") { + if (kind !== "toolcall" && (typeof update.content !== "string" || update.content !== block!.json)) return this.fail(); + block!.ended = true; + } + return { ...event, message: { ...this.active, content: [] }, assistantMessageEvent: { ...update, ...(toolCall ? { toolCall } : {}) } }; + } +} + +function toolSignature(id: unknown, name: unknown, args: unknown): string { + const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical) + : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value; + return JSON.stringify([id, name, canonical(args)]); +} + +/** A closed diagnostic vocabulary: raw exception text, paths, provider bodies + * and credentials never cross the wrapper boundary. Unknown failures stay + * generic rather than masquerading as a known local or provider condition. */ +export function piNativeFailure(value: unknown): { reason: string; summary: string } { + const message = value instanceof Error ? value.message : typeof value === "string" ? value : ""; + const known: Record = { + "Pi RPC message delta boundary is invalid": "rpc_delta_boundary_invalid", + "Pi native assistant message boundary is invalid": "assistant_boundary_invalid", + "Pi native tool invocation identity conflict": "tool_identity_conflict", + "Pi model iteration identity is ambiguous": "model_iteration_ambiguous", + "Pi model iteration identity is unavailable": "model_iteration_unavailable", + "Pi tool invocation has no active model iteration": "tool_iteration_missing", + "Pi tool identity is outside its iteration bound": "tool_iteration_bound", + "Pi RPC frame exceeds its bound": "rpc_frame_oversized", + "Pi RPC stream ended inside a frame": "rpc_frame_incomplete", + "Pi usage receipt is invalid": "usage_receipt_invalid", + "Pi process exited before its next request": "provider_process_exited", + }; + if (Object.hasOwn(known, message)) return { reason: known[message]!, summary: message }; + const status = /^(?:pi prompt failed: )?(401|403|429|500|502|503|504)(?::|\b)/.exec(message.slice(0, 128))?.[1]; + if (status) return { reason: `provider_http_${status}`, summary: `Pi provider request failed (HTTP ${status})` }; + return { reason: "unknown_native_failure", summary: "Pi native runtime failed; diagnostic details were withheld" }; +} + +/** IDs are allocated only at actual SDK assistant message_start events. Tool + * iterations, retries, notices and ACP prompts cannot create assistant IDs. */ +export class PiAssistantMessages { + private ordinal = 0; + private active: { messageId: string; timestamp: number } | null = null; + private poisoned = false; + private readonly namespace: string; + constructor(namespace: string) { + this.namespace = namespace; + if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi assistant namespace is invalid"); + } + private fail(): never { this.poisoned = true; throw new Error("Pi native assistant message boundary is invalid"); } + normalize(event: RecordValue): RecordValue { + if (this.poisoned) return this.fail(); + if (event.type === "agent_settled") { + if (this.active) return this.fail(); + return event; + } + if (!["message_start", "message_update", "message_end"].includes(String(event.type))) return event; + const message = event.message; + if (!message || typeof message !== "object" || Array.isArray(message)) return this.fail(); + const native = message as RecordValue; + if (native.role !== "assistant") { + if (event.type === "message_update" || this.active || !["user", "toolResult", "system"].includes(String(native.role))) return this.fail(); + // Pi 1 records prompt/tool declaration updates as structural system messages. + // They never receive an assistant occurrence or become final-answer content. + if (native.role === "system" && (typeof native.content !== "string" || !Number.isSafeInteger(native.timestamp) || (native.timestamp as number) < 0)) return this.fail(); + return event; + } + if (!Number.isSafeInteger(native.timestamp) || (native.timestamp as number) < 0 || !Array.isArray(native.content)) return this.fail(); + let boundary: PiAssistantBoundary; + if (event.type === "message_start") { + if (this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) return this.fail(); + const messageId = `pi-message-${createHash("sha256").update(JSON.stringify([this.namespace, ++this.ordinal])).digest("hex")}`; + this.active = { messageId, timestamp: native.timestamp as number }; + boundary = { messageId, phase: "start" }; + } else { + if (!this.active || this.active.timestamp !== native.timestamp) return this.fail(); + boundary = { messageId: this.active.messageId, phase: event.type === "message_end" ? "end" : "delta" }; + if (event.type === "message_end") { + if (!["stop", "length", "toolUse", "error", "aborted"].includes(String(native.stopReason))) return this.fail(); + boundary.stopReason = native.stopReason as PiAssistantBoundary["stopReason"]; + this.active = null; + } + } + return { ...event, paperclipAssistantMessage: boundary }; + } +} + +export function piAssistantChunk(boundaryValue: unknown, textValue: unknown = "", thought = false): RecordValue { + const boundary = record(boundaryValue); + const messageId = text(boundary.messageId, 96); + if (!/^pi-message-[a-f0-9]{64}$/.test(messageId) || !["start", "delta", "end"].includes(String(boundary.phase))) throw new Error("Pi assistant chunk lacks native provenance"); + if (boundary.phase === "end" && !["stop", "length", "toolUse", "error", "aborted"].includes(String(boundary.stopReason))) throw new Error("Pi assistant chunk end is invalid"); + const content = text(textValue, 262_144); + if (boundary.phase !== "delta" && (content || thought)) throw new Error("Pi assistant boundary cannot carry synthetic content"); + return { sessionUpdate: thought ? "agent_thought_chunk" : "agent_message_chunk", messageId, + content: { type: "text", text: content }, + _meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind: boundary.phase === "end" ? `end:${boundary.stopReason}` : boundary.phase }, + }; +} + +export function piHistoricalAssistantChunk(sessionId: string, messageIndex: number, value: string): RecordValue { + if (!sessionId || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi historical assistant identity is invalid"); + return { sessionUpdate: "agent_message_chunk", + messageId: `pi-history-message-${createHash("sha256").update(JSON.stringify([text(sessionId, 1024), messageIndex])).digest("hex")}`, + content: { type: "text", text: text(value, 262_144) }, + _meta: { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" }, + }; +} + +/** One trusted Pi model iteration, not one ACP prompt. Pi resets its own turnIndex + * on warm prompts; our ordinal is monotonic for the lifetime of the child. */ +export class PiToolIdentities { + private ordinal = 0; + private active = false; + private poisoned = false; + private entries = new Map(); + private namespace: string; + constructor(namespace: string) { + this.namespace = namespace; + if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi invocation namespace is invalid"); + } + private fail(message: string): never { this.poisoned = true; throw new Error(message); } + begin(): void { + if (this.poisoned || this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) this.fail("Pi model iteration identity is ambiguous"); + this.ordinal++; this.active = true; this.entries.clear(); + } + end(): void { + if (this.poisoned || !this.active) this.fail("Pi model iteration identity is unavailable"); + this.active = false; + } + identity(nativeId: unknown): string { + if (this.poisoned || this.ordinal === 0) this.fail("Pi model iteration identity is unavailable"); + let native: string; + try { native = text(nativeId, 256); } catch { return this.fail("Pi native tool identity is invalid"); } + if (!native) this.fail("Pi native tool identity is missing"); + const prior = this.entries.get(native); + if (prior) return prior.id; + if (!this.active || this.entries.size >= 4096) this.fail("Pi tool identity is outside its iteration bound"); + const id = `pi-${createHash("sha256").update(JSON.stringify([this.namespace, this.ordinal, native])).digest("hex")}`; + this.entries.set(native, { id, nativeId: native, claimed: false }); + return id; + } + bind(nativeId: string, name: string, args: unknown, claim = false): string { + if (this.poisoned || !this.active) this.fail("Pi tool invocation has no active model iteration"); + const id = this.identity(nativeId); const entry = this.entries.get(nativeId)!; + const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical) + : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value; + let encoded: string; + try { encoded = JSON.stringify([text(name, 256), canonical(args)]); } + catch { return this.fail("Pi tool invocation is invalid"); } + if (Buffer.byteLength(encoded) > 1_048_576) this.fail("Pi tool invocation is oversized"); + const fingerprint = createHash("sha256").update(encoded).digest("hex"); + if ((claim && entry.claimed) || (entry.fingerprint !== undefined && entry.fingerprint !== fingerprint)) this.fail("Pi native tool invocation identity conflict"); + entry.fingerprint = fingerprint; if (claim) entry.claimed = true; + return id; + } + provenance(id: string): RecordValue | undefined { + for (const entry of this.entries.values()) if (entry.id === id) return { nativeToolCallId: entry.nativeId, modelIteration: this.ordinal, identityScope: "native-model-iteration" }; + return undefined; + } + normalize(event: RecordValue): RecordValue { + if (event.type === "turn_start") { this.begin(); return event; } + if (event.type === "turn_end") { this.end(); return event; } + if (["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(String(event.type))) { + const native = text(event.toolCallId, 256); + const id = event.type === "tool_execution_start" ? this.bind(native, text(event.toolName, 256), event.args, true) : this.identity(native); + return { ...event, toolCallId: id }; + } + if (event.type === "message_update" && event.assistantMessageEvent) { + const update = record(event.assistantMessageEvent); + const normalizeTool = (value: unknown, direct = false): unknown => { + if (!value || typeof value !== "object") return value; + const block = record(value); + // Empty IDs during initial streaming do not identify an executable call. + return (direct || block.type === "toolCall") && typeof block.id === "string" && block.id + ? { ...block, id: direct && update.type === "toolcall_end" + ? this.bind(block.id, text(block.name, 256), block.arguments) : this.identity(block.id) } : value; + }; + const partial = update.partial && typeof update.partial === "object" ? record(update.partial) : undefined; + return { ...event, assistantMessageEvent: { ...update, + ...(update.toolCall ? { toolCall: normalizeTool(update.toolCall, true) } : {}), + ...(partial && Array.isArray(partial.content) ? { partial: { ...partial, content: partial.content.map((block) => normalizeTool(block)) } } : {}), + } }; + } + return event; + } +} + +/** Session history is presentation-only and must never acquire a live delivery ID. */ +export function piHistoricalToolIdentity(sessionId: string, messageIndex: number, nativeId: string): { id: string; provenance: RecordValue } { + const session = text(sessionId, 1024); const native = text(nativeId, 256); + if (!session || !native || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi history tool identity is invalid"); + return { id: `pi-history-${createHash("sha256").update(JSON.stringify([session, messageIndex, native])).digest("hex")}`, + provenance: { nativeToolCallId: native, historyMessageIndex: messageIndex, identityScope: "history-display-only" } }; +} + +/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */ +export class PiRpcFrames { + private decoder = new StringDecoder("utf8"); + private pending = ""; + private readonly receive: (value: RecordValue) => void; + private readonly limit: number; + constructor(receive: (value: RecordValue) => void, limit = 4 * 1024 * 1024) { this.receive = receive; this.limit = limit; } + write(chunk: Uint8Array): void { + this.pending += this.decoder.write(Buffer.from(chunk)); + for (;;) { + const index = this.pending.indexOf("\n"); + if (index < 0) break; + const line = this.pending.slice(0, index).replace(/\r$/, ""); + this.pending = this.pending.slice(index + 1); + if (Buffer.byteLength(line) > this.limit) throw new Error("Pi RPC frame exceeds its bound"); + if (line.trim()) this.receive(record(JSON.parse(line))); + } + if (Buffer.byteLength(this.pending) > this.limit) throw new Error("Pi RPC frame exceeds its bound"); + } + end(): void { + this.pending += this.decoder.end(); + if (this.pending.trim()) throw new Error("Pi RPC stream ended inside a frame"); + } +} + +function requiredFile(environment: NodeJS.ProcessEnv, name: string): string { + const path = environment[name]; + if (!path || !isAbsolute(path) || /[\0\r\n]/.test(path)) throw new Error(`Missing verified Pi launch binding: ${name}`); + // This checks the handoff shape. The runner command lease verifies all bytes, + // retains their directory identity, and owns the subprocess lifetime. + if (!lstatSync(path).isFile()) throw new Error(`Invalid verified Pi launch binding: ${name}`); + return path; +} +function pathArray(raw: string | undefined): string[] { + const paths: unknown = JSON.parse(raw ?? "[]"); + if (!Array.isArray(paths) || paths.length > 128 || paths.some((p) => typeof p !== "string" || !isAbsolute(p) || /[\0\r\n]/.test(p))) throw new Error("Invalid Pi runtime paths"); + return paths; +} + +/** Provider tool events are untrusted; diff projection cannot read host files. */ +export function snapshotPiWorkspaceFile(cwd: string, path: string, environment: NodeJS.ProcessEnv = process.env): string { + const root = realpathSync(cwd); + const logical = resolve(root, path); + const physical = realpathSync(logical); + const within = (base: string, target: string): boolean => { + const suffix = relative(base, target); + return !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`); + }; + if (!within(root, logical) || !within(root, physical)) throw new Error("Pi diff escaped its workspace"); + for (const protectedPath of pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS)) { + if (within(protectedPath, logical)) throw new Error("Pi diff targets protected state"); + let protectedPhysical: string; + try { protectedPhysical = realpathSync(protectedPath); } catch { continue; } + if (within(protectedPhysical, physical)) throw new Error("Pi diff targets protected state"); + } + const fd = openSync(physical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Pi diff file is unsupported"); + const result = readFileSync(fd, "utf8"); + const after = fstatSync(fd, { bigint: true }); + const named = lstatSync(physical, { bigint: true }); + if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.ctimeNs !== after.ctimeNs || named.ino !== before.ino || named.dev !== before.dev || realpathSync(logical) !== physical) throw new Error("Pi diff file changed during capture"); + return result; + } finally { closeSync(fd); } +} + +export function createPiLaunchSpec( + params: { cwd: string; mcpServers?: unknown[]; sessionPath?: string }, + environment: NodeJS.ProcessEnv, +): { command: string; args: string[]; env: NodeJS.ProcessEnv; invocationNamespace: string } { + if (environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw new Error("Pi requires an isolated runner launch"); + const command = requiredFile(environment, "PAPERCLIP_PI_NODE_EXECUTABLE"); + const entrypoint = requiredFile(environment, "PAPERCLIP_PI_ENTRYPOINT"); + const extension = requiredFile(environment, "PAPERCLIP_PI_EXTENSION_PATH"); + if (environment.PAPERCLIP_PI_READ_ONLY !== "0" && environment.PAPERCLIP_PI_READ_ONLY !== "1") throw new Error("Pi requires an explicit read-only policy"); + const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS); + const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS); + const workspace = realpathSync(params.cwd); + const suppliedAgentHome = environment.PAPERCLIP_PI_AGENT_HOME; + let agentHome: string | undefined; + if (suppliedAgentHome !== undefined) { + if (!isAbsolute(suppliedAgentHome) || /[\0\r\n]/.test(suppliedAgentHome) || suppliedAgentHome === "/" || !lstatSync(suppliedAgentHome).isDirectory() || realpathSync(suppliedAgentHome) !== suppliedAgentHome) throw new Error("Pi agent files require a canonical registered directory"); + agentHome = suppliedAgentHome; + } + const invocationNamespace = randomUUID(); + const configuration = JSON.stringify({ + invocationNamespace, workspace, servers: params.mcpServers ?? [], + readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1", + readRoots, protectedRoots, + ...(agentHome ? { agentHome } : {}), + instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "", + }); + if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound"); + const guard = environment.PAPERCLIP_PI_MODULE_GUARD_PATH === undefined ? [] + : ["--require", requiredFile(environment, "PAPERCLIP_PI_MODULE_GUARD_PATH")]; + const args = [...guard, entrypoint, "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension]; + for (const root of readRoots) args.push("--skill", root); + if (params.sessionPath) { + const home = environment.PI_CODING_AGENT_DIR; + if (!home) throw new Error("Pi session home is missing"); + const sessionRoot = realpathSync(resolve(home, "sessions")); + const sessionPath = realpathSync(params.sessionPath); + const suffix = relative(sessionRoot, sessionPath); + if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || !lstatSync(params.sessionPath).isFile()) throw new Error("Pi session escaped its private home"); + args.push("--session", sessionPath); + } + const env: NodeJS.ProcessEnv = { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration }; + // Ambient AGENT_HOME never grants filesystem authority. Only the runner's + // authenticated working-copy binding reaches both the model and tool gate. + if (agentHome) env.AGENT_HOME = agentHome; else delete env.AGENT_HOME; + return { command, args, invocationNamespace, env }; +} + +// Same UTF-16 character bound as the canonical question-set title/header/label. +export const PI_QUESTION_LABEL_MAX_LENGTH = 1_000; +export function piQuestionLabel(value: unknown): string { + if (typeof value !== "string" || !value.trim() || value.length > PI_QUESTION_LABEL_MAX_LENGTH) throw new Error("Pi question label is invalid or oversized"); + return value; +} + +interface UiConnection { + requestPermission(params: RecordValue): Promise; + unstable_createElicitation(params: RecordValue): Promise; +} +interface UiProcess { sendExtensionUiResponse(response: RecordValue): Promise } + +/** Live promises never become authority to replay an answer after provider death. */ +export class PiUiBridge { + private readonly pending = new Map(); + private readonly seen = new Set(); + private readonly sessionId: string; + private readonly connection: UiConnection; + private readonly process: UiProcess; + constructor(sessionId: string, connection: UiConnection, process: UiProcess) { this.sessionId = sessionId; this.connection = connection; this.process = process; } + + cancelAll(): void { for (const value of this.pending.values()) value.cancel(); } + + async handle(event: RecordValue): Promise { + const id = text(event.id, 256); + const method = text(event.method, 64); + if (!["select", "confirm", "input", "editor"].includes(method)) return; + if (this.seen.has(id)) return; + if (this.seen.size >= 4096) throw new Error("Pi UI request history exceeds its bound"); + this.seen.add(id); + let done = false; + let timeout: ReturnType | undefined; + const finish = async (response: RecordValue): Promise => { + if (done) return; + done = true; + if (timeout) clearTimeout(timeout); + this.pending.delete(id); + await this.process.sendExtensionUiResponse({ id, ...response }); + }; + this.pending.set(id, { cancel: () => { void finish({ cancelled: true }).catch(() => {}); } }); + if (typeof event.timeout === "number" && Number.isFinite(event.timeout)) { + timeout = setTimeout(() => { void finish({ cancelled: true }).catch(() => {}); }, Math.max(0, Math.min(event.timeout, 2_147_483_647))); + timeout.unref?.(); + } + try { + const title = text(event.title ?? "Additional information needed", 65_536); + if (method === "select" && title.startsWith(PERMISSION_PREFIX)) { + const permission = record(JSON.parse(title.slice(PERMISSION_PREFIX.length))); + const toolCallId = text(permission.toolCallId, 256); + const toolName = text(permission.toolName, 128); + const input = record(permission.input); + const result = record(await this.connection.requestPermission({ + sessionId: this.sessionId, + toolCall: { toolCallId, _meta: { paperclipPi: { nativeToolCallId: text(permission.nativeToolCallId, 256), modelIteration: permission.modelIteration } }, title: `Pi ${toolName}`, kind: toolName === "bash" ? "execute" : ["write", "edit"].includes(toolName) ? "edit" : "read", status: "pending", rawInput: input }, + options: PERMISSION_CHOICES, + })); + const outcome = record(result.outcome); + const selected = outcome.outcome === "selected" ? PERMISSION_CHOICES.find((option) => option.optionId === outcome.optionId) : undefined; + await finish(selected ? { value: selected.name } : { cancelled: true }); + return; + } + piQuestionLabel(title); + const property: RecordValue = { type: method === "confirm" ? "boolean" : "string", title }; + if (method === "select") { + if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options"); + property.enum = event.options.map((option) => piQuestionLabel(option)); + if (new Set(property.enum as string[]).size !== event.options.length) throw new Error("Ambiguous Pi question options"); + } + if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder); + if (method === "editor" && typeof event.prefill === "string") property.default = text(event.prefill); + const result = record(await this.connection.unstable_createElicitation({ + sessionId: this.sessionId, mode: "form", message: typeof event.message === "string" ? text(event.message) : title, + requestedSchema: { type: "object", title, properties: { answer: property }, required: ["answer"] }, + _meta: { paperclipPi: { version: 1, requestId: id, method } }, + })); + if (result.action !== "accept") { await finish({ cancelled: true }); return; } + const answer = record(result.content).answer; + if (method === "confirm") { + if (typeof answer !== "boolean") throw new Error("Invalid Pi confirmation response"); + await finish({ confirmed: answer }); + } else { + const value = text(answer, 65_536); + if (method === "select" && !(property.enum as string[]).includes(value)) throw new Error("Invalid Pi question response"); + await finish({ value }); + } + } catch { + if (done) return; // An expired request cannot fail a later live session. + await finish({ cancelled: true }).catch(() => {}); + throw new Error("Pi structured question is unsupported or invalid"); + } + } +} + +/** Sum native usage receipts; preserve missing fields and label catalog pricing. */ +export class PiTurnUsage { + private seen = new Set(); + private total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; + private cost = 0; + private unknown = new Set(); + private costObserved = false; + private costIncomplete = false; + private compactionObserved = false; + private failed = false; + private observed = false; + reset(): void { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.unknown.clear(); this.costObserved = false; this.costIncomplete = false; this.compactionObserved = false; this.failed = false; this.observed = false; } + accept(value: unknown): void { + const message = record(value); + if (message.role !== "assistant") return; + if (!message.usage || typeof message.usage !== "object") { + this.failed = message.stopReason === "error"; + return; + } + const key = JSON.stringify([message.timestamp, message.id, message.usage, message.content]); + if (this.seen.has(key)) return; + if (this.seen.size >= 8192) throw new Error("Pi usage receipts exceed their bound"); + this.seen.add(key); + this.failed = message.stopReason === "error"; + const usage = record(message.usage); + const valid = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + const fields = ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"] as const; + const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name]); + for (const [index, name] of fields.entries()) { + const value = numbers[index]; + if (valid(value)) { this.total[name] += value; this.observed = true; } + else this.unknown.add(name); + } + const total = valid(usage.totalTokens) ? usage.totalTokens + : numbers.every(valid) ? (numbers as number[]).reduce((sum, value) => sum + value, 0) : undefined; + if (valid(total)) this.total.totalTokens += total; + else this.unknown.add("totalTokens"); + const cost = usage.cost && typeof usage.cost === "object" ? record(usage.cost).total : undefined; + if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) { + this.cost += cost; this.costObserved = true; + } else this.costIncomplete = true; + } + + markIncomplete(): void { + for (const name of Object.keys(this.total)) this.unknown.add(name as keyof typeof this.total); + this.costIncomplete = true; + } + + acceptCompaction(value: unknown): void { + this.compactionObserved = true; + const result = value && typeof value === "object" && !Array.isArray(value) ? record(value) : {}; + if (!result.usage || typeof result.usage !== "object") { + // A compaction may consume tokens even if its terminal receipt is absent. + // Known assistant counters alone cannot establish the complete turn. + this.markIncomplete(); + return; + } + const previousFailure = this.failed; + this.accept({ role: "assistant", id: "compaction", timestamp: result.firstKeptEntryId, + content: [result.tokensBefore, result.summary], usage: result.usage }); + // A successful summary is not proof that an earlier failed model turn recovered. + this.failed = previousFailure; + } + + 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 && 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 new file mode 100644 index 0000000000..17463af390 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts @@ -0,0 +1,13 @@ +/** + * Candidate closure pins from the isolated npm lock and official Node 24.21.0. + * The non-Node graph is identical across targets, including platform resources. + * Pi 1.0.0 dependency graphs were independently installed for all three targets. + * Native platform execution and paid qualification remain separately required. + * Changing any package, helper, extension or bootstrap requires regenerating all + * three pins. Never accept a digest supplied only by an installed manifest. + */ +export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({ + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410", +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.test.ts new file mode 100644 index 0000000000..3244bbdfe3 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { createPiProfileExtensionAdapter, PI_NOTICE_METHOD } from "./pi-extension-adapter.js"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +const context = { workspacePath: "/fixture", sessionId: "session", turnId: "turn" }; +it("preserves bounded native notice severity, category and meaningful metadata without assistant output", async () => { + const adapter = createPiProfileExtensionAdapter(context); + for (const severity of ["info", "warning", "error"]) { + const events = await adapter.notification(PI_NOTICE_METHOD, { sessionId: "session", category: "auto_retry_start", severity, + summary: "Retry scheduled", details: { attempt: 2, maxAttempts: 3, delayMs: 200, errorMessage: "Authorization: Bearer fixture-private-token", secret: "DROP_ME" } }); + expect(events).toHaveLength(1); validateAcpxRichEvent(events[0]!); + expect(events[0]).toMatchObject({ eventType: "provider.notice.recorded", payload: { severity, category: "pi.auto_retry_start", scope: "session" } }); + expect(events[0]!.payload.details).toContainEqual({ name: "attempt", value: "2" }); + expect(JSON.stringify(events)).not.toMatch(/DROP_ME|fixture-private-token/); + } +}); +it("rejects malformed/cross-session notices and cannot answer requests or emit final replies", async () => { + const adapter = createPiProfileExtensionAdapter(context); + for (const changed of [{ sessionId: "other" }, { category: "invented" }, { severity: "fatal" }, { summary: "x".repeat(4001) }]) { + await expect(adapter.notification(PI_NOTICE_METHOD, { sessionId: "session", category: "compaction_end", severity: "info", summary: "Done", ...changed })).rejects.toThrow("contract"); + } + await expect(adapter.request("exec", {})).rejects.toThrow("no qualified"); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts new file mode 100644 index 0000000000..b2f320937a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; +import type { AcpxProfileExtensionAdapter, AcpxProfileExtensionContext } from "./profile-extensions.js"; + +export const PI_NOTICE_METHOD = "paperclip/pi_notice"; +const CATEGORIES = new Set(["startup", "extension_notify", "invalid_question", "auto_retry_start", "auto_retry_end", "compaction_start", "compaction_end", "summarization_retry_scheduled", "session_stats", "auto_compaction_policy", "runtime_failure"]); +const safeText = (value: string, limit = 4000) => String(redactPaperclipSemanticValue(value)).slice(0, limit); + +/** Notices are display-only session evidence. They carry neither assistant + * content nor authority to complete a turn, execute tools, or grant approval. */ +export function createPiProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter { + let sequence = 0; + return { + async request() { throw new Error("Pi has no qualified inbound extension request"); }, + async notification(method, params) { + if (method !== PI_NOTICE_METHOD) return []; + if (params.sessionId !== context.sessionId || typeof params.category !== "string" || !CATEGORIES.has(params.category) + || typeof params.summary !== "string" || params.summary.length > 4000 + || !["info", "warning", "error"].includes(String(params.severity)) || Buffer.byteLength(JSON.stringify(params)) > 32 * 1024) { + throw new Error("Pi native notice is outside its pinned contract"); + } + const details = [{ name: "source.method", value: PI_NOTICE_METHOD }, { name: "source.nativeEvent", value: params.category }, + { name: "source.sessionId", value: safeText(context.sessionId) }]; + if (params.details && typeof params.details === "object" && !Array.isArray(params.details)) { + const native = params.details as Record; + for (const key of ["attempt", "maxAttempts", "delayMs"]) if (Number.isSafeInteger(native[key]) && (native[key] as number) >= 0) details.push({ name: key, value: String(native[key]) }); + for (const key of ["success", "aborted", "willRetry", "enabled"]) if (typeof native[key] === "boolean") details.push({ name: key, value: String(native[key]) }); + for (const key of ["reason", "errorMessage"]) if (typeof native[key] === "string") details.push({ name: key, value: safeText(native[key] as string) }); + } + const noticeId = `pi-notice-${createHash("sha256").update(JSON.stringify([context.sessionId, context.turnId, ++sequence])).digest("hex")}`; + return [{ eventType: "provider.notice.recorded", itemId: noticeId, payload: { + schema: "paperclip.provider.notice.v1", noticeId, severity: params.severity, + category: `pi.${params.category}`, scope: "session", recoverable: params.severity !== "error", userActionable: false, + summary: safeText(params.summary), details, + } }]; + }, + }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts new file mode 100644 index 0000000000..c04f4c3f71 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -0,0 +1,204 @@ +import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { once } from "node:events"; +import { stripTypeScriptTypes } from "node:module"; +import { createHash } from "node:crypto"; +import { StringDecoder } from "node:string_decoder"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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 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 = (): AcpxReleaseProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi }); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", root); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(root, "package.json")); + const assets = join(root, "provider-assets/pi", `${process.platform}-${process.arch}`); + await mkdir(join(assets, "runtime"), { recursive: true }); + return { root, assets }; +} + +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("preserves profile 20 and binds only the configured environment and fixed dependency payload", 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(20), current = await read(21); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "closure", "credentialEnvironmentSourceSha256", "dependencySecurityPatchSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + + it("preserves profile 21 and binds the restriction on general IAM credentials", 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(21), current = await read(22); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "credentialEnvironmentSourceSha256", "providerConfigurationSourceSha256"]); + 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-v22-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))}`); + expect(QUALIFIED_ACPX_PROFILES.pi.commandDigest).toBe(identity.commandDigest); + for (const key of ["agent", "agentProfileVersion", "acpxVersion", "agentServerVersion", "agentRuntimeVersion"] as const) { + expect(declaration[key]).toBe(QUALIFIED_ACPX_PROFILES.pi[key]); + } + expect(declaration.closure).toEqual(PI_DISTRIBUTION_CLOSURE_SHA256); + expect(hash(await readFile(new URL("../../../../../patches/brace-expansion@5.0.9.patch", import.meta.url)))).toBe(declaration.dependencySecurityPatchSha256); + 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); + const helper = stripTypeScriptTypes(await readFile(new URL("./pi-acp-runtime.ts", import.meta.url), "utf8")).split("\n").map(line => line.trimEnd()).join("\n"); + const extension = stripTypeScriptTypes(await readFile(new URL("./pi-runtime-extension.ts", import.meta.url), "utf8")).replace('from "./pi-acp-runtime.js"', 'from "../node_modules/pi-acp/dist/paperclip-runtime.js"'); + expect(hash(helper)).toBe(declaration.helperSha256); + expect(hash(extension)).toBe(declaration.extensionSha256); + const materializer = await readFile(new URL("../../../scripts/materialize-pi-distribution.mjs", import.meta.url), "utf8"); + expect(materializer).toContain(`wrapperSha256: "${declaration.wrapperSha256}"`); + 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("a7c63900b513490cceed9e41bc3bd06f03f2cd56265f434aa2c5dc7925fa76b4"); + }); + + it("rejects legacy profiles and tampered executable identities", () => { + expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 22"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 22"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 22"); + for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { + expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); + } + }); + it("never takes closure authority from installed metadata", async () => { + const { assets } = await fixture(); + await writeFile(join(assets, "pi-distribution.json"), JSON.stringify({ + schema: "paperclip.pi-distribution.v1", runtimeRoot: "runtime", nativeClosureSha256: "0".repeat(64), + target: { platform: process.platform, architecture: process.arch, nodeVersion: "24.21.0" }, + pins: { nodeVersion: "24.21.0", wrapper: "0.0.33", runtime: "1.0.0", sdk: "0.26.0", zod: "3.25.76" }, + })); + await expect(verifyPiInstallation(candidate())).rejects.toThrow("trusted target pin"); + }); + it.runIf(process.env.PAPERCLIP_TEST_PI_DISTRIBUTION_ROOT)("launches the actual closed Pi snapshot with bound subprocess paths", async () => { + const { root, assets } = await fixture(); + await cp(process.env.PAPERCLIP_TEST_PI_DISTRIBUTION_ROOT!, assets, { recursive: true }); + const workspace = join(root, "workspace"); const agentHome = join(root, "agent"); + await mkdir(workspace); await mkdir(agentHome); + const installation = await verifyPiInstallation(candidate()); + const command = await installation.openCommand(); + const child = command.spawn([], { cwd: workspace, env: { + PATH: "/usr/bin:/bin", HOME: agentHome, PI_CODING_AGENT_DIR: agentHome, + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "1", + PAPERCLIP_PI_READ_ROOTS: "[]", PAPERCLIP_PI_PROTECTED_ROOTS: JSON.stringify([agentHome]), + PAPERCLIP_PI_ENTRYPOINT: "/unverified/ignored.js", PI_TELEMETRY: "0", + } }); + let stderr = ""; child.stderr!.on("data", chunk => { stderr += String(chunk); }); + const pending = new Map(); + const decoder = new StringDecoder("utf8"); let buffer = ""; let id = 0; + child.stdout!.on("data", chunk => { + buffer += decoder.write(chunk); + for (;;) { + const at = buffer.indexOf("\n"); if (at < 0) break; + const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); if (!line.trim()) continue; + const value = JSON.parse(line); const waiter = pending.get(value.id); + if (waiter) { pending.delete(value.id); if (value.error) waiter.reject(new Error(JSON.stringify(value.error))); else waiter.resolve(value.result); } + } + }); + child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`Pi exited: ${stderr}`)); pending.clear(); }); + const call = (method: string, params: unknown) => new Promise((resolve, reject) => { + const requestId = ++id; + const timer = setTimeout(() => reject(new Error(`Pi snapshot admission timed out: ${stderr}`)), 15_000); + pending.set(requestId, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } }); + child.stdin!.write(`${JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params })}\n`); + }); + try { + const initialized = await call("initialize", { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } }); + expect(initialized.agentCapabilities._meta.paperclipPi.steering).toBe(true); + expect(initialized.authMethods).toEqual([]); + await expect(call("session/new", { cwd: workspace, mcpServers: [] })).rejects.toThrow("Bind the configured OpenRouter API credential"); + } finally { + child.stdin!.end(); + if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await once(child, "close"); clearTimeout(timer); } + await command.close(); + } + }, 90_000); + + it("refuses symlinked target assets and metadata", async () => { + const { root, assets } = await fixture(); + await rm(assets, { recursive: true }); + await symlink(root, assets); + await expect(verifyPiInstallation(candidate())).rejects.toThrow("fixed asset directory"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts new file mode 100644 index 0000000000..2ca4e87037 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -0,0 +1,103 @@ +import { constants } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; +import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "./pi-closure-pins.js"; +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 AcpxReleaseProfile } from "./qualified-profiles.js"; + +const MAX_DISTRIBUTION_METADATA_BYTES = 4 * 1024 * 1024; +const FIXED_PATHS = Object.freeze({ + node: "node/bin/node", + piEntrypoint: "node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + extension: "extensions/paperclip.js", + wrapperEntrypoint: "node_modules/pi-acp/dist/index.js", +}); +function record(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Pi distribution metadata is invalid"); + return value as Record; +} + +/** Candidate identity is build-owned; model selection cannot substitute a CLI. */ +export function assertPiInstallationProfile(profile: AcpxReleaseProfile): void { + const trusted = QUALIFIED_ACPX_PROFILES.pi; + 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> { + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await file.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(MAX_DISTRIBUTION_METADATA_BYTES)) throw new Error("Pi distribution metadata is not a bounded private file"); + const bytes = await file.readFile(); + const after = await file.stat({ bigint: true }); + const named = await lstat(path, { bigint: true }); + if (bytes.length !== Number(before.size) || 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()) throw new Error("Pi distribution metadata changed while read"); + return record(JSON.parse(bytes.toString("utf8"))); + } finally { await file.close(); } +} + +/** + * Resolve only fixed runner package assets. The independently source-pinned + * native closure supplies the snapshot and provider-group lifetime guardian. + * Qualification status remains the caller's production admission gate. + */ +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"); + const expectedClosure = PI_DISTRIBUTION_CLOSURE_SHA256[target as keyof typeof PI_DISTRIBUTION_CLOSURE_SHA256]; + const assets = join(resolveRunnerProviderAssetsRoot(import.meta.url, "pi"), target); + const runtimeRoot = join(assets, "runtime"); + const assertDirectories = async (): Promise => { + for (const path of [assets, runtimeRoot]) { + const stat = await lstat(path); + if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path) throw new Error("Pi distribution escaped its fixed asset directory"); + } + }; + 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); + const pins = record(metadata.pins); + if (metadata.schema !== "paperclip.pi-distribution.v1" || metadata.runtimeRoot !== "runtime" || metadata.nativeClosureSha256 !== expectedClosure || targetMetadata.platform !== process.platform || targetMetadata.architecture !== process.arch || targetMetadata.nodeVersion !== PI_NODE_VERSION || pins.nodeVersion !== PI_NODE_VERSION || pins.wrapper !== "0.0.33" || pins.runtime !== "1.0.0" || pins.sdk !== "0.26.0" || pins.zod !== "3.25.76") throw new Error("Pi distribution metadata does not match its trusted target pin"); + const manifest = record(metadata.manifest) as unknown as PiRuntimeManifest; + for (const [key, value] of Object.entries(FIXED_PATHS)) { + if (manifest[key as keyof typeof FIXED_PATHS] !== value) throw new Error("Pi distribution changed a fixed launch path"); + } + const declaration = { + distributionRoot: runtimeRoot, + manifestPath: join(assets, "native-closure.json"), + expectedClosureSha256: expectedClosure, + executable: FIXED_PATHS.node, + entrypoint: "pi-entry.cjs", + fixedArguments: [], + }; + // Both declarations must name exactly the same source-pinned bytes. Actual + // content admission remains the descriptor-bound immutable snapshot below. + const entries = await readNativeAcpxDistributionEntries(declaration); + const layout = await verifyPiRuntimeLayoutForNativeSnapshot(runtimeRoot, manifest, { entries }, expectedClosure); + if (layout.manifestDigest !== metadata.manifestDigest) throw new Error("Pi runtime manifest digest does not match its distribution"); + const native = await verifyNativeAcpxInstallation(declaration); + return Object.freeze({ + 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(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(options); + }, + }); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.test.ts new file mode 100644 index 0000000000..c4a273778a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { createPiMessageProjection, piBoundaryClearsFinal } from "./pi-message-projection.js"; + +const id = (n: number) => `pi-message-${String(n).padStart(64, "0")}`; +const chunk = (n: number, kind: string, text = "", stream = "output") => ({ type: "text_delta", messageId: id(n), text, stream, + meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind } }); +function project(events: ReturnType[]) { + const projection = createPiMessageProjection>(); + const progress: string[] = []; let final = ""; + for (const raw of events) { + const event = projection.normalize(raw); + if (piBoundaryClearsFinal(event)) final = ""; + if (event.stream === "output") { final += event.text; progress.push(event.text); } + } + projection.settle(); return { final, progress }; +} +describe("Pi native message projection", () => { + it("retains pre-tool progress and thoughts while finalizing only the exact final native message", () => { + const result = project([chunk(1, "start"), chunk(1, "delta", "Calling finish."), chunk(1, "end:toolUse"), + chunk(2, "start"), chunk(2, "delta", "private thought", "thought"), chunk(2, "delta", "EXACT_"), chunk(2, "delta", "MARKER"), chunk(2, "end:stop")]); + expect(result.final).toBe("EXACT_MARKER"); expect(result.progress).toContain("Calling finish."); + expect(result.progress).not.toContain("private thought"); + }); + it.each(["toolUse", "error", "aborted"])("does not finalize stale narration after native %s", reason => { + expect(project([chunk(1, "start"), chunk(1, "delta", "Not a final answer"), chunk(1, `end:${reason}`)]).final).toBe(""); + }); + it("empty final messages clear earlier output and notice-only turns produce no answer", () => { + expect(project([chunk(1, "start"), chunk(1, "delta", "old"), chunk(1, "end:stop"), chunk(2, "start"), chunk(2, "end:stop")]).final).toBe(""); + const projection = createPiMessageProjection<{ type: string }>(); + expect(projection.normalize({ type: "status" })).toEqual({ type: "status" }); projection.settle(); + }); + it.each([ + [chunk(1, "delta", "missing start")], [chunk(1, "end:stop")], [chunk(1, "start"), chunk(1, "start")], + [chunk(1, "start"), chunk(2, "delta", "wrong id")], [chunk(1, "start"), chunk(1, "end:invented")], + [chunk(1, "start"), chunk(1, "end:stop"), chunk(1, "start")], [chunk(1, "start", "invented content")], + [chunk(1, "start", "", "thought")], [chunk(1, "start")], + [{ ...chunk(1, "start"), meta: { origin: "forged", source: "pi-rpc-message-v1", kind: "start" } }], + ])("fails closed for incomplete/duplicate/malformed provenance %#", (...events) => { + expect(() => project(events)).toThrow("provenance"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.ts b/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.ts new file mode 100644 index 0000000000..7c3a5bab90 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-message-projection.ts @@ -0,0 +1,48 @@ +type RuntimeMessageEvent = { type: string; text?: string; stream?: string; messageId?: string; meta?: Record }; +export interface PiMessageBoundary { phase: "start" | "end"; stopReason?: "stop" | "length" | "toolUse" | "error" | "aborted" } +export type PiProjectedMessageEvent = T & { piMessageBoundary?: PiMessageBoundary; piMessageHistory?: true }; + +/** Revalidate the pinned wrapper's actual SDK provenance. No tool event or + * message text can be used to infer a missing assistant-message boundary. */ +export function createPiMessageProjection() { + let active: string | null = null; + const seen = new Set(); + let poisoned = false; + const fail = (): never => { poisoned = true; throw new Error("Pi native message provenance is invalid or incomplete"); }; + return { + normalize(event: T): PiProjectedMessageEvent { + if (poisoned) return fail(); + if (event.type !== "text_delta") return event; + const { messageId, meta } = event; + if (meta?.origin === "pi-history-assistant") { + if (active || seen.size || meta.source !== "pi-session-history-v1" || meta.kind !== "history" + || typeof messageId !== "string" || !/^pi-history-message-[a-f0-9]{64}$/.test(messageId) + || typeof event.text !== "string" || event.stream !== "output") return fail(); + return { ...event, piMessageHistory: true }; + } + if (typeof messageId !== "string" || !/^pi-message-[a-f0-9]{64}$/.test(messageId) + || meta?.origin !== "pi-native-assistant" || meta.source !== "pi-rpc-message-v1" || typeof event.text !== "string") return fail(); + if (meta.kind === "delta") { + if (active !== messageId) return fail(); + return event; + } + if (event.text !== "" || event.stream !== "output") return fail(); + if (meta.kind === "start") { + if (active || seen.has(messageId) || seen.size >= 4096) return fail(); + seen.add(messageId); active = messageId; + return { ...event, piMessageBoundary: { phase: "start" } }; + } + if (active !== messageId || typeof meta.kind !== "string" || !/^end:(stop|length|toolUse|error|aborted)$/.test(meta.kind)) return fail(); + active = null; + return { ...event, piMessageBoundary: { phase: "end", stopReason: meta.kind.slice(4) as PiMessageBoundary["stopReason"] } }; + }, + settle(): void { if (poisoned || active) fail(); }, + }; +} + +/** A completed tool-use/error/abort message is still durable progress, but it + * cannot become an assistant final reply if the native loop ends there. */ +export function piBoundaryClearsFinal(event: { piMessageBoundary?: PiMessageBoundary }): boolean { + const boundary = event.piMessageBoundary; + return boundary?.phase === "start" || (boundary?.phase === "end" && !["stop", "length"].includes(boundary.stopReason ?? "")); +} 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 new file mode 100644 index 0000000000..33266401e8 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-native-admission.test.ts @@ -0,0 +1,66 @@ +import { createHash } from "node:crypto"; +import { chmod, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import * as credentials from "./codex-credentials.js"; +import * as skills from "./runtime-skill-lease.js"; +import * as bridge from "../runner-tool-bridge.js"; +import { verifyNativeAcpxInstallation } from "./installation-integrity.js"; +import { inventoryPiRuntimeFiles, verifyPiRuntimeLayoutForNativeSnapshot, type PiRuntimeManifest } from "./pi-verified-runtime.js"; +import { AcpxRuntimeHost } from "./runtime-host.js"; + +const roots: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); +}); + +it("rejects corrupt Pi bytes at the command boundary before runtime, spawn or bridge and releases lifetime/skills", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-native-admission-")); roots.push(root); + const source = join(root, "provider"); const workspace = join(root, "workspace"); + await mkdir(source); await mkdir(workspace); await mkdir(join(root, "runtime")); + for (const name of ["node", "pi.js", "extension.js", "wrapper.js"]) await writeFile(join(source, name), "pinned:" + name); + await chmod(join(source, "node"), 0o500); + const manifest: PiRuntimeManifest = { schema: "paperclip.pi-runtime-files.v1", node: "node", piEntrypoint: "pi.js", extension: "extension.js", wrapperEntrypoint: "wrapper.js", files: await inventoryPiRuntimeFiles(source) }; + const entries = await Promise.all(manifest.files.map(async entry => ({ path: entry.path, sha256: entry.sha256.slice(7), size: (await lstat(join(source, entry.path))).size, executable: entry.path === "node" }))); + entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const closure = createHash("sha256").update(JSON.stringify(entries)).digest("hex"); + const manifestPath = join(root, "native.json"); await writeFile(manifestPath, JSON.stringify({ entries })); + const installation = await verifyNativeAcpxInstallation({ distributionRoot: source, manifestPath, expectedClosureSha256: closure, executable: "node", entrypoint: "wrapper.js", fixedArguments: [] }); + await writeFile(join(source, "wrapper.js"), "x".repeat(entries.find(entry => entry.path === "wrapper.js")!.size)); + // Declaration/layout resolution intentionally does not claim byte admission. + await verifyPiRuntimeLayoutForNativeSnapshot(source, manifest, { entries }, closure); + const lifetimeCloses: ReturnType[] = []; const skillCloses: ReturnType[] = []; + const acquireLifetime = credentials.acquireAcpxProviderLifetimeLease; + vi.spyOn(credentials, "acquireAcpxProviderLifetimeLease").mockImplementation(async input => { + const lease = await acquireLifetime(input); const close = vi.fn(() => lease.close()); lifetimeCloses.push(close); + return { ...lease, close }; + }); + const acquireSkills = skills.createAcpxRuntimeSkillLease; + vi.spyOn(skills, "createAcpxRuntimeSkillLease").mockImplementation(async input => { + const lease = await acquireSkills(input); const close = vi.fn(() => lease.close()); skillCloses.push(close); + return { ...lease, close }; + }); + const toolBridge = vi.spyOn(bridge, "startRunnerToolBridge"); + const openRuntime = vi.fn(); const spawn = vi.fn(); + const openCommand = vi.fn(async () => { + const lease = await installation.openCommand(); + return { close: () => lease.close(), spawn }; + }); + 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() }, + }, { + verifyInstallation: async profile => ({ commandDigest: profile.commandDigest, agentServerPackageJsonPath: join(source, "package.json"), agentRuntimePackageJsonPath: null, openCommand }), + openRuntime, reportRetainedCleanupFailure: vi.fn(), + })).rejects.toThrow("digest mismatch"); + expect(openCommand).toHaveBeenCalledOnce(); + expect(openRuntime).not.toHaveBeenCalled(); expect(spawn).not.toHaveBeenCalled(); expect(toolBridge).not.toHaveBeenCalled(); + expect(lifetimeCloses).toHaveLength(1); expect(lifetimeCloses[0]).toHaveBeenCalledOnce(); + expect(skillCloses).toHaveLength(1); expect(skillCloses[0]).toHaveBeenCalledOnce(); + expect(lifetimeCloses[0]!.mock.invocationCallOrder[0]).toBeLessThan(skillCloses[0]!.mock.invocationCallOrder[0]!); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-node-pins.ts b/packages/paperclip-runner/src/drivers/acpx/pi-node-pins.ts new file mode 100644 index 0000000000..93d1b1a4e9 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-node-pins.ts @@ -0,0 +1,8 @@ +/** Official Node release SHASUMS256.txt, version 24.21.0, reviewed 2026-09-28. + * https://nodejs.org/dist/v24.21.0/SHASUMS256.txt — bundled Undici 7.29.1. */ +export const PI_NODE_VERSION = "24.21.0" as const; +export const PI_NODE_DISTRIBUTIONS = Object.freeze({ + "darwin-arm64": Object.freeze({ archiveSha256: "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", executableSha256: "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b", executableSize: 122129232 }), + "darwin-x64": Object.freeze({ archiveSha256: "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", executableSha256: "7abcf39bd37ab251015337ff75304d7555f0d8e88c6e0fbf04bce8ce34636f49", executableSize: 125270960 }), + "linux-x64": Object.freeze({ archiveSha256: "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", executableSha256: "7fde7b8afa198da66257f42ee2001d874c7355631e6d1579a5fb5ef1f246df4c", executableSize: 126595440 }), +}); 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..7f4fa25b50 --- /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", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"])("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..eca68f7de3 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.ts @@ -0,0 +1,115 @@ +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", + "PAPERCLIP_PI_PROVIDERS", +]); + +// General IAM credentials authorize services beyond the model provider. Bedrock +// uses its provider-scoped bearer credential instead. +export const PI_GENERAL_IAM_CREDENTIAL_NAMES = Object.freeze([ + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", +]); + +// Process startup controls and general IAM credentials are never provider inputs. +const RESERVED_PI_CREDENTIAL_NAMES = new Set([ + ...PI_GENERAL_IAM_CREDENTIAL_NAMES, + "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-rpc-deltas.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-rpc-deltas.test.ts new file mode 100644 index 0000000000..08539b6abd --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-rpc-deltas.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { PiAssistantMessages, PiRpcMessageDeltas, PiToolIdentities, piNativeFailure } from "./pi-acp-runtime.js"; + +// Pi 1 modes/json-event.js strips message and partial from message_update. +// The real SDK/package contract separately executes that actual serializer. +const start = (timestamp = 1) => ({ type: "message_start", message: { role: "assistant", timestamp, content: [], stopReason: "pending" } }); +const end = (stopReason = "stop", timestamp = 1) => ({ type: "message_end", message: { role: "assistant", timestamp, content: [], stopReason } }); +const delta = (type: string, contentIndex = 0, extra = {}) => ({ type: "message_update", usage: {}, assistantMessageEvent: { type, contentIndex, ...extra } }); +function fixture() { + const wire = new PiRpcMessageDeltas(); const messages = new PiAssistantMessages("00000000-0000-4000-8000-000000000001"); const tools = new PiToolIdentities("00000000-0000-4000-8000-000000000001"); + return (event: Record) => tools.normalize(messages.normalize(wire.normalize(event))); +} + +describe("pinned Pi 1 serialized message deltas", () => { + it.each(["text", "thinking"])("binds %s deltas to the native occurrence without synthetic final content", kind => { + const accept = fixture(); accept({ type: "turn_start" }); const first = accept(start()); + accept(delta(`${kind}_start`)); + const streamed = accept(delta(`${kind}_delta`, 0, { delta: "native text" })); + expect(streamed.paperclipAssistantMessage).toMatchObject({ messageId: (first.paperclipAssistantMessage as any).messageId, phase: "delta" }); + expect(streamed.assistantMessageEvent).toMatchObject({ delta: "native text" }); + accept(delta(`${kind}_end`, 0, { content: "native text" })); + expect(accept({ ...end(), message: { ...end().message, content: [{ type: kind, [kind === "text" ? "text" : "thinking"]: "native text" }] } }).paperclipAssistantMessage).toMatchObject({ phase: "end", stopReason: "stop" }); + accept({ type: "turn_end" }); accept({ type: "agent_settled" }); + }); + it.each(["text", "thinking"])("rejects a mismatched %s end and remains poisoned", kind => { + const wire = new PiRpcMessageDeltas(); wire.normalize(start()); wire.normalize(delta(`${kind}_start`)); + wire.normalize(delta(`${kind}_delta`, 0, { delta: "observed" })); + expect(() => wire.normalize(delta(`${kind}_end`, 0, { content: "different" }))).toThrow("boundary"); + expect(() => wire.normalize(start(2))).toThrow("boundary"); + }); + for (const kind of ["text", "thinking"]) for (const reason of ["stop", "error", "aborted"]) { + it.each(["missing", "wrong-type", "different-content"])(`rejects %s final ${kind} content after a valid end (${reason})`, change => { + const wire = new PiRpcMessageDeltas(); wire.normalize(start()); wire.normalize(delta(`${kind}_start`)); + wire.normalize(delta(`${kind}_delta`, 0, { delta: "observed" })); wire.normalize(delta(`${kind}_end`, 0, { content: "observed" })); + const block = { type: change === "wrong-type" ? "toolCall" : kind, [kind === "text" ? "text" : "thinking"]: change === "different-content" ? "different" : "observed" }; + expect(() => wire.normalize({ ...end(reason), message: { ...end(reason).message, content: change === "missing" ? [] : [block] } })).toThrow("boundary"); + expect(() => wire.normalize(start(2))).toThrow("boundary"); + }); + } + it("bounds aggregate streaming across blocks at exactly 4 MiB", () => { + const wire = new PiRpcMessageDeltas(); wire.normalize(start()); + wire.normalize(delta("text_start", 0)); wire.normalize(delta("thinking_start", 1)); + const chunk = "x".repeat(262_144); + for (let index = 0; index < 16; index++) expect(() => wire.normalize(delta(index % 2 ? "thinking_delta" : "text_delta", index % 2, { delta: chunk }))).not.toThrow(); + expect(() => wire.normalize(delta("text_delta", 0, { delta: "x" }))).toThrow("boundary"); + expect(() => wire.normalize(start(2))).toThrow("boundary"); + }); + it.each(["stop", "error", "aborted"])("retains a native final-only %s receipt without synthesizing streamed blocks", reason => { + // Pi 1 agent-loop may emit start(finalMessage), end(finalMessage) when a + // provider supplies a terminal result without a stream start. Unobserved + // final blocks are native receipt content, not attested streamed deltas. + const accept = fixture(); accept({ type: "turn_start" }); + const message = { ...end(reason).message, content: [{ type: "text", text: "native terminal body" }] }; + expect(accept({ type: "message_start", message }).assistantMessageEvent).toBeUndefined(); + const terminal = accept({ type: "message_end", message }); + expect(terminal.message).toEqual(message); expect(terminal.assistantMessageEvent).toBeUndefined(); + expect(terminal.paperclipAssistantMessage).toMatchObject({ phase: "end", stopReason: reason }); + accept({ type: "turn_end" }); accept({ type: "agent_settled" }); + }); + it("correlates interleaved tool indices and preserves final arguments and native IDs", () => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); + const ids = [0, 1].map(index => (accept(delta("toolcall_start", index, { id: `call_${index}`, toolName: `tool_${index}` })).assistantMessageEvent as any).toolCall.id); + for (const index of [1, 0]) { + expect((accept(delta("toolcall_delta", index, { delta: '{"value":' })).assistantMessageEvent as any).toolCall).toMatchObject({ id: ids[index], partialArgs: '{"value":' }); + accept(delta("toolcall_delta", index, { delta: `${index}}` })); + const final = accept(delta("toolcall_end", index, { toolCall: { type: "toolCall", id: `call_${index}`, name: `tool_${index}`, arguments: { value: index } } })); + expect((final.assistantMessageEvent as any).toolCall).toMatchObject({ id: ids[index], arguments: { value: index } }); + } + accept({ ...end("toolUse"), message: { ...end("toolUse").message, content: [0, 1].map(index => ({ type: "toolCall", id: `call_${index}`, name: `tool_${index}`, arguments: { value: index } })) } }); + for (const index of [0, 1]) expect(accept({ type: "tool_execution_start", toolCallId: `call_${index}`, toolName: `tool_${index}`, args: { value: index } }).toolCallId).toBe(ids[index]); + }); + it("defers an initially absent tool identity until its native final receipt", () => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); + expect((accept(delta("toolcall_start", 0, { id: "", toolName: "" })).assistantMessageEvent as any).toolCall.id).toBe(""); + accept(delta("toolcall_delta", 0, { delta: "{}" })); + expect((accept(delta("toolcall_end", 0, { toolCall: { type: "toolCall", id: "final-id", name: "tool", arguments: {} } })).assistantMessageEvent as any).toolCall.id).toMatch(/^pi-/); + }); + it.each(["missing", "foreign-id", "foreign-name", "foreign-arguments"])("rejects %s in the final native tool receipt", change => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); + const call = { type: "toolCall", id: "a", name: "tool", arguments: { value: 1 } }; + accept(delta("toolcall_start", 0, { id: "a", toolName: "tool" })); accept(delta("toolcall_end", 0, { toolCall: call })); + const changed = { ...call, ...(change === "foreign-id" ? { id: "b" } : change === "foreign-name" ? { name: "other" } : change === "foreign-arguments" ? { arguments: { value: 2 } } : {}) }; + expect(() => accept({ ...end("toolUse"), message: { ...end("toolUse").message, content: change === "missing" ? [] : [changed] } })).toThrow("boundary"); + }); + it("rejects actual execution arguments that disagree with the completed native stream", () => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); + const call = { type: "toolCall", id: "a", name: "tool", arguments: { value: 1 } }; + accept(delta("toolcall_start", 0, { id: "a", toolName: "tool" })); accept(delta("toolcall_end", 0, { toolCall: call })); + accept({ ...end("toolUse"), message: { ...end("toolUse").message, content: [call] } }); + expect(() => accept({ type: "tool_execution_start", toolCallId: "a", toolName: "tool", args: { value: 2 } })).toThrow("identity conflict"); + }); + it.each(["error", "aborted"])("closes incomplete stream blocks on authoritative %s, then admits a fresh occurrence", reason => { + const accept = fixture(); accept({ type: "turn_start" }); accept(start()); accept(delta("text_start")); accept(end(reason)); accept({ type: "turn_end" }); accept({ type: "agent_settled" }); + accept({ type: "turn_start" }); expect(accept(start(2)).paperclipAssistantMessage).toMatchObject({ phase: "start" }); + }); + it.each([ + [delta("text_delta", 0, { delta: "orphan" })], + [start(), delta("text_delta", 0, { delta: "no block" })], + [start(), delta("text_start"), delta("text_start")], + [start(), delta("text_start"), delta("thinking_delta", 0, { delta: "wrong kind" })], + [start(), delta("text_start"), end()], + [start(), end(), delta("text_start")], + [start(), start()], + [start(), delta("text_start", -1)], + [start(), delta("text_start", 4096)], + [start(), delta("text_start", 0.5)], + [start(), delta("text_start"), delta("text_end", 0, { content: "" }), delta("text_delta", 0, { delta: "stale" })], + [start(), { ...delta("text_start"), message: start().message }], + [start(), delta("text_start", 0, { partial: {} })], + [start(), delta("toolcall_start", 0, { id: "a", toolName: "tool" }), delta("toolcall_start", 1, { id: "a", toolName: "tool" })], + [start(), delta("toolcall_start", 0, { id: "a", toolName: "tool" }), delta("toolcall_end", 0, { toolCall: { type: "toolCall", id: "b", name: "tool", arguments: {} } })], + [start(), delta("toolcall_start", 0, { id: "a", toolName: "tool" }), delta("toolcall_end", 0, { toolCall: { type: "toolCall", id: "a", name: "other", arguments: {} } })], + [start(), delta("text_start"), delta("text_delta", 0, { delta: "x".repeat(262_145) })], + ])("rejects malformed, duplicate or stale wire sequences %# and remains poisoned", (...events) => { + const wire = new PiRpcMessageDeltas(); + expect(() => { for (const event of events) wire.normalize(event as any); }).toThrow("boundary"); + expect(() => wire.normalize(start(2))).toThrow("boundary"); + }); +}); + +describe("Pi diagnostic privacy", () => { + it("retains exact known reasons while withholding arbitrary native content", () => { + expect(piNativeFailure(new Error("Pi RPC message delta boundary is invalid"))).toMatchObject({ reason: "rpc_delta_boundary_invalid" }); + for (const sensitive of ["Bearer secret-canary", "/private/secret-canary", "https://example.invalid/?key=secret-canary"]) { + expect(piNativeFailure(new Error(`401: ${sensitive}`))).toEqual({ reason: "provider_http_401", summary: "Pi provider request failed (HTTP 401)" }); + expect(JSON.stringify(piNativeFailure(new Error(sensitive)))).not.toContain("secret-canary"); + expect(piNativeFailure(new Error(sensitive)).reason).toBe("unknown_native_failure"); + } + expect(piNativeFailure(new Error("Pi RPC message delta boundary is invalid Bearer secret-canary")).reason).toBe("unknown_native_failure"); + }); +}); 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 new file mode 100644 index 0000000000..c11999afac --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts @@ -0,0 +1,503 @@ +import { startRunnerToolBridge } from "../runner-tool-bridge.js"; +import { createServer } from "node:http"; +import { mkdtemp, mkdir, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; +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, +} from "./pi-runtime-extension.js"; + +const directories: string[] = []; +afterEach(async () => { for (const path of directories.splice(0)) await rm(path, { recursive: true, force: true }); }); +async function workspace() { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-extension-")); directories.push(root); + await mkdir(join(root, "workspace")); await mkdir(join(root, "skills")); await mkdir(join(root, "private")); + return { root, config: { + invocationNamespace: "00000000-0000-4000-8000-000000000000", + workspace: join(root, "workspace"), readOnly: false, + readRoots: [join(root, "skills")], protectedRoots: [join(root, "private")], + instructions: "Use the assigned Paperclip tools", servers: [], + } satisfies PiRuntimeConfiguration }; +} +function harness() { + const handlers = new Map any>(); + const tools: PiToolDefinition[] = []; + const nativeTools: PiToolDefinition[] = []; + const api = { on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler), registerTool: (tool: PiToolDefinition) => (tool.name === PI_NATIVE_QUESTION_TOOL ? nativeTools : tools).push(tool), registerCommand: vi.fn() } as unknown as PiExtensionApi; + return { api, handlers, tools, nativeTools }; +} + +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); + for (const action of ["warm", "stop"]) { + expect(h.handlers.get("cache_warming_decision")!({ action, warmCost: 0.01, missCost: 10, continuationProbability: 1 })).toEqual({ action: "stop" }); + } + }); + it("admits only the canonical registered agent-files root and revalidates it after approval", async () => { + const { config, root } = await workspace(); const h = harness(); + const agentHome = join(await realpath(root), "agent-files"); await mkdir(agentHome); + const assigned = { ...config, agentHome }; + await installPiRuntimeExtension(h.api, assigned); h.handlers.get("turn_start")!(); + const select = vi.fn().mockResolvedValue("Allow once"); const context = { cwd: config.workspace, ui: { select } }; + const event = { toolName: "write", toolCallId: "memory", input: { path: join(agentHome, "MEMORY.md") } }; + expect(await h.handlers.get("tool_call")!(event, context)).toBeUndefined(); + expect(await checkPiNativeTool(event, context, config)).toMatch("outside"); + assigned.readOnly = true; + expect(await checkPiNativeTool(event, context, assigned)).toMatch("reading only"); + expect(await checkPiNativeTool({ ...event, toolName: "read" }, context, assigned)).toBeNull(); + assigned.readOnly = false; + await symlink(join(root, "private"), join(agentHome, "escape")); + expect(await checkPiNativeTool({ ...event, input: { path: join(agentHome, "escape/secret") } }, context, assigned)).toMatch("protected"); + select.mockImplementation(async () => { + await rename(agentHome, `${agentHome}-old`); await symlink(join(root, "private"), agentHome); return "Allow once"; + }); + expect(await h.handlers.get("tool_call")!({ ...event, toolCallId: "late-memory" }, context)).toMatchObject({ block: true }); + await expect(installPiRuntimeExtension(harness().api, assigned)).rejects.toThrow("canonical"); + await expect(installPiRuntimeExtension(harness().api, { ...config, agentHome: await realpath(join(root, "private")) })).rejects.toThrow("overlap"); + }); + + it("pins the agent-files directory identity across same-path replacement", async () => { + const { config, root } = await workspace(); const h = harness(); + const agentHome = join(await realpath(root), "agent-files"); await mkdir(agentHome); + await installPiRuntimeExtension(h.api, { ...config, agentHome }); h.handlers.get("turn_start")!(); + const select = vi.fn().mockImplementation(async () => { + await rename(agentHome, `${agentHome}-old`); await mkdir(agentHome); return "Allow once"; + }); + expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: "memory", input: { path: join(agentHome, "MEMORY.md") } }, { cwd: config.workspace, ui: { select } })).toMatchObject({ block: true, reason: "Pi agent files were replaced after admission" }); + }); + + it.each([ + ["select", { options: [{ id: "blue", label: "Blue" }, { id: "red", label: "Red" }] }, "Blue", { status: "answered", optionId: "blue" }], + ["confirm", { message: "Continue?" }, true, { status: "answered", confirmed: true }], + ["confirm", { message: "Continue?" }, false, { status: "negative_or_cancelled", confirmed: false }], + ["input", { placeholder: "Name" }, "Ada", { status: "answered", value: "Ada" }], + ["editor", { prefill: "Old\ntext" }, "New\ntext", { status: "answered", value: "New\ntext" }], + ["input", {}, undefined, { status: "cancelled" }], + ])("exposes native %s questions with bounded typed answers and exact retry identity", async (method, extra, answer, expected) => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const ui = { select: vi.fn(), confirm: vi.fn(), input: vi.fn(), editor: vi.fn() }; + ui[method as keyof typeof ui].mockResolvedValue(answer); + const context = { cwd: config.workspace, ui }; const args = { method, title: "Question", ...extra }; + const tool = h.nativeTools[0]!; + expect(await h.handlers.get("tool_call")!({ toolName: tool.name, toolCallId: "question", input: args }, context)).toBeUndefined(); + const result = await tool.execute("question", args, undefined, undefined, context); + expect(result.details).toEqual(expected); + expect(await tool.execute("question", args, undefined, undefined, context)).toEqual(result); + expect(ui[method as keyof typeof ui]).toHaveBeenCalledTimes(1); + if (method !== "select") expect(ui.select).not.toHaveBeenCalled(); + }); + + it("rejects malformed native questions and never routes them as permissions", async () => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const ui = { select: vi.fn(), confirm: vi.fn(), input: vi.fn(), editor: vi.fn() }; + const context = { cwd: config.workspace, ui }; + const invalid = [ + { method: "input", title: `${PI_PERMISSION_TITLE_PREFIX}{}` }, + { method: "select", title: "Question", options: [{ id: "a", label: "Same" }, { id: "b", label: "Same" }] }, + { method: "select", title: "Question", options: [{ id: "a", label: "One" }, { id: "a", label: "Two" }] }, + { method: "input", title: "Question", command: "touch forbidden" }, + { method: "editor", title: "Question", prefill: "x".repeat(16385) }, + { method: "multi-select", title: "Question" }, + { method: "input", title: "x".repeat(1001) }, + { method: "select", title: "Question", options: [{ id: "a", label: "界".repeat(1001) }] }, + ]; + for (const [index, args] of invalid.entries()) await expect(h.nativeTools[0]!.execute(`invalid-${index}`, args, undefined, undefined, context)).rejects.toThrow(); + expect(Object.values(ui).every(fn => fn.mock.calls.length === 0)).toBe(true); + const abort = new AbortController(); abort.abort(); + expect((await h.nativeTools[0]!.execute("cancelled", { method: "input", title: "Question" }, abort.signal, undefined, context)).details).toEqual({ status: "cancelled" }); + }); + it("aliases MCP punctuation and long names without changing authenticated call identity", async () => { + const { config } = await workspace(); const h = harness(); + const names = ["connection:search", "connection_search", "connection.search", "x".repeat(128)]; + const request = vi.fn(async (_server, method) => method === "tools/list" + ? { tools: names.map(name => ({ name, inputSchema: { type: "object", properties: {} } })) } + : { content: [{ type: "text", text: "recorded" }] }); + const assigned = { ...config, servers: [{ type: "http" as const, name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }] }; + await installPiRuntimeExtension(h.api, assigned, request); h.handlers.get("turn_start")!(); + expect(h.tools.map(tool => tool.name).every(name => /^[A-Za-z0-9_-]{1,64}$/.test(name))).toBe(true); + expect(new Set(h.tools.map(tool => tool.name)).size).toBe(names.length); + for (const [index, tool] of h.tools.entries()) { + await tool.execute(`call-${index}`, { index }); + expect(request).toHaveBeenLastCalledWith(assigned.servers[0], "tools/call", { name: names[index], arguments: { index } }, expect.any(String), undefined); + } + const again = harness(); await installPiRuntimeExtension(again.api, assigned, request); + expect(again.tools.map(tool => tool.name)).toEqual(h.tools.map(tool => tool.name)); + const duplicate = harness(); + await expect(installPiRuntimeExtension(duplicate.api, assigned, async (_server, method) => method === "tools/list" + ? { tools: [names[0], names[0]].map(name => ({ name, inputSchema: { type: "object" } })) } : {})).rejects.toThrow("ambiguous"); + expect(duplicate.api.registerCommand).not.toHaveBeenCalled(); + }); + + it.each(["a".repeat(1000), "界".repeat(1000), "🌒".repeat(500)])("admits the exact shared label boundary before native UI", async label => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const select = vi.fn().mockResolvedValue(label); + expect((await h.nativeTools[0]!.execute("boundary", { method: "select", title: label, options: [{ id: "choice", label }] }, undefined, undefined, { cwd: config.workspace, ui: { select } })).details).toEqual({ status: "answered", optionId: "choice" }); + expect(select).toHaveBeenCalledWith(label, [label], expect.any(Object)); + }); + + it("requires explicit assigned configuration, authenticated HTTPS or numeric loopback HTTP", () => { + expect(() => readPiRuntimeConfiguration({})).toThrow("missing"); + const value = { invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace: "/work/project", readOnly: false, readRoots: [], protectedRoots: [], instructions: "", servers: [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234/mcp", headers: [{ name: "Authorization", value: "Bearer 1234567890123456" }] }] }; + const parse = () => readPiRuntimeConfiguration({ PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify(value) }); + expect(parse().servers).toHaveLength(1); + for (const url of ["http://127.0.0.1.evil.test/mcp", "http://paperclip.example/mcp", "http://localhost/mcp", "http://user:password@127.0.0.1/mcp", "https://user:password@paperclip.example/mcp", "https://paperclip.example/mcp#unbound", "file:///tmp/mcp"]) { + value.servers[0]!.url = url; + expect(parse).toThrow("requires HTTPS or numeric loopback HTTP"); + } + value.servers[0]!.url = "https://paperclip.example/mcp/gateway"; + value.servers[0]!.headers = []; + expect(parse).toThrow("authentication"); + }); + + it("initializes and executes only the exact assigned HTTPS gateway with its bound credential", async () => { + const { config } = await workspace(); const h = harness(); + const endpoint = "https://paperclip.example/api/mcp/gateway/assigned"; + const authorization = "Bearer 1234567890123456"; + const assigned = readPiRuntimeConfiguration({ PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify({ + ...config, servers: [{ type: "http", name: "paperclip-assigned", url: endpoint, headers: [{ name: "Authorization", value: authorization }] }], + }) }); + const fetch_ = vi.fn(async (_url, init) => { + const body = JSON.parse(String(init!.body)); + const result = body.method === "tools/list" + ? { tools: [{ name: "report_progress", description: "Report progress", inputSchema: { type: "object", properties: {} } }] } + : body.method === "tools/call" ? { content: [{ type: "text", text: "recorded" }] } : {}; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result })); + }); + await installPiRuntimeExtension(h.api, assigned, (server, method, params, id, signal) => piMcpRequest(server, method, params, id, signal, fetch_)); h.handlers.get("turn_start")!(); + expect(h.tools).toHaveLength(1); + expect(h.tools[0]!.name).toBe("mcp__paperclip-assigned__report_progress"); + const signal = new AbortController().signal; + await expect(h.tools[0]!.execute("assigned-call-1", { text: "done" }, signal)).resolves.toMatchObject({ content: [{ text: "recorded" }] }); + expect(fetch_.mock.calls.map(([, init]) => JSON.parse(String(init!.body)).method)).toEqual(["initialize", "tools/list", "tools/call"]); + for (const [url, init] of fetch_.mock.calls) { + expect(url).toBe(endpoint); + expect(init).toMatchObject({ method: "POST", redirect: "error", headers: { Authorization: authorization } }); + } + expect(JSON.parse(String(fetch_.mock.calls.at(-1)![1]!.body))).toEqual({ jsonrpc: "2.0", id: expect.stringMatching(/^pi-[a-f0-9]{64}$/), method: "tools/call", params: { name: "report_progress", arguments: { text: "done" } } }); + const context = { cwd: config.workspace, ui: { select: vi.fn() } }; + expect(await h.handlers.get("tool_call")!({ toolName: "mcp__unassigned__report_progress", toolCallId: "unassigned", input: {} }, context)).toMatchObject({ block: true }); + expect(fetch_).toHaveBeenCalledTimes(3); + }); + + it("denies escapes and read-only mutations before requesting provider permission", async () => { + const { root, config } = await workspace(); const h = harness(); + await symlink(join(root, "private"), join(config.workspace, "escape")); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const select = vi.fn().mockResolvedValue("Allow once"); + const context = { cwd: config.workspace, ui: { select } }; + const tool = h.handlers.get("tool_call")!; + expect(await tool({ toolName: "write", toolCallId: "t1", input: { path: "escape/new-file" } }, context)).toMatchObject({ block: true }); + expect(await tool({ toolName: "read", toolCallId: "t2", input: { path: "../../outside" } }, context)).toMatchObject({ block: true }); + config.readOnly = true; + expect(await tool({ toolName: "bash", toolCallId: "t3", input: { command: "touch result" } }, context)).toMatchObject({ block: true }); + expect(select).not.toHaveBeenCalled(); + }); + + it("revalidates paths after a human wait and never treats cancellation as approval", async () => { + const { root, config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const context = { cwd: config.workspace, ui: { select: vi.fn(async () => { + await symlink(join(root, "private"), join(config.workspace, "target")); + return "Allow once"; + }) } }; + expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: "t", input: { path: "target/new" } }, context)).toMatchObject({ block: true }); + expect(context.ui.select.mock.calls[0]![0]).toMatch(PI_PERMISSION_TITLE_PREFIX); + context.ui.select = vi.fn().mockResolvedValue(undefined); + expect(await h.handlers.get("tool_call")!({ toolName: "bash", toolCallId: "b", input: { command: "pwd" } }, context)).toMatchObject({ block: true }); + }); + + it("checks Pi path expansion against workspace and protected roots before approval", async () => { + const { config, root } = await workspace(); const h = harness(); + await mkdir(join(config.workspace, "protected")); + config.protectedRoots.push(join(config.workspace, "protected")); + await mkdir(join(config.workspace, "space name")); + await symlink(join(root, "private"), join(config.workspace, "space name", "escape")); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const select = vi.fn().mockResolvedValue("Allow once"); + const context = { cwd: config.workspace, ui: { select } }; + const paths = [ + `@${root}/private/secret`, `file://${root}/private/secret`, "~/secret", "~", "@~/secret", + `@${config.workspace}/protected/secret`, `file://${config.workspace}/protected/secret`, + "space\u00a0name/escape/secret", "space\u202fname/escape/secret", + "file:///tmp/invalid%00name", + ]; + for (const [index, path] of paths.entries()) { + expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: `expanded-${index}`, input: { path } }, context), path).toMatchObject({ block: true }); + } + expect(select).not.toHaveBeenCalled(); + for (const [index, path] of [`@${config.workspace}/safe`, `file://${config.workspace}/safe`, "space\u00a0name/safe"].entries()) { + expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: `safe-${index}`, input: { path } }, context)).toBeUndefined(); + } + expect(select).toHaveBeenCalledTimes(3); + }); + + it("rejects read fallback aliases to private state before and after a permission wait", async () => { + const { config, root } = await workspace(); const h = harness(); + const aliases = [["capture 1 PM.png", "capture 1\u202fPM.png"], ["caf\u00e9", "cafe\u0301"], ["a'b", "a\u2019b"], ["caf\u00e9'b", "cafe\u0301\u2019b"]]; + for (const [, alternate] of aliases) await symlink(join(root, "private"), join(config.workspace, alternate!)); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const select = vi.fn().mockResolvedValue("Allow once"); + const context = { cwd: config.workspace, ui: { select } }; + for (const [index, [path]] of aliases.entries()) { + expect(await h.handlers.get("tool_call")!({ toolName: "read", toolCallId: `alternate-${index}`, input: { path } }, context)).toMatchObject({ block: true }); + } + expect(select).not.toHaveBeenCalled(); + select.mockImplementation(async () => { + await symlink(join(root, "private"), join(config.workspace, "late\u2019alias")); + return "Allow once"; + }); + expect(await h.handlers.get("tool_call")!({ toolName: "read", toolCallId: "late", input: { path: "late'alias" } }, context)).toMatchObject({ block: true }); + }); + + it("makes assigned skills readable but never writable", async () => { + const { config } = await workspace(); + await writeFile(join(config.readRoots[0]!, "SKILL.md"), "assigned"); + const context = { cwd: config.workspace, ui: { select: vi.fn() } }; + expect(await checkPiNativeTool({ toolName: "read", toolCallId: "r", input: { path: join(config.readRoots[0]!, "SKILL.md") } }, context, config)).toBeNull(); + expect(await checkPiNativeTool({ toolName: "write", toolCallId: "w", input: { path: join(config.readRoots[0]!, "SKILL.md") } }, context, config)).toMatch("read-only"); + }); + + it("keeps workspace-nested skills immutable and refuses protected-root overlap", async () => { + const { config } = await workspace(); + const assigned = join(config.workspace, "assigned"); await mkdir(assigned); + config.readRoots = [assigned]; + const context = { cwd: config.workspace, ui: { select: vi.fn() } }; + expect(await checkPiNativeTool({ toolName: "write", toolCallId: "w", input: { path: join(assigned, "SKILL.md") } }, context, config)).toMatch("read-only"); + config.readRoots = [config.protectedRoots[0]!]; + const h = harness(); + await expect(installPiRuntimeExtension(h.api, config)).rejects.toThrow("overlap"); + expect(h.api.registerCommand).not.toHaveBeenCalled(); + }); + + it("limits session grants to identical operations and rechecks their paths", async () => { + const { config, root } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!(); + const select = vi.fn().mockResolvedValue("Allow for this session"); + const context = { cwd: config.workspace, ui: { select } }; + const tool = h.handlers.get("tool_call")!; + const input = { path: "target/new" }; + expect(await tool({ toolName: "write", toolCallId: "a", input }, context)).toBeUndefined(); + expect(await tool({ toolName: "write", toolCallId: "b", input }, context)).toBeUndefined(); + expect(select).toHaveBeenCalledTimes(1); + expect(await tool({ toolName: "write", toolCallId: "c", input: { path: "different" } }, context)).toBeUndefined(); + expect(select).toHaveBeenCalledTimes(2); + await symlink(join(root, "private"), join(config.workspace, "target")); + expect(await tool({ toolName: "write", toolCallId: "d", input }, context)).toMatchObject({ block: true }); + expect(select).toHaveBeenCalledTimes(2); + }); + + it("registers exact authenticated MCP tools with stable idempotency and abort signal", async () => { + const { config } = await workspace(); const h = harness(); + config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }]; + const request = vi.fn(async (_server, method) => method === "tools/list" + ? { tools: [{ name: "report_progress", description: "Report progress", inputSchema: { type: "object", properties: {} } }] } + : method === "tools/call" ? { content: [{ type: "text", text: "recorded" }] } : {}); + await installPiRuntimeExtension(h.api, config, request); h.handlers.get("turn_start")!(); + const tool = h.tools[0]!; expect(tool.name).toBe("mcp__paperclip__report_progress"); + const signal = new AbortController().signal; + await expect(tool.execute("call-1", { text: "done" }, signal)).resolves.toMatchObject({ content: [{ text: "recorded" }] }); + expect(request).toHaveBeenLastCalledWith(config.servers[0], "tools/call", { name: "report_progress", arguments: { text: "done" } }, expect.stringMatching(/^pi-[a-f0-9]{64}$/), signal); + const select = vi.fn(); const context = { cwd: config.workspace, ui: { select } }; + expect(await h.handlers.get("tool_call")!({ toolName: tool.name, toolCallId: "m", input: {} }, context)).toBeUndefined(); + expect(await h.handlers.get("tool_call")!({ toolName: "mcp__paperclip__invented", toolCallId: "i", input: {} }, context)).toMatchObject({ block: true }); + expect(select).not.toHaveBeenCalled(); + }); + + it("separates repeated provider call IDs across native iterations and warm prompts, but preserves exact retries", async () => { + const { config } = await workspace(); const h = harness(); + config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }]; + const calls = new Map }>(); + const request = vi.fn(async (_server, method, params, id) => { + if (method === "tools/list") return { tools: ["paperclip_finish", "get_task_context"].map(name => ({ name, inputSchema: { type: "object" } })) }; + if (method !== "tools/call") return {}; + const fingerprint = JSON.stringify(params); + const previous = calls.get(id); + if (previous && previous.fingerprint !== fingerprint) return { isError: true, content: [{ type: "text", text: "Duplicate call identity conflict." }] }; + if (previous) return previous.result; + const result = params.arguments.contractRevision === "1" + ? { isError: true, content: [{ type: "text", text: "Use contractRevision2 and human_response." }] } + : { content: [{ type: "text", text: "accepted" }] }; + calls.set(id, { fingerprint, result }); return result; + }); + await installPiRuntimeExtension(h.api, config, request); h.handlers.get("turn_start")!(); + const finish = h.tools[0]!; const contextTool = h.tools[1]!; + const context = { cwd: config.workspace, ui: { select: vi.fn() } }; + const invoke = async (tool: PiToolDefinition, args: Record) => { + const blocked = await h.handlers.get("tool_call")!({ toolName: tool.name, toolCallId: "call_0", input: args }, context); + expect(blocked).toBeUndefined(); return tool.execute("call_0", args); + }; + await expect(invoke(finish, { contractRevision: "1" })).rejects.toThrow("human_response"); + h.handlers.get("turn_end")?.(); h.handlers.get("turn_start")?.({ turnIndex: 1 }); + await expect(invoke(finish, { contractRevision: "2" })).resolves.toMatchObject({ content: [{ text: "accepted" }] }); + const correctedId = request.mock.calls.at(-1)![3]; + await expect(finish.execute("call_0", { contractRevision: "2" })).resolves.toMatchObject({ content: [{ text: "accepted" }] }); + expect(request.mock.calls.at(-1)![3]).toBe(correctedId); + h.handlers.get("turn_end")?.(); + // A fresh ACP prompt resets Pi's SDK turnIndex, not the runner's lifetime ordinal. + h.handlers.get("before_agent_start")!({ systemPrompt: "warm continuation" }); + h.handlers.get("turn_start")?.({ turnIndex: 0 }); + await expect(invoke(contextTool, {})).resolves.toMatchObject({ content: [{ text: "accepted" }] }); + expect(calls.size).toBe(3); + await expect(contextTool.execute("call_0", { changed: true })).rejects.toThrow(/identity|invocation/i); + expect(() => h.handlers.get("turn_end")!()).toThrow(/identity|iteration/i); + }); + + it("keeps real authenticated bridge replay authority across corrected calls and warm iterations", async () => { + const { config } = await workspace(); const h = harness(); + const handler = vi.fn(async ({ tool, arguments: args }: { tool: string; arguments: unknown }) => { + if (tool === "paperclip_finish" && (args as any).completionClaim.contractRevision === "1") throw new Error("Use contractRevision2 and human_response."); + return { accepted: tool }; + }); + const bridge = await startRunnerToolBridge({ tools: [{ name: "get_task_context", inputSchema: { type: "object" } }], handler }); + const server = { type: "http" as const, name: "paperclip", url: bridge.url, headers: [{ name: "Authorization", value: `Bearer ${bridge.secret}` }] }; + config.servers = [server]; + const deliveries: Array<{ id: string; params: Record }> = []; + try { + await installPiRuntimeExtension(h.api, config, (assigned, method, params, id, signal) => { + if (method === "tools/call") deliveries.push({ id, params }); + return piMcpRequest(assigned, method, params, id, signal); + }); + const finish = h.tools.find(tool => tool.name.endsWith("__paperclip_finish"))!; + const contextTool = h.tools.find(tool => tool.name.endsWith("__get_task_context"))!; + const result = (revision: string) => ({ reportedWorkDisposition: "done", summary: "Complete", completionClaim: { contractRevision: revision, objectiveSatisfied: true, criteria: [{ criterionId: "human_response", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, evidence: [], verification: [], attentionRequests: [], artifacts: [] }); + h.handlers.get("turn_start")!(); + await expect(finish.execute("call_0", result("1"))).rejects.toThrow("human_response"); + const stale = deliveries.at(-1)!; + await expect(finish.execute("call_0", result("1"))).rejects.toThrow("human_response"); + expect(handler).toHaveBeenCalledTimes(1); // Cached semantic failure, not another mutation. + expect(deliveries.at(-1)!.id).toBe(stale.id); + expect(await piMcpRequest(server, "tools/call", { name: "paperclip_finish", arguments: result("2") }, stale.id)).toMatchObject({ isError: true, content: [{ text: "Duplicate call identity conflict." }] }); + expect(handler).toHaveBeenCalledTimes(1); + h.handlers.get("turn_end")!(); h.handlers.get("turn_start")!(); + await Promise.all([finish.execute("call_0", result("2")), finish.execute("call_0", result("2"))]); + expect(handler).toHaveBeenCalledTimes(2); // Concurrent retries of one invocation execute once. + expect(deliveries.at(-1)!.id).not.toBe(stale.id); + h.handlers.get("turn_end")!(); h.handlers.get("before_agent_start")!({ systemPrompt: "warm prompt" }); h.handlers.get("turn_start")!(); + await contextTool.execute("call_0", {}); + expect(handler).toHaveBeenCalledTimes(3); + expect(new Set(handler.mock.calls.map(([call]) => (call as any).callId)).size).toBe(3); + } finally { await bridge.close(); } + }); + + it("poisons swallowed iteration errors and rejects ambiguous native invocations before a second tool effect", async () => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); + const select = vi.fn().mockResolvedValue("Allow once"); + const context = { cwd: config.workspace, ui: { select } }; + h.handlers.get("turn_start")!(); + const tool = { toolName: "bash", toolCallId: "call_0", input: { command: "pwd" } }; + expect(await h.handlers.get("tool_call")!(tool, context)).toBeUndefined(); + expect(await h.handlers.get("tool_call")!(tool, context)).toMatchObject({ block: true }); + expect(select).toHaveBeenCalledTimes(1); + expect(() => h.handlers.get("turn_start")!()).toThrow(/identity|iteration/i); + expect(await h.handlers.get("tool_call")!({ ...tool, toolCallId: "new" }, context)).toMatchObject({ block: true }); + expect(select).toHaveBeenCalledTimes(1); + }); + + it("preserves bounded MCP validation errors as failures and removes bound authentication", async () => { + const { config } = await workspace(); const h = harness(); + const authorization = "Bearer assigned-mcp-token-123456789"; + config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [{ name: "Authorization", value: authorization }] }]; + const request = vi.fn(async (_server, method) => method === "tools/list" + ? { tools: [{ name: "paperclip_finish", inputSchema: { type: "object" } }] } + : method === "tools/call" ? { isError: true, content: [{ type: "text", text: `completionClaim.contractRevision must equal 1; verification.items[0].status is invalid; Authorization: ${authorization}; token=other-private-value; \"password\":\"another-secret\"` }], structuredContent: { code: "INVALID_VERIFICATION", field: "verification.items[0].status" } } : {}); + await installPiRuntimeExtension(h.api, config, request); h.handlers.get("turn_start")!(); + const error = await h.tools[0]!.execute("original-call", {}).catch(value => value); + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("completionClaim.contractRevision must equal 1"); + expect(error.message).toContain("verification.items[0].status is invalid"); + expect(error.message).not.toContain("assigned-mcp-token-123456789"); + expect(error.message).not.toContain("other-private-value"); + expect(error.message).not.toContain("another-secret"); + expect(error.message).toContain("INVALID_VERIFICATION"); + expect(error.message).toContain("[REDACTED]"); + }); + + it.each([ + { isError: true, content: [{ type: "text", text: "x".repeat(9000) }] }, + { isError: true, content: [{ type: "text", text: { unsafe: "not text" } }] }, + { isError: true, content: [{ type: "image", data: "not an error description", mimeType: "image/png" }] }, + { isError: true, content: null }, + { isError: "true", content: [{ type: "text", text: "malformed error flag" }] }, + ])("fails closed for oversized or malformed MCP error content %#", async result => { + const { config } = await workspace(); const h = harness(); + config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }]; + const request = vi.fn(async (_server, method) => method === "tools/list" + ? { tools: [{ name: "paperclip_finish", inputSchema: { type: "object" } }] } + : method === "tools/call" ? result : {}); + await installPiRuntimeExtension(h.api, config, request); h.handlers.get("turn_start")!(); + const error = await h.tools[0]!.execute("call", {}).catch(value => value); + expect(error).toBeInstanceOf(Error); + expect(Buffer.byteLength(error.message)).toBeLessThanOrEqual(8192); + expect(error.message).not.toContain("not an error description"); + }); + + it("rejects incomplete catalogs and tool-result errors without exposing auth", async () => { + const { config } = await workspace(); const h = harness(); + config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }]; + const request = vi.fn(async (_server, method) => method === "tools/list" ? { tools: [], nextCursor: "more" } : {}); + await expect(installPiRuntimeExtension(h.api, config, request)).rejects.toThrow("incomplete"); + }); + + it("does not follow MCP redirects or forward credentials to another origin", async () => { + const server = createServer((_req, res) => { res.writeHead(302, { Location: "http://127.0.0.1:1/steal" }); res.end(); }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + try { + await expect(piMcpRequest({ type: "http", name: "paperclip", url: `http://127.0.0.1:${address.port}`, headers: [{ name: "Authorization", value: "Bearer secret" }] }, "tools/list", {}, "1")).rejects.toThrow(); + } finally { server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); } + }); + + it("validates response IDs and bounds tool output", async () => { + const server = { type: "http" as const, name: "paperclip", url: "http://127.0.0.1:1", headers: [] }; + const fetch_ = vi.fn(async () => new Response(JSON.stringify({ jsonrpc: "2.0", id: "other", result: {} }))); + await expect(piMcpRequest(server, "tools/list", {}, "1", undefined, fetch_)).rejects.toThrow("identity"); + fetch_.mockImplementation(async () => new Response("x".repeat(4 * 1024 * 1024 + 1))); + await expect(piMcpRequest(server, "tools/list", {}, "1", undefined, fetch_)).rejects.toThrow("oversized"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts new file mode 100644 index 0000000000..45ce3866cb --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts @@ -0,0 +1,467 @@ +/** + * The only extension admitted to the qualified Pi process. The launcher loads + * this immutable module explicitly; project/global extension discovery is off. + * Structural types keep this module independent of Pi's optional UI packages. + */ +import { createHash } from "node:crypto"; +import { PI_QUESTION_LABEL_MAX_LENGTH, piQuestionLabel, PiToolIdentities } from "./pi-acp-runtime.js"; +import { lstat, realpath } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const PI_PERMISSION_TITLE_PREFIX = "paperclip.pi.permission.v1:"; +export const PI_PERMISSION_OPTIONS = ["Allow once", "Allow for this session", "Deny"] as const; +export const PI_NATIVE_QUESTION_TOOL = "paperclip_native_question"; +const MAX_CONFIGURATION_BYTES = 64 * 1024; +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const MAX_TOOLS = 512; +// Match the sidecar safeText error bound; the extension is an immutable standalone module. +const MAX_ERROR_BYTES = 8_192; + +interface PiUi { + select(title: string, options: string[], options_?: { signal?: AbortSignal }): Promise; + confirm?(title: string, message: string, options?: { signal?: AbortSignal }): Promise; + input?(title: string, placeholder?: string, options?: { signal?: AbortSignal }): Promise; + editor?(title: string, prefill?: string): Promise; +} + +export interface PiExtensionContext { cwd: string; ui: PiUi } +export interface PiToolEvent { + toolName: string; + toolCallId: string; + input: Record; +} + +export interface PiToolDefinition { + name: string; + label: string; + description: string; + parameters: Record; + execute( + toolCallId: string, + arguments_: Record, + signal?: AbortSignal, + onUpdate?: unknown, + context?: PiExtensionContext, + ): Promise<{ content: Array>; details?: unknown }>; +} + +export interface PiExtensionApi { + registerTool(tool: PiToolDefinition): void; + registerCommand(name: string, command: { description: string; handler(): Promise }): void; + on(event: "turn_start" | "turn_end", handler: () => void): void; + on(event: "tool_call", handler: ( + event: PiToolEvent, context: PiExtensionContext, + ) => Promise<{ block: true; reason: string } | undefined>): void; + on(event: "user_bash", handler: () => { result: { output: string; exitCode: number; cancelled: boolean; truncated: boolean } }): void; + on(event: "cache_warming_decision", handler: () => { action: "stop" }): void; + on(event: "project_trust", handler: () => { trusted: "no"; remember: false }): void; + on(event: "before_agent_start", handler: (event: { systemPrompt: string }) => { systemPrompt: string } | undefined): void; +} + +export interface PiMcpServer { + type: "http"; + name: string; + url: string; + headers: Array<{ name: string; value: string }>; +} + +export interface PiRuntimeConfiguration { + invocationNamespace: string; + servers: PiMcpServer[]; + workspace: string; + /** Canonical server-registered agent_files working copy, never ambient HOME. */ + agentHome?: string; + readOnly: boolean; + readRoots: string[]; + protectedRoots: string[]; + instructions: string; +} + +/** This environment is synthesized by the verified wrapper, never the model. */ +export function readPiRuntimeConfiguration(environment: NodeJS.ProcessEnv): PiRuntimeConfiguration { + const raw = environment.PAPERCLIP_PI_RUNTIME_CONFIGURATION; + if (!raw || Buffer.byteLength(raw) > MAX_CONFIGURATION_BYTES) throw new Error("Pi runtime configuration is missing or oversized"); + const value = asRecord(JSON.parse(raw)); + const invocationNamespace = value.invocationNamespace; + if (typeof invocationNamespace !== "string" || !/^[a-f0-9-]{36}$/.test(invocationNamespace)) throw new Error("Pi invocation namespace is invalid"); + const workspace = absolutePath(value.workspace, "workspace"); + if (typeof value.readOnly !== "boolean") throw new Error("Pi read-only policy is missing"); + if (!Array.isArray(value.servers) || value.servers.length > 16) throw new Error("Pi MCP configuration is invalid"); + const names = new Set(); + const servers = value.servers.map((entry): PiMcpServer => { + const server = asRecord(entry); + if (server.type !== "http" || typeof server.name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(server.name) || names.has(server.name)) { + throw new Error("Pi MCP server identity is invalid"); + } + names.add(server.name); + if (typeof server.url !== "string") throw new Error("Pi MCP endpoint is invalid"); + const url = new URL(server.url); + // Only session-bound gateways reach this configuration. Match the host's + // assigned transport policy: HTTPS remotely, cleartext only on loopback. + const cleartextLoopback = url.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(url.hostname); + if ((url.protocol !== "https:" && !cleartextLoopback) || url.username || url.password || url.hash) { + throw new Error("Pi assigned MCP endpoint requires HTTPS or numeric loopback HTTP"); + } + if (!Array.isArray(server.headers) || server.headers.length !== 1) throw new Error("Pi MCP authentication is invalid"); + const header = asRecord(server.headers[0]); + if (header.name !== "Authorization" || typeof header.value !== "string" || !/^Bearer [A-Za-z0-9._~-]{16,4096}$/.test(header.value)) { + throw new Error("Pi MCP authentication is invalid"); + } + return { type: "http", name: server.name, url: url.href, headers: [{ name: "Authorization", value: header.value }] }; + }); + if (typeof value.instructions !== "string" || Buffer.byteLength(value.instructions) > 32 * 1024) throw new Error("Pi runtime instructions are invalid"); + return { + invocationNamespace, servers, workspace, readOnly: value.readOnly, + ...(value.agentHome === undefined ? {} : { agentHome: absolutePath(value.agentHome, "agent home") }), + readRoots: pathList(value.readRoots, "read roots"), + protectedRoots: pathList(value.protectedRoots, "protected roots"), + instructions: value.instructions, + }; +} + +function pathList(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.length > 128) throw new Error(`Pi ${label} are invalid`); + return value.map((path) => absolutePath(path, label)); +} + +function absolutePath(value: unknown, label: string): string { + if (typeof value !== "string" || !isAbsolute(value) || /[\0\r\n]/.test(value) || value === "/") throw new Error(`Pi ${label} path is invalid`); + return resolve(value); +} + +function asRecord(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Pi runtime record is invalid"); + return value as Record; +} + +function inside(root: string, target: string): boolean { + const suffix = relative(root, target); + return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); +} + +/** Resolve every existing ancestor, including symlinks, before creating files. */ +async function physicalPath(path: string): Promise { + try { return await realpath(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(path) === path) throw error; + return resolve(await physicalPath(dirname(path)), relative(dirname(path), path)); + } +} + +/** Match the pinned Pi 1.0.0 tools/path-utils.js before authorizing a path. + * Read may select any of its filename fallbacks; validate all of them so an + * alternate spelling cannot select an unchecked symlink after approval. + */ +export function piNativeToolPaths(value: string, workspace: string, read: boolean): string[] { + let normalized = value.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " "); + if (normalized.startsWith("@")) normalized = normalized.slice(1); + if (normalized === "~") normalized = homedir(); + else if (normalized.startsWith("~/")) normalized = join(homedir(), normalized.slice(2)); + else if (normalized.startsWith("file://")) normalized = fileURLToPath(normalized); + if (/[\0\r\n]/.test(normalized)) throw new Error("Pi tool path is invalid"); + const path = resolve(workspace, normalized); + if (!read) return [path]; + const nfd = path.normalize("NFD"); + return [...new Set([path, path.replace(/ (AM|PM)\./gi, "\u202F$1."), nfd, path.replace(/'/g, "\u2019"), nfd.replace(/'/g, "\u2019")])]; +} + +export async function checkPiNativeTool( + event: PiToolEvent, context: PiExtensionContext, config: PiRuntimeConfiguration, +): Promise { + if (await realpath(context.cwd) !== await realpath(config.workspace)) return "Pi workspace changed after admission"; + if (config.agentHome && await realpath(config.agentHome) !== config.agentHome) return "Pi agent files changed after admission"; + const readTools = new Set(["read", "grep", "find", "ls"]); + const fileTools = new Set([...readTools, "edit", "write"]); + if (!fileTools.has(event.toolName) && event.toolName !== "bash") return "Unregistered Pi tool is not admitted"; + if (config.readOnly && !readTools.has(event.toolName)) return "This execution permits reading only"; + // Shell syntax is not a filesystem authorization language. The host sandbox + // remains authoritative for commands; every command still crosses ACP policy. + if (event.toolName === "bash") return null; + const pathValue = event.input.path ?? (readTools.has(event.toolName) ? "." : undefined); + if (typeof pathValue !== "string" || /[\0\r\n]/.test(pathValue)) return "Pi tool path is invalid"; + for (const logical of piNativeToolPaths(pathValue, config.workspace, event.toolName === "read")) { + const target = await physicalPath(logical); + if (!readTools.has(event.toolName)) { + for (const root of config.readRoots) { + if (inside(root, logical) || inside(await realpath(root), target)) return "Assigned Pi skills are read-only"; + } + } + for (const root of config.protectedRoots) { + if (inside(root, logical) || inside(await physicalPath(root), target)) return "Pi tool targets protected runtime state"; + } + const writableRoots = [config.workspace, ...(config.agentHome ? [config.agentHome] : [])]; + const roots = readTools.has(event.toolName) ? [...writableRoots, ...config.readRoots] : writableRoots; + let admitted = false; + for (const root of roots) { + if (inside(root, logical) && inside(await realpath(root), target)) { admitted = true; break; } + } + if (!admitted) return "Pi tool path is outside its assigned workspace and agent files"; + } + return null; +} + +async function askPiNativeQuestion(args: Record, ui: PiUi, signal?: AbortSignal): Promise> { + const boundedText = (value: unknown, limit = 16_384): string => { + if (typeof value !== "string" || Buffer.byteLength(value) > limit) throw new Error("Pi question text is invalid or oversized"); + return value; + }; + const title = piQuestionLabel(args.title); + if (!title.trim() || title.startsWith(PI_PERMISSION_TITLE_PREFIX)) throw new Error("Pi question title is invalid"); + const method = args.method; + const permitted = ["method", "title", ...(method === "select" ? ["options"] : method === "confirm" ? ["message"] : method === "input" ? ["placeholder"] : method === "editor" ? ["prefill"] : [])]; + if (Object.keys(args).some(key => !permitted.includes(key))) throw new Error("Pi question has unsupported fields"); + if (signal?.aborted) return { status: "cancelled" }; + if (method === "select") { + if (!Array.isArray(args.options) || args.options.length < 1 || args.options.length > 128) throw new Error("Pi question options are invalid"); + const options = args.options.map(value => { + const option = asRecord(value); const id = boundedText(option.id, 128); const label = piQuestionLabel(option.label); + if (!/^[A-Za-z0-9_-]+$/.test(id) || !label.trim() || Object.keys(option).some(key => !["id", "label"].includes(key))) throw new Error("Pi question option is invalid"); + return { id, label }; + }); + if (new Set(options.map(option => option.id)).size !== options.length || new Set(options.map(option => option.label)).size !== options.length) throw new Error("Pi question options are ambiguous"); + const answer = await ui.select(title, options.map(option => option.label), { signal }); + if (signal?.aborted || answer === undefined) return { status: "cancelled" }; + const selected = options.find(option => option.label === answer); + if (!selected) throw new Error("Pi question returned an unoffered option"); + return { status: "answered", optionId: selected.id }; + } + if (method === "confirm") { + if (!ui.confirm) throw new Error("Pi native confirmation is unavailable"); + const answer = await ui.confirm(title, boundedText(args.message), { signal }); + if (signal?.aborted) return { status: "cancelled" }; + if (typeof answer !== "boolean") throw new Error("Pi confirmation response is invalid"); + // Native Pi returns false for both No and dismissal. Never invent an answer. + return { status: answer ? "answered" : "negative_or_cancelled", confirmed: answer }; + } + if (method !== "input" && method !== "editor") throw new Error("Pi question method is unsupported"); + if (method === "input" && !ui.input || method === "editor" && !ui.editor) throw new Error("Pi native text question is unavailable"); + const value = method === "input" + ? await ui.input!(title, args.placeholder === undefined ? undefined : boundedText(args.placeholder), { signal }) + : await ui.editor!(title, args.prefill === undefined ? undefined : boundedText(args.prefill)); + return signal?.aborted || value === undefined ? { status: "cancelled" } : { status: "answered", value: boundedText(value, 65_536) }; +} + +/** Keep authenticated tool validation useful without exposing transport credentials. */ +function piMcpToolError(result: Record, config: PiRuntimeConfiguration): Error { + const fallback = "Paperclip tool request was rejected"; + if (!Array.isArray(result.content) || result.content.length > MAX_TOOLS) return new Error(`${fallback}: invalid error content`); + const parts: string[] = []; + for (const item of result.content) { + if (!item || typeof item !== "object" || Array.isArray(item)) return new Error(`${fallback}: invalid error content`); + const block = item as Record; + if (block.type !== "text" || typeof block.text !== "string") return new Error(`${fallback}: invalid error content`); + parts.push(block.text); + } + if (result.structuredContent !== undefined) { + if (!result.structuredContent || typeof result.structuredContent !== "object" || Array.isArray(result.structuredContent)) return new Error(`${fallback}: invalid error details`); + parts.push(JSON.stringify(result.structuredContent)); + } + let message = `${fallback}: ${parts.join("\n").trim() || "no validation details"}`; + if (Buffer.byteLength(message) > MAX_ERROR_BYTES) return new Error(`${fallback}: error content exceeds ${MAX_ERROR_BYTES} bytes`); + const boundSecrets = [process.env.OPENROUTER_API_KEY, ...config.servers.flatMap(server => server.headers.flatMap(header => [header.value, header.value.replace(/^Bearer /, "")]))]; + for (const secret of boundSecrets) if (secret) message = message.split(secret).join("[REDACTED]"); + // Use the same labelled-secret rule as sidecar safeText, also covering JSON + // quoted keys and bearer values that can occur in gateway error text. + message = message.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [REDACTED]") + .replace(/(key|token|secret|password|authorization)["']?\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,}\]]+)/gi, "$1=[REDACTED]"); + return new Error(Buffer.byteLength(message) <= MAX_ERROR_BYTES ? message : `${fallback}: redacted error content exceeds ${MAX_ERROR_BYTES} bytes`); +} + +/** Bounded JSON MCP client for runner-owned HTTP bridges. No redirects or files. */ +export async function piMcpRequest( + server: PiMcpServer, + method: string, + params: Record, + id: string, + signal?: AbortSignal, + fetch_: typeof fetch = fetch, +): Promise> { + const request = JSON.stringify({ jsonrpc: "2.0", id, method, params }); + if (Buffer.byteLength(request) > 1_048_576) throw new Error("Pi MCP request is oversized"); + const timeout = AbortSignal.timeout(method === "tools/call" ? 24 * 60 * 60 * 1000 : 30_000); + const response = await fetch_(server.url, { + method: "POST", redirect: "error", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...Object.fromEntries(server.headers.map((h) => [h.name, h.value])) }, + body: request, signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + if (!response.ok || !response.body) throw new Error(`Pi MCP ${method} failed (HTTP ${response.status})`); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + length += item.value.byteLength; + if (length > MAX_RESPONSE_BYTES) throw new Error("Pi MCP response is oversized"); + chunks.push(item.value); + } + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } + const message = asRecord(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + if (message.jsonrpc !== "2.0" || message.id !== id) throw new Error("Pi MCP response identity mismatch"); + if (message.error) throw new Error(`Pi MCP ${method} was rejected`); + return asRecord(message.result); +} + +export async function installPiRuntimeExtension( + pi: PiExtensionApi, + config: PiRuntimeConfiguration, + request: typeof piMcpRequest = piMcpRequest, +): Promise { + // Registered names are held in this closure; provider-originated metadata + // cannot promote a tool to the authenticated Paperclip bridge. + const identities = new PiToolIdentities(config.invocationNamespace); + pi.on("turn_start", () => identities.begin()); + pi.on("turn_end", () => identities.end()); + const bridgeTools = new Set(); + const permissionGrants = new Set(); + const agentHomeIdentity = config.agentHome ? await lstat(config.agentHome, { bigint: true }) : undefined; + if (config.agentHome) { + if (!agentHomeIdentity!.isDirectory() || await realpath(config.agentHome) !== config.agentHome) throw new Error("Pi agent files are not a canonical registered directory"); + for (const root of config.protectedRoots) { + const physical = await physicalPath(root); + if (inside(config.agentHome, physical) || inside(physical, config.agentHome)) throw new Error("Pi agent files overlap protected runtime state"); + } + } + const checkPolicy = async (event: PiToolEvent, context: PiExtensionContext): Promise => { + if (config.agentHome && agentHomeIdentity) { + const named = await lstat(config.agentHome, { bigint: true }); + if (!named.isDirectory() || named.dev !== agentHomeIdentity.dev || named.ino !== agentHomeIdentity.ino) return "Pi agent files were replaced after admission"; + } + return checkPiNativeTool(event, context, config); + }; + // Assigned skills are a separate immutable lease. A config or executable + // directory must never gain readability by being presented as a skill root. + for (const root of config.readRoots) { + const physicalRoot = await realpath(root); + for (const protectedRoot of config.protectedRoots) { + const physicalProtected = await physicalPath(protectedRoot); + if (inside(protectedRoot, root) || inside(root, protectedRoot) || inside(physicalProtected, physicalRoot) || inside(physicalRoot, physicalProtected)) { + throw new Error("Pi assigned skills overlap protected runtime state"); + } + } + } + // Pi 1 defaults to paid streaming cache refreshes. Runner admits only explicit + // turns; background inference must not outlive or bypass that cost authority. + pi.on("cache_warming_decision", () => ({ action: "stop" })); + pi.on("project_trust", () => ({ trusted: "no", remember: false })); + pi.on("user_bash", () => ({ result: { output: "Interactive shell commands are disabled in Paperclip Runner", exitCode: 1, cancelled: false, truncated: false } })); + pi.on("before_agent_start", (event) => config.instructions + ? { systemPrompt: `${event.systemPrompt}\n\n${config.instructions}` } : undefined); + pi.on("tool_call", async (event, context) => { + try { + const toolCallId = identities.bind(event.toolCallId, event.toolName, event.input, true); + if (bridgeTools.has(event.toolName) || event.toolName === PI_NATIVE_QUESTION_TOOL) return; + const denial = await checkPolicy(event, context); + if (denial) return { block: true, reason: denial }; + const detail = JSON.stringify({ toolCallId, ...identities.provenance(toolCallId), toolName: event.toolName, input: event.input }); + if (Buffer.byteLength(detail) > 48 * 1024) return { block: true, reason: "Pi permission request is oversized" }; + // A session grant covers only the identical operation, never a whole + // tool class or a subsequent path. Paths are still revalidated each time. + const grantKey = JSON.stringify([event.toolName, event.input]); + const selection = permissionGrants.has(grantKey) ? "Allow for this session" + : await context.ui.select(`${PI_PERMISSION_TITLE_PREFIX}${detail}`, [...PI_PERMISSION_OPTIONS]); + if (selection !== "Allow once" && selection !== "Allow for this session") return { block: true, reason: "Pi operation was denied or cancelled" }; + // Re-check file bindings after a human wait; approval never freezes paths. + const changed = await checkPolicy(event, context); + if (!changed && selection === "Allow for this session" && permissionGrants.size < 4096) permissionGrants.add(grantKey); + return changed ? { block: true, reason: changed } : undefined; + } catch { return { block: true, reason: "Pi permission boundary is unavailable" }; } + }); + const questions = new Map>; details: unknown }>>(); + 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 }, + 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 }, + }, 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; + if (!context?.ui || questions.size >= 4096) throw new Error("Pi native question context is unavailable or exhausted"); + const pending = askPiNativeQuestion(args, context.ui, signal).then(result => ({ content: [{ type: "text", text: JSON.stringify(result) }], details: result })); + questions.set(id, pending); return pending; + }, + }); + let count = 0; + for (const server of config.servers) { + await request(server, "initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "paperclip-pi", version: "1" } }, `pi-init-${server.name}`); + const catalog = await request(server, "tools/list", {}, `pi-tools-${server.name}`); + if (!Array.isArray(catalog.tools) || catalog.nextCursor) throw new Error("Pi MCP catalog is incomplete"); + for (const rawTool of catalog.tools) { + const tool = asRecord(rawTool); + if (typeof tool.name !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(tool.name)) throw new Error("Pi MCP tool name is invalid"); + const readableName = `mcp__${server.name}__${tool.name}`; + // MCP names may contain colons/periods and exceed model tool-name limits. + // Preserve the exact source identity only in the authenticated call closure; + // a framed digest avoids lossy replacement collisions (a:b versus a_b). + const nativeName = /^[A-Za-z0-9_-]{1,64}$/.test(readableName) ? readableName + : `mcp__${createHash("sha256").update(JSON.stringify([server.name, tool.name])).digest("hex").slice(0, 59)}`; + if (bridgeTools.has(nativeName) || ++count > MAX_TOOLS) throw new Error("Pi MCP catalog is ambiguous or oversized"); + const schema = asRecord(tool.inputSchema); + if (schema.type !== "object") throw new Error("Pi MCP tool parameters must be an object"); + const sourceName = tool.name; + const definition: PiToolDefinition = { + name: nativeName, label: `${server.name}: ${sourceName}`, + description: typeof tool.description === "string" ? tool.description : sourceName, + parameters: structuredClone(schema), + async execute(callId, arguments_, signal) { + const requestId = identities.bind(callId, nativeName, arguments_); + const result = await request(server, "tools/call", { name: sourceName, arguments: arguments_ }, requestId, signal); + if (result.isError !== undefined && typeof result.isError !== "boolean") throw new Error("Pi MCP tool error flag is invalid"); + if (result.isError === true) throw piMcpToolError(result, config); + if (!Array.isArray(result.content)) throw new Error("Pi MCP tool result is invalid"); + const content = result.content.map((item) => { + const block = asRecord(item); + if (block.type === "text" && typeof block.text === "string") return { type: "text", text: block.text }; + if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") return { type: "image", data: block.data, mimeType: block.mimeType }; + // Resource links and structured outputs stay visible as data. They + // never cause the extension to fetch a URL or open a returned path. + return { type: "text", text: JSON.stringify(block) }; + }); + return { content, ...(result.structuredContent === undefined ? {} : { details: result.structuredContent }) }; + }, + }; + pi.registerTool(definition); + bridgeTools.add(nativeName); + } + } + // Pi reports extension-load failures and may continue. The wrapper requires + // this exact sentinel before admitting prompts, so a failed extension cannot + // silently leave native tools unguarded. Register only after every MCP binds. + pi.registerCommand("paperclip-runtime-ready-v1", { + description: "Paperclip runtime gate v1", + async handler() {}, + }); +} + +export default async function paperclipPiExtension(pi: PiExtensionApi): Promise { + const configuration = readPiRuntimeConfiguration(process.env); + // Do not let the model's shell tool inherit authenticated MCP credentials. + delete process.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION; + await installPiRuntimeExtension(pi, configuration); +} 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/pi-verified-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts new file mode 100644 index 0000000000..6a08c7a3f9 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts @@ -0,0 +1,226 @@ +import { chmod, link, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, symlink, writeFile, type FileHandle } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { inventoryPiRuntimeFiles, PI_RUNTIME_MANIFEST_SCHEMA, verifyPiRuntimeManifest, verifyPiRuntimeLayoutForNativeSnapshot, type PiRuntimeManifest } from "./pi-verified-runtime.js"; + +import { verifyNativeAcpxInstallation } from "./installation-integrity.js"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, open: vi.fn(original.open), lstat: vi.fn(original.lstat), readdir: vi.fn(original.readdir) }; +}); + +const temporary: string[] = []; +afterEach(async () => { vi.restoreAllMocks(); for (const root of temporary.splice(0)) await rm(root, { recursive: true, force: true }); }); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-verified-")); temporary.push(root); + await mkdir(join(root, "assets")); + for (const name of ["node", "pi.js", "extension.js", "wrapper.js", "assets/native.node", "assets/image.wasm"]) await writeFile(join(root, name), `pinned:${name}`); + const manifest: PiRuntimeManifest = { schema: PI_RUNTIME_MANIFEST_SCHEMA, node: "node", piEntrypoint: "pi.js", extension: "extension.js", wrapperEntrypoint: "wrapper.js", files: await inventoryPiRuntimeFiles(root) }; + return { root, manifest }; +} + +describe("Pi complete runtime manifest", () => { + it("binds interpreter, Pi, extension, wrapper, native modules and resources", async () => { + const { root, manifest } = await fixture(); + const result = await verifyPiRuntimeManifest(root, manifest); + expect(result.environment.PAPERCLIP_PI_EXTENSION_PATH).toBe(await realpath(join(root, "extension.js"))); + expect(result.manifestDigest).toMatch(/^sha256:[a-f0-9]{64}$/); + await writeFile(join(root, "assets/image.wasm"), "replacement"); + await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph"); + }); + + it("keeps exact traversal order and all file digests across concurrent hash batches", async () => { + const { root } = await fixture(); + await mkdir(join(root, "a")); + await writeFile(join(root, "a/large"), Buffer.alloc(2 * 1024 * 1024, 7)); + await writeFile(join(root, "a-after"), "after directory"); + const expected = new Map(); + for (let index = 0; index < 33; index++) { + const path = `batch-${String(index).padStart(2, "0")}`; + const bytes = Buffer.alloc(index % 3 === 0 ? 512 * 1024 : index + 1, index); + await writeFile(join(root, path), bytes); + expected.set(path, `sha256:${createHash("sha256").update(bytes).digest("hex")}`); + } + const first = await inventoryPiRuntimeFiles(root); + expect(first.slice(0, 2).map(entry => entry.path)).toEqual(["a/large", "a-after"]); + for (const [path, digest] of expected) expect(first.find(entry => entry.path === path)?.sha256).toBe(digest); + expect(await inventoryPiRuntimeFiles(root)).toEqual(first); + const manifest: PiRuntimeManifest = { schema: PI_RUNTIME_MANIFEST_SCHEMA, node: "node", piEntrypoint: "pi.js", extension: "extension.js", wrapperEntrypoint: "wrapper.js", files: first }; + await writeFile(join(root, "batch-32"), "changed"); + await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph"); + }); + + it("bounds live hash descriptors and drains a failed batch before rejecting", async () => { + const { root } = await fixture(); + for (let index = 0; index < 40; index++) await writeFile(join(root, `batch-${index}`), "checked bytes"); + const probe = await open(join(root, "node"), "r"); const prototype = Object.getPrototypeOf(probe) as FileHandle; await probe.close(); + const originalStream = prototype.createReadStream; + let release!: () => void; const hold = new Promise(resolve => { release = resolve; }); + let started = 0; let active = 0; let peak = 0; let settled = false; + vi.spyOn(prototype, "createReadStream").mockImplementation(function (this: FileHandle, options): any { + const file = this; const index = started++; const originalClose = file.close.bind(file); let closed = false; + active++; peak = Math.max(peak, active); + file.close = async () => { try { await originalClose(); } finally { if (!closed) { closed = true; active--; } } }; + return (async function* () { + if (index === 0) throw new Error("fixture hash read failure"); + await hold; yield* originalStream.call(file, options); + })(); + }); + const inventory = inventoryPiRuntimeFiles(root); + void inventory.then(() => { settled = true; }, () => { settled = true; }); + try { + await vi.waitFor(() => expect(started).toBe(32)); + expect(settled).toBe(false); expect(peak).toBeLessThanOrEqual(32); + } finally { release(); } + await expect(inventory).rejects.toThrow("fixture hash read failure"); + expect(active).toBe(0); expect(started).toBe(32); + }); + + it("bounds discovery and drains a failed batch before descent or later scheduling", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-discovery-")); temporary.push(root); + for (let index = 0; index < 40; index++) await writeFile(join(root, `file-${String(index).padStart(2, "0")}`), "data"); + const original = await vi.importActual("node:fs/promises"); + let release!: () => void; const hold = new Promise(resolve => { release = resolve; }); + let active = 0; let peak = 0; let started = 0; let settled = false; + vi.mocked(lstat).mockImplementation((async (path: any, options: any) => { + if (String(path) === root) return original.lstat(path, options); + const index = started++; active++; peak = Math.max(peak, active); + try { + if (index === 0) throw new Error("fixture discovery failure"); + await hold; return await original.lstat(path, options); + } finally { active--; } + }) as typeof lstat); + const inventory = inventoryPiRuntimeFiles(root); + void inventory.then(() => { settled = true; }, () => { settled = true; }); + try { + await vi.waitFor(() => expect(started).toBe(32)); + expect(peak).toBeLessThanOrEqual(32); expect(peak).toBeGreaterThan(1); expect(settled).toBe(false); + } finally { release(); } + try { + await expect(inventory).rejects.toThrow("fixture discovery failure"); + expect(active).toBe(0); expect(started).toBe(32); + } finally { vi.mocked(lstat).mockImplementation(original.lstat); } + }); + + it("rechecks queued directories after earlier subtrees and never descends into a replacement symlink", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-discovery-race-"))); temporary.push(root); + const outside = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-outside-"))); temporary.push(outside); + await mkdir(join(root, "a")); await mkdir(join(root, "b")); + await writeFile(join(root, "a/trigger"), "inside"); await writeFile(join(outside, "private"), "must not read"); + const original = await vi.importActual("node:fs/promises"); + let replaced = false; + const readsStart = vi.mocked(readdir).mock.calls.length; + vi.mocked(lstat).mockImplementation((async (path: any, options: any) => { + if (String(path) === join(root, "a/trigger") && !replaced) { + replaced = true; + await rename(join(root, "b"), join(root, "b-retired")); + await symlink(outside, join(root, "b")); + } + return original.lstat(path, options); + }) as typeof lstat); + try { + await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("directory changed before traversal"); + expect(replaced).toBe(true); + const visited = vi.mocked(readdir).mock.calls.slice(readsStart).map(([path]) => String(path)); + expect(visited).not.toContain(join(root, "b")); expect(visited).not.toContain(outside); + } finally { vi.mocked(lstat).mockImplementation(original.lstat); } + }); + + it("refuses unrecorded resources and duplicate manifest entries", async () => { + const { root, manifest } = await fixture(); + await writeFile(join(root, "extra.js"), "untrusted"); + await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph"); + manifest.files.push(manifest.files[0]!); + await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("entry"); + }); + + it("allows only in-pack relative links and rejects hardlinked mutable files", async () => { + const { root, manifest } = await fixture(); + await symlink("assets", join(root, "resources")); + manifest.files = await inventoryPiRuntimeFiles(root); + await expect(verifyPiRuntimeManifest(root, manifest)).resolves.toHaveProperty("manifestDigest"); + await symlink(tmpdir(), join(root, "escape")); + await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("escapes"); + await rm(join(root, "escape")); + await link(join(root, "pi.js"), join(root, "hardlink.js")); + await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("writable name"); + }); +}); + +async function nativeFixture() { + const { root, manifest } = await fixture(); + await chmod(join(root, "node"), 0o500); + const entries = await Promise.all(manifest.files.map(async entry => ({ + path: entry.path, sha256: entry.sha256.slice(7), size: (await lstat(join(root, entry.path))).size, + executable: entry.path === "node", + }))); + entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + const closure = createHash("sha256").update(JSON.stringify(entries)).digest("hex"); + const manifestPath = root + "-native.json"; temporary.push(manifestPath); + await writeFile(manifestPath, JSON.stringify({ entries })); + const input = { distributionRoot: root, manifestPath, expectedClosureSha256: closure, executable: "node", entrypoint: "wrapper.js", fixedArguments: [] }; + return { root, manifest, entries, closure, input }; +} + +describe("Pi native launch layout admission", () => { + it("cross-binds declarations without opening content, then freshly hashes every command snapshot", async () => { + const f = await nativeFixture(); + const expected = await verifyPiRuntimeManifest(f.root, f.manifest); + vi.mocked(open).mockClear(); + expect(await verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).toEqual({ manifestDigest: expected.manifestDigest }); + expect(open).not.toHaveBeenCalled(); + const installation = await verifyNativeAcpxInstallation(f.input); + const lease = await installation.openCommand(); await lease.close(); + const opened = vi.mocked(open).mock.calls.map(([path]) => String(path)); + for (const entry of f.entries) expect(opened).toContain(join(await realpath(f.root), entry.path)); + // No cached installation verdict may authorize a later mutable source. + await writeFile(join(f.root, "wrapper.js"), "x".repeat(f.entries.find(e => e.path === "wrapper.js")!.size)); + await expect(installation.openCommand()).rejects.toThrow("digest mismatch"); + }); + + it("rejects a substituted native pin and any Pi/native declaration disagreement", async () => { + const f = await nativeFixture(); + await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, "0".repeat(64))).rejects.toThrow("manifest digest mismatch"); + const altered = structuredClone(f.manifest); altered.files[0]!.sha256 = `sha256:${"0".repeat(64)}`; + await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, altered, { entries: f.entries }, f.closure)).rejects.toThrow("pinned native closure"); + altered.files.shift(); + await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, altered, { entries: f.entries }, f.closure)).rejects.toThrow("pinned native closure"); + }); + + it("rejects extra or missing files during complete structural discovery", async () => { + const f = await nativeFixture(); + await writeFile(join(f.root, "extra"), "not admitted"); + await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).rejects.toThrow("package graph"); + await rm(join(f.root, "extra")); await rm(join(f.root, "assets/image.wasm")); + await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).rejects.toThrow("package graph"); + }); + + it("rejects links even when their names appear in the pinned closure", async () => { + const f = await nativeFixture(); + await rm(join(f.root, "pi.js")); await symlink("wrapper.js", join(f.root, "pi.js")); + await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).rejects.toThrow("package graph"); + await rm(join(f.root, "pi.js")); await link(join(f.root, "wrapper.js"), join(f.root, "pi.js")); + await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).rejects.toThrow("writable name"); + }); + + it("never treats layout success as byte admission for same-size corruption", async () => { + const f = await nativeFixture(); + await writeFile(join(f.root, "wrapper.js"), "x".repeat(f.entries.find(e => e.path === "wrapper.js")!.size)); + await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).resolves.toHaveProperty("manifestDigest"); + // The independent generic verifier retains its original full-byte guarantee. + await expect(verifyPiRuntimeManifest(f.root, f.manifest)).rejects.toThrow("package graph"); + const installation = await verifyNativeAcpxInstallation(f.input); + await expect(installation.openCommand()).rejects.toThrow("digest mismatch"); + }); + + it("rechecks source identities after layout, before granting any command lease", async () => { + const f = await nativeFixture(); + await verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure); + const installation = await verifyNativeAcpxInstallation(f.input); + await rm(join(f.root, "wrapper.js")); await symlink("pi.js", join(f.root, "wrapper.js")); + await expect(installation.openCommand()).rejects.toThrow("symbolic link"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts new file mode 100644 index 0000000000..15b69dca49 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts @@ -0,0 +1,187 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open, readdir, readlink, realpath } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +export const PI_RUNTIME_MANIFEST_SCHEMA = "paperclip.pi-runtime-files.v1" as const; +export interface PiRuntimeFile { + path: string; + kind: "file" | "symlink"; + sha256: string; + /** A link target is data in the manifest and must remain inside the pack. */ + target?: string; +} +export interface PiRuntimeManifest { + schema: typeof PI_RUNTIME_MANIFEST_SCHEMA; + node: string; + piEntrypoint: string; + extension: string; + wrapperEntrypoint: string; + files: PiRuntimeFile[]; +} + +function contained(root: string, path: string): boolean { + const suffix = relative(root, path); + return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix); +} +function safeRelative(path: string): boolean { + return path.length > 0 && !isAbsolute(path) && !/[\0\r\n\\]/.test(path) && path.split("/").every((part) => part !== "" && part !== "." && part !== ".."); +} +// Hash streams are bounded; at most 32 descriptors/stream buffers are live. +const PI_INVENTORY_HASH_CONCURRENCY = 32; +const PI_INVENTORY_DISCOVERY_CONCURRENCY = 32; + +function hash(bytes: Uint8Array | string): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; } + +/** Discover the complete graph with the same path, link and identity checks for both callers. */ +async function discoverPiRuntimeFiles(root: string): Promise<{ files: PiRuntimeFile[]; regular: Array<{ path: string; entry: PiRuntimeFile }> }> { + const physicalRoot = await realpath(root); + if ((await lstat(root)).isSymbolicLink()) throw new Error("Pi runtime root must not be a symlink"); + const files: PiRuntimeFile[] = []; + const regular: Array<{ path: string; entry: PiRuntimeFile }> = []; + const visit = async (directory: string): Promise => { + const entries = (await readdir(directory)).sort(); + for (let start = 0; start < entries.length; start += PI_INVENTORY_DISCOVERY_CONCURRENCY) { + const discovered = await Promise.allSettled(entries.slice(start, start + PI_INVENTORY_DISCOVERY_CONCURRENCY).map(async name => { + const path = join(directory, name); + const rel = relative(physicalRoot, path).split(sep).join("/"); + if (!safeRelative(rel)) throw new Error("Pi runtime contains an invalid filename"); + return { path, rel, stat: await lstat(path) }; + })); + // Drain every admitted operation before failure or descent. Consuming the + // sorted batch serially preserves the original depth-first manifest order. + const failed = discovered.find(result => result.status === "rejected"); + if (failed?.status === "rejected") throw failed.reason; + for (const result of discovered) { + if (result.status !== "fulfilled") continue; + const { path, rel, stat } = result.value; + if (stat.isDirectory()) { + // A previous sibling may have required a complete subtree walk since + // this batch was inspected. Never descend using that stale identity. + const current = await lstat(path); + if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino || await realpath(path) !== path) throw new Error("Pi runtime directory changed before traversal"); + await visit(path); continue; + } + if (files.length >= 100_000) throw new Error("Pi runtime file inventory exceeds its bound"); + if (stat.isSymbolicLink()) { + const target = await readlink(path); + if (isAbsolute(target) || !contained(physicalRoot, resolve(dirname(path), target)) || !contained(physicalRoot, await realpath(path))) throw new Error("Pi runtime link escapes its pack"); + files.push({ path: rel, kind: "symlink", target, sha256: hash(target) }); + } else if (stat.isFile()) { + if (stat.nlink !== 1) throw new Error("Pi runtime file has another writable name"); + const entry: PiRuntimeFile = { path: rel, kind: "file", sha256: "" }; + files.push(entry); regular.push({ path, entry }); + } else throw new Error("Pi runtime contains a non-file resource"); + } + } + }; + await visit(physicalRoot); + return { files, regular }; +} + +/** + * Inventory a complete provider pack, not just Pi's cli.js. Native modules, + * WASM, data, package metadata, and the owned extension all participate. + * Used while building a pack; the caller records/signs this immutable result. + */ +export async function inventoryPiRuntimeFiles(root: string): Promise { + const { files, regular } = await discoverPiRuntimeFiles(root); + // Keep discovery order independent of completion order. Do not cache: every + // admission still reads every regular file through its checked descriptor. + for (let index = 0; index < regular.length; index += PI_INVENTORY_HASH_CONCURRENCY) { + const batch = regular.slice(index, index + PI_INVENTORY_HASH_CONCURRENCY); + const results = await Promise.allSettled(batch.map(async ({ path, entry }) => { entry.sha256 = await hashFile(path); })); + // Await every worker's finally/close before reporting a failed batch. No + // subsequent batch is scheduled after a rejection. + const failed = results.find(result => result.status === "rejected"); + if (failed?.status === "rejected") throw failed.reason; + } + return files; +} + +async function hashFile(path: string): Promise { + const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const before = await file.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size > 512n * 1024n * 1024n) throw new Error("Pi runtime file identity is invalid"); + const digest = createHash("sha256"); + for await (const chunk of file.createReadStream({ autoClose: false })) digest.update(chunk); + const after = await file.stat({ bigint: true }); + if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) throw new Error("Pi runtime file changed during verification"); + return `sha256:${digest.digest("hex")}`; + } finally { await file.close(); } +} + +/** + * Verify an immutable private snapshot at admission. This returns only launch + * paths; the common command lease must continue holding its snapshot and + * process guardian. This function alone is deliberately not a command lease. + */ +export async function verifyPiRuntimeManifest( + root: string, manifest: PiRuntimeManifest, +): Promise<{ environment: Record; manifestDigest: string }> { + const { sorted, unique } = validatePiRuntimeManifest(manifest); + const actual = (await inventoryPiRuntimeFiles(root)).sort((a, b) => a.path.localeCompare(b.path, "en")); + if (JSON.stringify(actual) !== JSON.stringify(sorted)) throw new Error("Pi runtime package graph differs from its qualified manifest"); + return bindPiRuntimeManifestPaths(root, manifest, sorted, unique); +} + +function validatePiRuntimeManifest(manifest: PiRuntimeManifest): { sorted: PiRuntimeFile[]; unique: Set } { + if (manifest.schema !== PI_RUNTIME_MANIFEST_SCHEMA || !Array.isArray(manifest.files) || manifest.files.length === 0 || manifest.files.length > 100_000) throw new Error("Pi runtime manifest is invalid"); + const unique = new Set(); + for (const entry of manifest.files) { + if (!safeRelative(entry.path) || unique.has(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256) || !["file", "symlink"].includes(entry.kind)) throw new Error("Pi runtime manifest entry is invalid"); + if (entry.kind === "symlink" ? typeof entry.target !== "string" : entry.target !== undefined) throw new Error("Pi runtime manifest link is invalid"); + unique.add(entry.path); + } + const sorted = manifest.files.map((entry): PiRuntimeFile => entry.kind === "file" + ? { path: entry.path, kind: entry.kind, sha256: entry.sha256 } + : { path: entry.path, kind: entry.kind, target: entry.target, sha256: entry.sha256 }) + .sort((a, b) => a.path.localeCompare(b.path, "en")); + return { sorted, unique }; +} + +async function bindPiRuntimeManifestPaths(root: string, manifest: PiRuntimeManifest, sorted: PiRuntimeFile[], unique: Set): Promise<{ environment: Record; manifestDigest: string }> { + const physicalRoot = await realpath(root); + const boundPath = async (path: string): Promise => { + if (!safeRelative(path) || !unique.has(path)) throw new Error("Pi executable is absent from its manifest"); + const value = await realpath(join(physicalRoot, path)); + if (!contained(physicalRoot, value) || !(await lstat(value)).isFile()) throw new Error("Pi executable escaped its verified snapshot"); + return value; + }; + await boundPath(manifest.wrapperEntrypoint); + return { + environment: { + PAPERCLIP_PI_NODE_EXECUTABLE: await boundPath(manifest.node), + PAPERCLIP_PI_ENTRYPOINT: await boundPath(manifest.piEntrypoint), + PAPERCLIP_PI_EXTENSION_PATH: await boundPath(manifest.extension), + }, + manifestDigest: hash(JSON.stringify({ ...manifest, files: sorted })), + }; +} + +/** + * Pi's native launch already hashes every byte while creating its immutable + * command snapshot. Check layout and both declarations here, without doing a + * second byte pass. This result is NOT byte admission and cannot launch code; + * the caller must still acquire a freshly verified native command snapshot. + * The generic full-byte verifier above remains independent of this path. + */ +export async function verifyPiRuntimeLayoutForNativeSnapshot( + root: string, manifest: PiRuntimeManifest, + nativeManifest: unknown, expectedClosureSha256: string, +): Promise<{ manifestDigest: string }> { + // The materializer imports the generic verifier directly from TypeScript. + // Resolve the compiled native helper only on the runtime snapshot path. + const { parseNativeAcpxDistributionEntries } = await import("./native-distribution-integrity.js"); + const entries = parseNativeAcpxDistributionEntries(nativeManifest, expectedClosureSha256); + const { sorted, unique } = validatePiRuntimeManifest(manifest); + const nativeFiles: PiRuntimeFile[] = entries.map((entry): PiRuntimeFile => ({ path: entry.path, kind: "file", sha256: `sha256:${entry.sha256}` })) + .sort((a, b) => a.path.localeCompare(b.path, "en")); + if (JSON.stringify(nativeFiles) !== JSON.stringify(sorted)) throw new Error("Pi runtime manifest differs from its pinned native closure"); + const { files } = await discoverPiRuntimeFiles(root); + const layout = (values: PiRuntimeFile[]) => values.map(({ path, kind }) => ({ path, kind })).sort((a, b) => a.path.localeCompare(b.path, "en")); + if (JSON.stringify(layout(files)) !== JSON.stringify(layout(sorted))) throw new Error("Pi runtime package graph differs from its qualified manifest"); + const { manifestDigest } = await bindPiRuntimeManifestPaths(root, manifest, sorted, unique); + return { manifestDigest }; +} 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..87dd26db7e 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 | 20; +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, 20], 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/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 f8b66548ab..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,4 +1,10 @@ -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +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, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -736,6 +742,30 @@ describe("ACPX runtime host", () => { expect(fixture.commandClose).toHaveBeenCalledOnce(); }); + it("forwards semantic receipt capture through the real runtime host and authenticated bridge", async () => { + const fixture = await hostFixture(); + const events: CanonicalProviderEvent[] = []; + const evidence = createCopilotToolEvidence({ sessionId: "backend-1", turnId: "turn-1", workingDirectory: fixture.options.workingDirectory, + active: () => true, emit: event => { validateAcpxRichEvent(event); events.push(JSON.parse(JSON.stringify(event))); } }); + let bridge: AcpxRuntimePortOpenOptions["mcpServers"][number] | undefined; + const host = await AcpxRuntimeHost.open({ ...fixture.options, agent: "copilot", model: "gpt-5.6-sol", + permissionMode: "deny-all", environment: { COPILOT_GITHUB_TOKEN: "fixture-not-a-real-credential" }, + semanticTools: { tools: [{ name: "get_task_context", inputSchema: { type: "object", properties: {}, additionalProperties: false } }], + handler: async () => ({ accepted: false }), captureSemanticReceipt: () => evidence.captureSemanticReceipt() }, + }, fixture.dependencies({ openRuntime: async options => { bridge = options.mcpServers[0]; return runtimePort(); } })); + try { + const response = await fetch(bridge!.url, { method: "POST", headers: { Authorization: `Bearer ${bridge!.bearerToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "get_task_context", arguments: {} } }) }); + const body = await response.json() as { result: SemanticToolResult }; + const receipt = readNativeSemanticReceipt({ contents: body.result.content }); + expect(receipt).not.toBeNull(); + expect(events).toHaveLength(1); + expect(events[0]!.payload).toMatchObject({ category: "paperclip_semantic_tool_receipt_v2", provenance: { sessionId: "backend-1", turnId: "turn-1" }, + details: expect.arrayContaining([{ name: "schema", value: "paperclip.semantic_tool_receipt.v2" }, { name: "normalizedInputSha256", value: "null" }, { name: "callIdentitySha256", value: receipt!.callIdentitySha256 }, { name: "resultSha256", value: receipt!.resultSha256 }]) }); + } finally { await host.close({ reason: "receipt forwarding verified" }); } + await expect(fetch(bridge!.url)).rejects.toThrow(); + }); + it("owns an authenticated semantic bridge without persisting its secret", async () => { const fixture = await hostFixture(); const handler = vi.fn(async ({ tool }) => ({ tool, ok: true })); @@ -840,13 +870,10 @@ 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" }; - await expect(AcpxRuntimeHost.open(options, { openRuntime, reportRetainedCleanupFailure: vi.fn() })) - .rejects.toThrow("verified candidate distribution is not installed"); - expect(openRuntime).not.toHaveBeenCalled(); const host = await AcpxRuntimeHost.open(options, fixture.dependencies({ verifyInstallation: async () => ({ commandDigest: profile.commandDigest, agentServerPackageJsonPath: join(fixture.root, "package.json"), agentRuntimePackageJsonPath: null, @@ -857,6 +884,128 @@ 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, ...(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" }), + }, + }; + 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" ? { 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); + expect(opened.at(-1)!.launchEnvironment.AGENT_HOME).toBeUndefined(); + expect(opened.at(-1)!.launchEnvironment.PAPERCLIP_PI_AGENT_HOME).toBeUndefined(); + await withoutCopy.close({ reason: "no registered copy" }); + for (const run of ["first", "second"]) { + const rootPath = join(copies, run); await mkdir(rootPath); + const runtimeContext = { instructions: { workingCopy: { kind: "agent_files", rootPath, entryPath: "AGENTS.md" } }, skills: [], mcp: { bindingId: null } } as unknown as NativeRuntimeContextSnapshot; + const host = await AcpxRuntimeHost.open({ ...options, runtimeContext }, dependencies); + const launch = opened.at(-1)!; + expect(launch.launchEnvironment.AGENT_HOME?.endsWith(`/${run}`)).toBe(true); + expect(launch.launchEnvironment.PAPERCLIP_PI_AGENT_HOME).toBe(agent === "pi" ? launch.launchEnvironment.AGENT_HOME : undefined); + expect(launch.launchEnvironment.HOME).not.toBe(launch.launchEnvironment.AGENT_HOME); + if (run === "second") { + await rename(rootPath, `${rootPath}-replaced`); await mkdir(rootPath); + expect(() => launch.assertWorkspaceHeld?.()).toThrow("changed"); + expect(() => host.startTurn({ text: "test", requestId: "stale-directory" })).toThrow("changed"); + } + await host.close({ reason: "registered copy test complete" }); + } + }); + + 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; @@ -1371,6 +1520,34 @@ describe("ACPX runtime host", () => { ); }); + it("keeps host shutdown independent of a concurrent managed turn cancellation", async () => { + const fixture = await hostFixture(); + const raw = runtimeTurn(); // Native cancel acknowledges, but never resolves result. + const runtime = runtimePort({ startTurn: () => raw }); + const host = await AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "approve-reads", + environment: { PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "{}" }, + }, + fixture.dependencies({ openRuntime: async () => runtime }), + ); + const managed = host.startTurn({ text: "Work", requestId: "turn-1" }); + // The host retains raw.cancel, so its shutdown cannot wait on the managed + // cancellation which falls back to that same host's close promise. + const closed = host.close({ reason: "controller shutdown" }); + const cancelled = managed.cancel({ reason: "operator stop" }); + await expect(Promise.all([closed, cancelled])).resolves.toEqual([undefined, undefined]); + await expect(managed.result).resolves.toEqual({ + status: "cancelled", stopReason: "cancelled_after_runtime_close", + }); + expect(host.isClosed()).toBe(true); + expect(runtime.close).toHaveBeenCalledExactlyOnceWith({ reason: "controller shutdown" }); + expect(fixture.commandClose).toHaveBeenCalledOnce(); + }); + it("clones ephemeral capabilities and fences steering controls to an acknowledged active turn", async () => { const fixture = await hostFixture(); const turn = runtimeTurn(); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index c1230c9394..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"; @@ -55,6 +57,7 @@ import { } from "./recovery-identity.js"; import { prepareAcpxRuntimeSandbox, + refreshCopilotSystemInstructions, type AcpxRuntimeSandbox, type AcpxProviderRuntimePolicy, } from "./runtime-sandbox.js"; @@ -79,6 +82,7 @@ const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000; export interface AcpxRuntimePortIdentity { mode?: string; + piThinkingLevel?: PiThinkingLevel; acpxRecordId: string; backendSessionId: string; agentSessionId: string; @@ -145,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; @@ -156,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. */ @@ -230,6 +237,7 @@ export interface OpenAcpxRuntimeHostOptions { model: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: PiThinkingLevel; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; @@ -338,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 } : {}), }), @@ -473,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, @@ -517,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) => @@ -525,7 +544,7 @@ export class AcpxRuntimeHost { }); const commandOwner = createAcpxCommandLeaseOwner( command, - () => installation.openCommand(), + signal => installation.openCommand({ signal }), ); command = commandOwner.command; toolBridge = options.semanticTools @@ -558,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, ), @@ -620,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 fbe2ff00fa..ab14aa666f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -21,6 +21,7 @@ import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import { createAcpxRecoveryBinding } from "./recovery-identity.js"; import { prepareAcpxRuntimeSandbox, + refreshCopilotSystemInstructions, readAcpxRecoveryWorkspace, } from "./runtime-sandbox.js"; @@ -422,6 +423,38 @@ describe("ACPX runtime sandbox", () => { ); }); +it("refreshes Copilot native instructions privately on every admission, including removal", async () => { + const fixture = await sandboxFixture("copilot"); + const prepare = async (systemInstructions: string) => { + const sandbox = await prepareAcpxRuntimeSandbox({ + binding: fixture.binding, agent: "copilot", providerPolicy: { readOnly: false, systemInstructions }, + }); + await refreshCopilotSystemInstructions(sandbox, systemInstructions); + return sandbox; + }; + const first = await prepare("Original registered instructions."); + const path = join(first.agentHomeDirectory, "copilot-instructions.md"); + expect(await readFile(path, "utf8")).toBe("Original registered instructions.\n"); + await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "copilot", + providerPolicy: { readOnly: false, systemInstructions: "Contender must not write during preparation." } }); + expect(await readFile(path, "utf8")).toBe("Original registered instructions.\n"); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(first.protectedPaths).toContain(dirname(dirname(first.root))); + expect(path.startsWith(`${first.protectedPaths[0]}/`)).toBe(true); + const target = join(fixture.root, "unrelated-file"); + await writeFile(target, "Do not overwrite."); + await rm(path); + await symlink(target, path); + await prepare("Current registered instructions."); + expect((await lstat(path)).isSymbolicLink()).toBe(false); + expect(await readFile(target, "utf8")).toBe("Do not overwrite."); + expect(await readFile(path, "utf8")).toBe("Current registered instructions.\n"); + await prepare(""); + expect(await readFile(path, "utf8")).toBe("\n"); + await expect(prepare("x".repeat(32 * 1024 + 1))).rejects.toThrow("bounded size"); + expect(await readFile(path, "utf8")).toBe("\n"); +}); + async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot") { const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-sandbox-")); temporaryDirectories.push(root); @@ -442,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 f38f174712..3e0e52d250 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -1,4 +1,6 @@ +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"; import { constants, @@ -388,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 @@ -635,6 +641,21 @@ async function ensurePrivateDirectory( return physical; } +/** Call only while the host owns the provider lifetime lease, before launch. */ +export async function refreshCopilotSystemInstructions( + sandbox: Pick, + instructions: string, +): Promise { + if (instructions.includes("\0") || Buffer.byteLength(instructions) > 32 * 1024) { + throw new Error("Provider runtime instructions exceed their bounded size"); + } + // Native Copilot reloads this file on session/load. Empty text clears old text. + await writePrivateFile( + join(sandbox.agentHomeDirectory, COPILOT_SYSTEM_INSTRUCTIONS_FILE), + `${instructions}\n`, + ); +} + async function writePrivateFile( filePath: string, value: string, 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/acpx/usage-accounting.test.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts index d8d3e84191..3e6b51999c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { persistedAcpxTurnUsage, + parseCursorPromptUsage, + persistedCursorUsageNotice, acpxUsageEstimateNotice, qualifiedAcpxUsageBreakdown, } from "./usage-accounting.js"; @@ -170,3 +172,57 @@ describe("Pi prompt accounting authority", () => { }); }); }); + + +describe("partial native Cursor observations", () => { + const receipt = () => ({ request_id: "request-1", prompt_message_id: "message-1", receipt: { + schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", + completeness: "partial", reasons: ["native_counter_semantics_unverified", "child_run_attribution_unverified"], + observations: [{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 12, outputTokens: 3 } }], + limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false, + } }); + const before = { lastRequestId: "old-request", promptMessageIds: ["old-message"], requestTokenUsage: {} }; + const after = () => ({ lastRequestId: "request-1", promptMessageIds: ["old-message", "message-1"], cursorPromptUsage: receipt(), requestTokenUsage: {} }); + it("emits only a bounded unsummed partial notice, with no accounting side effects", () => { + const current = after(), original = structuredClone(current); + const notice = persistedCursorUsageNotice(before, current, "request-1", "cursor", "turn:cursor-native-usage"); + expect(notice).toMatchObject({ eventType: "provider.notice.recorded", payload: { category: "cursor_native_usage_observed" } }); + expect(JSON.stringify(notice)).toContain('native_counter_semantics_unverified'); + expect(JSON.stringify(notice)).not.toContain('request-1'); + expect(JSON.stringify(notice)).not.toContain('12345678'); + expect(current).toEqual(original); + expect(persistedAcpxTurnUsage(before, current, "request-1", "cursor")).toBeNull(); + }); + it("rejects stale, replaced, foreign, missing, and duplicate message bindings", () => { + for (const agent of [null, "codex", "copilot", "pi"] as const) expect(persistedCursorUsageNotice(before, after(), "request-1", agent, "notice")).toBeNull(); + for (const current of [ + { ...after(), lastRequestId: "wrong" }, + { ...after(), cursorPromptUsage: { ...receipt(), request_id: "wrong" } }, + { ...after(), promptMessageIds: [] }, + { ...after(), promptMessageIds: ["message-1", "message-1"] }, + ]) expect(persistedCursorUsageNotice(before, current, "request-1", "cursor", "notice")).toBeNull(); + for (const prior of [after(), { ...before, promptMessageIds: ["message-1"] }, { ...before, cursorPromptUsage: { ...receipt(), request_id: "old-request", prompt_message_id: "old-message" } }]) + expect(persistedCursorUsageNotice(prior, after(), "request-1", "cursor", "notice")).toBeNull(); + }); + it("drops malformed or excessive metadata without retaining unknown/free-text fields", () => { + for (const value of [null, {}, { ...receipt(), extra: "secret" }, { ...receipt(), request_id: "x".repeat(241) }, + { ...receipt(), receipt: { ...receipt().receipt, extra: "secret" } }, + { ...receipt(), receipt: { ...receipt().receipt, reasons: ["secret"] } }, + { ...receipt(), receipt: { ...receipt().receipt, observations: [{ ...receipt().receipt.observations[0], counters: { inputTokens: -1 } }] } }, + ]) expect(parseCursorPromptUsage(value)).toBeNull(); + const circular: Record = {}; circular.self = circular; + expect(parseCursorPromptUsage(circular)).toBeNull(); + }); + it("retains all legal observations within canonical per-detail bounds", () => { + const value = receipt(); + value.receipt.observations = Array.from({ length: 64 }, (_, i) => ({ invocationId: `invocation-${i + 1}`, role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 12, outputTokens: 3 } })); + const notice = persistedCursorUsageNotice(before, { ...after(), cursorPromptUsage: value }, "request-1", "cursor", "notice")!; + const details = notice.payload.details as {name:string;value:string}[]; + expect(details.length).toBeLessThanOrEqual(64); + expect(details.every(d => Buffer.byteLength(d.value) <= 4000)).toBe(true); + expect(details.filter(d => d.name.startsWith("Native observations")).flatMap(d => JSON.parse(d.value))).toEqual(value.receipt.observations); + const maxIds = { ...receipt(), request_id: "r".repeat(240), prompt_message_id: "m".repeat(240) }; + expect(parseCursorPromptUsage(maxIds)).not.toBeNull(); + expect(Buffer.byteLength(JSON.stringify(maxIds)) - Buffer.byteLength(JSON.stringify(maxIds.receipt))).toBeLessThanOrEqual(640); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts index 2d8c2ff357..4143f655a6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts @@ -95,3 +95,70 @@ export function acpxUsageEstimateNotice(usage: Record, itemId: }, }; } + +type CursorPromptUsage = { + request_id: string; prompt_message_id: string; + receipt: { + schema: "paperclip.cursor.native-usage.v1"; source: "native_turn_ended"; promptId: string; + completeness: "partial"; reasons: string[]; truncated: boolean; + limits: { maxObservations: 64; maxInvocations: 64; maxBytes: 16384 }; + observations: Array<{ invocationId: string; role: "parent" | "child"; nativeRun: number; sequence: number; counters: Record }>; + }; +}; +/** Closed diagnostic receipt. Counter semantics and billing are always unverified. */ +export function parseCursorPromptUsage(raw: unknown): CursorPromptUsage | null { + try { + const text = JSON.stringify(raw); + if (!text || Buffer.byteLength(text, "utf8") > 16384) return null; + const v = JSON.parse(text) as CursorPromptUsage; + const object = (x: unknown): x is object => x !== null && typeof x === "object" && !Array.isArray(x); + const exact = (x: object, keys: readonly string[]) => Object.keys(x).length === keys.length && keys.every(k => Object.hasOwn(x, k)); + const id = (x: unknown) => typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(x); + if (!object(v) || !exact(v, ["request_id", "prompt_message_id", "receipt"]) || !id(v.request_id) || !id(v.prompt_message_id)) return null; + const r = v.receipt; + if (!object(r) || !exact(r, ["schema", "source", "promptId", "completeness", "reasons", "observations", "limits", "truncated"])) return null; + if (r.schema !== "paperclip.cursor.native-usage.v1" || r.source !== "native_turn_ended" || r.completeness !== "partial" || typeof r.promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(r.promptId) || typeof r.truncated !== "boolean") return null; + const reasons = ["native_counter_semantics_unverified", "native_counters_missing", "invalid_native_counter", "multiple_terminal_observations", "native_terminal_not_observed", "observation_limit_reached", "child_run_attribution_unverified"]; + if (!Array.isArray(r.reasons) || r.reasons.length > reasons.length || !r.reasons.includes(reasons[0]!) || new Set(r.reasons).size !== r.reasons.length || !r.reasons.every(x => reasons.includes(x))) return null; + if (!object(r.limits) || !exact(r.limits, ["maxObservations", "maxInvocations", "maxBytes"]) || r.limits.maxObservations !== 64 || r.limits.maxInvocations !== 64 || r.limits.maxBytes !== 16384 || !Array.isArray(r.observations) || r.observations.length > 64) return null; + const fields = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"]; + const invocations = new Map(); + for (const o of r.observations) { + if (!object(o) || !exact(o, ["invocationId", "role", "nativeRun", "sequence", "counters"]) || typeof o.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(o.invocationId) || (o.role !== "parent" && o.role !== "child") || !Number.isSafeInteger(o.nativeRun) || o.nativeRun < 1 || !Number.isSafeInteger(o.sequence) || o.sequence < 1 || !object(o.counters)) return null; + if (!Object.entries(o.counters).every(([k, n]) => fields.includes(k) && Number.isSafeInteger(n) && n >= 0)) return null; + const previous = invocations.get(o.invocationId); + if (previous && (previous.role !== o.role || previous.nativeRun !== o.nativeRun || o.sequence <= previous.sequence)) return null; + invocations.set(o.invocationId, { role: o.role, nativeRun: o.nativeRun, sequence: o.sequence }); + } + return v; + } catch { return null; } +} + +/** Partial native observations never enter usage_update, aggregate tokens, or USD. */ +export function persistedCursorUsageNotice(before: unknown, after: unknown, requestId: string, agent: QualifiedAcpxAgent | null, itemId: string): CanonicalProviderEvent | null { + if (agent !== "cursor") return null; + const current = record(after), prior = record(before); + if (current.lastRequestId !== requestId || prior.lastRequestId === requestId) return null; + const receipt = parseCursorPromptUsage(current.cursorPromptUsage); + if (!receipt || receipt.request_id !== requestId) return null; + const previous = parseCursorPromptUsage(prior.cursorPromptUsage); + if (previous && (previous.request_id === receipt.request_id || previous.prompt_message_id === receipt.prompt_message_id || previous.receipt.promptId === receipt.receipt.promptId)) return null; + // A message must have been created by this turn; replacing metadata on an old message is not fresh evidence. + if (!Array.isArray(current.promptMessageIds) || current.promptMessageIds.filter(id => id === receipt.prompt_message_id).length !== 1 + || !Array.isArray(prior.promptMessageIds) || prior.promptMessageIds.includes(receipt.prompt_message_id)) return null; + const details = [ + { name: "Provenance", value: "Cursor native turnEnded observations; partial, unsummed, unverified counter semantics" }, + { name: "Partial reasons", value: receipt.receipt.reasons.join(", ") }, + { name: "Collector truncated", value: String(receipt.receipt.truncated) }, + ...Array.from({ length: Math.ceil(receipt.receipt.observations.length / 8) }, (_, group) => ({ + name: `Native observations ${group * 8 + 1}-${Math.min((group + 1) * 8, receipt.receipt.observations.length)}`, + value: JSON.stringify(receipt.receipt.observations.slice(group * 8, (group + 1) * 8)), + })), + ]; + // Eight closed observations fit within each 4,000-byte detail, without dropping any counters. + return { + eventType: "provider.notice.recorded", itemId, + payload: { schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", category: "cursor_native_usage_observed", scope: "turn", recoverable: true, userActionable: false, + summary: "Cursor reported partial native counters. These observations are not authoritative token usage or billing cost.", details }, + }; +} diff --git a/packages/paperclip-runner/src/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 e29d2203d8..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,28 +44,65 @@ import { } from "./codex-app-server-driver.test-support.js"; import { NativeSessionProtocolIntegrityError } from "../../contracts/native-session-backend.js"; +function cursorProviderIdentity(mode: unknown) { + return { + kind: "acpx", + normalizedSessionId: "normalized-cursor-recovery", + acpxRecordId: "acpx-record-1", + backendSessionId: "backend-session-1", + agentSessionId: "agent-session-1", + profileDigest: `sha256:${"a".repeat(64)}`, + workspaceDigest: `sha256:${"b".repeat(64)}`, + requestedModel: "explicit-cursor-model", + effectiveModel: "explicit-cursor-model", + permissionMode: "approve-all", + ...(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/drivers/runner-tool-bridge.test.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts index 5bf87169d2..b61d2c32f3 100644 --- a/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts @@ -1,3 +1,5 @@ +import { readNativeSemanticReceipt, semanticInputSha256, type SemanticToolReceipt, type SemanticToolResult } from "./semantic-tool-receipt.js"; +import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js"; import { connect } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -192,14 +194,12 @@ describe("runner semantic MCP bridge", () => { expect(handler).toHaveBeenCalledTimes(2); }); - it("times out calls and honors MCP cancellation", async () => { - const starts: string[] = []; + it("times out calls", async () => { const bridge = await startRunnerToolBridge({ tools: [tool("documents.read")], timeoutMs: 20, - handler: ({ callId, signal }) => + handler: ({ signal }) => new Promise((_resolve, reject) => { - starts.push(callId); signal.addEventListener( "abort", () => reject(new Error("handler aborted")), @@ -223,15 +223,36 @@ describe("runner semantic MCP bridge", () => { content: [{ text: "Paperclip tool call timed out" }], }, }); + }); + + it("honors MCP cancellation after the handler starts", async () => { + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const aborted = vi.fn(); + const bridge = await startRunnerToolBridge({ + tools: [tool("documents.read")], + timeoutMs: 60_000, + handler: ({ signal }) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted(); + reject(new Error("handler aborted")); + }, { once: true }); + markStarted(); + }), + }); + bridges.push(bridge); const pending = rpc(bridge, { id: "cancel-me", method: "tools/call", params: { name: "documents.read", arguments: {} }, }); - await vi.waitFor(() => expect(starts).toContain("cancel-me"), { - interval: 1, - timeout: 15, - }); + // Observe request arrival without racing the separate timeout behavior. + // Retain the rejection for the assertion while avoiding an unhandled fetch + // rejection if a failed test closes the bridge before the request arrives. + void pending.catch(() => undefined); + await started; expect( ( await rpc(bridge, { @@ -246,6 +267,7 @@ describe("runner semantic MCP bridge", () => { content: [{ text: "Paperclip tool call cancelled" }], }, }); + expect(aborted).toHaveBeenCalledOnce(); }); it("preserves successful mutation identity when the result is oversized", async () => { @@ -451,3 +473,109 @@ describe("runner semantic MCP bridge", () => { function tool(name: string): Readonly> { return { name, inputSchema: { type: "object" } }; } + +describe("Copilot semantic receipt opt-in", () => { + const rawFinish = { reportedWorkDisposition: "done", summary: "The task is complete.", evidence: [], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + it.each(["committed", "not-forwarded", "invalid", "duplicate-capture", "conflicting-capture", "duplicate-commit"])( + "captures only the invocation's exact forwarded normalized input: %s", async mode => { + const receipts: SemanticToolReceipt[] = []; + let capturedInput: unknown; + const bridge = await startRunnerToolBridge({ captureSemanticReceipt: () => receipt => receipts.push(receipt), handler: async call => { + const validation = validatePrpStructuredRunResult(call.arguments); + if (!validation.ok) throw new Error("Invalid fixture input"); + capturedInput = structuredClone(validation.result); + const invalid: Record = {}; invalid.self = invalid; + const commit = call.captureNormalizedInput!(mode === "invalid" ? invalid : validation.result); + if (mode === "duplicate-capture") call.captureNormalizedInput!(validation.result)(); + if (mode === "conflicting-capture") call.captureNormalizedInput!({ ...validation.result, summary: "foreign" })(); + if (mode !== "not-forwarded") commit(); + if (mode === "duplicate-commit") commit(); + // The digest is a snapshot of forwarded input, not a later mutable value. + validation.result.summary = "later mutation"; + return { accepted: true }; + } }); + bridges.push(bridge); + const request = { id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } }; + const body = await (await rpc(bridge, request)).json(); + expect(receipts).toHaveLength(1); + expect(readNativeSemanticReceipt({ contents: body.result.content })).toEqual(receipts[0]); + expect(receipts[0]).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", inputSha256: semanticInputSha256(rawFinish), + normalizedInputSha256: mode === "committed" ? semanticInputSha256(capturedInput) : null }); + expect(semanticInputSha256(rawFinish)).not.toBe(semanticInputSha256(capturedInput)); + expect(await (await rpc(bridge, request)).json()).toEqual(body); + expect(receipts).toHaveLength(1); + }, + ); + it("keeps concurrent call captures separate and ignores captures after settlement", async () => { + const pending = new Map; settle: () => void }>(); + const receipts: SemanticToolReceipt[] = []; + const bridge = await startRunnerToolBridge({ captureSemanticReceipt: () => receipt => receipts.push(receipt), handler: call => new Promise(resolve => { + pending.set(call.callId, { capture: call.captureNormalizedInput!, settle: () => resolve({ accepted: true }) }); + }) }); + bridges.push(bridge); + const first = rpc(bridge, { id: "first", method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } }); + const secondInput = { ...rawFinish, summary: "Second call" }; + const second = rpc(bridge, { id: "second", method: "tools/call", params: { name: "paperclip_finish", arguments: secondInput } }); + await vi.waitFor(() => expect(pending.size).toBe(2)); + const normalizedFirst = validatePrpStructuredRunResult(rawFinish); const normalizedSecond = validatePrpStructuredRunResult(secondInput); + if (!normalizedFirst.ok || !normalizedSecond.ok) throw new Error("Invalid fixture"); + pending.get("second")!.capture(normalizedSecond.result)(); pending.get("second")!.settle(); + const secondReceipt = readNativeSemanticReceipt({ contents: (await (await second).json()).result.content }); + expect(secondReceipt).toMatchObject({ inputSha256: semanticInputSha256(secondInput), normalizedInputSha256: semanticInputSha256(normalizedSecond.result) }); + pending.get("second")!.capture(normalizedFirst.result)(); + pending.get("first")!.capture(normalizedFirst.result)(); pending.get("first")!.settle(); + const firstReceipt = readNativeSemanticReceipt({ contents: (await (await first).json()).result.content }); + expect(firstReceipt).toMatchObject({ inputSha256: semanticInputSha256(rawFinish), normalizedInputSha256: semanticInputSha256(normalizedFirst.result) }); + expect(receipts).toEqual([secondReceipt, firstReceipt]); + }); + it("does not accept model metadata as normalized authority or widen non-Copilot calls", async () => { + const modelMetadata = { normalizedInputSha256: "a".repeat(64), captureNormalizedInput: "forged" }; + const withEvidence = await startRunnerToolBridge({ tools: [tool("documents.read")], captureSemanticReceipt: () => () => {}, handler: async call => { + expect(call.captureNormalizedInput).toBeUndefined(); return modelMetadata; + } }); bridges.push(withEvidence); + const body = await (await rpc(withEvidence, { id: 1, method: "tools/call", params: { name: "documents.read", arguments: modelMetadata } })).json(); + expect(readNativeSemanticReceipt({ contents: body.result.content })).toHaveProperty("normalizedInputSha256", null); + const plain = await startRunnerToolBridge({ handler: async call => { expect(call.captureNormalizedInput).toBeUndefined(); return { accepted: true }; } }); bridges.push(plain); + const plainBody = await (await rpc(plain, { id: 2, method: "tools/call", params: { name: "paperclip_finish", arguments: rawFinish } })).json(); + expect(readNativeSemanticReceipt({ contents: plainBody.result.content })).toBeNull(); + }); + it.each(["small", "chunked", "tagged", "error"])("preserves %s result encoding and captures scope before dispatch", async encoding => { + const receipts: SemanticToolReceipt[] = []; + let resolve!: (value: unknown) => void; + let reject!: (error: Error) => void; + const pending = new Promise((yes, no) => { resolve = yes; reject = no; }); + const capture = vi.fn(() => (receipt: SemanticToolReceipt) => receipts.push(receipt)); + const handler = vi.fn(() => pending); + const bridge = await startRunnerToolBridge({ tools: [tool("documents.read")], handler, captureSemanticReceipt: capture }); + bridges.push(bridge); + const request = { id: 3, method: "tools/call", params: { name: "documents.read", arguments: {} } }; + const response = rpc(bridge, request); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(capture).toHaveBeenCalledTimes(1); + if (encoding === "error") reject(new Error("bounded failure")); + else resolve(encoding === "chunked" ? "x".repeat(70000) : encoding === "tagged" ? { bigint: 1n } : { accepted: false }); + const body = await (await response).json() as { result: SemanticToolResult }; + expect(receipts).toHaveLength(1); + expect(readNativeSemanticReceipt({ contents: body.result.content })).toEqual(receipts[0]); + expect(body.result.isError === true).toBe(encoding === "error"); + if (encoding === "small") expect(JSON.parse(body.result.content[0].text)).toEqual({ accepted: false }); + if (encoding === "error") expect(body.result.content[0].text).toBe("bounded failure"); + if (encoding === "chunked") expect(body.result.content.length).toBeGreaterThan(2); + const replay = await (await rpc(bridge, request)).json(); + expect(replay).toEqual(body); + expect(receipts).toHaveLength(1); + expect(handler).toHaveBeenCalledTimes(1); + }); + it("does not receipt unauthorized/unadmitted calls or let diagnostic errors change results", async () => { + const capture = vi.fn(() => () => { throw new Error("diagnostic only"); }); + const bridge = await startRunnerToolBridge({ tools: [tool("documents.read")], handler: async () => ({ ok: true }), captureSemanticReceipt: capture }); + bridges.push(bridge); + await rpc(bridge, { id: 1, method: "tools/call", params: { name: "documents.read" } }, "wrong"); + await rpc(bridge, { id: 2, method: "tools/call", params: { name: "unknown" } }); + expect(capture).not.toHaveBeenCalled(); + const body = await (await rpc(bridge, { id: 3, method: "tools/call", params: { name: "documents.read" } })).json() as { result: SemanticToolResult }; + expect(JSON.parse(body.result.content[0].text)).toEqual({ ok: true }); + expect(readNativeSemanticReceipt({ contents: body.result.content })).not.toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts index c8207c7183..06c59a8a8d 100644 --- a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { appendSemanticToolReceipt, semanticCanonicalJson as canonicalJson, semanticInputSha256, type SemanticToolReceipt } from "./semantic-tool-receipt.js"; import { createServer, type IncomingMessage, @@ -28,6 +29,9 @@ export interface RunnerToolCall { callId: string; arguments: unknown; signal: AbortSignal; + /** Internal, invocation-owned evidence. Snapshot before forwarding; commit only + * after that exact normalized input is forwarded. Never supplied by the model. */ + captureNormalizedInput?: (input: unknown) => (() => void); } export interface RunnerToolBridgeOptions { @@ -35,6 +39,8 @@ export interface RunnerToolBridgeOptions { /** Runner-owned operations that are callable but never model-visible. */ privateTools?: readonly Readonly>[]; handler(call: RunnerToolCall): Promise; + /** Copilot-only opt-in; capture the active invocation scope before dispatch. */ + captureSemanticReceipt?: () => ((receipt: SemanticToolReceipt) => void) | undefined; timeoutMs?: number; privateToolTimeoutMs?: number; maxBodyBytes?: number; @@ -60,6 +66,7 @@ interface RunnerToolTextContent { } interface RunnerToolCallResult { + isError?: boolean; content: RunnerToolTextContent[]; } @@ -123,6 +130,7 @@ export async function startRunnerToolBridge( calls, controllers, handler: options.handler, + captureSemanticReceipt: options.captureSemanticReceipt, timeoutMs: positiveBoundedInteger( options.timeoutMs, DEFAULT_TIMEOUT_MS, @@ -192,6 +200,7 @@ async function handleRequest( calls: Map; controllers: Map; handler: RunnerToolBridgeOptions["handler"]; + captureSemanticReceipt: RunnerToolBridgeOptions["captureSemanticReceipt"]; timeoutMs: number; privateToolTimeoutMs: number; privateToolNames: ReadonlySet; @@ -321,6 +330,38 @@ async function handleRequest( } } const controller = existing === undefined ? new AbortController() : undefined; + let observeReceipt: ((receipt: SemanticToolReceipt) => void) | undefined; + if (!existing) { try { observeReceipt = context.captureSemanticReceipt?.(); } catch { /* Optional evidence cannot prevent dispatch. */ } } + let receiptSealed = false; + let normalizationCaptured = false; + let normalizationInvalid = false; + let normalizedInputSha256: string | null = null; + const captureNormalizedInput = (input: unknown): (() => void) => { + if (receiptSealed || controller?.signal.aborted) return () => {}; + if (normalizationCaptured) { normalizationInvalid = true; return () => {}; } + normalizationCaptured = true; + let digest: string; + try { + if (!isRecord(input) || Buffer.byteLength(JSON.stringify(input)) > context.maxBodyBytes) throw new Error("Invalid normalized input"); + digest = semanticInputSha256(input); + } catch { normalizationInvalid = true; return () => {}; } + let committed = false; + return () => { + if (receiptSealed || controller?.signal.aborted) return; + if (committed) { normalizationInvalid = true; return; } + committed = true; + if (!normalizationInvalid) normalizedInputSha256 = digest; + }; + }; + const withReceipt = (result: RunnerToolCallResult): RunnerToolCallResult => { + receiptSealed = true; + if (!context.captureSemanticReceipt) return result; + try { + const bound = appendSemanticToolReceipt({ tool, callId, arguments: args, normalizedInputSha256: normalizationInvalid ? null : normalizedInputSha256 }, result); + try { observeReceipt?.(bound.receipt); } catch { /* Evidence cannot change the tool outcome. */ } + return bound.result; + } catch { return result; } + }; const execution: Promise = existing?.promise ?? withCancellationAndTimeout( @@ -331,6 +372,8 @@ async function handleRequest( callId, arguments: structuredClone(args), signal: controller!.signal, + ...(context.captureSemanticReceipt && (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME) + ? { captureNormalizedInput } : {}), }), ) .then((result) => successfulToolResult(tool, callId, result)), @@ -338,7 +381,10 @@ async function handleRequest( context.privateToolNames.has(tool) ? context.privateToolTimeoutMs : context.timeoutMs, - ); + ).then(withReceipt, error => { + if (!context.captureSemanticReceipt) throw error; + return withReceipt({ isError: true, content: [{ type: "text", text: safeError(error) }] }); + }); if (!existing) { context.calls.set(callKey, { fingerprint, promise: execution }); context.controllers.set(callKey, controller!); @@ -813,16 +859,6 @@ function taggedObjectType(value: object): string { : "Object"; } -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (isRecord(value)) { - return `{${Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) - .join(",")}}`; - } - return JSON.stringify(value) ?? "undefined"; -} function isJsonContentType(value: string | undefined): boolean { return value?.split(";", 1)[0]?.trim().toLowerCase() === "application/json"; diff --git a/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts new file mode 100644 index 0000000000..5392c5d829 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { appendSemanticToolReceipt, parseSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "./semantic-tool-receipt.js"; +const call = { tool: "paperclip_finish", callId: "3", arguments: { revision: "current", summary: "private" } }; +const original = { content: [{ type: "text" as const, text: '{"accepted":false}' }] }; +describe("bounded semantic receipt carrier", () => { + it("preserves existing content and transport-vs-semantic acceptance", () => { + const { result, receipt } = appendSemanticToolReceipt(call, original); + expect(result.content.slice(0, -1)).toEqual(original.content); + expect(receipt.outcome).toBe("returned"); + expect(JSON.stringify(receipt)).not.toContain("private"); + expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt); + expect(semanticInputSha256({ a: 1, b: 2 })).toBe(semanticInputSha256({ b: 2, a: 1 })); + }); + it("preserves error content without converting error into returned acceptance", () => { + const { result, receipt } = appendSemanticToolReceipt(call, { ...original, isError: true }); + expect(result.isError).toBe(true); + expect(receipt.outcome).toBe("error"); + expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt); + }); + it.each(["unknown", "malformed", "oversized", "modified", "duplicate", "missing", "type", "reordered"])("rejects %s native carrier", kind => { + const { result } = appendSemanticToolReceipt(call, original); + const blocks = structuredClone(result.content); + const receipt = JSON.parse(blocks.at(-1)!.text); + if (kind === "unknown") blocks.at(-1)!.text = JSON.stringify({ ...receipt, extra: "private" }); + if (kind === "malformed") blocks.at(-1)!.text = "{"; + if (kind === "oversized") blocks.at(-1)!.text = "x".repeat(2049); + if (kind === "modified") blocks[0]!.text = "different"; + if (kind === "duplicate") blocks.unshift(blocks.at(-1)!); + if (kind === "missing") blocks.pop(); + if (kind === "type") Object.assign(blocks[0]!, { type: "image" }); + if (kind === "reordered") blocks.reverse(); + expect(readNativeSemanticReceipt({ contents: blocks })).toBeNull(); + }); + it("bounds the whole repeated native output while still serializing large-result receipts", () => { + const bound = appendSemanticToolReceipt(call, { content: [{ type: "text", text: "x".repeat(90_000) }] }); + expect(readNativeSemanticReceipt({ contents: bound.result.content })).toEqual(bound.receipt); + expect(readNativeSemanticReceipt({ contents: bound.result.content, content: "x".repeat(90_000), detailedContent: "x".repeat(90_000) })).toBeNull(); + expect(bound.result.content.at(-1)!.text.length).toBeLessThan(2048); + }); + it("rejects unknown fields and unsafe identities without truncation", () => { + const { receipt } = appendSemanticToolReceipt(call, original); + expect(parseSemanticToolReceipt({ ...receipt, operationId: "bad\nname" })).toBeNull(); + expect(parseSemanticToolReceipt({ ...receipt, inputSha256: "a".repeat(65) })).toBeNull(); + expect(parseSemanticToolReceipt({ ...receipt, outcome: "accepted" })).toBeNull(); + }); + it("keeps raw and forwarded input identities separate and versions historical receipts honestly", () => { + const normalizedInputSha256 = semanticInputSha256({ ...call.arguments, schema: "paperclip.run_result.v1" }); + const { receipt, result } = appendSemanticToolReceipt({ ...call, normalizedInputSha256 }, original); + expect(receipt).toMatchObject({ schema: "paperclip.semantic_tool_receipt.v2", normalizedInputSha256, + inputSha256: semanticInputSha256(call.arguments) }); + expect(receipt.inputSha256).not.toBe(normalizedInputSha256); + expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt); + expect(appendSemanticToolReceipt(call, original).receipt).toHaveProperty("normalizedInputSha256", null); + const { normalizedInputSha256: _, ...legacyFields } = receipt as typeof receipt & { normalizedInputSha256: unknown }; + const legacy = { ...legacyFields, schema: "paperclip.semantic_tool_receipt.v1" }; + expect(parseSemanticToolReceipt(legacy)).toEqual(legacy); + expect(parseSemanticToolReceipt({ ...legacy, normalizedInputSha256 })).toBeNull(); + expect(parseSemanticToolReceipt({ ...receipt, normalizedInputSha256: "null" })).toBeNull(); + expect(parseSemanticToolReceipt({ ...receipt, normalizedInputSha256: "a".repeat(65) })).toBeNull(); + expect(parseSemanticToolReceipt({ ...legacyFields, schema: "paperclip.semantic_tool_receipt.v2" })).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts new file mode 100644 index 0000000000..0d69026156 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts @@ -0,0 +1,83 @@ +import { createHash } from "node:crypto"; + +export const SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v2"; +const LEGACY_SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v1"; +export const MAX_SEMANTIC_RECEIPT_BYTES = 2048; +const MAX_NATIVE_BYTES = 256 * 1024; +interface SemanticToolReceiptFields { + operationId: string; + callIdentitySha256: string; + inputSha256: string; + resultSha256: string; + /** Transport outcome only: a returned value may explicitly reject a request. */ + outcome: "returned" | "error"; +} +export type SemanticToolReceipt = SemanticToolReceiptFields & ( + | { schema: typeof LEGACY_SEMANTIC_RECEIPT_SCHEMA } + | { schema: typeof SEMANTIC_RECEIPT_SCHEMA; normalizedInputSha256: string | null } +); +export interface SemanticToolResult { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +} +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +const object = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); +/** Same canonical JSON used by the bridge's admitted-call fingerprint. */ +export function semanticCanonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(semanticCanonicalJson).join(",")}]`; + if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${semanticCanonicalJson(value[key])}`).join(",")}}`; + return JSON.stringify(value) ?? "undefined"; +} +export const semanticInputSha256 = (value: unknown): string => hash(semanticCanonicalJson(value)); +export function parseSemanticToolReceipt(value: unknown): SemanticToolReceipt | null { + if (!object(value)) return null; + const legacy = value.schema === LEGACY_SEMANTIC_RECEIPT_SCHEMA; + const keys = legacy ? "callIdentitySha256,inputSha256,operationId,outcome,resultSha256,schema" + : "callIdentitySha256,inputSha256,normalizedInputSha256,operationId,outcome,resultSha256,schema"; + if (Object.keys(value).sort().join(",") !== keys + || (!legacy && value.schema !== SEMANTIC_RECEIPT_SCHEMA) + || typeof value.operationId !== "string" || !/^[A-Za-z0-9_.:-]{1,256}$/.test(value.operationId) + || ![value.callIdentitySha256, value.inputSha256, value.resultSha256].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v)) + || (!legacy && value.normalizedInputSha256 !== null && (typeof value.normalizedInputSha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.normalizedInputSha256))) + || (value.outcome !== "returned" && value.outcome !== "error") + || Buffer.byteLength(JSON.stringify(value)) > MAX_SEMANTIC_RECEIPT_BYTES) return null; + const fields: SemanticToolReceiptFields = { operationId: value.operationId, + callIdentitySha256: value.callIdentitySha256 as string, inputSha256: value.inputSha256 as string, + resultSha256: value.resultSha256 as string, outcome: value.outcome }; + return legacy ? { schema: LEGACY_SEMANTIC_RECEIPT_SCHEMA, ...fields } + : { schema: SEMANTIC_RECEIPT_SCHEMA, ...fields, normalizedInputSha256: value.normalizedInputSha256 as string | null }; +} +function resultDigest(result: SemanticToolResult): string { + return semanticInputSha256({ content: result.content, isError: result.isError === true }); +} +/** Append, never rewrite, the original admitted result encoding and content. */ +export function appendSemanticToolReceipt(call: { tool: string; callId: string; arguments: unknown; normalizedInputSha256?: string | null }, result: SemanticToolResult) { + const receipt = parseSemanticToolReceipt({ schema: SEMANTIC_RECEIPT_SCHEMA, operationId: call.tool, + callIdentitySha256: hash(call.callId), inputSha256: semanticInputSha256(call.arguments), + normalizedInputSha256: call.normalizedInputSha256 ?? null, + resultSha256: resultDigest(result), outcome: result.isError ? "error" : "returned" }); + if (!receipt) throw new Error("Invalid semantic receipt identity"); + return { receipt, result: { ...result, content: [...result.content, { type: "text" as const, text: JSON.stringify(receipt) }] } }; +} +/** + * A native echo is only a candidate. Authority comes from the invocation callback. + * The 256 KiB bound applies to the WHOLE native rawOutput, including repeated + * content/detailedContent. Larger results still receive bridge receipts, but + * intentionally provide no native correlation evidence; never truncate to match. + */ +export function readNativeSemanticReceipt(raw: unknown): SemanticToolReceipt | null { + try { + if (!object(raw) || Buffer.byteLength(JSON.stringify(raw)) > MAX_NATIVE_BYTES || !Array.isArray(raw.contents) + || raw.contents.length < 2 || raw.contents.length > 128) return null; + const blocks = raw.contents; + if (!blocks.every(b => object(b) && Object.keys(b).sort().join(",") === "text,type" && b.type === "text" && typeof b.text === "string")) return null; + const last = blocks.at(-1) as { text: string }; + if (Buffer.byteLength(last.text) > MAX_SEMANTIC_RECEIPT_BYTES) return null; + const receipt = parseSemanticToolReceipt(JSON.parse(last.text)); + if (!receipt) return null; + const original = blocks.slice(0, -1) as SemanticToolResult["content"]; + if (original.some(b => { try { return parseSemanticToolReceipt(JSON.parse(b.text)) !== null; } catch { return false; } })) return null; + return resultDigest({ content: original, isError: receipt.outcome === "error" }) === receipt.resultSha256 ? receipt : null; + } catch { return null; } +} +export const sameSemanticReceipt = (a: SemanticToolReceipt, b: SemanticToolReceipt): boolean => semanticCanonicalJson(a) === semanticCanonicalJson(b); diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 8b498a1093..fe7e4c7097 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -73,6 +73,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"; @@ -85,5 +86,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/cursor-usage-notice.ts b/packages/paperclip-runner/src/live/cursor-usage-notice.ts new file mode 100644 index 0000000000..f31b0c301f --- /dev/null +++ b/packages/paperclip-runner/src/live/cursor-usage-notice.ts @@ -0,0 +1,98 @@ +import type { CanonicalProviderEvent } from "../provider-events.js"; + +const SUMMARY = "Cursor reported partial native counters. These observations are not authoritative token usage or billing cost."; +const PROVENANCE = "Cursor native turnEnded observations; partial, unsummed, unverified counter semantics"; +const REASONS = new Set([ + "native_counter_semantics_unverified", "native_counters_missing", "invalid_native_counter", + "multiple_terminal_observations", "native_terminal_not_observed", "observation_limit_reached", + "child_run_attribution_unverified", +]); +const COUNTERS = new Set(["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"]); +const object = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); +const positive = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0; + +/** + * The eval transport wraps rich events in a Codex-shaped notification. Admit + * only the closed Cursor diagnostic, never arbitrary canonical provider data. + * This notice no longer contains the private prompt/request receipt IDs, so + * validate its projected shape without inventing a persisted usage receipt. + * Neither these unsummed observations nor their absence are accounting facts. + */ +export function cursorUsageNotice( + raw: unknown, + owner: { provider?: string; agent?: string; threadId: string; turnId: string | null }, +): CanonicalProviderEvent | null { + try { + if (owner.provider !== "acpx" || owner.agent !== "cursor" || !owner.threadId || !owner.turnId + || !object(raw) || !exact(raw, ["threadId", "turnId", "eventType", "itemId", "payload"]) + || raw.threadId !== owner.threadId || raw.turnId !== owner.turnId + || raw.eventType !== "provider.notice.recorded" || typeof raw.itemId !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(raw.itemId)) return null; + // Runnerd's envelope item is the durable authority item. The native notice + // keeps its own display identity inside the payload; these are independent. + const p = raw.payload; + if (!object(p) || !exact(p, ["schema", "noticeId", "severity", "category", "scope", "recoverable", "userActionable", "summary", "details"]) + || p.schema !== "paperclip.provider.notice.v1" || typeof p.noticeId !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(p.noticeId) + || p.severity !== "info" || p.category !== "cursor_native_usage_observed" || p.scope !== "turn" + || p.recoverable !== true || p.userActionable !== false || p.summary !== SUMMARY + || !Array.isArray(p.details) || p.details.length < 3 || p.details.length > 11) return null; + // Bound strings before JSON parsing/cloning. The collector's complete + // envelope is <=16 KiB; its observation strings cannot exceed that budget. + const details: Array<{ name: string; value: string }> = []; + let bytes = 0; + for (const detail of p.details) { + if (!object(detail) || !exact(detail, ["name", "value"]) || typeof detail.name !== "string" + || detail.name.length > 64 || typeof detail.value !== "string" || detail.value.length > 4_000) return null; + bytes += Buffer.byteLength(detail.value, "utf8"); + if (bytes > 16_384) return null; + details.push({ name: detail.name, value: detail.value }); + } + if (details[0]!.name !== "Provenance" || details[0]!.value !== PROVENANCE + || details[1]!.name !== "Partial reasons" || details[2]!.name !== "Collector truncated" + || !["true", "false"].includes(details[2]!.value)) return null; + const reasons = details[1]!.value.split(", "); + if (!reasons.includes("native_counter_semantics_unverified") || reasons.length > REASONS.size + || new Set(reasons).size !== reasons.length || !reasons.every(reason => REASONS.has(reason))) return null; + const invocations = new Map(); + let count = 0; + const retained = details.slice(0, 3); + for (let group = 3; group < details.length; group++) { + const values: unknown = JSON.parse(details[group]!.value); + if (!Array.isArray(values) || values.length < 1 || values.length > 8 + || (group < details.length - 1 && values.length !== 8)) return null; + if (details[group]!.name !== `Native observations ${count + 1}-${count + values.length}`) return null; + const observations = []; + for (const value of values) { + if (!object(value) || !exact(value, ["invocationId", "role", "nativeRun", "sequence", "counters"]) + || typeof value.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(value.invocationId) + || (value.role !== "parent" && value.role !== "child") || !positive(value.nativeRun) + || !positive(value.sequence) || !object(value.counters)) return null; + const counters: Record = {}; + for (const [key, number] of Object.entries(value.counters)) { + if (!COUNTERS.has(key) || !Number.isSafeInteger(number) || Number(number) < 0) return null; + counters[key] = Number(number); + } + const prior = invocations.get(value.invocationId); + if (prior && (prior.role !== value.role || prior.nativeRun !== value.nativeRun || prior.sequence >= value.sequence)) return null; + invocations.set(value.invocationId, { role: value.role, nativeRun: value.nativeRun, sequence: value.sequence }); + observations.push({ invocationId: value.invocationId, role: value.role, nativeRun: value.nativeRun, sequence: value.sequence, counters }); + } + count += observations.length; + if (count > 64) return null; + retained.push({ name: details[group]!.name, value: JSON.stringify(observations) }); + } + return { + eventType: "provider.notice.recorded", itemId: raw.itemId, + payload: { schema: "paperclip.provider.notice.v1", noticeId: p.noticeId, severity: "info", + category: "cursor_native_usage_observed", scope: "turn", recoverable: true, userActionable: false, + summary: SUMMARY, details: retained }, + }; + } catch { + // Optional diagnostics, including malformed JSON, cannot poison settlement. + return null; + } +} diff --git a/packages/paperclip-runner/src/live/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 c7e881670c..27fdc73202 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -1,7 +1,8 @@ +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"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -23,20 +24,34 @@ import { } from "./live-session.js"; import { DurableCapabilityLiveSessionStore } from "./durable-live-session-store.js"; import { defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js"; +import { persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js"; import { captureTurnRejection } from "../../test/capture-turn-rejection.js"; import * as workspaceDiff from "./workspace-diff.js"; -it.each(["pi", "copilot"] as const)("requires separately bound evaluation opt-in for %s", async (acpxAgent) => { +it.each(["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" })) .rejects.toThrow("explicit evaluation opt-in"); const mismatched = new CapabilityLiveSessionService({ transportOptions: { - acpxCandidateProfile: acpxAgent === "pi" ? "cursor" : "pi", + acpxCandidateProfile: "pi", } }); await expect(mismatched.create({ provider: "acpx", acpxAgent, 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", piThinkingLevel: "low", requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731" }); + try { + expect(session.snapshot().config.acpxProfile).toMatchObject({ + agent: "pi", ...resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"), + }); + 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"); } +}); + class AsyncNotifications implements AsyncIterable { #values: CodexRpcNotification[] = []; #waiters: Array<(value: IteratorResult) => void> = []; @@ -1166,6 +1181,180 @@ describe("Capability live runnerd and Codex session", () => { await service.shutdown(session.id); }); + describe("Cursor partial usage notice capture", () => { + function notice(threadId: string, turnId: string): CodexRpcNotification { + const canonical = persistedCursorUsageNotice({ promptMessageIds: [] }, { + lastRequestId: "request-1", promptMessageIds: ["prompt-1"], cursorPromptUsage: { + request_id: "request-1", prompt_message_id: "prompt-1", receipt: { + schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", + promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial", + reasons: ["native_counter_semantics_unverified", "child_run_attribution_unverified"], + observations: [ + { invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 12, outputTokens: 3 } }, + { invocationId: "invocation-2", role: "child", nativeRun: 1, sequence: 1, counters: { cacheReadTokens: 2, cacheWriteTokens: 4, reasoningTokens: 1 } }, + ], + limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false, + }, + }, + }, "request-1", "cursor", `${turnId}:cursor-native-usage`)!; + // Rust keeps payload.noticeId but replaces the display wrapper itemId + // with its durable authority item (durable/state.rs, runnerd transport). + return { method: "paperclip/canonicalProviderEvent", params: { threadId, turnId, ...canonical, itemId: "item_lab_fixture" } }; + } + const retained = (snapshot: CapabilityLiveSessionSnapshot) => snapshot.evidence.filter(entry => + entry.kind === "provider_event" && entry.data.canonical === true && entry.data.event === "provider.notice.recorded"); + + it("persists and reloads bounded parent/child observations without authoritative usage", async () => { + const state = providerState(); state.omitReadUsage = true; + state.onUsage = (queue, turnId) => { + queue.push(notice(state.threadId, turnId)); + queue.push({ method: "turn/completed", params: { threadId: state.threadId, turn: { id: turnId, status: "completed" } } }); + }; + const directory = await mkdtemp(join(tmpdir(), "cursor-usage-checkpoint-")); + const binding = { sessionId: "cursor-usage-session", runId: "cursor-usage-run", companyId: "company-1", actorId: "actor-1", taskId: "task-1" }; + const store = new DurableCapabilityLiveSessionStore({ directory, binding }); + const service = new CapabilityLiveSessionService({ store, transportFactory: fakeTransportFactory(state), transportOptions: { acpxCandidateProfile: "cursor" } }); + const session = await service.create({ ...binding, provider: "acpx", acpxAgent: "cursor", requestedModel: "exact-model" }); + try { + const result = await session.sendMessage("Orient to this task."); + expect(result.status).toBe("completed"); + const rows = retained(result.snapshot); + expect(rows).toHaveLength(1); + expect(rows[0]?.turnId).toBe(result.turnId); + expect(rows[0]?.data.itemId).toBe("item_lab_fixture"); + expect((rows[0]?.data.payload as Record).noticeId).toBe(`${result.turnId}:cursor-native-usage`); + expect(rows[0]?.data.payload).toEqual(notice(state.threadId, result.turnId).params.payload); + expect(result.snapshot.usageLedger).toEqual([]); + expect(result.snapshot.usageUnavailable).toEqual([expect.objectContaining({ tokenUsage: null, costNanodollars: null, reason: "provider_did_not_report_usage" })]); + expect(retained((await store.load(session.id))!)).toEqual(rows); + await service.shutdown(session.id); + const reloadedStore = new DurableCapabilityLiveSessionStore({ directory, binding }); + expect(retained((await reloadedStore.load(session.id))!)).toEqual(rows); + } finally { + await service.shutdown(session.id); + await rm(directory, { recursive: true, force: true }); + } + }); + + it("keeps terminal settlement durable after an optional diagnostic save fails", async () => { + const state = providerState(); state.omitReadUsage = true; + state.onUsage = (queue, turnId) => { + queue.push(notice(state.threadId, turnId)); + queue.push({ method: "turn/completed", params: { threadId: state.threadId, turn: { id: turnId, status: "completed" } } }); + }; + const delegate = new InMemoryCapabilityLiveSessionStore(); + let rejectedDiagnosticSaves = 0; + const store: CapabilityLiveSessionStore = { + load: id => delegate.load(id), + delete: id => delegate.delete(id), + save: async snapshot => { + if (retained(snapshot).length > 0 && rejectedDiagnosticSaves === 0) { + rejectedDiagnosticSaves++; + throw new Error("transient optional diagnostic save failure"); + } + await delegate.save(snapshot); + }, + }; + const service = new CapabilityLiveSessionService({ store, transportFactory: fakeTransportFactory(state), transportOptions: { acpxCandidateProfile: "cursor" } }); + const session = await service.create({ provider: "acpx", acpxAgent: "cursor", requestedModel: "exact-model" }); + try { + const result = await session.sendMessage("Orient to this task."); + expect(rejectedDiagnosticSaves).toBe(1); + expect(result.status).toBe("completed"); + const saved = (await store.load(session.id))!; + expect(saved.terminalTurns).toContainEqual(expect.objectContaining({ turnId: result.turnId, status: "completed" })); + expect(retained(saved)).toEqual(retained(result.snapshot)); + expect(retained(saved)).toHaveLength(1); + expect(saved.usageLedger).toEqual([]); + expect(saved.usageUnavailable).toHaveLength(1); + } finally { await service.shutdown(session.id); } + }); + + const malformedNotices: Array<(params: Record, payload: Record) => void> = [ + params => { params.threadId = "foreign-thread"; }, + params => { params.turnId = "previous-turn"; }, + params => { delete params.turnId; }, + params => { params.itemId = "raw private identity with spaces"; }, + params => { params.unexpected = "SECRET_CANARY"; }, + (_params, payload) => { payload.noticeId = "raw private identity with spaces"; }, + (_params, payload) => { payload.noticeId = "x".repeat(161); }, + (_params, payload) => { delete payload.noticeId; }, + (_params, payload) => { payload.unexpected = "SECRET_CANARY"; }, + (_params, payload) => { payload.summary = "SECRET_CANARY"; }, + (_params, payload) => { payload.details.push({ name: "rawProviderText", value: "SECRET_CANARY" }); }, + (_params, payload) => { payload.details.push(payload.details[0]); }, + (_params, payload) => { payload.details[1].value = "native_counter_semantics_unverified, SECRET_CANARY"; }, + (_params, payload) => { payload.details[3].value = "{"; }, + (_params, payload) => { payload.details[3].value = "x".repeat(4001); }, + (_params, payload) => { payload.details[3].value = JSON.stringify([{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: -1 } }]); }, + (_params, payload) => { payload.details[3].value = JSON.stringify([{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 1, rawText: "SECRET_CANARY" } }]); }, + (_params, payload) => { const values = JSON.parse(payload.details[3].value); values[1] = values[0]; payload.details[3].value = JSON.stringify(values); }, + (_params, payload) => { const values = JSON.parse(payload.details[3].value); values[1].invocationId = values[0].invocationId; values[1].sequence = 2; payload.details[3].value = JSON.stringify(values); }, + (_params, payload) => { payload.details[3].name = "Native observations 2-3"; }, + (_params, payload) => { payload.details = Array.from({ length: 12 }, () => payload.details[0]); }, + (_params, payload) => { const values = JSON.parse(payload.details[3].value); values[0].counters.inputTokens = Number.MAX_SAFE_INTEGER + 1; payload.details[3].value = JSON.stringify(values); }, + (_params, payload) => { const values = JSON.parse(payload.details[3].value); values[0].rawText = "SECRET_CANARY"; payload.details[3].value = JSON.stringify(values); }, + ]; + it.each(malformedNotices.map((mutate, index) => ({ mutate, index })))("rejects malformed/foreign notice $index without poisoning settlement", async ({ mutate }) => { + const state = providerState(); state.omitReadUsage = true; + state.onUsage = (queue, turnId) => { + const value = notice(state.threadId, turnId); + mutate(value.params, value.params.payload as Record); + queue.push(value); + queue.push({ method: "turn/completed", params: { threadId: state.threadId, turn: { id: turnId, status: "completed" } } }); + }; + const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state), transportOptions: { acpxCandidateProfile: "cursor" } }); + const session = await service.create({ provider: "acpx", acpxAgent: "cursor", requestedModel: "exact-model" }); + try { + const result = await session.sendMessage("Orient to this task."); + expect(result.status).toBe("completed"); expect(retained(result.snapshot)).toEqual([]); + expect(JSON.stringify(result.snapshot.evidence)).not.toContain("SECRET_CANARY"); + expect(result.snapshot.usageLedger).toEqual([]); expect(result.snapshot.usageUnavailable).toHaveLength(1); + } finally { await service.shutdown(session.id); } + }); + + it.each([0, 64])("retains %i bounded observations, deduplicates notices and rejects stale turns", async count => { + const state = providerState(); state.omitReadUsage = true; + state.onUsage = (queue, turnId) => { + queue.push(notice(state.threadId, "previous-turn")); + const value = notice(state.threadId, turnId); + const payload = value.params.payload as Record; + const observation = JSON.parse(payload.details[3].value)[0]; + payload.details = payload.details.slice(0, 3); + if (count === 0) payload.details[1].value += ", native_terminal_not_observed"; + for (let offset = 0; offset < count; offset += 8) { + payload.details.push({ name: `Native observations ${offset + 1}-${offset + 8}`, value: JSON.stringify(Array.from({ length: 8 }, (_, index) => ({ ...observation, invocationId: `invocation-${offset + index + 1}` }))) }); + } + queue.push(value); queue.push(structuredClone(value)); + queue.push({ method: "turn/completed", params: { threadId: state.threadId, turn: { id: turnId, status: "completed" } } }); + }; + const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state), transportOptions: { acpxCandidateProfile: "cursor" } }); + const session = await service.create({ provider: "acpx", acpxAgent: "cursor", requestedModel: "exact-model" }); + try { + const result = await session.sendMessage("Orient to this task."); + expect(result.status).toBe("completed"); expect(retained(result.snapshot)).toHaveLength(1); + const payload = retained(result.snapshot)[0]!.data.payload as Record; + expect(payload.details.slice(3).flatMap((detail: { value: string }) => JSON.parse(detail.value))).toHaveLength(count); + expect(result.snapshot.usageLedger).toEqual([]); expect(result.snapshot.usageUnavailable).toHaveLength(1); + } finally { await service.shutdown(session.id); } + }); + + it.each(["pi", "copilot"] as const)("does not accept a Cursor receipt from %s", async acpxAgent => { + const state = providerState(); state.omitReadUsage = true; + state.onUsage = (queue, turnId) => { + queue.push(notice(state.threadId, turnId)); + 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, ...(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([]); + expect(result.snapshot.usageLedger).toEqual([]); + } finally { await service.shutdown(session.id); } + }); + }); + it.each(["pi", "cursor", "copilot"] as const)("retains a completed %s result with explicit unavailable usage and no fabricated receipt", async (acpxAgent) => { const state = providerState(); state.omitReadUsage = true; @@ -1181,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"); @@ -1627,6 +1816,9 @@ describe("Capability live runnerd and Codex session", () => { sessionId: binding.sessionId, attemptId: "attempt-resumed", resumeOf: "attempt-killed", + // Only the killed attempt deliberately uses the short timeout. Allow + // durable disk writes to complete during the resumed successful turn. + turnTimeoutMs: 5_000, }); expect(resumed.snapshot().providerThreadId).toBe(state.threadId); expect(resumed.snapshot().attempts).toMatchObject([ 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-settlement.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport-settlement.test.ts index b760a59e09..826d5c466f 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport-settlement.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport-settlement.test.ts @@ -273,7 +273,14 @@ it.each([ builder.queueCommand("turn.stop", { reason: "interrupted original close", }); - if (!missingHome) builder.queueCommand("runner.suspend", {}); + // The outbound-ACK row must reach its injected loss after retained event + // commits finish. An already queued suspend starts the real two-second + // ACK deadline behind that commit queue and can time out without ever + // sending the frame this row is meant to withhold. Maintenance queues its + // own suspend after draining; the other rows keep the old pending command. + if (!missingHome && completedTerminalAck !== "completed") { + builder.queueCommand("runner.suspend", {}); + } // Match the retained production split: runner-owned unacknowledged // output plus another full provider-owned prefix behind the old suspend. const runnerFile = join(original, "runner/runner-state.json"); @@ -349,7 +356,13 @@ it.each([ ), ) .digest("hex"); - const appendEvent = vi.fn(async (_event: PrpEvent) => {}); + const appendEvent = vi.fn(async (_event: PrpEvent) => { + // Reproduce slow durable commits without extending the runtime ACK + // deadline: all 218 retained events still have to commit exactly once. + if (completedTerminalAck === "completed") { + await new Promise((resolveCommit) => setTimeout(resolveCommit, 30)); + } + }); const maintenanceAbort = new AbortController(); let finalAuthorityRevoked = false; const authorize = vi.fn(async () => { 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 486deb680b..857109376d 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,8 +24,9 @@ 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"; import type { NativeSession, @@ -49,6 +52,7 @@ import { PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, canonicalNativeRuntimeContextDigest, + composeNativeSystemInstructions, nativeRuntimePromptDigest, type NativeRuntimeContextSnapshot, } from "../contracts/runtime-context.js"; @@ -525,6 +529,120 @@ it("carries the provider attachment seed across consecutive authority rotations" }); }); +it("restores ACPX with the current registered copy after the old run copy is collected", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-acpx-context-rotation-")); + const priorRoot = join(root, "prior-run-copy"); + const currentRoot = join(root, "current-run-copy"); + await Promise.all([mkdir(priorRoot), mkdir(currentRoot)]); + const context = (rootPath: string) => { + const value = assignedRuntimeContext(join(root, "skills"), join(rootPath, "bundle")); + value.instructions.workingCopy = { kind: "agent_files", rootPath, entryPath: "AGENTS.md" }; + return value; + }; + const prior = context(priorRoot); + const current = context(currentRoot); + const customInstructions = `Keep custom instructions intact. A historical path example is ${priorRoot}.`; + const desired = { + runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "new-run", + normalizedSessionId: "same-session", turnId: "new-turn", itemId: "new-item", + }; + const provider = { + kind: "acpx", agent: "copilot", runId: "old-run", + normalizedSessionId: "same-session", commandDigest: "sha256:immutable", + model: "explicit-model", runtimeContext: prior, + instructions: composeNativeSystemInstructions(prior, customInstructions), + }; + const state = { + runAttachTemplate: { provider, workspace: { cwd: root } }, + commands: [{ type: "turn.start", payload: { text: "must not replay" } }], + }; + try { + await rm(priorRoot, { recursive: true }); + expect(() => bindAcpxAgentFiles(prior, [])).toThrowError( + expect.objectContaining({ code: "ENOENT" }), + ); + const restored = runnerdRecoveryInternals.rotatedRunAttachPayload( + state, desired, null, undefined, current, + ); + const restoredProvider = restored.provider as typeof provider; + expect(restoredProvider).toEqual({ + ...provider, runId: desired.runId, runtimeContext: current, + instructions: composeNativeSystemInstructions(current, customInstructions), + }); + expect(restoredProvider.instructions).toContain(customInstructions); + expect(restoredProvider.instructions).not.toContain(`(AGENT_HOME) is ${priorRoot}.`); + expect(restoredProvider.instructions).toContain(`(AGENT_HOME) is ${currentRoot}.`); + expect(bindAcpxAgentFiles(restoredProvider.runtimeContext, [])?.root).toContain("current-run-copy"); + expect(restored).not.toHaveProperty("text"); + expect(state.runAttachTemplate.provider.runtimeContext).toBe(prior); + // A reopened provider can retain the current grant on another restoration. + const same = runnerdRecoveryInternals.rotatedRunAttachPayload( + { runAttachTemplate: restored }, desired, null, undefined, current, + ); + expect(same).toEqual(restored); + expect((same.provider as typeof provider).runtimeContext).not.toBe(current); + // Live warm attachment has no new provider process and must preserve its + // existing context until settlement, rather than replace its filesystem grant. + const live = runnerdRecoveryInternals.rotatedRunAttachPayload( + state, desired, null, undefined, + ); + expect((live.provider as typeof provider).runtimeContext).toEqual(prior); + expect((live.provider as typeof provider).instructions).toBe(provider.instructions); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it("retargets only composed instruction framing and retains legacy custom instructions", () => { + const prior = assignedRuntimeContext("/skills", "/old-bundle"); + prior.instructions.workingCopy = { kind: "agent_files", rootPath: "/old-copy", entryPath: "AGENTS.md" }; + const current = assignedRuntimeContext("/skills", "/new-bundle"); + current.instructions.workingCopy = { kind: "agent_files", rootPath: "/new-copy", entryPath: "AGENTS.md" }; + const desired = { + runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "new-run", + normalizedSessionId: "same-session", turnId: "turn", itemId: "item", + }; + const restore = ( + oldContext: NativeRuntimeContextSnapshot, + nextContext: NativeRuntimeContextSnapshot | null, + instructions: string, + currentInstructions?: string, + ) => (runnerdRecoveryInternals.rotatedRunAttachPayload( + { runAttachTemplate: { provider: { kind: "acpx", runtimeContext: oldContext, instructions } } }, + desired, null, undefined, nextContext, + currentInstructions === undefined ? undefined : { text: currentInstructions, context: nextContext }, + ).provider as { instructions: string }).instructions; + // A quoted complete old asset paragraph is custom text, not a rewrite target. + const quotedOldBlock = composeNativeSystemInstructions(prior, "").slice(prior.prompt.text.length); + for (const custom of ["", `Historical example:${quotedOldBlock}\n\nKeep this exact custom ending.`]) { + expect(restore(prior, current, composeNativeSystemInstructions(prior, custom))) + .toBe(composeNativeSystemInstructions(current, custom)); + expect(restore(prior, null, composeNativeSystemInstructions(prior, custom))) + .toBe([prior.prompt.text, custom].filter(Boolean).join("\n\n")); + } + const noWorkingCopy = assignedRuntimeContext("/skills", "/plain-bundle"); + expect(restore(noWorkingCopy, current, composeNativeSystemInstructions(noWorkingCopy, "Custom"))) + .toBe(composeNativeSystemInstructions(current, "Custom")); + expect(restore(prior, noWorkingCopy, composeNativeSystemInstructions(prior, "Custom"))) + .toBe(composeNativeSystemInstructions(noWorkingCopy, "Custom")); + const opaque = "Legacy custom instructions mention /old-copy; keep every byte."; + expect(restore(prior, current, opaque)).toBe(opaque); + const fresh = composeNativeSystemInstructions(current, "Updated registered entry content"); + expect(restore(prior, current, composeNativeSystemInstructions(prior, "Old content"), fresh)) + .toBe(fresh); + const remote = assignedRuntimeContext("/runner/skills", "/runner/context/instructions"); + remote.instructions.workingCopy = { ...current.instructions.workingCopy, rootPath: "/runner/agent-home" }; + const custom = "Current instructions keep a literal example /new-copy unchanged."; + const remoteProvider = runnerdRecoveryInternals.rotatedRunAttachPayload( + { runAttachTemplate: { provider: { kind: "acpx", runtimeContext: prior, instructions: composeNativeSystemInstructions(prior, "Old") } } }, + desired, null, undefined, remote, + { text: composeNativeSystemInstructions(current, custom), context: current }, + ).provider as { instructions: string; runtimeContext: NativeRuntimeContextSnapshot }; + expect(remoteProvider.instructions).toBe(composeNativeSystemInstructions(remote, custom)); + expect(remoteProvider.runtimeContext).toEqual(remote); + +}); + it("replays the durable run attachment outcome and latest provider identity", () => { expect( runnerdRecoveryInternals.recoveredRunAttachment({ @@ -595,6 +713,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([ @@ -605,6 +726,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) => { @@ -1271,6 +1393,42 @@ it("reserves a bounded suspension window after close preparation", () => { }); }); +it.each([ + { provider: "acpx" as const, acpxAgent: "pi" as const, closeGraceMs: undefined, drained: true }, + { provider: "codex" as const, acpxAgent: undefined, closeGraceMs: undefined, drained: false }, + { provider: "acpx" as const, acpxAgent: "pi" as const, closeGraceMs: 400, drained: false }, +])("keeps Pi stop, remote drain and suspension within a finite close budget ($provider, $closeGraceMs)", async ({ drained, ...options }) => { + vi.useFakeTimers(); + try { + const { preparationDeadline, closeDeadline } = runnerdRecoveryInternals.runnerCloseDeadlines( + Date.now(), runnerdRecoveryInternals.runnerCloseGraceMs(options), + ); + // The retained restart failure spent 5.2s stopping idle Pi before its + // remote drain acknowledgement crossed the next command round trip. + await vi.advanceTimersByTimeAsync(5_200); + const commands: { commandId: string; status: string; result?: unknown }[] = []; + const result = runnerdRecoveryInternals.awaitProviderDrainBarrier({ + readProviderState: () => null, + semanticResultsSettled: () => true, + commands: () => commands, + queueDrain: commandId => { + const command = { commandId, status: "pending", result: undefined as unknown }; + commands.push(command); + setTimeout(() => { + command.status = "completed"; + command.result = { result: { retainedEventsDrained: true } }; + }, 2_800); + }, + pump: () => undefined, + deadline: Date.now() + Math.min(5_000, Math.max(0, preparationDeadline - Date.now())), + }); + await vi.advanceTimersByTimeAsync(5_000); + expect(await result).toBe(drained); + expect(closeDeadline - preparationDeadline).toBeLessThanOrEqual(2_500); + expect(runnerdRecoveryInternals.runnerCloseGraceMs(options)).toBeLessThanOrEqual(15_000); + } finally { vi.useRealTimers(); } +}); + it("joins an already-completed suspension without queuing a command to an exited runner", async () => { const commands = [ { commandId: "exact-suspend", type: "runner.suspend", status: "completed" }, @@ -1770,12 +1928,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", ], }, @@ -1911,6 +2067,21 @@ it.each(["opencode", "acpx"] as const)( }, ); +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", piThinkingLevel: "low", stateDirectory: root }); + try { + await expect(transport.request("collaborationMode/list", {})).resolves.toMatchObject({ data: [{ mode: "plan" }] }); + for (const acpxAgent of ["copilot"] as const) { + expect(() => createCapabilityRunnerdCodexTransport({ provider: "acpx", acpxAgent, stateDirectory: root })) + .toThrow("explicit evaluation opt-in"); + } + } finally { + await transport.close(); + await rm(root, { recursive: true, force: true }); + } +}); + it("allows trusted package-manager runtime roots without exposing HOME paths", () => { expect( trustedRuntimeReadOnlyRoots({ @@ -4589,9 +4760,14 @@ it("steers the active provider turn through the durable PRP command path", async } }, 30_000); -it.each(["held-ack", "lost-ack", "rejected-attach"] as const)( - "preserves old warm-attach authority and event ownership across %s", - async (mode) => { +it.each([ + ["held-ack", "normal"], + ["lost-ack", "normal"], + ["rejected-attach", "normal"], + ["lost-ack", "after-activation"], +] as const)( + "preserves old warm-attach authority and event ownership across %s (%s observer)", + async (mode, observer) => { const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-warm-ack-")); const callsPath = join(stateDirectory, "calls.log"); const cores: DurablePrpControlPlane[] = []; @@ -4727,6 +4903,34 @@ it.each(["held-ack", "lost-ack", "rejected-attach"] as const)( const runnerPid = bundle.evidence().runnerPid; providerPid = bundle.evidence().codexPid; const rotations: (typeof core.store.state)[] = []; + const preparedStates: (typeof core.store.state)[] = []; + const commit = core.store.commit.bind(core.store); + vi.spyOn(core.store, "commit").mockImplementation((candidate) => { + commit(candidate); + // Capture the durable old epoch at publication. The authenticated + // successor may activate before attachRun observes the completed command. + if ( + candidate.identity.runId === oldIdentity.runId && + candidate.warmTransition?.phase === "prepared" + ) { + preparedStates.push(structuredClone(core.store.state)); + } + }); + if (observer === "after-activation") { + const getCommand = core.getCommand.bind(core); + vi.spyOn(core, "getCommand").mockImplementation((commandId) => { + const command = getCommand(commandId); + if ( + command?.type === "run.attach" && + command.status === "completed" && + core.store.state.completedWarmTransition === undefined + ) { + // Deterministically exercise an observer that misses the old epoch. + return { ...command, status: "pending", result: null }; + } + return command; + }); + } const rotate = core.rotateRunIdentity.bind(core); vi.spyOn(core, "rotateRunIdentity").mockImplementation( (identity, template) => { @@ -4800,7 +5004,16 @@ it.each(["held-ack", "lost-ack", "rejected-attach"] as const)( releaseCommit(); await within("warm attach after old ACK", attachment, 10_000); expect(rotations).toHaveLength(1); - const retired = rotations[0]!; + expect(preparedStates.length).toBeGreaterThan(0); + const retired = preparedStates[0]!; + if (observer === "after-activation") { + expect(rotations[0]!.identity.runId).toBe("run-warm-ack-next"); + expect( + rotations[0]!.committedEvents.some( + (entry) => entry.sourceEventId === heldEvent!.sourceEventId, + ), + ).toBe(false); + } const attachedEvent = retired.committedEvents.find( (entry) => entry.sourceEventId === heldEvent!.sourceEventId, )!; @@ -6234,6 +6447,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-"), @@ -7413,12 +7695,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, @@ -7473,6 +7757,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 }); } @@ -7651,7 +7941,7 @@ it("preserves prepared input and completion feedback through runnerd and the rea // because its signing and dylib lookup can depend on that location. const providerNode = process.platform === "linux" ? join(root, "node") : process.execPath; if (process.platform === "linux") { - await cp(process.execPath, providerNode); + await cp(process.execPath, providerNode, { dereference: true }); await chmod(providerNode, 0o500); } // The qualified launch boundary unlinks its executable after exec. Use a @@ -7740,3 +8030,86 @@ it("preserves prepared input and completion feedback through runnerd and the rea evidence: () => bundle.evidence(), }); }, 30_000); + + +it.each([ + { 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(/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 222c884056..4290d68d90 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"; @@ -7,6 +9,7 @@ import { codexExecutableReadOnlyRoots } from "../drivers/codex/codex-security-co import { resolveCodexCommand } from "../drivers/codex/codex-command.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, @@ -99,7 +102,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( @@ -579,7 +582,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) @@ -596,7 +601,8 @@ function providerDrainStateFromSnapshot(state: Record): { pendingEventCount: pending + queued, activeProviderTurnId, providerSettled: - activeProviderTurnId === null && state.ambiguousTurnStartPending !== true, + activeProviderTurnId === null && state.ambiguousTurnStartPending !== true + && state.providerExitUnconfirmed !== true, }; } @@ -833,6 +839,14 @@ async function awaitRunnerSuspensionBarrier(input: { return false; } +function runnerCloseGraceMs(options: Pick): number { + // Idle Pi retirement may consume its five-second RPC stop window. Keep a + // separate five-second drain round trip and the bounded suspension reserve. + // Explicit caller deadlines remain authoritative; all settlement proofs stay + // mandatory even when the provider already completed its task. + return options.closeGraceMs ?? (options.provider === "acpx" && options.acpxAgent === "pi" ? 15_000 : 10_000); +} + function runnerCloseDeadlines( startedAtMs: number, graceMs: number, @@ -923,8 +937,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, @@ -943,6 +961,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, @@ -1138,6 +1186,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; @@ -1161,6 +1211,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. */ @@ -3522,9 +3573,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"); } @@ -3533,7 +3586,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"); } @@ -3722,7 +3775,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", @@ -3783,6 +3839,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 @@ -3880,6 +3945,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; } @@ -3999,7 +4072,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"); @@ -4187,7 +4269,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 = @@ -4200,9 +4282,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; @@ -4221,7 +4311,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; } @@ -4328,6 +4420,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) && @@ -4339,7 +4437,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // fresh run authority never inherits the prior run's pending events. const { preparationDeadline, closeDeadline } = runnerCloseDeadlines( Date.now(), - this.options.closeGraceMs ?? 10_000, + runnerCloseGraceMs(this.options), ); if (!(await this.#runnerHasExited())) { // Let an already-admitted tool result reach its original provider @@ -4358,7 +4456,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())), ); @@ -4374,6 +4472,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }, readRunnerState: async () => { const state = await this.#readDurableRunnerState(); + lastSuspensionState = state; assertSuspendedRunnerState(state, this.#core!.store.state.identity); return state; }, @@ -4382,6 +4481,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", ); @@ -4492,6 +4606,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const settlement = { runnerSuspended, providerDrained, + suspensionState, semanticTools: this.#core?.semanticToolSettlementDiagnostics(), finalProviderState, }; @@ -4757,6 +4872,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, ), @@ -4893,6 +5009,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", @@ -4947,9 +5064,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); @@ -5514,6 +5631,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({ @@ -5612,11 +5731,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 @@ -5624,7 +5745,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", @@ -5642,6 +5763,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { !this.#checkpointProviderIdentityConfirmed ? "session.resumed" : undefined, + admissionDeadline, ); this.#startupComplete = true; this.#diagnostic( @@ -5909,12 +6031,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`); @@ -5969,12 +6092,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 && @@ -5996,28 +6126,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, + }); } @@ -6427,6 +6549,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) { @@ -6932,6 +7056,7 @@ export const runnerdRecoveryInternals = Object.freeze({ recoveredRunAttachment, releaseRunnerProcessOwnership, runnerCloseDeadlines, + runnerCloseGraceMs, rotatedRunAttachPayload, rotateExternalAuthorityEpoch, turnStartCommandResultValid, @@ -6965,3 +7090,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 18f17f2746..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": [ { @@ -3362,6 +3390,11 @@ export const questionSetSchema = { "text" ] }, + "initialText": { + "type": "string", + "maxLength": 100000, + "description": "Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds." + }, "options": { "type": "array", "maxItems": 128, @@ -3420,6 +3453,11 @@ export const questionSetSchema = { "required": [ "options" ], + "not": { + "required": [ + "initialText" + ] + }, "properties": { "options": { "type": "array", diff --git a/packages/paperclip-runner/src/protocol/generated/standalone-validators.ts b/packages/paperclip-runner/src/protocol/generated/standalone-validators.ts index 7b47ad1075..45bd5bdf83 100644 --- a/packages/paperclip-runner/src/protocol/generated/standalone-validators.ts +++ b/packages/paperclip-runner/src/protocol/generated/standalone-validators.ts @@ -4,4 +4,4 @@ import equalModule from "ajv/dist/runtime/equal.js"; import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js"; -"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2,3]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v3/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v3/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v3/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema47 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema48 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v2/command.schema.json","title":"PRP v2 runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v2"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown","session.goal.get","session.goal.set","session.goal.clear"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"type":{"const":"session.goal.set"}}},"then":{"properties":{"payload":{"type":"object","properties":{"requestId":{"type":"string","minLength":1,"maxLength":160},"objective":{"type":"string","minLength":1,"maxLength":4000},"status":{"enum":["active","paused"]},"tokenBudget":{"type":["integer","null"],"minimum":1}},"additionalProperties":true}}}}],"additionalProperties":true};const schema49 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v3/command.schema.json","title":"PRP v3 runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v3"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown","session.goal.get","session.goal.set","session.goal.clear","external_provider.operation"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"type":{"const":"session.goal.set"}},"type":"object"},"then":{"properties":{"payload":{"type":"object","properties":{"requestId":{"type":"string","minLength":1,"maxLength":160},"objective":{"type":"string","minLength":1,"maxLength":4000},"status":{"enum":["active","paused"]},"tokenBudget":{"type":["integer","null"],"minimum":1}},"additionalProperties":true}},"type":"object"}},{"if":{"properties":{"type":{"const":"external_provider.operation"}},"type":"object"},"then":{"properties":{"payload":{"type":"object","required":["requestId","bindingId","runId","normalizedSessionId","turnId","bindingGeneration","assignmentRevision","digest","action","input"],"properties":{"requestId":{"type":"string","minLength":1,"maxLength":240},"bindingId":{"type":"string","minLength":1,"maxLength":240},"runId":{"type":"string","minLength":1,"maxLength":240},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":240},"turnId":{"type":"string","minLength":1,"maxLength":240},"bindingGeneration":{"type":"integer","minimum":1},"assignmentRevision":{"type":"integer","minimum":1},"digest":{"type":"string","pattern":"^sha256:[0-9a-f]{64}$"},"action":{"enum":["accept","tool","progress","finish"]},"input":{"type":"object"}},"additionalProperties":false}},"type":"object"}},{"if":{"properties":{"type":{"const":"external_provider.operation"},"payload":{"properties":{"action":{"const":"accept"}},"type":"object"}},"type":"object"},"then":{"properties":{"payload":{"properties":{"input":{"type":"object","properties":{},"required":[],"additionalProperties":false}},"type":"object"}},"type":"object"}},{"if":{"properties":{"type":{"const":"external_provider.operation"},"payload":{"properties":{"action":{"const":"tool"}},"type":"object"}},"type":"object"},"then":{"properties":{"payload":{"properties":{"input":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":160},"arguments":{"type":"object"}},"required":["name","arguments"],"additionalProperties":false}},"type":"object"}},"type":"object"}},{"if":{"properties":{"type":{"const":"external_provider.operation"},"payload":{"properties":{"action":{"const":"progress"}},"type":"object"}},"type":"object"},"then":{"properties":{"payload":{"properties":{"input":{"type":"object","properties":{"text":{"type":"string","minLength":1,"maxLength":12000}},"required":["text"],"additionalProperties":false}},"type":"object"}},"type":"object"}},{"if":{"properties":{"type":{"const":"external_provider.operation"},"payload":{"properties":{"action":{"const":"finish"}},"type":"object"}},"type":"object"},"then":{"properties":{"payload":{"properties":{"input":{"type":"object","properties":{"result":{"type":"object"}},"required":["result"],"additionalProperties":false}},"type":"object"}},"type":"object"}}],"additionalProperties":true};const schema202 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/result.schema.json","title":"PRP structured run result","type":"object","required":["schema","reportedWorkDisposition","summary","completionClaim","evidence","verification","attentionRequests","artifacts"],"properties":{"schema":{"const":"paperclip.run_result.v1"},"reportedWorkDisposition":{"enum":["done","blocked","needs_review","yielded"]},"summary":{"type":"string","minLength":1,"maxLength":12000},"completionClaim":{"type":"object","required":["contractRevision","objectiveSatisfied","criteria","remainingWork"],"properties":{"contractRevision":{"type":"string","minLength":1},"objectiveSatisfied":{"type":"boolean"},"criteria":{"type":"array","items":{"type":"object","required":["criterionId","status","evidenceRefs"],"properties":{"criterionId":{"type":"string","minLength":1},"status":{"enum":["satisfied","not_satisfied","unknown"]},"evidenceRefs":{"type":"array","items":{"type":"string"}},"explanation":{"type":"string"}},"additionalProperties":true}},"remainingWork":{"type":"array","items":{"type":"object","required":["description","blocksCompletion"],"properties":{"description":{"type":"string","minLength":1},"blocksCompletion":{"type":"boolean"}},"additionalProperties":true}}},"additionalProperties":true},"evidence":{"type":"array","items":{"type":"object"}},"verification":{"type":"array","description":"Checks used to verify the work. Use failed only when the check actually ran and found a defect in the work. If a check could not run or complete because the environment, tool, dependency, permission, credential, policy, external service, or budget was unavailable, use not_run with reasonCode even when the attempted command exited non-zero.","items":{"type":"object","required":["commandOrCheck","status"],"properties":{"commandOrCheck":{"type":"string","minLength":1},"status":{"enum":["passed","failed","not_run"],"description":"passed means the check ran and succeeded; failed means it ran to a meaningful verdict and found the work incorrect; not_run means no meaningful verdict was possible because the check was not attempted or could not complete."},"reasonCode":{"description":"Required for not_run; identify why the check had no meaningful verdict.","enum":["environment_unavailable","tool_unavailable","dependency_missing","permission_denied","credential_missing","policy_restricted","external_service_unavailable","budget_exhausted","other"]},"detail":{"type":"string"},"artifactRef":{"type":"string"}},"additionalProperties":true}},"blocker":{"type":"object","required":["reasonCode","owner","unblockAction","scope"],"properties":{"reasonCode":{"type":"string","minLength":1},"owner":{"type":"object","required":["kind","name"],"properties":{"kind":{"enum":["agent","user","system","external"]},"name":{"type":"string","minLength":1}},"additionalProperties":true},"unblockAction":{"type":"string","minLength":1},"scope":{"enum":["current_track","task_wide"]}},"additionalProperties":true},"attentionRequests":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","minLength":1},"summary":{"type":"string","minLength":1},"ownerClass":{"enum":["human","agent","external_system"]},"targetAgentId":{"type":"string","minLength":1}},"additionalProperties":true}},"artifacts":{"type":"array","items":{"type":"object","required":["kind","ref"],"properties":{"kind":{"type":"string","minLength":1},"ref":{"type":"string","minLength":1},"title":{"type":"string"}},"additionalProperties":true}},"continuation":{"type":"object","required":["kind","summary","idempotencyKey"],"properties":{"kind":{"enum":["same_agent","retry","delegated_issue","response_wake","monitor"]},"summary":{"type":"string","minLength":1},"idempotencyKey":{"type":"string","minLength":1}},"additionalProperties":true}},"allOf":[{"if":{"properties":{"reportedWorkDisposition":{"const":"blocked"}}},"then":{"required":["blocker"]}},{"if":{"properties":{"reportedWorkDisposition":{"const":"yielded"}}},"then":{"required":["continuation"]}}],"additionalProperties":true};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const func66 = Object.prototype.hasOwnProperty;const schema32 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/identity.schema.json","title":"PRP identity graph","type":"object","required":["schema","companyId","issueId","runId","environmentLeaseId","runnerInstanceId","normalizedSessionId","driverSessionId"],"properties":{"schema":{"const":"paperclip.prp.identity.v1"},"companyId":{"$ref":"#/$defs/stableId"},"issueId":{"$ref":"#/$defs/stableId"},"runId":{"$ref":"#/$defs/stableId"},"environmentLeaseId":{"$ref":"#/$defs/stableId"},"runnerInstanceId":{"$ref":"#/$defs/stableId"},"normalizedSessionId":{"$ref":"#/$defs/stableId"},"driverSessionId":{"$ref":"#/$defs/stableId"},"providerSessionId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true,"$defs":{"stableId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"}}};const schema33 = {"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate21(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/identity.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate21.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.companyId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "companyId"},message:"must have required property '"+"companyId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.issueId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "issueId"},message:"must have required property '"+"issueId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.runId === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.environmentLeaseId === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "environmentLeaseId"},message:"must have required property '"+"environmentLeaseId"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.runnerInstanceId === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runnerInstanceId"},message:"must have required property '"+"runnerInstanceId"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.normalizedSessionId === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.driverSessionId === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "driverSessionId"},message:"must have required property '"+"driverSessionId"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.schema !== undefined){if("paperclip.prp.identity.v1" !== data.schema){const err8 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.identity.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.companyId !== undefined){let data1 = data.companyId;if(typeof data1 === "string"){if(func1(data1) > 160){const err9 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data1) < 1){const err10 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data1)){const err11 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.issueId !== undefined){let data2 = data.issueId;if(typeof data2 === "string"){if(func1(data2) > 160){const err13 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data2) < 1){const err14 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(!pattern4.test(data2)){const err15 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.runId !== undefined){let data3 = data.runId;if(typeof data3 === "string"){if(func1(data3) > 160){const err17 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data3) < 1){const err18 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(!pattern4.test(data3)){const err19 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}else {const err20 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.environmentLeaseId !== undefined){let data4 = data.environmentLeaseId;if(typeof data4 === "string"){if(func1(data4) > 160){const err21 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data4) < 1){const err22 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data4)){const err23 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.runnerInstanceId !== undefined){let data5 = data.runnerInstanceId;if(typeof data5 === "string"){if(func1(data5) > 160){const err25 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(func1(data5) < 1){const err26 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(!pattern4.test(data5)){const err27 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}else {const err28 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data.normalizedSessionId !== undefined){let data6 = data.normalizedSessionId;if(typeof data6 === "string"){if(func1(data6) > 160){const err29 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(func1(data6) < 1){const err30 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(!pattern4.test(data6)){const err31 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}else {const err32 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}if(data.driverSessionId !== undefined){let data7 = data.driverSessionId;if(typeof data7 === "string"){if(func1(data7) > 160){const err33 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(func1(data7) < 1){const err34 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(!pattern4.test(data7)){const err35 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}else {const err36 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.providerSessionId !== undefined){let data8 = data.providerSessionId;if(typeof data8 === "string"){if(func1(data8) > 160){const err37 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(func1(data8) < 1){const err38 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(!pattern4.test(data8)){const err39 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}else {const err40 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}}else {const err41 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}validate21.errors = vErrors;return errors === 0;}validate21.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json","title":"PRP v2 negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents","sessionGoals"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v2"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"sessionGoals":{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema44 = {"type":"object","required":["availability","actions","autonomousUpdates","persistentAcrossResume","maxObjectiveChars","tokenBudgetControl","usageReporting"],"properties":{"availability":{"enum":["available","unsupported","policy_disabled"]},"actions":{"type":"array","items":{"enum":["set","pause","resume","clear"]},"uniqueItems":true},"autonomousUpdates":{"type":"boolean"},"persistentAcrossResume":{"type":"boolean"},"maxObjectiveChars":{"type":"integer","minimum":1,"maximum":4000},"tokenBudgetControl":{"type":"boolean"},"usageReporting":{"type":"boolean"},"reasonCode":{"type":"string","minLength":1,"maxLength":160},"reason":{"type":"string","minLength":1,"maxLength":1000}},"additionalProperties":true};const func0 = typeof equalModule === "function" ? equalModule : equalModule.default;function validate23(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v2/capabilities.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate23.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.sessionReusePolicy === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sessionReusePolicy"},message:"must have required property '"+"sessionReusePolicy"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.driver === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "driver"},message:"must have required property '"+"driver"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.steer === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "steer"},message:"must have required property '"+"steer"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.interrupt === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "interrupt"},message:"must have required property '"+"interrupt"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.resume === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "resume"},message:"must have required property '"+"resume"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.runtimeRequests === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runtimeRequests"},message:"must have required property '"+"runtimeRequests"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.structuredResult === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "structuredResult"},message:"must have required property '"+"structuredResult"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.typedEvents === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "typedEvents"},message:"must have required property '"+"typedEvents"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.sessionGoals === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sessionGoals"},message:"must have required property '"+"sessionGoals"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.schema !== undefined){if("paperclip.prp.capabilities.v2" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.capabilities.v2"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.sessionReusePolicy !== undefined){let data1 = data.sessionReusePolicy;if(!(((data1 === "new_per_run") || (data1 === "reuse_per_issue")) || (data1 === "reuse_per_workspace"))){const err11 = {instancePath:instancePath+"/sessionReusePolicy",schemaPath:"#/properties/sessionReusePolicy/enum",keyword:"enum",params:{allowedValues: schema42.properties.sessionReusePolicy.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.driver !== undefined){let data2 = data.driver;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.kind === undefined){const err12 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.version === undefined){const err13 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data2.kind !== undefined){let data3 = data2.kind;if(typeof data3 === "string"){if(func1(data3) > 80){const err14 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(func1(data3) < 1){const err15 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data2.version !== undefined){let data4 = data2.version;if(typeof data4 === "string"){if(func1(data4) > 80){const err17 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data4) < 1){const err18 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}else {const err20 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.steer !== undefined){if(typeof data.steer !== "boolean"){const err21 = {instancePath:instancePath+"/steer",schemaPath:"#/properties/steer/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.interrupt !== undefined){if(typeof data.interrupt !== "boolean"){const err22 = {instancePath:instancePath+"/interrupt",schemaPath:"#/properties/interrupt/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.resume !== undefined){if(typeof data.resume !== "boolean"){const err23 = {instancePath:instancePath+"/resume",schemaPath:"#/properties/resume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}if(data.runtimeRequests !== undefined){if(typeof data.runtimeRequests !== "boolean"){const err24 = {instancePath:instancePath+"/runtimeRequests",schemaPath:"#/properties/runtimeRequests/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.structuredResult !== undefined){if(typeof data.structuredResult !== "boolean"){const err25 = {instancePath:instancePath+"/structuredResult",schemaPath:"#/properties/structuredResult/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.typedEvents !== undefined){if(typeof data.typedEvents !== "boolean"){const err26 = {instancePath:instancePath+"/typedEvents",schemaPath:"#/properties/typedEvents/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data.sessionGoals !== undefined){let data11 = data.sessionGoals;if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.availability === undefined){const err27 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "availability"},message:"must have required property '"+"availability"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data11.actions === undefined){const err28 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "actions"},message:"must have required property '"+"actions"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data11.autonomousUpdates === undefined){const err29 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "autonomousUpdates"},message:"must have required property '"+"autonomousUpdates"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data11.persistentAcrossResume === undefined){const err30 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "persistentAcrossResume"},message:"must have required property '"+"persistentAcrossResume"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data11.maxObjectiveChars === undefined){const err31 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "maxObjectiveChars"},message:"must have required property '"+"maxObjectiveChars"+"'"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(data11.tokenBudgetControl === undefined){const err32 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "tokenBudgetControl"},message:"must have required property '"+"tokenBudgetControl"+"'"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(data11.usageReporting === undefined){const err33 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "usageReporting"},message:"must have required property '"+"usageReporting"+"'"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(data11.availability !== undefined){let data12 = data11.availability;if(!(((data12 === "available") || (data12 === "unsupported")) || (data12 === "policy_disabled"))){const err34 = {instancePath:instancePath+"/sessionGoals/availability",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/availability/enum",keyword:"enum",params:{allowedValues: schema44.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data11.actions !== undefined){let data13 = data11.actions;if(Array.isArray(data13)){const len0 = data13.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data13[i1], data13[j0])){const err36 = {instancePath:instancePath+"/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;break outer0;}}}}}else {const err37 = {instancePath:instancePath+"/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data11.autonomousUpdates !== undefined){if(typeof data11.autonomousUpdates !== "boolean"){const err38 = {instancePath:instancePath+"/sessionGoals/autonomousUpdates",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/autonomousUpdates/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data11.persistentAcrossResume !== undefined){if(typeof data11.persistentAcrossResume !== "boolean"){const err39 = {instancePath:instancePath+"/sessionGoals/persistentAcrossResume",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/persistentAcrossResume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}if(data11.maxObjectiveChars !== undefined){let data17 = data11.maxObjectiveChars;if(!(((typeof data17 == "number") && (!(data17 % 1) && !isNaN(data17))) && (isFinite(data17)))){const err40 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if((typeof data17 == "number") && (isFinite(data17))){if(data17 > 4000 || isNaN(data17)){const err41 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/maximum",keyword:"maximum",params:{comparison: "<=", limit: 4000},message:"must be <= 4000"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data17 < 1 || isNaN(data17)){const err42 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}if(data11.tokenBudgetControl !== undefined){if(typeof data11.tokenBudgetControl !== "boolean"){const err43 = {instancePath:instancePath+"/sessionGoals/tokenBudgetControl",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/tokenBudgetControl/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}if(data11.usageReporting !== undefined){if(typeof data11.usageReporting !== "boolean"){const err44 = {instancePath:instancePath+"/sessionGoals/usageReporting",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/usageReporting/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data11.reasonCode !== undefined){let data20 = data11.reasonCode;if(typeof data20 === "string"){if(func1(data20) > 160){const err45 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(func1(data20) < 1){const err46 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}else {const err47 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data11.reason !== undefined){let data21 = data11.reason;if(typeof data21 === "string"){if(func1(data21) > 1000){const err48 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}if(func1(data21) < 1){const err49 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data.unsupported !== undefined){let data22 = data.unsupported;if(Array.isArray(data22)){const len1 = data22.length;for(let i2=0; i2 1){const indices0 = {};for(;i3--;){let item0 = data22[i3];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j1 = indices0[item0];const err54 = {instancePath:instancePath+"/unsupported",schemaPath:"#/properties/unsupported/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;break;}indices0[item0] = i3;}}}else {const err55 = {instancePath:instancePath+"/unsupported",schemaPath:"#/properties/unsupported/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}}else {const err56 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}validate23.errors = vErrors;return errors === 0;}validate23.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v3/capabilities.schema.json","title":"PRP v3 negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents","sessionGoals"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v3"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"sessionGoals":{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true},"externalProvider":{"type":"boolean"}},"additionalProperties":true};function validate26(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v3/capabilities.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate26.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.sessionReusePolicy === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sessionReusePolicy"},message:"must have required property '"+"sessionReusePolicy"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.driver === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "driver"},message:"must have required property '"+"driver"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.steer === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "steer"},message:"must have required property '"+"steer"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.interrupt === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "interrupt"},message:"must have required property '"+"interrupt"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.resume === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "resume"},message:"must have required property '"+"resume"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.runtimeRequests === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runtimeRequests"},message:"must have required property '"+"runtimeRequests"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.structuredResult === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "structuredResult"},message:"must have required property '"+"structuredResult"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.typedEvents === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "typedEvents"},message:"must have required property '"+"typedEvents"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.sessionGoals === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sessionGoals"},message:"must have required property '"+"sessionGoals"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.schema !== undefined){if("paperclip.prp.capabilities.v3" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.capabilities.v3"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.sessionReusePolicy !== undefined){let data1 = data.sessionReusePolicy;if(!(((data1 === "new_per_run") || (data1 === "reuse_per_issue")) || (data1 === "reuse_per_workspace"))){const err11 = {instancePath:instancePath+"/sessionReusePolicy",schemaPath:"#/properties/sessionReusePolicy/enum",keyword:"enum",params:{allowedValues: schema45.properties.sessionReusePolicy.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.driver !== undefined){let data2 = data.driver;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.kind === undefined){const err12 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.version === undefined){const err13 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data2.kind !== undefined){let data3 = data2.kind;if(typeof data3 === "string"){if(func1(data3) > 80){const err14 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(func1(data3) < 1){const err15 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data2.version !== undefined){let data4 = data2.version;if(typeof data4 === "string"){if(func1(data4) > 80){const err17 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data4) < 1){const err18 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}else {const err20 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.steer !== undefined){if(typeof data.steer !== "boolean"){const err21 = {instancePath:instancePath+"/steer",schemaPath:"#/properties/steer/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.interrupt !== undefined){if(typeof data.interrupt !== "boolean"){const err22 = {instancePath:instancePath+"/interrupt",schemaPath:"#/properties/interrupt/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.resume !== undefined){if(typeof data.resume !== "boolean"){const err23 = {instancePath:instancePath+"/resume",schemaPath:"#/properties/resume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}if(data.runtimeRequests !== undefined){if(typeof data.runtimeRequests !== "boolean"){const err24 = {instancePath:instancePath+"/runtimeRequests",schemaPath:"#/properties/runtimeRequests/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.structuredResult !== undefined){if(typeof data.structuredResult !== "boolean"){const err25 = {instancePath:instancePath+"/structuredResult",schemaPath:"#/properties/structuredResult/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.typedEvents !== undefined){if(typeof data.typedEvents !== "boolean"){const err26 = {instancePath:instancePath+"/typedEvents",schemaPath:"#/properties/typedEvents/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data.sessionGoals !== undefined){let data11 = data.sessionGoals;if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.availability === undefined){const err27 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "availability"},message:"must have required property '"+"availability"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data11.actions === undefined){const err28 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "actions"},message:"must have required property '"+"actions"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data11.autonomousUpdates === undefined){const err29 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "autonomousUpdates"},message:"must have required property '"+"autonomousUpdates"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data11.persistentAcrossResume === undefined){const err30 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "persistentAcrossResume"},message:"must have required property '"+"persistentAcrossResume"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data11.maxObjectiveChars === undefined){const err31 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "maxObjectiveChars"},message:"must have required property '"+"maxObjectiveChars"+"'"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(data11.tokenBudgetControl === undefined){const err32 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "tokenBudgetControl"},message:"must have required property '"+"tokenBudgetControl"+"'"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(data11.usageReporting === undefined){const err33 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "usageReporting"},message:"must have required property '"+"usageReporting"+"'"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(data11.availability !== undefined){let data12 = data11.availability;if(!(((data12 === "available") || (data12 === "unsupported")) || (data12 === "policy_disabled"))){const err34 = {instancePath:instancePath+"/sessionGoals/availability",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/availability/enum",keyword:"enum",params:{allowedValues: schema44.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data11.actions !== undefined){let data13 = data11.actions;if(Array.isArray(data13)){const len0 = data13.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data13[i1], data13[j0])){const err36 = {instancePath:instancePath+"/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;break outer0;}}}}}else {const err37 = {instancePath:instancePath+"/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data11.autonomousUpdates !== undefined){if(typeof data11.autonomousUpdates !== "boolean"){const err38 = {instancePath:instancePath+"/sessionGoals/autonomousUpdates",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/autonomousUpdates/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data11.persistentAcrossResume !== undefined){if(typeof data11.persistentAcrossResume !== "boolean"){const err39 = {instancePath:instancePath+"/sessionGoals/persistentAcrossResume",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/persistentAcrossResume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}if(data11.maxObjectiveChars !== undefined){let data17 = data11.maxObjectiveChars;if(!(((typeof data17 == "number") && (!(data17 % 1) && !isNaN(data17))) && (isFinite(data17)))){const err40 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if((typeof data17 == "number") && (isFinite(data17))){if(data17 > 4000 || isNaN(data17)){const err41 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/maximum",keyword:"maximum",params:{comparison: "<=", limit: 4000},message:"must be <= 4000"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data17 < 1 || isNaN(data17)){const err42 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}if(data11.tokenBudgetControl !== undefined){if(typeof data11.tokenBudgetControl !== "boolean"){const err43 = {instancePath:instancePath+"/sessionGoals/tokenBudgetControl",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/tokenBudgetControl/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}if(data11.usageReporting !== undefined){if(typeof data11.usageReporting !== "boolean"){const err44 = {instancePath:instancePath+"/sessionGoals/usageReporting",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/usageReporting/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data11.reasonCode !== undefined){let data20 = data11.reasonCode;if(typeof data20 === "string"){if(func1(data20) > 160){const err45 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(func1(data20) < 1){const err46 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}else {const err47 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data11.reason !== undefined){let data21 = data11.reason;if(typeof data21 === "string"){if(func1(data21) > 1000){const err48 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}if(func1(data21) < 1){const err49 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data.unsupported !== undefined){let data22 = data.unsupported;if(Array.isArray(data22)){const len1 = data22.length;for(let i2=0; i2 1){const indices0 = {};for(;i3--;){let item0 = data22[i3];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j1 = indices0[item0];const err54 = {instancePath:instancePath+"/unsupported",schemaPath:"#/properties/unsupported/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;break;}indices0[item0] = i3;}}}else {const err55 = {instancePath:instancePath+"/unsupported",schemaPath:"#/properties/unsupported/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data.externalProvider !== undefined){if(typeof data.externalProvider !== "boolean"){const err56 = {instancePath:instancePath+"/externalProvider",schemaPath:"#/properties/externalProvider/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}}else {const err57 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}validate26.errors = vErrors;return errors === 0;}validate26.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema50 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/event.schema.json","title":"PRP native event","type":"object","required":["schema","sourceEventId","sourceSeq","sourceInstanceId","sourceKind","runId","eventType","schemaVersion","priority","emittedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.event.v1"},"sourceEventId":{"type":"string","minLength":1,"maxLength":160},"sourceSeq":{"type":"integer","minimum":1,"maximum":9007199254740991},"sourceInstanceId":{"type":"string","minLength":1,"maxLength":160},"sourceKind":{"enum":["runner","control_plane"]},"runId":{"type":"string","minLength":1,"maxLength":160},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":160},"turnId":{"type":"string","minLength":1,"maxLength":240},"itemId":{"type":"string","minLength":1,"maxLength":240},"eventType":{"enum":["runner.connected","runner.reconnected","runner.reconciled","runner.disconnected","runner.draining","runner.backpressure","runner.suspending","runner.suspended","runner.stopped","runner.diagnostic","runtime.phase.changed","sandbox.metric","workspace.ready","workspace.change.updated","workspace.diff.recorded","workspace.file.referenced","harness.starting","harness.ready","harness.exited","harness.diagnostic","plan.updated","tool.execution.started","tool.execution.progressed","tool.execution.completed","research.started","research.progressed","research.completed","delegation.started","delegation.updated","delegation.completed","model.route.changed","model.verification.updated","context.compacted","artifact.viewed","artifact.generated","review.mode.changed","hook.started","hook.completed","memory.citation.referenced","safety.review.started","safety.review.completed","terminal.input.sent","wait.started","wait.completed","provider.notice.recorded","session.starting","session.started","session.resuming","session.resumed","session.reconciled","session.updated","session.closed","session.failed","turn.submitted","turn.accepted","turn.started","turn.completed","turn.failed","turn.interrupted","turn.cancelled","item.started","item.delta","item.completed","item.failed","usage.reported","semantic_tool.input","semantic_tool.result","semantic_tool.reconciled","mcp_app.discovered","mcp_app.resource.resolved","mcp_app.initializing","mcp_app.ready","mcp_app.tool_input","mcp_app.tool_result","mcp_app.action.requested","mcp_app.action.resolved","mcp_app.host_context.changed","mcp_app.failed","mcp_app.teardown","runtime_request.created","runtime_request.resolved","runtime_request.expired","runtime_request.cancelled","interaction.request.proposed","interaction.request.materialized","interaction.request.rejected","interaction.response.progressed","interaction.response.resolved","interaction.response.delivered","run.attached","run.detached","run.result.proposed","run.result.accepted","run.result.rejected","attention.request.proposed","attention.request.routed","attention.request.resolved","attention.request.expired","attention.request.superseded","work.assessment.recorded","issue.status.decision.recorded","issue.status.decision.applied","issue.status.decision.rejected","issue.status.decision.superseded","run.terminal"]},"schemaVersion":{"const":1},"priority":{"enum":[0,1,2]},"emittedAt":{"type":"string","format":"date-time"},"observedAt":{"type":"string","format":"date-time"},"payload":{"type":"object","properties":{"channel":{"enum":["progress","final","summary","detail","unknown"]},"providerPhase":{"type":"string"},"providerMethod":{"type":"string"}},"additionalProperties":true},"debug":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"eventType":{"const":"plan.updated"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/plan"}}}},{"if":{"properties":{"eventType":{"enum":["tool.execution.started","tool.execution.progressed","tool.execution.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/toolExecution"}}}},{"if":{"properties":{"eventType":{"enum":["research.started","research.progressed","research.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/research"}}}},{"if":{"properties":{"eventType":{"enum":["delegation.started","delegation.updated","delegation.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/delegation"}}}},{"if":{"properties":{"eventType":{"const":"model.route.changed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelRoute"}}}},{"if":{"properties":{"eventType":{"const":"model.verification.updated"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelVerification"}}}},{"if":{"properties":{"eventType":{"const":"context.compacted"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/contextCompacted"}}}},{"if":{"properties":{"eventType":{"const":"artifact.viewed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactViewed"}}}},{"if":{"properties":{"eventType":{"const":"artifact.generated"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactGenerated"}}}},{"if":{"properties":{"eventType":{"const":"review.mode.changed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/reviewMode"}}}},{"if":{"properties":{"eventType":{"enum":["hook.started","hook.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/hook"}}}},{"if":{"properties":{"eventType":{"const":"memory.citation.referenced"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/memoryCitation"}}}},{"if":{"properties":{"eventType":{"enum":["safety.review.started","safety.review.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/safetyReview"}}}},{"if":{"properties":{"eventType":{"const":"terminal.input.sent"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/terminalInput"}}}},{"if":{"properties":{"eventType":{"enum":["wait.started","wait.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/wait"}}}},{"if":{"properties":{"eventType":{"const":"provider.notice.recorded"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/providerNotice"}}}},{"if":{"properties":{"eventType":{"const":"workspace.file.referenced"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json"}}}},{"if":{"properties":{"eventType":{"enum":["workspace.change.updated","workspace.diff.recorded"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json"}}}},{"if":{"properties":{"eventType":{"const":"workspace.diff.recorded"}}},"then":{"properties":{"payload":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json"},{"type":"object","properties":{"complete":{"const":true}}}]}}}},{"if":{"properties":{"eventType":{"enum":["semantic_tool.input","mcp_app.tool_input"]}}},"then":{"properties":{"payload":{"type":"object","properties":{"semantic_tool":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"},{"type":"object","properties":{"phase":{"const":"input"}}}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"enum":["semantic_tool.result","mcp_app.tool_result"]}}},"then":{"properties":{"payload":{"type":"object","properties":{"semantic_tool":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"},{"type":"object","properties":{"phase":{"const":"result"}}}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"const":"semantic_tool.reconciled"}}},"then":{"properties":{"payload":{"type":"object","properties":{"semantic_tool":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"},{"type":"object","properties":{"phase":{"const":"reconciled"}}}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"const":"run.terminal"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/terminal.schema.json"}}}},{"if":{"properties":{"eventType":{"const":"run.result.proposed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}}}}],"additionalProperties":true};const schema174 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json","title":"Paperclip workspace file reference","type":"object","required":["schema","referenceId","source","path","displayName","mediaType","presentation","line","preview","previewTruncated","contentDigest"],"properties":{"schema":{"const":"paperclip.workspace.file_reference.v1"},"referenceId":{"type":"string","minLength":1,"maxLength":240},"source":{"enum":["harness_reported","runner_verified"]},"path":{"type":"string","minLength":1,"maxLength":1024,"pattern":"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},"displayName":{"type":"string","minLength":1,"maxLength":255},"mediaType":{"type":["string","null"],"maxLength":160},"presentation":{"enum":["document","code","image","generic"]},"line":{"type":["integer","null"],"minimum":1},"preview":{"type":["string","null"],"maxLength":262144},"previewTruncated":{"type":"boolean"},"contentDigest":{"oneOf":[{"type":"null"},{"type":"string","pattern":"^sha256:[a-f0-9]{64}$"}]}},"additionalProperties":false};const schema175 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json","title":"Paperclip workspace diff","type":"object","required":["schema","changeSetId","revision","source","complete","files","totals","patchArtifactRef"],"properties":{"schema":{"const":"paperclip.workspace.diff.v1"},"changeSetId":{"type":"string","minLength":1,"maxLength":200},"revision":{"type":"integer","minimum":1},"source":{"enum":["harness_reported","runner_verified"]},"complete":{"type":"boolean"},"files":{"type":"array","maxItems":2000,"items":{"type":"object","required":["path","operation","previousPath","additions","deletions","binary","diff"],"properties":{"path":{"type":"string","minLength":1,"maxLength":1024,"pattern":"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},"operation":{"enum":["create","modify","delete","rename","mode_change"]},"previousPath":{"oneOf":[{"type":"null"},{"type":"string","minLength":1,"maxLength":1024,"pattern":"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"}]},"additions":{"type":["integer","null"],"minimum":0},"deletions":{"type":["integer","null"],"minimum":0},"binary":{"type":"boolean"},"diff":{"type":["string","null"],"maxLength":262144}},"additionalProperties":false}},"totals":{"type":"object","required":["files","additions","deletions"],"properties":{"files":{"type":"integer","minimum":0},"additions":{"type":["integer","null"],"minimum":0},"deletions":{"type":["integer","null"],"minimum":0}},"additionalProperties":false},"patchArtifactRef":{"type":["string","null"],"maxLength":2048}},"additionalProperties":false};const schema52 = {"type":"object","description":"A complete provider-authored within-turn checklist snapshot. Consumers replace the prior snapshot with the same planId in PRP sourceSeq order; this is not Paperclip's durable Plan document.","required":["schema","planId","revision","steps","complete","syncStatus"],"properties":{"schema":{"const":"paperclip.plan.updated.v1"},"planId":{"$ref":"#/$defs/id"},"revision":{"type":"integer","minimum":1},"explanation":{"$ref":"#/$defs/nullableShortText"},"steps":{"type":"array","maxItems":256,"items":{"type":"object","required":["stepId","body","status"],"properties":{"stepId":{"$ref":"#/$defs/id"},"body":{"$ref":"#/$defs/shortText"},"status":{"enum":["pending","in_progress","completed","blocked"]}},"additionalProperties":false}},"complete":{"type":"boolean"},"syncStatus":{"description":"Legacy compatibility field. Within-turn checklist snapshots use not_applicable.","enum":["streaming","pending","synchronized","conflict","not_applicable"]},"documentRevision":{"description":"Legacy compatibility field. Within-turn checklist snapshots use null.","type":["integer","null"],"minimum":1}},"additionalProperties":false};const schema53 = {"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};const schema54 = {"type":["string","null"],"maxLength":4000};const schema56 = {"type":"string","maxLength":4000};function validate62(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate62.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.planId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "planId"},message:"must have required property '"+"planId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.revision === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.steps === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "steps"},message:"must have required property '"+"steps"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.complete === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "complete"},message:"must have required property '"+"complete"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.syncStatus === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "syncStatus"},message:"must have required property '"+"syncStatus"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((((key0 === "schema") || (key0 === "planId")) || (key0 === "revision")) || (key0 === "explanation")) || (key0 === "steps")) || (key0 === "complete")) || (key0 === "syncStatus")) || (key0 === "documentRevision"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.plan.updated.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.plan.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.planId !== undefined){let data1 = data.planId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/planId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/planId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/planId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/planId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.revision !== undefined){let data2 = data.revision;if(!(((typeof data2 == "number") && (!(data2 % 1) && !isNaN(data2))) && (isFinite(data2)))){const err12 = {instancePath:instancePath+"/revision",schemaPath:"#/properties/revision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if((typeof data2 == "number") && (isFinite(data2))){if(data2 < 1 || isNaN(data2)){const err13 = {instancePath:instancePath+"/revision",schemaPath:"#/properties/revision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}if(data.explanation !== undefined){let data3 = data.explanation;if((typeof data3 !== "string") && (data3 !== null)){const err14 = {instancePath:instancePath+"/explanation",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(typeof data3 === "string"){if(func1(data3) > 4000){const err15 = {instancePath:instancePath+"/explanation",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}if(data.steps !== undefined){let data4 = data.steps;if(Array.isArray(data4)){if(data4.length > 256){const err16 = {instancePath:instancePath+"/steps",schemaPath:"#/properties/steps/maxItems",keyword:"maxItems",params:{limit: 256},message:"must NOT have more than 256 items"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}const len0 = data4.length;for(let i0=0; i0 160){const err21 = {instancePath:instancePath+"/steps/" + i0+"/stepId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data6) < 1){const err22 = {instancePath:instancePath+"/steps/" + i0+"/stepId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data6)){const err23 = {instancePath:instancePath+"/steps/" + i0+"/stepId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/steps/" + i0+"/stepId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data5.body !== undefined){let data7 = data5.body;if(typeof data7 === "string"){if(func1(data7) > 4000){const err25 = {instancePath:instancePath+"/steps/" + i0+"/body",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}else {const err26 = {instancePath:instancePath+"/steps/" + i0+"/body",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data5.status !== undefined){let data8 = data5.status;if(!((((data8 === "pending") || (data8 === "in_progress")) || (data8 === "completed")) || (data8 === "blocked"))){const err27 = {instancePath:instancePath+"/steps/" + i0+"/status",schemaPath:"#/properties/steps/items/properties/status/enum",keyword:"enum",params:{allowedValues: schema52.properties.steps.items.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}}else {const err28 = {instancePath:instancePath+"/steps/" + i0,schemaPath:"#/properties/steps/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}else {const err29 = {instancePath:instancePath+"/steps",schemaPath:"#/properties/steps/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data.complete !== undefined){if(typeof data.complete !== "boolean"){const err30 = {instancePath:instancePath+"/complete",schemaPath:"#/properties/complete/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}if(data.syncStatus !== undefined){let data10 = data.syncStatus;if(!(((((data10 === "streaming") || (data10 === "pending")) || (data10 === "synchronized")) || (data10 === "conflict")) || (data10 === "not_applicable"))){const err31 = {instancePath:instancePath+"/syncStatus",schemaPath:"#/properties/syncStatus/enum",keyword:"enum",params:{allowedValues: schema52.properties.syncStatus.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data.documentRevision !== undefined){let data11 = data.documentRevision;if((!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))) && (data11 !== null)){const err32 = {instancePath:instancePath+"/documentRevision",schemaPath:"#/properties/documentRevision/type",keyword:"type",params:{type: schema52.properties.documentRevision.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if((typeof data11 == "number") && (isFinite(data11))){if(data11 < 1 || isNaN(data11)){const err33 = {instancePath:instancePath+"/documentRevision",schemaPath:"#/properties/documentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}}else {const err34 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}validate62.errors = vErrors;return errors === 0;}validate62.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema57 = {"type":"object","required":["schema","executionId","transport","operation","status","output","outputBytes","outputTruncated","outputDigest"],"properties":{"schema":{"const":"paperclip.tool.execution.v1"},"executionId":{"$ref":"#/$defs/id"},"transport":{"enum":["process","mcp","dynamic","builtin"]},"operation":{"enum":["read","search","list","execute","edit","unknown"]},"name":{"$ref":"#/$defs/nullableShortText"},"target":{"$ref":"#/$defs/safePath"},"namespace":{"type":["string","null"],"maxLength":240},"readOnly":{"type":["boolean","null"]},"inputUpdated":{"type":"boolean"},"status":{"enum":["running","completed","failed","cancelled","interrupted"]},"durationMs":{"type":["integer","null"],"minimum":0},"exitCode":{"type":["integer","null"]},"progress":{"$ref":"#/$defs/nullableShortText"},"output":{"type":["string","null"],"maxLength":65536},"outputBytes":{"type":"integer","minimum":0},"outputTruncated":{"type":"boolean"},"outputDigest":{"type":["string","null"],"pattern":"^sha256:[a-f0-9]{64}$"}},"additionalProperties":false};const schema60 = {"type":["string","null"],"maxLength":4096,"pattern":"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"};const pattern19 = new RegExp("^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$", "u");const pattern20 = new RegExp("^sha256:[a-f0-9]{64}$", "u");function validate64(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate64.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.executionId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "executionId"},message:"must have required property '"+"executionId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.transport === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "transport"},message:"must have required property '"+"transport"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.operation === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "operation"},message:"must have required property '"+"operation"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.status === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.output === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "output"},message:"must have required property '"+"output"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.outputBytes === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "outputBytes"},message:"must have required property '"+"outputBytes"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.outputTruncated === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "outputTruncated"},message:"must have required property '"+"outputTruncated"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.outputDigest === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "outputDigest"},message:"must have required property '"+"outputDigest"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}for(const key0 in data){if(!(func66.call(schema57.properties, key0))){const err9 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.schema !== undefined){if("paperclip.tool.execution.v1" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.tool.execution.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.executionId !== undefined){let data1 = data.executionId;if(typeof data1 === "string"){if(func1(data1) > 160){const err11 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(func1(data1) < 1){const err12 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(!pattern4.test(data1)){const err13 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}else {const err14 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.transport !== undefined){let data2 = data.transport;if(!((((data2 === "process") || (data2 === "mcp")) || (data2 === "dynamic")) || (data2 === "builtin"))){const err15 = {instancePath:instancePath+"/transport",schemaPath:"#/properties/transport/enum",keyword:"enum",params:{allowedValues: schema57.properties.transport.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.operation !== undefined){let data3 = data.operation;if(!((((((data3 === "read") || (data3 === "search")) || (data3 === "list")) || (data3 === "execute")) || (data3 === "edit")) || (data3 === "unknown"))){const err16 = {instancePath:instancePath+"/operation",schemaPath:"#/properties/operation/enum",keyword:"enum",params:{allowedValues: schema57.properties.operation.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.name !== undefined){let data4 = data.name;if((typeof data4 !== "string") && (data4 !== null)){const err17 = {instancePath:instancePath+"/name",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 4000){const err18 = {instancePath:instancePath+"/name",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}if(data.target !== undefined){let data5 = data.target;if((typeof data5 !== "string") && (data5 !== null)){const err19 = {instancePath:instancePath+"/target",schemaPath:"#/$defs/safePath/type",keyword:"type",params:{type: schema60.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(typeof data5 === "string"){if(func1(data5) > 4096){const err20 = {instancePath:instancePath+"/target",schemaPath:"#/$defs/safePath/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(!pattern19.test(data5)){const err21 = {instancePath:instancePath+"/target",schemaPath:"#/$defs/safePath/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"+"\""};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}if(data.namespace !== undefined){let data6 = data.namespace;if((typeof data6 !== "string") && (data6 !== null)){const err22 = {instancePath:instancePath+"/namespace",schemaPath:"#/properties/namespace/type",keyword:"type",params:{type: schema57.properties.namespace.type},message:"must be string,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(typeof data6 === "string"){if(func1(data6) > 240){const err23 = {instancePath:instancePath+"/namespace",schemaPath:"#/properties/namespace/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}if(data.readOnly !== undefined){let data7 = data.readOnly;if((typeof data7 !== "boolean") && (data7 !== null)){const err24 = {instancePath:instancePath+"/readOnly",schemaPath:"#/properties/readOnly/type",keyword:"type",params:{type: schema57.properties.readOnly.type},message:"must be boolean,null"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.inputUpdated !== undefined){if(typeof data.inputUpdated !== "boolean"){const err25 = {instancePath:instancePath+"/inputUpdated",schemaPath:"#/properties/inputUpdated/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.status !== undefined){let data9 = data.status;if(!(((((data9 === "running") || (data9 === "completed")) || (data9 === "failed")) || (data9 === "cancelled")) || (data9 === "interrupted"))){const err26 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema57.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data.durationMs !== undefined){let data10 = data.durationMs;if((!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))) && (data10 !== null)){const err27 = {instancePath:instancePath+"/durationMs",schemaPath:"#/properties/durationMs/type",keyword:"type",params:{type: schema57.properties.durationMs.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if((typeof data10 == "number") && (isFinite(data10))){if(data10 < 0 || isNaN(data10)){const err28 = {instancePath:instancePath+"/durationMs",schemaPath:"#/properties/durationMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}if(data.exitCode !== undefined){let data11 = data.exitCode;if((!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))) && (data11 !== null)){const err29 = {instancePath:instancePath+"/exitCode",schemaPath:"#/properties/exitCode/type",keyword:"type",params:{type: schema57.properties.exitCode.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data.progress !== undefined){let data12 = data.progress;if((typeof data12 !== "string") && (data12 !== null)){const err30 = {instancePath:instancePath+"/progress",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(typeof data12 === "string"){if(func1(data12) > 4000){const err31 = {instancePath:instancePath+"/progress",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}}if(data.output !== undefined){let data13 = data.output;if((typeof data13 !== "string") && (data13 !== null)){const err32 = {instancePath:instancePath+"/output",schemaPath:"#/properties/output/type",keyword:"type",params:{type: schema57.properties.output.type},message:"must be string,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(typeof data13 === "string"){if(func1(data13) > 65536){const err33 = {instancePath:instancePath+"/output",schemaPath:"#/properties/output/maxLength",keyword:"maxLength",params:{limit: 65536},message:"must NOT have more than 65536 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}if(data.outputBytes !== undefined){let data14 = data.outputBytes;if(!(((typeof data14 == "number") && (!(data14 % 1) && !isNaN(data14))) && (isFinite(data14)))){const err34 = {instancePath:instancePath+"/outputBytes",schemaPath:"#/properties/outputBytes/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if((typeof data14 == "number") && (isFinite(data14))){if(data14 < 0 || isNaN(data14)){const err35 = {instancePath:instancePath+"/outputBytes",schemaPath:"#/properties/outputBytes/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}}if(data.outputTruncated !== undefined){if(typeof data.outputTruncated !== "boolean"){const err36 = {instancePath:instancePath+"/outputTruncated",schemaPath:"#/properties/outputTruncated/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.outputDigest !== undefined){let data16 = data.outputDigest;if((typeof data16 !== "string") && (data16 !== null)){const err37 = {instancePath:instancePath+"/outputDigest",schemaPath:"#/properties/outputDigest/type",keyword:"type",params:{type: schema57.properties.outputDigest.type},message:"must be string,null"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(typeof data16 === "string"){if(!pattern20.test(data16)){const err38 = {instancePath:instancePath+"/outputDigest",schemaPath:"#/properties/outputDigest/pattern",keyword:"pattern",params:{pattern: "^sha256:[a-f0-9]{64}$"},message:"must match pattern \""+"^sha256:[a-f0-9]{64}$"+"\""};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}}}else {const err39 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}validate64.errors = vErrors;return errors === 0;}validate64.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema62 = {"type":"object","required":["schema","researchId","action","status","sources"],"properties":{"schema":{"const":"paperclip.research.v1"},"researchId":{"$ref":"#/$defs/id"},"action":{"enum":["search","open_page","find_in_page","other"]},"status":{"enum":["running","completed","failed","cancelled"]},"query":{"$ref":"#/$defs/nullableShortText"},"url":{"$ref":"#/$defs/safeUrl"},"pattern":{"$ref":"#/$defs/nullableShortText"},"sources":{"type":"array","maxItems":64,"items":{"type":"object","required":["sourceId","title","url","snippet"],"properties":{"sourceId":{"$ref":"#/$defs/id"},"title":{"$ref":"#/$defs/shortText"},"url":{"$ref":"#/$defs/safeUrl"},"snippet":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false}}},"additionalProperties":false};const schema65 = {"type":["string","null"],"maxLength":8192,"pattern":"^https?://"};const pattern22 = new RegExp("^https?://", "u");function validate66(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate66.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.researchId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "researchId"},message:"must have required property '"+"researchId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.action === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "action"},message:"must have required property '"+"action"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.status === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.sources === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sources"},message:"must have required property '"+"sources"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!((((((((key0 === "schema") || (key0 === "researchId")) || (key0 === "action")) || (key0 === "status")) || (key0 === "query")) || (key0 === "url")) || (key0 === "pattern")) || (key0 === "sources"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.research.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.research.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.researchId !== undefined){let data1 = data.researchId;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/researchId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/researchId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/researchId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/researchId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.action !== undefined){let data2 = data.action;if(!((((data2 === "search") || (data2 === "open_page")) || (data2 === "find_in_page")) || (data2 === "other"))){const err11 = {instancePath:instancePath+"/action",schemaPath:"#/properties/action/enum",keyword:"enum",params:{allowedValues: schema62.properties.action.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.status !== undefined){let data3 = data.status;if(!((((data3 === "running") || (data3 === "completed")) || (data3 === "failed")) || (data3 === "cancelled"))){const err12 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema62.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.query !== undefined){let data4 = data.query;if((typeof data4 !== "string") && (data4 !== null)){const err13 = {instancePath:instancePath+"/query",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 4000){const err14 = {instancePath:instancePath+"/query",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}if(data.url !== undefined){let data5 = data.url;if((typeof data5 !== "string") && (data5 !== null)){const err15 = {instancePath:instancePath+"/url",schemaPath:"#/$defs/safeUrl/type",keyword:"type",params:{type: schema65.type},message:"must be string,null"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(typeof data5 === "string"){if(func1(data5) > 8192){const err16 = {instancePath:instancePath+"/url",schemaPath:"#/$defs/safeUrl/maxLength",keyword:"maxLength",params:{limit: 8192},message:"must NOT have more than 8192 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(!pattern22.test(data5)){const err17 = {instancePath:instancePath+"/url",schemaPath:"#/$defs/safeUrl/pattern",keyword:"pattern",params:{pattern: "^https?://"},message:"must match pattern \""+"^https?://"+"\""};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}if(data.pattern !== undefined){let data6 = data.pattern;if((typeof data6 !== "string") && (data6 !== null)){const err18 = {instancePath:instancePath+"/pattern",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(typeof data6 === "string"){if(func1(data6) > 4000){const err19 = {instancePath:instancePath+"/pattern",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}if(data.sources !== undefined){let data7 = data.sources;if(Array.isArray(data7)){if(data7.length > 64){const err20 = {instancePath:instancePath+"/sources",schemaPath:"#/properties/sources/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}const len0 = data7.length;for(let i0=0; i0 160){const err26 = {instancePath:instancePath+"/sources/" + i0+"/sourceId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(func1(data9) < 1){const err27 = {instancePath:instancePath+"/sources/" + i0+"/sourceId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(!pattern4.test(data9)){const err28 = {instancePath:instancePath+"/sources/" + i0+"/sourceId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}else {const err29 = {instancePath:instancePath+"/sources/" + i0+"/sourceId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data8.title !== undefined){let data10 = data8.title;if(typeof data10 === "string"){if(func1(data10) > 4000){const err30 = {instancePath:instancePath+"/sources/" + i0+"/title",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}else {const err31 = {instancePath:instancePath+"/sources/" + i0+"/title",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data8.url !== undefined){let data11 = data8.url;if((typeof data11 !== "string") && (data11 !== null)){const err32 = {instancePath:instancePath+"/sources/" + i0+"/url",schemaPath:"#/$defs/safeUrl/type",keyword:"type",params:{type: schema65.type},message:"must be string,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(typeof data11 === "string"){if(func1(data11) > 8192){const err33 = {instancePath:instancePath+"/sources/" + i0+"/url",schemaPath:"#/$defs/safeUrl/maxLength",keyword:"maxLength",params:{limit: 8192},message:"must NOT have more than 8192 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(!pattern22.test(data11)){const err34 = {instancePath:instancePath+"/sources/" + i0+"/url",schemaPath:"#/$defs/safeUrl/pattern",keyword:"pattern",params:{pattern: "^https?://"},message:"must match pattern \""+"^https?://"+"\""};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}if(data8.snippet !== undefined){let data12 = data8.snippet;if((typeof data12 !== "string") && (data12 !== null)){const err35 = {instancePath:instancePath+"/sources/" + i0+"/snippet",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(typeof data12 === "string"){if(func1(data12) > 4000){const err36 = {instancePath:instancePath+"/sources/" + i0+"/snippet",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}}}else {const err37 = {instancePath:instancePath+"/sources/" + i0,schemaPath:"#/properties/sources/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}}else {const err38 = {instancePath:instancePath+"/sources",schemaPath:"#/properties/sources/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}}else {const err39 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}validate66.errors = vErrors;return errors === 0;}validate66.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema71 = {"type":"object","required":["schema","delegationId","action","status","children"],"properties":{"schema":{"const":"paperclip.delegation.v1"},"delegationId":{"$ref":"#/$defs/id"},"action":{"enum":["spawn","message","resume","wait","close"]},"status":{"enum":["running","waiting","completed","failed","interrupted","closed"]},"children":{"type":"array","maxItems":64,"items":{"type":"object","required":["childId","role","model","status","summary","activitySummary"],"properties":{"childId":{"$ref":"#/$defs/id"},"role":{"type":["string","null"],"maxLength":160},"model":{"type":["string","null"],"maxLength":240},"status":{"enum":["running","waiting","completed","failed","interrupted","closed"]},"summary":{"$ref":"#/$defs/nullableShortText"},"activitySummary":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false}}},"additionalProperties":false};function validate68(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate68.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.delegationId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "delegationId"},message:"must have required property '"+"delegationId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.action === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "action"},message:"must have required property '"+"action"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.status === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.children === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "children"},message:"must have required property '"+"children"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "delegationId")) || (key0 === "action")) || (key0 === "status")) || (key0 === "children"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.delegation.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.delegation.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.delegationId !== undefined){let data1 = data.delegationId;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/delegationId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/delegationId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/delegationId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/delegationId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.action !== undefined){let data2 = data.action;if(!(((((data2 === "spawn") || (data2 === "message")) || (data2 === "resume")) || (data2 === "wait")) || (data2 === "close"))){const err11 = {instancePath:instancePath+"/action",schemaPath:"#/properties/action/enum",keyword:"enum",params:{allowedValues: schema71.properties.action.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.status !== undefined){let data3 = data.status;if(!((((((data3 === "running") || (data3 === "waiting")) || (data3 === "completed")) || (data3 === "failed")) || (data3 === "interrupted")) || (data3 === "closed"))){const err12 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema71.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.children !== undefined){let data4 = data.children;if(Array.isArray(data4)){if(data4.length > 64){const err13 = {instancePath:instancePath+"/children",schemaPath:"#/properties/children/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}const len0 = data4.length;for(let i0=0; i0 160){const err21 = {instancePath:instancePath+"/children/" + i0+"/childId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data6) < 1){const err22 = {instancePath:instancePath+"/children/" + i0+"/childId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data6)){const err23 = {instancePath:instancePath+"/children/" + i0+"/childId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/children/" + i0+"/childId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data5.role !== undefined){let data7 = data5.role;if((typeof data7 !== "string") && (data7 !== null)){const err25 = {instancePath:instancePath+"/children/" + i0+"/role",schemaPath:"#/properties/children/items/properties/role/type",keyword:"type",params:{type: schema71.properties.children.items.properties.role.type},message:"must be string,null"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(typeof data7 === "string"){if(func1(data7) > 160){const err26 = {instancePath:instancePath+"/children/" + i0+"/role",schemaPath:"#/properties/children/items/properties/role/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}if(data5.model !== undefined){let data8 = data5.model;if((typeof data8 !== "string") && (data8 !== null)){const err27 = {instancePath:instancePath+"/children/" + i0+"/model",schemaPath:"#/properties/children/items/properties/model/type",keyword:"type",params:{type: schema71.properties.children.items.properties.model.type},message:"must be string,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(typeof data8 === "string"){if(func1(data8) > 240){const err28 = {instancePath:instancePath+"/children/" + i0+"/model",schemaPath:"#/properties/children/items/properties/model/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}if(data5.status !== undefined){let data9 = data5.status;if(!((((((data9 === "running") || (data9 === "waiting")) || (data9 === "completed")) || (data9 === "failed")) || (data9 === "interrupted")) || (data9 === "closed"))){const err29 = {instancePath:instancePath+"/children/" + i0+"/status",schemaPath:"#/properties/children/items/properties/status/enum",keyword:"enum",params:{allowedValues: schema71.properties.children.items.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data5.summary !== undefined){let data10 = data5.summary;if((typeof data10 !== "string") && (data10 !== null)){const err30 = {instancePath:instancePath+"/children/" + i0+"/summary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(typeof data10 === "string"){if(func1(data10) > 4000){const err31 = {instancePath:instancePath+"/children/" + i0+"/summary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}}if(data5.activitySummary !== undefined){let data11 = data5.activitySummary;if((typeof data11 !== "string") && (data11 !== null)){const err32 = {instancePath:instancePath+"/children/" + i0+"/activitySummary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(typeof data11 === "string"){if(func1(data11) > 4000){const err33 = {instancePath:instancePath+"/children/" + i0+"/activitySummary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}}else {const err34 = {instancePath:instancePath+"/children/" + i0,schemaPath:"#/properties/children/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}else {const err35 = {instancePath:instancePath+"/children",schemaPath:"#/properties/children/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}}else {const err36 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}validate68.errors = vErrors;return errors === 0;}validate68.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema76 = {"type":"object","required":["schema","routeId","provider","requestedModel","fromModel","effectiveModel","reason"],"properties":{"schema":{"const":"paperclip.model.route_changed.v1"},"routeId":{"$ref":"#/$defs/id"},"provider":{"type":"string","minLength":1,"maxLength":160},"requestedModel":{"type":"string","minLength":1,"maxLength":240},"fromModel":{"type":["string","null"],"maxLength":240},"effectiveModel":{"type":"string","minLength":1,"maxLength":240},"reason":{"$ref":"#/$defs/shortText"}},"additionalProperties":false};function validate70(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate70.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.routeId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "routeId"},message:"must have required property '"+"routeId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.provider === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.requestedModel === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "requestedModel"},message:"must have required property '"+"requestedModel"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.fromModel === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "fromModel"},message:"must have required property '"+"fromModel"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.effectiveModel === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "effectiveModel"},message:"must have required property '"+"effectiveModel"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.reason === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reason"},message:"must have required property '"+"reason"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}for(const key0 in data){if(!(((((((key0 === "schema") || (key0 === "routeId")) || (key0 === "provider")) || (key0 === "requestedModel")) || (key0 === "fromModel")) || (key0 === "effectiveModel")) || (key0 === "reason"))){const err7 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.schema !== undefined){if("paperclip.model.route_changed.v1" !== data.schema){const err8 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.model.route_changed.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.routeId !== undefined){let data1 = data.routeId;if(typeof data1 === "string"){if(func1(data1) > 160){const err9 = {instancePath:instancePath+"/routeId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data1) < 1){const err10 = {instancePath:instancePath+"/routeId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data1)){const err11 = {instancePath:instancePath+"/routeId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/routeId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.provider !== undefined){let data2 = data.provider;if(typeof data2 === "string"){if(func1(data2) > 160){const err13 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data2) < 1){const err14 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.requestedModel !== undefined){let data3 = data.requestedModel;if(typeof data3 === "string"){if(func1(data3) > 240){const err16 = {instancePath:instancePath+"/requestedModel",schemaPath:"#/properties/requestedModel/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data3) < 1){const err17 = {instancePath:instancePath+"/requestedModel",schemaPath:"#/properties/requestedModel/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}else {const err18 = {instancePath:instancePath+"/requestedModel",schemaPath:"#/properties/requestedModel/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.fromModel !== undefined){let data4 = data.fromModel;if((typeof data4 !== "string") && (data4 !== null)){const err19 = {instancePath:instancePath+"/fromModel",schemaPath:"#/properties/fromModel/type",keyword:"type",params:{type: schema76.properties.fromModel.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 240){const err20 = {instancePath:instancePath+"/fromModel",schemaPath:"#/properties/fromModel/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data.effectiveModel !== undefined){let data5 = data.effectiveModel;if(typeof data5 === "string"){if(func1(data5) > 240){const err21 = {instancePath:instancePath+"/effectiveModel",schemaPath:"#/properties/effectiveModel/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data5) < 1){const err22 = {instancePath:instancePath+"/effectiveModel",schemaPath:"#/properties/effectiveModel/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}else {const err23 = {instancePath:instancePath+"/effectiveModel",schemaPath:"#/properties/effectiveModel/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}if(data.reason !== undefined){let data6 = data.reason;if(typeof data6 === "string"){if(func1(data6) > 4000){const err24 = {instancePath:instancePath+"/reason",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}else {const err25 = {instancePath:instancePath+"/reason",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}else {const err26 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}validate70.errors = vErrors;return errors === 0;}validate70.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema79 = {"type":"object","required":["schema","verificationId","status","classes","buffering","summary"],"properties":{"schema":{"const":"paperclip.model.verification.v1"},"verificationId":{"$ref":"#/$defs/id"},"status":{"enum":["running","completed","failed"]},"classes":{"type":"array","maxItems":32,"items":{"type":"string","maxLength":160}},"buffering":{"type":"boolean"},"summary":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate72(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate72.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.verificationId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "verificationId"},message:"must have required property '"+"verificationId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.status === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.classes === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "classes"},message:"must have required property '"+"classes"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.buffering === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "buffering"},message:"must have required property '"+"buffering"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.summary === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "verificationId")) || (key0 === "status")) || (key0 === "classes")) || (key0 === "buffering")) || (key0 === "summary"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.model.verification.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.model.verification.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.verificationId !== undefined){let data1 = data.verificationId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/verificationId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/verificationId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/verificationId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/verificationId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.status !== undefined){let data2 = data.status;if(!(((data2 === "running") || (data2 === "completed")) || (data2 === "failed"))){const err12 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema79.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.classes !== undefined){let data3 = data.classes;if(Array.isArray(data3)){if(data3.length > 32){const err13 = {instancePath:instancePath+"/classes",schemaPath:"#/properties/classes/maxItems",keyword:"maxItems",params:{limit: 32},message:"must NOT have more than 32 items"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}const len0 = data3.length;for(let i0=0; i0 160){const err14 = {instancePath:instancePath+"/classes/" + i0,schemaPath:"#/properties/classes/items/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/classes/" + i0,schemaPath:"#/properties/classes/items/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}else {const err16 = {instancePath:instancePath+"/classes",schemaPath:"#/properties/classes/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.buffering !== undefined){if(typeof data.buffering !== "boolean"){const err17 = {instancePath:instancePath+"/buffering",schemaPath:"#/properties/buffering/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.summary !== undefined){let data6 = data.summary;if((typeof data6 !== "string") && (data6 !== null)){const err18 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(typeof data6 === "string"){if(func1(data6) > 4000){const err19 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}}else {const err20 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}validate72.errors = vErrors;return errors === 0;}validate72.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema82 = {"type":"object","required":["schema","compactionId","reason","preTokens","postTokens","sameSession"],"properties":{"schema":{"const":"paperclip.context.compacted.v1"},"compactionId":{"$ref":"#/$defs/id"},"reason":{"enum":["context_window","manual","provider","unknown"]},"preTokens":{"type":["integer","null"],"minimum":0},"postTokens":{"type":["integer","null"],"minimum":0},"sameSession":{"type":"boolean"}},"additionalProperties":false};function validate74(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate74.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.compactionId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "compactionId"},message:"must have required property '"+"compactionId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.reason === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reason"},message:"must have required property '"+"reason"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.preTokens === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "preTokens"},message:"must have required property '"+"preTokens"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.postTokens === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "postTokens"},message:"must have required property '"+"postTokens"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.sameSession === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sameSession"},message:"must have required property '"+"sameSession"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "compactionId")) || (key0 === "reason")) || (key0 === "preTokens")) || (key0 === "postTokens")) || (key0 === "sameSession"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.context.compacted.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.context.compacted.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.compactionId !== undefined){let data1 = data.compactionId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/compactionId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/compactionId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/compactionId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/compactionId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.reason !== undefined){let data2 = data.reason;if(!((((data2 === "context_window") || (data2 === "manual")) || (data2 === "provider")) || (data2 === "unknown"))){const err12 = {instancePath:instancePath+"/reason",schemaPath:"#/properties/reason/enum",keyword:"enum",params:{allowedValues: schema82.properties.reason.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.preTokens !== undefined){let data3 = data.preTokens;if((!(((typeof data3 == "number") && (!(data3 % 1) && !isNaN(data3))) && (isFinite(data3)))) && (data3 !== null)){const err13 = {instancePath:instancePath+"/preTokens",schemaPath:"#/properties/preTokens/type",keyword:"type",params:{type: schema82.properties.preTokens.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if((typeof data3 == "number") && (isFinite(data3))){if(data3 < 0 || isNaN(data3)){const err14 = {instancePath:instancePath+"/preTokens",schemaPath:"#/properties/preTokens/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}if(data.postTokens !== undefined){let data4 = data.postTokens;if((!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))) && (data4 !== null)){const err15 = {instancePath:instancePath+"/postTokens",schemaPath:"#/properties/postTokens/type",keyword:"type",params:{type: schema82.properties.postTokens.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if((typeof data4 == "number") && (isFinite(data4))){if(data4 < 0 || isNaN(data4)){const err16 = {instancePath:instancePath+"/postTokens",schemaPath:"#/properties/postTokens/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}if(data.sameSession !== undefined){if(typeof data.sameSession !== "boolean"){const err17 = {instancePath:instancePath+"/sameSession",schemaPath:"#/properties/sameSession/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}else {const err18 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}validate74.errors = vErrors;return errors === 0;}validate74.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema84 = {"type":"object","required":["schema","artifactId","reference","mediaType","title"],"properties":{"schema":{"const":"paperclip.artifact.viewed.v1"},"artifactId":{"$ref":"#/$defs/id"},"reference":{"$ref":"#/$defs/safePath"},"mediaType":{"type":["string","null"],"maxLength":160},"title":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate76(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate76.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.artifactId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "artifactId"},message:"must have required property '"+"artifactId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.reference === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reference"},message:"must have required property '"+"reference"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.mediaType === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "mediaType"},message:"must have required property '"+"mediaType"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.title === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "title"},message:"must have required property '"+"title"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "artifactId")) || (key0 === "reference")) || (key0 === "mediaType")) || (key0 === "title"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.artifact.viewed.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.artifact.viewed.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.artifactId !== undefined){let data1 = data.artifactId;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.reference !== undefined){let data2 = data.reference;if((typeof data2 !== "string") && (data2 !== null)){const err11 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/type",keyword:"type",params:{type: schema60.type},message:"must be string,null"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(typeof data2 === "string"){if(func1(data2) > 4096){const err12 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(!pattern19.test(data2)){const err13 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"+"\""};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}if(data.mediaType !== undefined){let data3 = data.mediaType;if((typeof data3 !== "string") && (data3 !== null)){const err14 = {instancePath:instancePath+"/mediaType",schemaPath:"#/properties/mediaType/type",keyword:"type",params:{type: schema84.properties.mediaType.type},message:"must be string,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(typeof data3 === "string"){if(func1(data3) > 160){const err15 = {instancePath:instancePath+"/mediaType",schemaPath:"#/properties/mediaType/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}if(data.title !== undefined){let data4 = data.title;if((typeof data4 !== "string") && (data4 !== null)){const err16 = {instancePath:instancePath+"/title",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 4000){const err17 = {instancePath:instancePath+"/title",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}}else {const err18 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}validate76.errors = vErrors;return errors === 0;}validate76.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema88 = {"type":"object","required":["schema","artifactId","status","reference","mediaType","registered","failure"],"properties":{"schema":{"const":"paperclip.artifact.generated.v1"},"artifactId":{"$ref":"#/$defs/id"},"status":{"enum":["running","completed","failed"]},"reference":{"$ref":"#/$defs/safePath"},"mediaType":{"type":["string","null"],"maxLength":160},"registered":{"type":"boolean"},"transparentBackground":{"type":["boolean","null"]},"failure":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate78(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate78.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.artifactId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "artifactId"},message:"must have required property '"+"artifactId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.status === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.reference === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reference"},message:"must have required property '"+"reference"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.mediaType === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "mediaType"},message:"must have required property '"+"mediaType"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.registered === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "registered"},message:"must have required property '"+"registered"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.failure === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "failure"},message:"must have required property '"+"failure"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}for(const key0 in data){if(!((((((((key0 === "schema") || (key0 === "artifactId")) || (key0 === "status")) || (key0 === "reference")) || (key0 === "mediaType")) || (key0 === "registered")) || (key0 === "transparentBackground")) || (key0 === "failure"))){const err7 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.schema !== undefined){if("paperclip.artifact.generated.v1" !== data.schema){const err8 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.artifact.generated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.artifactId !== undefined){let data1 = data.artifactId;if(typeof data1 === "string"){if(func1(data1) > 160){const err9 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data1) < 1){const err10 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data1)){const err11 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.status !== undefined){let data2 = data.status;if(!(((data2 === "running") || (data2 === "completed")) || (data2 === "failed"))){const err13 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema88.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.reference !== undefined){let data3 = data.reference;if((typeof data3 !== "string") && (data3 !== null)){const err14 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/type",keyword:"type",params:{type: schema60.type},message:"must be string,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(typeof data3 === "string"){if(func1(data3) > 4096){const err15 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(!pattern19.test(data3)){const err16 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"+"\""};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}if(data.mediaType !== undefined){let data4 = data.mediaType;if((typeof data4 !== "string") && (data4 !== null)){const err17 = {instancePath:instancePath+"/mediaType",schemaPath:"#/properties/mediaType/type",keyword:"type",params:{type: schema88.properties.mediaType.type},message:"must be string,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 160){const err18 = {instancePath:instancePath+"/mediaType",schemaPath:"#/properties/mediaType/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}if(data.registered !== undefined){if(typeof data.registered !== "boolean"){const err19 = {instancePath:instancePath+"/registered",schemaPath:"#/properties/registered/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.transparentBackground !== undefined){let data6 = data.transparentBackground;if((typeof data6 !== "boolean") && (data6 !== null)){const err20 = {instancePath:instancePath+"/transparentBackground",schemaPath:"#/properties/transparentBackground/type",keyword:"type",params:{type: schema88.properties.transparentBackground.type},message:"must be boolean,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.failure !== undefined){let data7 = data.failure;if((typeof data7 !== "string") && (data7 !== null)){const err21 = {instancePath:instancePath+"/failure",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(typeof data7 === "string"){if(func1(data7) > 4000){const err22 = {instancePath:instancePath+"/failure",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}}else {const err23 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}validate78.errors = vErrors;return errors === 0;}validate78.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema92 = {"type":"object","required":["schema","reviewId","state","scope"],"properties":{"schema":{"const":"paperclip.review.mode_changed.v1"},"reviewId":{"$ref":"#/$defs/id"},"state":{"enum":["entered","exited"]},"scope":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate80(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate80.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.reviewId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reviewId"},message:"must have required property '"+"reviewId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.state === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "state"},message:"must have required property '"+"state"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.scope === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "scope"},message:"must have required property '"+"scope"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}for(const key0 in data){if(!((((key0 === "schema") || (key0 === "reviewId")) || (key0 === "state")) || (key0 === "scope"))){const err4 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.schema !== undefined){if("paperclip.review.mode_changed.v1" !== data.schema){const err5 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.review.mode_changed.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.reviewId !== undefined){let data1 = data.reviewId;if(typeof data1 === "string"){if(func1(data1) > 160){const err6 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(func1(data1) < 1){const err7 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(!pattern4.test(data1)){const err8 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.state !== undefined){let data2 = data.state;if(!((data2 === "entered") || (data2 === "exited"))){const err10 = {instancePath:instancePath+"/state",schemaPath:"#/properties/state/enum",keyword:"enum",params:{allowedValues: schema92.properties.state.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.scope !== undefined){let data3 = data.scope;if((typeof data3 !== "string") && (data3 !== null)){const err11 = {instancePath:instancePath+"/scope",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(typeof data3 === "string"){if(func1(data3) > 4000){const err12 = {instancePath:instancePath+"/scope",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}}else {const err13 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}validate80.errors = vErrors;return errors === 0;}validate80.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema95 = {"type":"object","required":["schema","hookId","event","scope","status","blocking","durationMs","summary"],"properties":{"schema":{"const":"paperclip.hook.v1"},"hookId":{"$ref":"#/$defs/id"},"event":{"type":"string","minLength":1,"maxLength":160},"scope":{"type":"string","minLength":1,"maxLength":160},"status":{"enum":["running","completed","failed","cancelled"]},"blocking":{"type":"boolean"},"durationMs":{"type":["integer","null"],"minimum":0},"summary":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate82(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate82.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.hookId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "hookId"},message:"must have required property '"+"hookId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.event === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "event"},message:"must have required property '"+"event"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.scope === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "scope"},message:"must have required property '"+"scope"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.status === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.blocking === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "blocking"},message:"must have required property '"+"blocking"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.durationMs === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "durationMs"},message:"must have required property '"+"durationMs"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.summary === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}for(const key0 in data){if(!((((((((key0 === "schema") || (key0 === "hookId")) || (key0 === "event")) || (key0 === "scope")) || (key0 === "status")) || (key0 === "blocking")) || (key0 === "durationMs")) || (key0 === "summary"))){const err8 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.schema !== undefined){if("paperclip.hook.v1" !== data.schema){const err9 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.hook.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.hookId !== undefined){let data1 = data.hookId;if(typeof data1 === "string"){if(func1(data1) > 160){const err10 = {instancePath:instancePath+"/hookId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(func1(data1) < 1){const err11 = {instancePath:instancePath+"/hookId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(!pattern4.test(data1)){const err12 = {instancePath:instancePath+"/hookId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {const err13 = {instancePath:instancePath+"/hookId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.event !== undefined){let data2 = data.event;if(typeof data2 === "string"){if(func1(data2) > 160){const err14 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(func1(data2) < 1){const err15 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.scope !== undefined){let data3 = data.scope;if(typeof data3 === "string"){if(func1(data3) > 160){const err17 = {instancePath:instancePath+"/scope",schemaPath:"#/properties/scope/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data3) < 1){const err18 = {instancePath:instancePath+"/scope",schemaPath:"#/properties/scope/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/scope",schemaPath:"#/properties/scope/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.status !== undefined){let data4 = data.status;if(!((((data4 === "running") || (data4 === "completed")) || (data4 === "failed")) || (data4 === "cancelled"))){const err20 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema95.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.blocking !== undefined){if(typeof data.blocking !== "boolean"){const err21 = {instancePath:instancePath+"/blocking",schemaPath:"#/properties/blocking/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.durationMs !== undefined){let data6 = data.durationMs;if((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (data6 !== null)){const err22 = {instancePath:instancePath+"/durationMs",schemaPath:"#/properties/durationMs/type",keyword:"type",params:{type: schema95.properties.durationMs.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err23 = {instancePath:instancePath+"/durationMs",schemaPath:"#/properties/durationMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}if(data.summary !== undefined){let data7 = data.summary;if((typeof data7 !== "string") && (data7 !== null)){const err24 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(typeof data7 === "string"){if(func1(data7) > 4000){const err25 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}}else {const err26 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}validate82.errors = vErrors;return errors === 0;}validate82.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema98 = {"type":"object","required":["schema","citationId","messageItemId","label","available","reference"],"properties":{"schema":{"const":"paperclip.memory.citation.v1"},"citationId":{"$ref":"#/$defs/id"},"messageItemId":{"$ref":"#/$defs/id"},"label":{"$ref":"#/$defs/shortText"},"available":{"type":"boolean"},"reference":{"type":["string","null"],"maxLength":4096}},"additionalProperties":false};function validate84(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate84.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.citationId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "citationId"},message:"must have required property '"+"citationId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.messageItemId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "messageItemId"},message:"must have required property '"+"messageItemId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.label === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "label"},message:"must have required property '"+"label"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.available === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "available"},message:"must have required property '"+"available"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.reference === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reference"},message:"must have required property '"+"reference"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "citationId")) || (key0 === "messageItemId")) || (key0 === "label")) || (key0 === "available")) || (key0 === "reference"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.memory.citation.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.memory.citation.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.citationId !== undefined){let data1 = data.citationId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/citationId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/citationId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/citationId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/citationId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.messageItemId !== undefined){let data2 = data.messageItemId;if(typeof data2 === "string"){if(func1(data2) > 160){const err12 = {instancePath:instancePath+"/messageItemId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(func1(data2) < 1){const err13 = {instancePath:instancePath+"/messageItemId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(!pattern4.test(data2)){const err14 = {instancePath:instancePath+"/messageItemId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/messageItemId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.label !== undefined){let data3 = data.label;if(typeof data3 === "string"){if(func1(data3) > 4000){const err16 = {instancePath:instancePath+"/label",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}else {const err17 = {instancePath:instancePath+"/label",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.available !== undefined){if(typeof data.available !== "boolean"){const err18 = {instancePath:instancePath+"/available",schemaPath:"#/properties/available/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.reference !== undefined){let data5 = data.reference;if((typeof data5 !== "string") && (data5 !== null)){const err19 = {instancePath:instancePath+"/reference",schemaPath:"#/properties/reference/type",keyword:"type",params:{type: schema98.properties.reference.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(typeof data5 === "string"){if(func1(data5) > 4096){const err20 = {instancePath:instancePath+"/reference",schemaPath:"#/properties/reference/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}}else {const err21 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}validate84.errors = vErrors;return errors === 0;}validate84.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema102 = {"type":"object","required":["schema","reviewId","targetExecutionId","status","decision","summary"],"properties":{"schema":{"const":"paperclip.safety.review.v1"},"reviewId":{"$ref":"#/$defs/id"},"targetExecutionId":{"anyOf":[{"$ref":"#/$defs/id"},{"type":"null"}]},"status":{"enum":["running","completed","failed"]},"decision":{"enum":["pending","allowed","denied","user_required","unknown"]},"summary":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate86(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate86.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.reviewId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reviewId"},message:"must have required property '"+"reviewId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.targetExecutionId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "targetExecutionId"},message:"must have required property '"+"targetExecutionId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.status === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.decision === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "decision"},message:"must have required property '"+"decision"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.summary === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "reviewId")) || (key0 === "targetExecutionId")) || (key0 === "status")) || (key0 === "decision")) || (key0 === "summary"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.safety.review.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.safety.review.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.reviewId !== undefined){let data1 = data.reviewId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.targetExecutionId !== undefined){let data2 = data.targetExecutionId;const _errs7 = errors;let valid2 = false;const _errs8 = errors;if(typeof data2 === "string"){if(func1(data2) > 160){const err12 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(func1(data2) < 1){const err13 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(!pattern4.test(data2)){const err14 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}var _valid0 = _errs8 === errors;valid2 = valid2 || _valid0;const _errs11 = errors;if(data2 !== null){const err16 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/properties/targetExecutionId/anyOf/1/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}var _valid0 = _errs11 === errors;valid2 = valid2 || _valid0;if(!valid2){const err17 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/properties/targetExecutionId/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}else {errors = _errs7;if(vErrors !== null){if(_errs7){vErrors.length = _errs7;}else {vErrors = null;}}}}if(data.status !== undefined){let data3 = data.status;if(!(((data3 === "running") || (data3 === "completed")) || (data3 === "failed"))){const err18 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema102.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.decision !== undefined){let data4 = data.decision;if(!(((((data4 === "pending") || (data4 === "allowed")) || (data4 === "denied")) || (data4 === "user_required")) || (data4 === "unknown"))){const err19 = {instancePath:instancePath+"/decision",schemaPath:"#/properties/decision/enum",keyword:"enum",params:{allowedValues: schema102.properties.decision.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.summary !== undefined){let data5 = data.summary;if((typeof data5 !== "string") && (data5 !== null)){const err20 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(typeof data5 === "string"){if(func1(data5) > 4000){const err21 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}}else {const err22 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}validate86.errors = vErrors;return errors === 0;}validate86.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema106 = {"type":"object","required":["schema","executionId","origin","inputClass","byteCount"],"properties":{"schema":{"const":"paperclip.terminal.input_sent.v1"},"executionId":{"$ref":"#/$defs/id"},"origin":{"enum":["agent","user","system"]},"inputClass":{"enum":["text","control","eof"]},"byteCount":{"type":"integer","minimum":0}},"additionalProperties":false};function validate88(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate88.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.executionId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "executionId"},message:"must have required property '"+"executionId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.origin === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "origin"},message:"must have required property '"+"origin"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.inputClass === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "inputClass"},message:"must have required property '"+"inputClass"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.byteCount === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "byteCount"},message:"must have required property '"+"byteCount"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "executionId")) || (key0 === "origin")) || (key0 === "inputClass")) || (key0 === "byteCount"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.terminal.input_sent.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.terminal.input_sent.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.executionId !== undefined){let data1 = data.executionId;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.origin !== undefined){let data2 = data.origin;if(!(((data2 === "agent") || (data2 === "user")) || (data2 === "system"))){const err11 = {instancePath:instancePath+"/origin",schemaPath:"#/properties/origin/enum",keyword:"enum",params:{allowedValues: schema106.properties.origin.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.inputClass !== undefined){let data3 = data.inputClass;if(!(((data3 === "text") || (data3 === "control")) || (data3 === "eof"))){const err12 = {instancePath:instancePath+"/inputClass",schemaPath:"#/properties/inputClass/enum",keyword:"enum",params:{allowedValues: schema106.properties.inputClass.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.byteCount !== undefined){let data4 = data.byteCount;if(!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))){const err13 = {instancePath:instancePath+"/byteCount",schemaPath:"#/properties/byteCount/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if((typeof data4 == "number") && (isFinite(data4))){if(data4 < 0 || isNaN(data4)){const err14 = {instancePath:instancePath+"/byteCount",schemaPath:"#/properties/byteCount/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}}else {const err15 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}validate88.errors = vErrors;return errors === 0;}validate88.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema108 = {"type":"object","required":["schema","waitId","reason","status","plannedDurationMs","elapsedDurationMs"],"properties":{"schema":{"const":"paperclip.wait.v1"},"waitId":{"$ref":"#/$defs/id"},"reason":{"enum":["timer","provider","rate_limit","unknown"]},"status":{"enum":["running","completed","interrupted","cancelled"]},"plannedDurationMs":{"type":["integer","null"],"minimum":0},"elapsedDurationMs":{"type":["integer","null"],"minimum":0}},"additionalProperties":false};function validate90(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate90.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.waitId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "waitId"},message:"must have required property '"+"waitId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.reason === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reason"},message:"must have required property '"+"reason"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.status === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.plannedDurationMs === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "plannedDurationMs"},message:"must have required property '"+"plannedDurationMs"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.elapsedDurationMs === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "elapsedDurationMs"},message:"must have required property '"+"elapsedDurationMs"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "waitId")) || (key0 === "reason")) || (key0 === "status")) || (key0 === "plannedDurationMs")) || (key0 === "elapsedDurationMs"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.wait.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.wait.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.waitId !== undefined){let data1 = data.waitId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/waitId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/waitId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/waitId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/waitId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.reason !== undefined){let data2 = data.reason;if(!((((data2 === "timer") || (data2 === "provider")) || (data2 === "rate_limit")) || (data2 === "unknown"))){const err12 = {instancePath:instancePath+"/reason",schemaPath:"#/properties/reason/enum",keyword:"enum",params:{allowedValues: schema108.properties.reason.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.status !== undefined){let data3 = data.status;if(!((((data3 === "running") || (data3 === "completed")) || (data3 === "interrupted")) || (data3 === "cancelled"))){const err13 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema108.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.plannedDurationMs !== undefined){let data4 = data.plannedDurationMs;if((!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))) && (data4 !== null)){const err14 = {instancePath:instancePath+"/plannedDurationMs",schemaPath:"#/properties/plannedDurationMs/type",keyword:"type",params:{type: schema108.properties.plannedDurationMs.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if((typeof data4 == "number") && (isFinite(data4))){if(data4 < 0 || isNaN(data4)){const err15 = {instancePath:instancePath+"/plannedDurationMs",schemaPath:"#/properties/plannedDurationMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}if(data.elapsedDurationMs !== undefined){let data5 = data.elapsedDurationMs;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err16 = {instancePath:instancePath+"/elapsedDurationMs",schemaPath:"#/properties/elapsedDurationMs/type",keyword:"type",params:{type: schema108.properties.elapsedDurationMs.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 0 || isNaN(data5)){const err17 = {instancePath:instancePath+"/elapsedDurationMs",schemaPath:"#/properties/elapsedDurationMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}}else {const err18 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}validate90.errors = vErrors;return errors === 0;}validate90.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema110 = {"type":"object","required":["schema","noticeId","severity","category","scope","recoverable","userActionable","summary"],"properties":{"schema":{"const":"paperclip.provider.notice.v1"},"noticeId":{"$ref":"#/$defs/id"},"severity":{"enum":["info","warning","error"]},"category":{"type":"string","minLength":1,"maxLength":160},"scope":{"enum":["turn","session","environment","account"]},"recoverable":{"type":"boolean"},"userActionable":{"type":"boolean"},"summary":{"$ref":"#/$defs/shortText"},"provenance":{"type":"object","required":["method","eventType","sessionId","turnId"],"properties":{"method":{"type":"string","maxLength":160},"eventType":{"type":"string","maxLength":160},"sessionId":{"type":"string","maxLength":240},"turnId":{"type":"string","maxLength":240},"agentId":{"type":"string","maxLength":240},"timestamp":{"type":"string","maxLength":80}},"additionalProperties":false},"details":{"type":"array","maxItems":64,"items":{"type":"object","required":["name","value"],"properties":{"name":{"type":"string","minLength":1,"maxLength":160},"value":{"type":"string","maxLength":4000}},"additionalProperties":false}}},"additionalProperties":false};function validate92(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate92.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.noticeId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "noticeId"},message:"must have required property '"+"noticeId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.severity === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "severity"},message:"must have required property '"+"severity"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.category === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "category"},message:"must have required property '"+"category"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.scope === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "scope"},message:"must have required property '"+"scope"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.recoverable === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "recoverable"},message:"must have required property '"+"recoverable"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.userActionable === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "userActionable"},message:"must have required property '"+"userActionable"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.summary === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}for(const key0 in data){if(!(func66.call(schema110.properties, key0))){const err8 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.schema !== undefined){if("paperclip.provider.notice.v1" !== data.schema){const err9 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.provider.notice.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.noticeId !== undefined){let data1 = data.noticeId;if(typeof data1 === "string"){if(func1(data1) > 160){const err10 = {instancePath:instancePath+"/noticeId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(func1(data1) < 1){const err11 = {instancePath:instancePath+"/noticeId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(!pattern4.test(data1)){const err12 = {instancePath:instancePath+"/noticeId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {const err13 = {instancePath:instancePath+"/noticeId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.severity !== undefined){let data2 = data.severity;if(!(((data2 === "info") || (data2 === "warning")) || (data2 === "error"))){const err14 = {instancePath:instancePath+"/severity",schemaPath:"#/properties/severity/enum",keyword:"enum",params:{allowedValues: schema110.properties.severity.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.category !== undefined){let data3 = data.category;if(typeof data3 === "string"){if(func1(data3) > 160){const err15 = {instancePath:instancePath+"/category",schemaPath:"#/properties/category/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(func1(data3) < 1){const err16 = {instancePath:instancePath+"/category",schemaPath:"#/properties/category/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}else {const err17 = {instancePath:instancePath+"/category",schemaPath:"#/properties/category/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.scope !== undefined){let data4 = data.scope;if(!((((data4 === "turn") || (data4 === "session")) || (data4 === "environment")) || (data4 === "account"))){const err18 = {instancePath:instancePath+"/scope",schemaPath:"#/properties/scope/enum",keyword:"enum",params:{allowedValues: schema110.properties.scope.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.recoverable !== undefined){if(typeof data.recoverable !== "boolean"){const err19 = {instancePath:instancePath+"/recoverable",schemaPath:"#/properties/recoverable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.userActionable !== undefined){if(typeof data.userActionable !== "boolean"){const err20 = {instancePath:instancePath+"/userActionable",schemaPath:"#/properties/userActionable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.summary !== undefined){let data7 = data.summary;if(typeof data7 === "string"){if(func1(data7) > 4000){const err21 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}else {const err22 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.provenance !== undefined){let data8 = data.provenance;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.method === undefined){const err23 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/required",keyword:"required",params:{missingProperty: "method"},message:"must have required property '"+"method"+"'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data8.eventType === undefined){const err24 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/required",keyword:"required",params:{missingProperty: "eventType"},message:"must have required property '"+"eventType"+"'"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(data8.sessionId === undefined){const err25 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/required",keyword:"required",params:{missingProperty: "sessionId"},message:"must have required property '"+"sessionId"+"'"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(data8.turnId === undefined){const err26 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}for(const key1 in data8){if(!((((((key1 === "method") || (key1 === "eventType")) || (key1 === "sessionId")) || (key1 === "turnId")) || (key1 === "agentId")) || (key1 === "timestamp"))){const err27 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}if(data8.method !== undefined){let data9 = data8.method;if(typeof data9 === "string"){if(func1(data9) > 160){const err28 = {instancePath:instancePath+"/provenance/method",schemaPath:"#/properties/provenance/properties/method/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}else {const err29 = {instancePath:instancePath+"/provenance/method",schemaPath:"#/properties/provenance/properties/method/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data8.eventType !== undefined){let data10 = data8.eventType;if(typeof data10 === "string"){if(func1(data10) > 160){const err30 = {instancePath:instancePath+"/provenance/eventType",schemaPath:"#/properties/provenance/properties/eventType/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}else {const err31 = {instancePath:instancePath+"/provenance/eventType",schemaPath:"#/properties/provenance/properties/eventType/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data8.sessionId !== undefined){let data11 = data8.sessionId;if(typeof data11 === "string"){if(func1(data11) > 240){const err32 = {instancePath:instancePath+"/provenance/sessionId",schemaPath:"#/properties/provenance/properties/sessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}else {const err33 = {instancePath:instancePath+"/provenance/sessionId",schemaPath:"#/properties/provenance/properties/sessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}if(data8.turnId !== undefined){let data12 = data8.turnId;if(typeof data12 === "string"){if(func1(data12) > 240){const err34 = {instancePath:instancePath+"/provenance/turnId",schemaPath:"#/properties/provenance/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}else {const err35 = {instancePath:instancePath+"/provenance/turnId",schemaPath:"#/properties/provenance/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}if(data8.agentId !== undefined){let data13 = data8.agentId;if(typeof data13 === "string"){if(func1(data13) > 240){const err36 = {instancePath:instancePath+"/provenance/agentId",schemaPath:"#/properties/provenance/properties/agentId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}else {const err37 = {instancePath:instancePath+"/provenance/agentId",schemaPath:"#/properties/provenance/properties/agentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data8.timestamp !== undefined){let data14 = data8.timestamp;if(typeof data14 === "string"){if(func1(data14) > 80){const err38 = {instancePath:instancePath+"/provenance/timestamp",schemaPath:"#/properties/provenance/properties/timestamp/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}else {const err39 = {instancePath:instancePath+"/provenance/timestamp",schemaPath:"#/properties/provenance/properties/timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}}else {const err40 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}if(data.details !== undefined){let data15 = data.details;if(Array.isArray(data15)){if(data15.length > 64){const err41 = {instancePath:instancePath+"/details",schemaPath:"#/properties/details/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}const len0 = data15.length;for(let i0=0; i0 160){const err45 = {instancePath:instancePath+"/details/" + i0+"/name",schemaPath:"#/properties/details/items/properties/name/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(func1(data17) < 1){const err46 = {instancePath:instancePath+"/details/" + i0+"/name",schemaPath:"#/properties/details/items/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}else {const err47 = {instancePath:instancePath+"/details/" + i0+"/name",schemaPath:"#/properties/details/items/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data16.value !== undefined){let data18 = data16.value;if(typeof data18 === "string"){if(func1(data18) > 4000){const err48 = {instancePath:instancePath+"/details/" + i0+"/value",schemaPath:"#/properties/details/items/properties/value/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}else {const err49 = {instancePath:instancePath+"/details/" + i0+"/value",schemaPath:"#/properties/details/items/properties/value/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}}else {const err50 = {instancePath:instancePath+"/details/" + i0,schemaPath:"#/properties/details/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath:instancePath+"/details",schemaPath:"#/properties/details/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}}else {const err52 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}validate92.errors = vErrors;return errors === 0;}validate92.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema177 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json","title":"PRP provider-neutral semantic tool envelope","type":"object","required":["schema","schemaVersion","phase","operationId","callId","correlation","idempotencyKey","content"],"properties":{"schema":{"const":"paperclip.prp.semantic_tool.v1"},"schemaVersion":{"const":1},"phase":{"enum":["input","result","reconciled"]},"operationId":{"$ref":"#/$defs/stableId"},"callId":{"$ref":"#/$defs/stableId"},"correlation":{"type":"object","required":["runId","normalizedSessionId","turnId","itemId"],"properties":{"runId":{"$ref":"#/$defs/stableId"},"normalizedSessionId":{"$ref":"#/$defs/stableId"},"turnId":{"$ref":"#/$defs/stableId"},"itemId":{"$ref":"#/$defs/stableId"},"requestId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true},"idempotencyKey":{"type":["string","null"],"minLength":1,"maxLength":240},"content":{"$ref":"#/$defs/safeContent"},"outcome":{"enum":["succeeded","denied","conflict","duplicate","unavailable","failed"]},"code":{"$ref":"#/$defs/stableCode"},"retryable":{"type":"boolean"},"authorizationBoundary":{"enum":["company","actor","active_task","grant","governed_action","lock","revision"]},"operationReceiptId":{"$ref":"#/$defs/stableId"},"auditReceiptId":{"$ref":"#/$defs/stableId"},"currentRevision":{"oneOf":[{"type":"integer","minimum":0},{"$ref":"#/$defs/stableId"}]},"duplicateOfReceiptId":{"$ref":"#/$defs/stableId"},"artifactRefs":{"type":"array","items":{"allOf":[{"$ref":"#/$defs/safeReference"},{"type":"object","properties":{"kind":{"enum":["artifact","work_product"]}}}]},"uniqueItems":true},"targets":{"type":"array","items":{"$ref":"#/$defs/immutableTarget"},"uniqueItems":true},"causalRefs":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"allOf":[{"if":{"properties":{"phase":{"enum":["result","reconciled"]}},"required":["phase"]},"then":{"required":["outcome","code","retryable","authorizationBoundary","operationReceiptId"]}}],"additionalProperties":true,"$defs":{"stableId":{"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"stableCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"},"safeContent":{"type":"object","required":["digest","redactionDisposition","references"],"properties":{"digest":{"type":"string","pattern":"^sha256:[a-f0-9]{64}$"},"redactionDisposition":{"enum":["digest_only","allowlisted_references","redacted"]},"references":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"additionalProperties":true},"safeReference":{"type":"object","required":["kind","id"],"properties":{"kind":{"enum":["task","document_revision","interaction","approval","decision","artifact","work_product","wake","monitor","audit","operation"]},"id":{"$ref":"#/$defs/stableId"}},"additionalProperties":true},"immutableTarget":{"type":"object","required":["kind","id","immutable"],"properties":{"kind":{"enum":["document_revision","interaction","approval","decision"]},"id":{"$ref":"#/$defs/stableId"},"immutable":{"const":true},"revisionId":{"$ref":"#/$defs/stableId"},"decisionId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true}}};const schema178 = {"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};const schema188 = {"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"};const pattern14 = new RegExp("^[a-z][a-z0-9_.:-]*$", "u");const schema185 = {"type":"object","required":["digest","redactionDisposition","references"],"properties":{"digest":{"type":"string","pattern":"^sha256:[a-f0-9]{64}$"},"redactionDisposition":{"enum":["digest_only","allowlisted_references","redacted"]},"references":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"additionalProperties":true};const schema186 = {"type":"object","required":["kind","id"],"properties":{"kind":{"enum":["task","document_revision","interaction","approval","decision","artifact","work_product","wake","monitor","audit","operation"]},"id":{"$ref":"#/$defs/stableId"}},"additionalProperties":true};function validate96(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate96.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.kind === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.id === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.kind !== undefined){let data0 = data.kind;if(!(((((((((((data0 === "task") || (data0 === "document_revision")) || (data0 === "interaction")) || (data0 === "approval")) || (data0 === "decision")) || (data0 === "artifact")) || (data0 === "work_product")) || (data0 === "wake")) || (data0 === "monitor")) || (data0 === "audit")) || (data0 === "operation"))){const err2 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema186.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.id !== undefined){let data1 = data.id;if(typeof data1 === "string"){if(func1(data1) > 240){const err3 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(func1(data1) < 1){const err4 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(!pattern4.test(data1)){const err5 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}else {const err6 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}else {const err7 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}validate96.errors = vErrors;return errors === 0;}validate96.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate95(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate95.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.digest === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "digest"},message:"must have required property '"+"digest"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.redactionDisposition === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "redactionDisposition"},message:"must have required property '"+"redactionDisposition"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.references === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "references"},message:"must have required property '"+"references"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.digest !== undefined){let data0 = data.digest;if(typeof data0 === "string"){if(!pattern20.test(data0)){const err3 = {instancePath:instancePath+"/digest",schemaPath:"#/properties/digest/pattern",keyword:"pattern",params:{pattern: "^sha256:[a-f0-9]{64}$"},message:"must match pattern \""+"^sha256:[a-f0-9]{64}$"+"\""};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}else {const err4 = {instancePath:instancePath+"/digest",schemaPath:"#/properties/digest/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.redactionDisposition !== undefined){let data1 = data.redactionDisposition;if(!(((data1 === "digest_only") || (data1 === "allowlisted_references")) || (data1 === "redacted"))){const err5 = {instancePath:instancePath+"/redactionDisposition",schemaPath:"#/properties/redactionDisposition/enum",keyword:"enum",params:{allowedValues: schema185.properties.redactionDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.references !== undefined){let data2 = data.references;if(Array.isArray(data2)){const len0 = data2.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data2[i1], data2[j0])){const err6 = {instancePath:instancePath+"/references",schemaPath:"#/properties/references/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;break outer0;}}}}}else {const err7 = {instancePath:instancePath+"/references",schemaPath:"#/properties/references/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}}else {const err8 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}validate95.errors = vErrors;return errors === 0;}validate95.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema193 = {"type":"object","required":["kind","id","immutable"],"properties":{"kind":{"enum":["document_revision","interaction","approval","decision"]},"id":{"$ref":"#/$defs/stableId"},"immutable":{"const":true},"revisionId":{"$ref":"#/$defs/stableId"},"decisionId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true};function validate100(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate100.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.kind === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.id === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.immutable === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "immutable"},message:"must have required property '"+"immutable"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.kind !== undefined){let data0 = data.kind;if(!((((data0 === "document_revision") || (data0 === "interaction")) || (data0 === "approval")) || (data0 === "decision"))){const err3 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema193.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.id !== undefined){let data1 = data.id;if(typeof data1 === "string"){if(func1(data1) > 240){const err4 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(func1(data1) < 1){const err5 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(!pattern4.test(data1)){const err6 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {const err7 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.immutable !== undefined){if(true !== data.immutable){const err8 = {instancePath:instancePath+"/immutable",schemaPath:"#/properties/immutable/const",keyword:"const",params:{allowedValue: true},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.revisionId !== undefined){let data3 = data.revisionId;if(typeof data3 === "string"){if(func1(data3) > 240){const err9 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data3) < 1){const err10 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data3)){const err11 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.decisionId !== undefined){let data4 = data.decisionId;if(typeof data4 === "string"){if(func1(data4) > 240){const err13 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data4) < 1){const err14 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(!pattern4.test(data4)){const err15 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}else {const err17 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}validate100.errors = vErrors;return errors === 0;}validate100.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate94(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate94.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((data.phase === undefined) && (missing0 = "phase")){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}else {if(data.phase !== undefined){let data0 = data.phase;if(!((data0 === "result") || (data0 === "reconciled"))){const err1 = {};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.outcome === undefined){const err2 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "outcome"},message:"must have required property '"+"outcome"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.code === undefined){const err3 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "code"},message:"must have required property '"+"code"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.retryable === undefined){const err4 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "retryable"},message:"must have required property '"+"retryable"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.authorizationBoundary === undefined){const err5 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "authorizationBoundary"},message:"must have required property '"+"authorizationBoundary"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.operationReceiptId === undefined){const err6 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "operationReceiptId"},message:"must have required property '"+"operationReceiptId"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}var _valid0 = _errs5 === errors;valid1 = _valid0;}if(!valid1){const err7 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.schemaVersion === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.phase === undefined){const err10 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "phase"},message:"must have required property '"+"phase"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data.operationId === undefined){const err11 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "operationId"},message:"must have required property '"+"operationId"+"'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data.callId === undefined){const err12 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "callId"},message:"must have required property '"+"callId"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data.correlation === undefined){const err13 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "correlation"},message:"must have required property '"+"correlation"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data.idempotencyKey === undefined){const err14 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "idempotencyKey"},message:"must have required property '"+"idempotencyKey"+"'"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(data.content === undefined){const err15 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "content"},message:"must have required property '"+"content"+"'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data.schema !== undefined){if("paperclip.prp.semantic_tool.v1" !== data.schema){const err16 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.semantic_tool.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err17 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.phase !== undefined){let data3 = data.phase;if(!(((data3 === "input") || (data3 === "result")) || (data3 === "reconciled"))){const err18 = {instancePath:instancePath+"/phase",schemaPath:"#/properties/phase/enum",keyword:"enum",params:{allowedValues: schema177.properties.phase.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.operationId !== undefined){let data4 = data.operationId;if(typeof data4 === "string"){if(func1(data4) > 240){const err19 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(func1(data4) < 1){const err20 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(!pattern4.test(data4)){const err21 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}else {const err22 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.callId !== undefined){let data5 = data.callId;if(typeof data5 === "string"){if(func1(data5) > 240){const err23 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(func1(data5) < 1){const err24 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(!pattern4.test(data5)){const err25 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}else {const err26 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data.correlation !== undefined){let data6 = data.correlation;if(data6 && typeof data6 == "object" && !Array.isArray(data6)){if(data6.runId === undefined){const err27 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data6.normalizedSessionId === undefined){const err28 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data6.turnId === undefined){const err29 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data6.itemId === undefined){const err30 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "itemId"},message:"must have required property '"+"itemId"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data6.runId !== undefined){let data7 = data6.runId;if(typeof data7 === "string"){if(func1(data7) > 240){const err31 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(func1(data7) < 1){const err32 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(!pattern4.test(data7)){const err33 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}else {const err34 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data6.normalizedSessionId !== undefined){let data8 = data6.normalizedSessionId;if(typeof data8 === "string"){if(func1(data8) > 240){const err35 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(func1(data8) < 1){const err36 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}if(!pattern4.test(data8)){const err37 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}else {const err38 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data6.turnId !== undefined){let data9 = data6.turnId;if(typeof data9 === "string"){if(func1(data9) > 240){const err39 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}if(func1(data9) < 1){const err40 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if(!pattern4.test(data9)){const err41 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}else {const err42 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}if(data6.itemId !== undefined){let data10 = data6.itemId;if(typeof data10 === "string"){if(func1(data10) > 240){const err43 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}if(func1(data10) < 1){const err44 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}if(!pattern4.test(data10)){const err45 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}else {const err46 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}if(data6.requestId !== undefined){let data11 = data6.requestId;if(typeof data11 === "string"){if(func1(data11) > 240){const err47 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}if(func1(data11) < 1){const err48 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}if(!pattern4.test(data11)){const err49 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data.idempotencyKey !== undefined){let data12 = data.idempotencyKey;if((typeof data12 !== "string") && (data12 !== null)){const err52 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/type",keyword:"type",params:{type: schema177.properties.idempotencyKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}if(typeof data12 === "string"){if(func1(data12) > 240){const err53 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}if(func1(data12) < 1){const err54 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}}if(data.content !== undefined){if(!(validate95(data.content, {instancePath:instancePath+"/content",parentData:data,parentDataProperty:"content",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate95.errors : vErrors.concat(validate95.errors);errors = vErrors.length;}}if(data.outcome !== undefined){let data14 = data.outcome;if(!((((((data14 === "succeeded") || (data14 === "denied")) || (data14 === "conflict")) || (data14 === "duplicate")) || (data14 === "unavailable")) || (data14 === "failed"))){const err55 = {instancePath:instancePath+"/outcome",schemaPath:"#/properties/outcome/enum",keyword:"enum",params:{allowedValues: schema177.properties.outcome.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data.code !== undefined){let data15 = data.code;if(typeof data15 === "string"){if(func1(data15) > 160){const err56 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}if(func1(data15) < 1){const err57 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}if(!pattern14.test(data15)){const err58 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}else {const err59 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data.retryable !== undefined){if(typeof data.retryable !== "boolean"){const err60 = {instancePath:instancePath+"/retryable",schemaPath:"#/properties/retryable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}if(data.authorizationBoundary !== undefined){let data17 = data.authorizationBoundary;if(!(((((((data17 === "company") || (data17 === "actor")) || (data17 === "active_task")) || (data17 === "grant")) || (data17 === "governed_action")) || (data17 === "lock")) || (data17 === "revision"))){const err61 = {instancePath:instancePath+"/authorizationBoundary",schemaPath:"#/properties/authorizationBoundary/enum",keyword:"enum",params:{allowedValues: schema177.properties.authorizationBoundary.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}if(data.operationReceiptId !== undefined){let data18 = data.operationReceiptId;if(typeof data18 === "string"){if(func1(data18) > 240){const err62 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(func1(data18) < 1){const err63 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}if(!pattern4.test(data18)){const err64 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}else {const err65 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}if(data.auditReceiptId !== undefined){let data19 = data.auditReceiptId;if(typeof data19 === "string"){if(func1(data19) > 240){const err66 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}if(func1(data19) < 1){const err67 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}if(!pattern4.test(data19)){const err68 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}else {const err69 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}if(data.currentRevision !== undefined){let data20 = data.currentRevision;const _errs51 = errors;let valid15 = false;let passing0 = null;const _errs52 = errors;if(!(((typeof data20 == "number") && (!(data20 % 1) && !isNaN(data20))) && (isFinite(data20)))){const err70 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf/0/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if((typeof data20 == "number") && (isFinite(data20))){if(data20 < 0 || isNaN(data20)){const err71 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf/0/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}var _valid1 = _errs52 === errors;if(_valid1){valid15 = true;passing0 = 0;}const _errs54 = errors;if(typeof data20 === "string"){if(func1(data20) > 240){const err72 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(func1(data20) < 1){const err73 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}if(!pattern4.test(data20)){const err74 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}}else {const err75 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}var _valid1 = _errs54 === errors;if(_valid1 && valid15){valid15 = false;passing0 = [passing0, 1];}else {if(_valid1){valid15 = true;passing0 = 1;}}if(!valid15){const err76 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}else {errors = _errs51;if(vErrors !== null){if(_errs51){vErrors.length = _errs51;}else {vErrors = null;}}}}if(data.duplicateOfReceiptId !== undefined){let data21 = data.duplicateOfReceiptId;if(typeof data21 === "string"){if(func1(data21) > 240){const err77 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(func1(data21) < 1){const err78 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}if(!pattern4.test(data21)){const err79 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}}else {const err80 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}}if(data.artifactRefs !== undefined){let data22 = data.artifactRefs;if(Array.isArray(data22)){const len0 = data22.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data22[i1], data22[j0])){const err83 = {instancePath:instancePath+"/artifactRefs",schemaPath:"#/properties/artifactRefs/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;break outer0;}}}}}else {const err84 = {instancePath:instancePath+"/artifactRefs",schemaPath:"#/properties/artifactRefs/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}}if(data.targets !== undefined){let data25 = data.targets;if(Array.isArray(data25)){const len1 = data25.length;for(let i2=0; i2 1){outer1:for(;i3--;){for(j1 = i3; j1--;){if(func0(data25[i3], data25[j1])){const err85 = {instancePath:instancePath+"/targets",schemaPath:"#/properties/targets/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;break outer1;}}}}}else {const err86 = {instancePath:instancePath+"/targets",schemaPath:"#/properties/targets/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}}if(data.causalRefs !== undefined){let data27 = data.causalRefs;if(Array.isArray(data27)){const len2 = data27.length;for(let i4=0; i4 1){outer2:for(;i5--;){for(j2 = i5; j2--;){if(func0(data27[i5], data27[j2])){const err87 = {instancePath:instancePath+"/causalRefs",schemaPath:"#/properties/causalRefs/uniqueItems",keyword:"uniqueItems",params:{i: i5, j: j2},message:"must NOT have duplicate items (items ## "+j2+" and "+i5+" are identical)"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;break outer2;}}}}}else {const err88 = {instancePath:instancePath+"/causalRefs",schemaPath:"#/properties/causalRefs/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}}}else {const err89 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}validate94.errors = vErrors;return errors === 0;}validate94.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema197 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/terminal.schema.json","title":"PRP run terminal state","type":"object","required":["schema","turnTerminalState","runTerminalState","reportedWorkDisposition"],"properties":{"schema":{"const":"paperclip.prp.terminal.v1"},"turnTerminalState":{"enum":["completed","failed","interrupted","cancelled"]},"runTerminalState":{"enum":["succeeded","failed","cancelled"]},"reportedWorkDisposition":{"enum":["done","blocked","needs_review","yielded"]},"workAssessmentId":{"type":"string","minLength":1},"statusDecisionId":{"type":"string","minLength":1},"stopReason":{"$ref":"https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json"}},"additionalProperties":true};const schema198 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json","title":"PRP terminal stop reason receipt","type":"object","required":["schema","schemaVersion","receiptId","kind","code","retryable","decisionId","limitClass","aggregate"],"properties":{"schema":{"const":"paperclip.prp.stop_reason.v1"},"schemaVersion":{"const":1},"receiptId":{"$ref":"#/$defs/stableId"},"kind":{"enum":["budget","cost"]},"code":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"},"retryable":{"type":"boolean"},"decisionId":{"$ref":"#/$defs/stableId"},"limitClass":{"enum":["company_monthly","actor_monthly","project_monthly","run_cost","provider_quota"]},"aggregate":{"type":"object","required":["unit","observed","limit","window"],"properties":{"unit":{"enum":["cents","usd_micros","tokens"]},"observed":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0},"window":{"enum":["run","monthly_utc","provider_window"]}},"additionalProperties":true},"relatedReceiptIds":{"type":"array","items":{"$ref":"#/$defs/stableId"},"uniqueItems":true}},"additionalProperties":true,"$defs":{"stableId":{"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"}}};const schema199 = {"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};function validate107(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate107.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.schemaVersion === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.receiptId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "receiptId"},message:"must have required property '"+"receiptId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.kind === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.code === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "code"},message:"must have required property '"+"code"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.retryable === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "retryable"},message:"must have required property '"+"retryable"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.decisionId === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "decisionId"},message:"must have required property '"+"decisionId"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.limitClass === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "limitClass"},message:"must have required property '"+"limitClass"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.aggregate === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "aggregate"},message:"must have required property '"+"aggregate"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.schema !== undefined){if("paperclip.prp.stop_reason.v1" !== data.schema){const err9 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.stop_reason.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err10 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.receiptId !== undefined){let data2 = data.receiptId;if(typeof data2 === "string"){if(func1(data2) > 240){const err11 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(func1(data2) < 1){const err12 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(!pattern4.test(data2)){const err13 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}else {const err14 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.kind !== undefined){let data3 = data.kind;if(!((data3 === "budget") || (data3 === "cost"))){const err15 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema198.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.code !== undefined){let data4 = data.code;if(typeof data4 === "string"){if(func1(data4) > 160){const err16 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data4) < 1){const err17 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(!pattern14.test(data4)){const err18 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.retryable !== undefined){if(typeof data.retryable !== "boolean"){const err20 = {instancePath:instancePath+"/retryable",schemaPath:"#/properties/retryable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.decisionId !== undefined){let data6 = data.decisionId;if(typeof data6 === "string"){if(func1(data6) > 240){const err21 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data6) < 1){const err22 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data6)){const err23 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.limitClass !== undefined){let data7 = data.limitClass;if(!(((((data7 === "company_monthly") || (data7 === "actor_monthly")) || (data7 === "project_monthly")) || (data7 === "run_cost")) || (data7 === "provider_quota"))){const err25 = {instancePath:instancePath+"/limitClass",schemaPath:"#/properties/limitClass/enum",keyword:"enum",params:{allowedValues: schema198.properties.limitClass.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.aggregate !== undefined){let data8 = data.aggregate;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.unit === undefined){const err26 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "unit"},message:"must have required property '"+"unit"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data8.observed === undefined){const err27 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "observed"},message:"must have required property '"+"observed"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data8.limit === undefined){const err28 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "limit"},message:"must have required property '"+"limit"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data8.window === undefined){const err29 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "window"},message:"must have required property '"+"window"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data8.unit !== undefined){let data9 = data8.unit;if(!(((data9 === "cents") || (data9 === "usd_micros")) || (data9 === "tokens"))){const err30 = {instancePath:instancePath+"/aggregate/unit",schemaPath:"#/properties/aggregate/properties/unit/enum",keyword:"enum",params:{allowedValues: schema198.properties.aggregate.properties.unit.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}if(data8.observed !== undefined){let data10 = data8.observed;if(!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))){const err31 = {instancePath:instancePath+"/aggregate/observed",schemaPath:"#/properties/aggregate/properties/observed/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if((typeof data10 == "number") && (isFinite(data10))){if(data10 < 0 || isNaN(data10)){const err32 = {instancePath:instancePath+"/aggregate/observed",schemaPath:"#/properties/aggregate/properties/observed/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}if(data8.limit !== undefined){let data11 = data8.limit;if(!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))){const err33 = {instancePath:instancePath+"/aggregate/limit",schemaPath:"#/properties/aggregate/properties/limit/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if((typeof data11 == "number") && (isFinite(data11))){if(data11 < 0 || isNaN(data11)){const err34 = {instancePath:instancePath+"/aggregate/limit",schemaPath:"#/properties/aggregate/properties/limit/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}if(data8.window !== undefined){let data12 = data8.window;if(!(((data12 === "run") || (data12 === "monthly_utc")) || (data12 === "provider_window"))){const err35 = {instancePath:instancePath+"/aggregate/window",schemaPath:"#/properties/aggregate/properties/window/enum",keyword:"enum",params:{allowedValues: schema198.properties.aggregate.properties.window.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}}else {const err36 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.relatedReceiptIds !== undefined){let data13 = data.relatedReceiptIds;if(Array.isArray(data13)){const len0 = data13.length;for(let i0=0; i0 240){const err37 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(func1(data14) < 1){const err38 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(!pattern4.test(data14)){const err39 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}else {const err40 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}let i1 = data13.length;let j0;if(i1 > 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data13[i1], data13[j0])){const err41 = {instancePath:instancePath+"/relatedReceiptIds",schemaPath:"#/properties/relatedReceiptIds/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;break outer0;}}}}}else {const err42 = {instancePath:instancePath+"/relatedReceiptIds",schemaPath:"#/properties/relatedReceiptIds/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}else {const err43 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}validate107.errors = vErrors;return errors === 0;}validate107.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate106(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/terminal.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate106.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.turnTerminalState === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "turnTerminalState"},message:"must have required property '"+"turnTerminalState"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.runTerminalState === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runTerminalState"},message:"must have required property '"+"runTerminalState"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.reportedWorkDisposition === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reportedWorkDisposition"},message:"must have required property '"+"reportedWorkDisposition"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.schema !== undefined){if("paperclip.prp.terminal.v1" !== data.schema){const err4 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.terminal.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.turnTerminalState !== undefined){let data1 = data.turnTerminalState;if(!((((data1 === "completed") || (data1 === "failed")) || (data1 === "interrupted")) || (data1 === "cancelled"))){const err5 = {instancePath:instancePath+"/turnTerminalState",schemaPath:"#/properties/turnTerminalState/enum",keyword:"enum",params:{allowedValues: schema197.properties.turnTerminalState.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.runTerminalState !== undefined){let data2 = data.runTerminalState;if(!(((data2 === "succeeded") || (data2 === "failed")) || (data2 === "cancelled"))){const err6 = {instancePath:instancePath+"/runTerminalState",schemaPath:"#/properties/runTerminalState/enum",keyword:"enum",params:{allowedValues: schema197.properties.runTerminalState.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.reportedWorkDisposition !== undefined){let data3 = data.reportedWorkDisposition;if(!((((data3 === "done") || (data3 === "blocked")) || (data3 === "needs_review")) || (data3 === "yielded"))){const err7 = {instancePath:instancePath+"/reportedWorkDisposition",schemaPath:"#/properties/reportedWorkDisposition/enum",keyword:"enum",params:{allowedValues: schema197.properties.reportedWorkDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.workAssessmentId !== undefined){let data4 = data.workAssessmentId;if(typeof data4 === "string"){if(func1(data4) < 1){const err8 = {instancePath:instancePath+"/workAssessmentId",schemaPath:"#/properties/workAssessmentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/workAssessmentId",schemaPath:"#/properties/workAssessmentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.statusDecisionId !== undefined){let data5 = data.statusDecisionId;if(typeof data5 === "string"){if(func1(data5) < 1){const err10 = {instancePath:instancePath+"/statusDecisionId",schemaPath:"#/properties/statusDecisionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/statusDecisionId",schemaPath:"#/properties/statusDecisionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.stopReason !== undefined){if(!(validate107(data.stopReason, {instancePath:instancePath+"/stopReason",parentData:data,parentDataProperty:"stopReason",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate107.errors : vErrors.concat(validate107.errors);errors = vErrors.length;}}}else {const err12 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}validate106.errors = vErrors;return errors === 0;}validate106.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const pattern70 = new RegExp("^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "u");const formats0 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;function validate28(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/event.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate28.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("plan.updated" !== data.eventType){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate62(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate62.errors : vErrors.concat(validate62.errors);errors = vErrors.length;}}}var _valid0 = _errs5 === errors;valid1 = _valid0;if(valid1){var props0 = {};props0.payload = true;props0.eventType = true;}}if(!valid1){const err1 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}const _errs8 = errors;let valid4 = true;const _errs9 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data2 = data.eventType;if(!(((data2 === "tool.execution.started") || (data2 === "tool.execution.progressed")) || (data2 === "tool.execution.completed"))){const err2 = {};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}}var _valid1 = _errs9 === errors;errors = _errs8;if(vErrors !== null){if(_errs8){vErrors.length = _errs8;}else {vErrors = null;}}if(_valid1){const _errs11 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate64(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate64.errors : vErrors.concat(validate64.errors);errors = vErrors.length;}}}var _valid1 = _errs11 === errors;valid4 = _valid1;if(valid4){var props1 = {};props1.payload = true;props1.eventType = true;}}if(!valid4){const err3 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(props0 !== true && props1 !== undefined){if(props1 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props1);}}const _errs14 = errors;let valid7 = true;const _errs15 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data4 = data.eventType;if(!(((data4 === "research.started") || (data4 === "research.progressed")) || (data4 === "research.completed"))){const err4 = {};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}var _valid2 = _errs15 === errors;errors = _errs14;if(vErrors !== null){if(_errs14){vErrors.length = _errs14;}else {vErrors = null;}}if(_valid2){const _errs17 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate66(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate66.errors : vErrors.concat(validate66.errors);errors = vErrors.length;}}}var _valid2 = _errs17 === errors;valid7 = _valid2;if(valid7){var props2 = {};props2.payload = true;props2.eventType = true;}}if(!valid7){const err5 = {instancePath,schemaPath:"#/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(props0 !== true && props2 !== undefined){if(props2 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props2);}}const _errs20 = errors;let valid10 = true;const _errs21 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data6 = data.eventType;if(!(((data6 === "delegation.started") || (data6 === "delegation.updated")) || (data6 === "delegation.completed"))){const err6 = {};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}var _valid3 = _errs21 === errors;errors = _errs20;if(vErrors !== null){if(_errs20){vErrors.length = _errs20;}else {vErrors = null;}}if(_valid3){const _errs23 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate68(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate68.errors : vErrors.concat(validate68.errors);errors = vErrors.length;}}}var _valid3 = _errs23 === errors;valid10 = _valid3;if(valid10){var props3 = {};props3.payload = true;props3.eventType = true;}}if(!valid10){const err7 = {instancePath,schemaPath:"#/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(props0 !== true && props3 !== undefined){if(props3 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props3);}}const _errs26 = errors;let valid13 = true;const _errs27 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("model.route.changed" !== data.eventType){const err8 = {};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}var _valid4 = _errs27 === errors;errors = _errs26;if(vErrors !== null){if(_errs26){vErrors.length = _errs26;}else {vErrors = null;}}if(_valid4){const _errs29 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate70(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate70.errors : vErrors.concat(validate70.errors);errors = vErrors.length;}}}var _valid4 = _errs29 === errors;valid13 = _valid4;if(valid13){var props4 = {};props4.payload = true;props4.eventType = true;}}if(!valid13){const err9 = {instancePath,schemaPath:"#/allOf/4/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(props0 !== true && props4 !== undefined){if(props4 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props4);}}const _errs32 = errors;let valid16 = true;const _errs33 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("model.verification.updated" !== data.eventType){const err10 = {};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}}var _valid5 = _errs33 === errors;errors = _errs32;if(vErrors !== null){if(_errs32){vErrors.length = _errs32;}else {vErrors = null;}}if(_valid5){const _errs35 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate72(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate72.errors : vErrors.concat(validate72.errors);errors = vErrors.length;}}}var _valid5 = _errs35 === errors;valid16 = _valid5;if(valid16){var props5 = {};props5.payload = true;props5.eventType = true;}}if(!valid16){const err11 = {instancePath,schemaPath:"#/allOf/5/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(props0 !== true && props5 !== undefined){if(props5 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props5);}}const _errs38 = errors;let valid19 = true;const _errs39 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("context.compacted" !== data.eventType){const err12 = {};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}var _valid6 = _errs39 === errors;errors = _errs38;if(vErrors !== null){if(_errs38){vErrors.length = _errs38;}else {vErrors = null;}}if(_valid6){const _errs41 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate74(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate74.errors : vErrors.concat(validate74.errors);errors = vErrors.length;}}}var _valid6 = _errs41 === errors;valid19 = _valid6;if(valid19){var props6 = {};props6.payload = true;props6.eventType = true;}}if(!valid19){const err13 = {instancePath,schemaPath:"#/allOf/6/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(props0 !== true && props6 !== undefined){if(props6 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props6);}}const _errs44 = errors;let valid22 = true;const _errs45 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("artifact.viewed" !== data.eventType){const err14 = {};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}var _valid7 = _errs45 === errors;errors = _errs44;if(vErrors !== null){if(_errs44){vErrors.length = _errs44;}else {vErrors = null;}}if(_valid7){const _errs47 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate76(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate76.errors : vErrors.concat(validate76.errors);errors = vErrors.length;}}}var _valid7 = _errs47 === errors;valid22 = _valid7;if(valid22){var props7 = {};props7.payload = true;props7.eventType = true;}}if(!valid22){const err15 = {instancePath,schemaPath:"#/allOf/7/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(props0 !== true && props7 !== undefined){if(props7 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props7);}}const _errs50 = errors;let valid25 = true;const _errs51 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("artifact.generated" !== data.eventType){const err16 = {};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}var _valid8 = _errs51 === errors;errors = _errs50;if(vErrors !== null){if(_errs50){vErrors.length = _errs50;}else {vErrors = null;}}if(_valid8){const _errs53 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate78(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate78.errors : vErrors.concat(validate78.errors);errors = vErrors.length;}}}var _valid8 = _errs53 === errors;valid25 = _valid8;if(valid25){var props8 = {};props8.payload = true;props8.eventType = true;}}if(!valid25){const err17 = {instancePath,schemaPath:"#/allOf/8/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(props0 !== true && props8 !== undefined){if(props8 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props8);}}const _errs56 = errors;let valid28 = true;const _errs57 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("review.mode.changed" !== data.eventType){const err18 = {};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}var _valid9 = _errs57 === errors;errors = _errs56;if(vErrors !== null){if(_errs56){vErrors.length = _errs56;}else {vErrors = null;}}if(_valid9){const _errs59 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate80(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate80.errors : vErrors.concat(validate80.errors);errors = vErrors.length;}}}var _valid9 = _errs59 === errors;valid28 = _valid9;if(valid28){var props9 = {};props9.payload = true;props9.eventType = true;}}if(!valid28){const err19 = {instancePath,schemaPath:"#/allOf/9/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(props0 !== true && props9 !== undefined){if(props9 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props9);}}const _errs62 = errors;let valid31 = true;const _errs63 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data20 = data.eventType;if(!((data20 === "hook.started") || (data20 === "hook.completed"))){const err20 = {};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}var _valid10 = _errs63 === errors;errors = _errs62;if(vErrors !== null){if(_errs62){vErrors.length = _errs62;}else {vErrors = null;}}if(_valid10){const _errs65 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate82(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate82.errors : vErrors.concat(validate82.errors);errors = vErrors.length;}}}var _valid10 = _errs65 === errors;valid31 = _valid10;if(valid31){var props10 = {};props10.payload = true;props10.eventType = true;}}if(!valid31){const err21 = {instancePath,schemaPath:"#/allOf/10/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(props0 !== true && props10 !== undefined){if(props10 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props10);}}const _errs68 = errors;let valid34 = true;const _errs69 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("memory.citation.referenced" !== data.eventType){const err22 = {};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}var _valid11 = _errs69 === errors;errors = _errs68;if(vErrors !== null){if(_errs68){vErrors.length = _errs68;}else {vErrors = null;}}if(_valid11){const _errs71 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate84(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate84.errors : vErrors.concat(validate84.errors);errors = vErrors.length;}}}var _valid11 = _errs71 === errors;valid34 = _valid11;if(valid34){var props11 = {};props11.payload = true;props11.eventType = true;}}if(!valid34){const err23 = {instancePath,schemaPath:"#/allOf/11/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(props0 !== true && props11 !== undefined){if(props11 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props11);}}const _errs74 = errors;let valid37 = true;const _errs75 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data24 = data.eventType;if(!((data24 === "safety.review.started") || (data24 === "safety.review.completed"))){const err24 = {};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}var _valid12 = _errs75 === errors;errors = _errs74;if(vErrors !== null){if(_errs74){vErrors.length = _errs74;}else {vErrors = null;}}if(_valid12){const _errs77 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate86(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate86.errors : vErrors.concat(validate86.errors);errors = vErrors.length;}}}var _valid12 = _errs77 === errors;valid37 = _valid12;if(valid37){var props12 = {};props12.payload = true;props12.eventType = true;}}if(!valid37){const err25 = {instancePath,schemaPath:"#/allOf/12/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(props0 !== true && props12 !== undefined){if(props12 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props12);}}const _errs80 = errors;let valid40 = true;const _errs81 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("terminal.input.sent" !== data.eventType){const err26 = {};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}var _valid13 = _errs81 === errors;errors = _errs80;if(vErrors !== null){if(_errs80){vErrors.length = _errs80;}else {vErrors = null;}}if(_valid13){const _errs83 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate88(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate88.errors : vErrors.concat(validate88.errors);errors = vErrors.length;}}}var _valid13 = _errs83 === errors;valid40 = _valid13;if(valid40){var props13 = {};props13.payload = true;props13.eventType = true;}}if(!valid40){const err27 = {instancePath,schemaPath:"#/allOf/13/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(props0 !== true && props13 !== undefined){if(props13 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props13);}}const _errs86 = errors;let valid43 = true;const _errs87 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data28 = data.eventType;if(!((data28 === "wait.started") || (data28 === "wait.completed"))){const err28 = {};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}var _valid14 = _errs87 === errors;errors = _errs86;if(vErrors !== null){if(_errs86){vErrors.length = _errs86;}else {vErrors = null;}}if(_valid14){const _errs89 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate90(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate90.errors : vErrors.concat(validate90.errors);errors = vErrors.length;}}}var _valid14 = _errs89 === errors;valid43 = _valid14;if(valid43){var props14 = {};props14.payload = true;props14.eventType = true;}}if(!valid43){const err29 = {instancePath,schemaPath:"#/allOf/14/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(props0 !== true && props14 !== undefined){if(props14 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props14);}}const _errs92 = errors;let valid46 = true;const _errs93 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("provider.notice.recorded" !== data.eventType){const err30 = {};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}}var _valid15 = _errs93 === errors;errors = _errs92;if(vErrors !== null){if(_errs92){vErrors.length = _errs92;}else {vErrors = null;}}if(_valid15){const _errs95 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate92(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate92.errors : vErrors.concat(validate92.errors);errors = vErrors.length;}}}var _valid15 = _errs95 === errors;valid46 = _valid15;if(valid46){var props15 = {};props15.payload = true;props15.eventType = true;}}if(!valid46){const err31 = {instancePath,schemaPath:"#/allOf/15/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(props0 !== true && props15 !== undefined){if(props15 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props15);}}const _errs98 = errors;let valid49 = true;const _errs99 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("workspace.file.referenced" !== data.eventType){const err32 = {};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}var _valid16 = _errs99 === errors;errors = _errs98;if(vErrors !== null){if(_errs98){vErrors.length = _errs98;}else {vErrors = null;}}if(_valid16){const _errs101 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data33 = data.payload;if(data33 && typeof data33 == "object" && !Array.isArray(data33)){if(data33.schema === undefined){const err33 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(data33.referenceId === undefined){const err34 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "referenceId"},message:"must have required property '"+"referenceId"+"'"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(data33.source === undefined){const err35 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(data33.path === undefined){const err36 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "path"},message:"must have required property '"+"path"+"'"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}if(data33.displayName === undefined){const err37 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "displayName"},message:"must have required property '"+"displayName"+"'"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(data33.mediaType === undefined){const err38 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "mediaType"},message:"must have required property '"+"mediaType"+"'"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(data33.presentation === undefined){const err39 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "presentation"},message:"must have required property '"+"presentation"+"'"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}if(data33.line === undefined){const err40 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "line"},message:"must have required property '"+"line"+"'"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if(data33.preview === undefined){const err41 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "preview"},message:"must have required property '"+"preview"+"'"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data33.previewTruncated === undefined){const err42 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "previewTruncated"},message:"must have required property '"+"previewTruncated"+"'"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if(data33.contentDigest === undefined){const err43 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "contentDigest"},message:"must have required property '"+"contentDigest"+"'"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}for(const key0 in data33){if(!(func66.call(schema174.properties, key0))){const err44 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data33.schema !== undefined){if("paperclip.workspace.file_reference.v1" !== data33.schema){const err45 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.workspace.file_reference.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}if(data33.referenceId !== undefined){let data35 = data33.referenceId;if(typeof data35 === "string"){if(func1(data35) > 240){const err46 = {instancePath:instancePath+"/payload/referenceId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/referenceId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(func1(data35) < 1){const err47 = {instancePath:instancePath+"/payload/referenceId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/referenceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}else {const err48 = {instancePath:instancePath+"/payload/referenceId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/referenceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}if(data33.source !== undefined){let data36 = data33.source;if(!((data36 === "harness_reported") || (data36 === "runner_verified"))){const err49 = {instancePath:instancePath+"/payload/source",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/source/enum",keyword:"enum",params:{allowedValues: schema174.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}if(data33.path !== undefined){let data37 = data33.path;if(typeof data37 === "string"){if(func1(data37) > 1024){const err50 = {instancePath:instancePath+"/payload/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/path/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}if(func1(data37) < 1){const err51 = {instancePath:instancePath+"/payload/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/path/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}if(!pattern70.test(data37)){const err52 = {instancePath:instancePath+"/payload/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/path/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}else {const err53 = {instancePath:instancePath+"/payload/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}if(data33.displayName !== undefined){let data38 = data33.displayName;if(typeof data38 === "string"){if(func1(data38) > 255){const err54 = {instancePath:instancePath+"/payload/displayName",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/displayName/maxLength",keyword:"maxLength",params:{limit: 255},message:"must NOT have more than 255 characters"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}if(func1(data38) < 1){const err55 = {instancePath:instancePath+"/payload/displayName",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/displayName/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}else {const err56 = {instancePath:instancePath+"/payload/displayName",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/displayName/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}if(data33.mediaType !== undefined){let data39 = data33.mediaType;if((typeof data39 !== "string") && (data39 !== null)){const err57 = {instancePath:instancePath+"/payload/mediaType",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/mediaType/type",keyword:"type",params:{type: schema174.properties.mediaType.type},message:"must be string,null"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}if(typeof data39 === "string"){if(func1(data39) > 160){const err58 = {instancePath:instancePath+"/payload/mediaType",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/mediaType/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}}if(data33.presentation !== undefined){let data40 = data33.presentation;if(!((((data40 === "document") || (data40 === "code")) || (data40 === "image")) || (data40 === "generic"))){const err59 = {instancePath:instancePath+"/payload/presentation",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/presentation/enum",keyword:"enum",params:{allowedValues: schema174.properties.presentation.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data33.line !== undefined){let data41 = data33.line;if((!(((typeof data41 == "number") && (!(data41 % 1) && !isNaN(data41))) && (isFinite(data41)))) && (data41 !== null)){const err60 = {instancePath:instancePath+"/payload/line",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/line/type",keyword:"type",params:{type: schema174.properties.line.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}if((typeof data41 == "number") && (isFinite(data41))){if(data41 < 1 || isNaN(data41)){const err61 = {instancePath:instancePath+"/payload/line",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/line/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}}if(data33.preview !== undefined){let data42 = data33.preview;if((typeof data42 !== "string") && (data42 !== null)){const err62 = {instancePath:instancePath+"/payload/preview",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/preview/type",keyword:"type",params:{type: schema174.properties.preview.type},message:"must be string,null"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(typeof data42 === "string"){if(func1(data42) > 262144){const err63 = {instancePath:instancePath+"/payload/preview",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/preview/maxLength",keyword:"maxLength",params:{limit: 262144},message:"must NOT have more than 262144 characters"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}}if(data33.previewTruncated !== undefined){if(typeof data33.previewTruncated !== "boolean"){const err64 = {instancePath:instancePath+"/payload/previewTruncated",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/previewTruncated/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}if(data33.contentDigest !== undefined){let data44 = data33.contentDigest;const _errs124 = errors;let valid54 = false;let passing0 = null;const _errs125 = errors;if(data44 !== null){const err65 = {instancePath:instancePath+"/payload/contentDigest",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/contentDigest/oneOf/0/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}var _valid17 = _errs125 === errors;if(_valid17){valid54 = true;passing0 = 0;}const _errs127 = errors;if(typeof data44 === "string"){if(!pattern20.test(data44)){const err66 = {instancePath:instancePath+"/payload/contentDigest",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/contentDigest/oneOf/1/pattern",keyword:"pattern",params:{pattern: "^sha256:[a-f0-9]{64}$"},message:"must match pattern \""+"^sha256:[a-f0-9]{64}$"+"\""};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}}else {const err67 = {instancePath:instancePath+"/payload/contentDigest",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/contentDigest/oneOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}var _valid17 = _errs127 === errors;if(_valid17 && valid54){valid54 = false;passing0 = [passing0, 1];}else {if(_valid17){valid54 = true;passing0 = 1;}}if(!valid54){const err68 = {instancePath:instancePath+"/payload/contentDigest",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/contentDigest/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}else {errors = _errs124;if(vErrors !== null){if(_errs124){vErrors.length = _errs124;}else {vErrors = null;}}}}}else {const err69 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}}var _valid16 = _errs101 === errors;valid49 = _valid16;if(valid49){var props16 = {};props16.payload = true;props16.eventType = true;}}if(!valid49){const err70 = {instancePath,schemaPath:"#/allOf/16/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if(props0 !== true && props16 !== undefined){if(props16 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props16);}}const _errs130 = errors;let valid55 = true;const _errs131 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data45 = data.eventType;if(!((data45 === "workspace.change.updated") || (data45 === "workspace.diff.recorded"))){const err71 = {};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}}var _valid18 = _errs131 === errors;errors = _errs130;if(vErrors !== null){if(_errs130){vErrors.length = _errs130;}else {vErrors = null;}}if(_valid18){const _errs133 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data46 = data.payload;if(data46 && typeof data46 == "object" && !Array.isArray(data46)){if(data46.schema === undefined){const err72 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(data46.changeSetId === undefined){const err73 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "changeSetId"},message:"must have required property '"+"changeSetId"+"'"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}if(data46.revision === undefined){const err74 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}if(data46.source === undefined){const err75 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}if(data46.complete === undefined){const err76 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "complete"},message:"must have required property '"+"complete"+"'"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}if(data46.files === undefined){const err77 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(data46.totals === undefined){const err78 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "totals"},message:"must have required property '"+"totals"+"'"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}if(data46.patchArtifactRef === undefined){const err79 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "patchArtifactRef"},message:"must have required property '"+"patchArtifactRef"+"'"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}for(const key1 in data46){if(!((((((((key1 === "schema") || (key1 === "changeSetId")) || (key1 === "revision")) || (key1 === "source")) || (key1 === "complete")) || (key1 === "files")) || (key1 === "totals")) || (key1 === "patchArtifactRef"))){const err80 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}}if(data46.schema !== undefined){if("paperclip.workspace.diff.v1" !== data46.schema){const err81 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.workspace.diff.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}}if(data46.changeSetId !== undefined){let data48 = data46.changeSetId;if(typeof data48 === "string"){if(func1(data48) > 200){const err82 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/maxLength",keyword:"maxLength",params:{limit: 200},message:"must NOT have more than 200 characters"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(func1(data48) < 1){const err83 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}}else {const err84 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}}if(data46.revision !== undefined){let data49 = data46.revision;if(!(((typeof data49 == "number") && (!(data49 % 1) && !isNaN(data49))) && (isFinite(data49)))){const err85 = {instancePath:instancePath+"/payload/revision",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/revision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}if((typeof data49 == "number") && (isFinite(data49))){if(data49 < 1 || isNaN(data49)){const err86 = {instancePath:instancePath+"/payload/revision",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/revision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}}}if(data46.source !== undefined){let data50 = data46.source;if(!((data50 === "harness_reported") || (data50 === "runner_verified"))){const err87 = {instancePath:instancePath+"/payload/source",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/source/enum",keyword:"enum",params:{allowedValues: schema175.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}if(data46.complete !== undefined){if(typeof data46.complete !== "boolean"){const err88 = {instancePath:instancePath+"/payload/complete",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/complete/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}}if(data46.files !== undefined){let data52 = data46.files;if(Array.isArray(data52)){if(data52.length > 2000){const err89 = {instancePath:instancePath+"/payload/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/maxItems",keyword:"maxItems",params:{limit: 2000},message:"must NOT have more than 2000 items"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}const len0 = data52.length;for(let i0=0; i0 1024){const err98 = {instancePath:instancePath+"/payload/files/" + i0+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}if(func1(data54) < 1){const err99 = {instancePath:instancePath+"/payload/files/" + i0+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}if(!pattern70.test(data54)){const err100 = {instancePath:instancePath+"/payload/files/" + i0+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}}else {const err101 = {instancePath:instancePath+"/payload/files/" + i0+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}if(data53.operation !== undefined){let data55 = data53.operation;if(!(((((data55 === "create") || (data55 === "modify")) || (data55 === "delete")) || (data55 === "rename")) || (data55 === "mode_change"))){const err102 = {instancePath:instancePath+"/payload/files/" + i0+"/operation",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/operation/enum",keyword:"enum",params:{allowedValues: schema175.properties.files.items.properties.operation.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}if(data53.previousPath !== undefined){let data56 = data53.previousPath;const _errs155 = errors;let valid63 = false;let passing1 = null;const _errs156 = errors;if(data56 !== null){const err103 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/0/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}var _valid19 = _errs156 === errors;if(_valid19){valid63 = true;passing1 = 0;}const _errs158 = errors;if(typeof data56 === "string"){if(func1(data56) > 1024){const err104 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}if(func1(data56) < 1){const err105 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}if(!pattern70.test(data56)){const err106 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}}else {const err107 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}var _valid19 = _errs158 === errors;if(_valid19 && valid63){valid63 = false;passing1 = [passing1, 1];}else {if(_valid19){valid63 = true;passing1 = 1;}}if(!valid63){const err108 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf",keyword:"oneOf",params:{passingSchemas: passing1},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}else {errors = _errs155;if(vErrors !== null){if(_errs155){vErrors.length = _errs155;}else {vErrors = null;}}}}if(data53.additions !== undefined){let data57 = data53.additions;if((!(((typeof data57 == "number") && (!(data57 % 1) && !isNaN(data57))) && (isFinite(data57)))) && (data57 !== null)){const err109 = {instancePath:instancePath+"/payload/files/" + i0+"/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/additions/type",keyword:"type",params:{type: schema175.properties.files.items.properties.additions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}if((typeof data57 == "number") && (isFinite(data57))){if(data57 < 0 || isNaN(data57)){const err110 = {instancePath:instancePath+"/payload/files/" + i0+"/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}}if(data53.deletions !== undefined){let data58 = data53.deletions;if((!(((typeof data58 == "number") && (!(data58 % 1) && !isNaN(data58))) && (isFinite(data58)))) && (data58 !== null)){const err111 = {instancePath:instancePath+"/payload/files/" + i0+"/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/deletions/type",keyword:"type",params:{type: schema175.properties.files.items.properties.deletions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}if((typeof data58 == "number") && (isFinite(data58))){if(data58 < 0 || isNaN(data58)){const err112 = {instancePath:instancePath+"/payload/files/" + i0+"/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}}}if(data53.binary !== undefined){if(typeof data53.binary !== "boolean"){const err113 = {instancePath:instancePath+"/payload/files/" + i0+"/binary",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/binary/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}if(data53.diff !== undefined){let data60 = data53.diff;if((typeof data60 !== "string") && (data60 !== null)){const err114 = {instancePath:instancePath+"/payload/files/" + i0+"/diff",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/diff/type",keyword:"type",params:{type: schema175.properties.files.items.properties.diff.type},message:"must be string,null"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}if(typeof data60 === "string"){if(func1(data60) > 262144){const err115 = {instancePath:instancePath+"/payload/files/" + i0+"/diff",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/diff/maxLength",keyword:"maxLength",params:{limit: 262144},message:"must NOT have more than 262144 characters"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}}}}else {const err116 = {instancePath:instancePath+"/payload/files/" + i0,schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}}else {const err117 = {instancePath:instancePath+"/payload/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}}if(data46.totals !== undefined){let data61 = data46.totals;if(data61 && typeof data61 == "object" && !Array.isArray(data61)){if(data61.files === undefined){const err118 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}if(data61.additions === undefined){const err119 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "additions"},message:"must have required property '"+"additions"+"'"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}if(data61.deletions === undefined){const err120 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "deletions"},message:"must have required property '"+"deletions"+"'"};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}for(const key3 in data61){if(!(((key3 === "files") || (key3 === "additions")) || (key3 === "deletions"))){const err121 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}if(data61.files !== undefined){let data62 = data61.files;if(!(((typeof data62 == "number") && (!(data62 % 1) && !isNaN(data62))) && (isFinite(data62)))){const err122 = {instancePath:instancePath+"/payload/totals/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/files/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}if((typeof data62 == "number") && (isFinite(data62))){if(data62 < 0 || isNaN(data62)){const err123 = {instancePath:instancePath+"/payload/totals/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/files/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}}if(data61.additions !== undefined){let data63 = data61.additions;if((!(((typeof data63 == "number") && (!(data63 % 1) && !isNaN(data63))) && (isFinite(data63)))) && (data63 !== null)){const err124 = {instancePath:instancePath+"/payload/totals/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/additions/type",keyword:"type",params:{type: schema175.properties.totals.properties.additions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}if((typeof data63 == "number") && (isFinite(data63))){if(data63 < 0 || isNaN(data63)){const err125 = {instancePath:instancePath+"/payload/totals/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}}}if(data61.deletions !== undefined){let data64 = data61.deletions;if((!(((typeof data64 == "number") && (!(data64 % 1) && !isNaN(data64))) && (isFinite(data64)))) && (data64 !== null)){const err126 = {instancePath:instancePath+"/payload/totals/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/deletions/type",keyword:"type",params:{type: schema175.properties.totals.properties.deletions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}if((typeof data64 == "number") && (isFinite(data64))){if(data64 < 0 || isNaN(data64)){const err127 = {instancePath:instancePath+"/payload/totals/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;}}}}else {const err128 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}}if(data46.patchArtifactRef !== undefined){let data65 = data46.patchArtifactRef;if((typeof data65 !== "string") && (data65 !== null)){const err129 = {instancePath:instancePath+"/payload/patchArtifactRef",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/patchArtifactRef/type",keyword:"type",params:{type: schema175.properties.patchArtifactRef.type},message:"must be string,null"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}if(typeof data65 === "string"){if(func1(data65) > 2048){const err130 = {instancePath:instancePath+"/payload/patchArtifactRef",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/patchArtifactRef/maxLength",keyword:"maxLength",params:{limit: 2048},message:"must NOT have more than 2048 characters"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}}}}else {const err131 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}}}var _valid18 = _errs133 === errors;valid55 = _valid18;if(valid55){var props17 = {};props17.payload = true;props17.eventType = true;}}if(!valid55){const err132 = {instancePath,schemaPath:"#/allOf/17/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;}if(props0 !== true && props17 !== undefined){if(props17 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props17);}}const _errs180 = errors;let valid65 = true;const _errs181 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("workspace.diff.recorded" !== data.eventType){const err133 = {};if(vErrors === null){vErrors = [err133];}else {vErrors.push(err133);}errors++;}}}var _valid20 = _errs181 === errors;errors = _errs180;if(vErrors !== null){if(_errs180){vErrors.length = _errs180;}else {vErrors = null;}}if(_valid20){const _errs183 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data67 = data.payload;if(data67 && typeof data67 == "object" && !Array.isArray(data67)){if(data67.schema === undefined){const err134 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}if(data67.changeSetId === undefined){const err135 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "changeSetId"},message:"must have required property '"+"changeSetId"+"'"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}if(data67.revision === undefined){const err136 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err136];}else {vErrors.push(err136);}errors++;}if(data67.source === undefined){const err137 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err137];}else {vErrors.push(err137);}errors++;}if(data67.complete === undefined){const err138 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "complete"},message:"must have required property '"+"complete"+"'"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}if(data67.files === undefined){const err139 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}if(data67.totals === undefined){const err140 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "totals"},message:"must have required property '"+"totals"+"'"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}if(data67.patchArtifactRef === undefined){const err141 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "patchArtifactRef"},message:"must have required property '"+"patchArtifactRef"+"'"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}for(const key4 in data67){if(!((((((((key4 === "schema") || (key4 === "changeSetId")) || (key4 === "revision")) || (key4 === "source")) || (key4 === "complete")) || (key4 === "files")) || (key4 === "totals")) || (key4 === "patchArtifactRef"))){const err142 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err142];}else {vErrors.push(err142);}errors++;}}if(data67.schema !== undefined){if("paperclip.workspace.diff.v1" !== data67.schema){const err143 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.workspace.diff.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}}if(data67.changeSetId !== undefined){let data69 = data67.changeSetId;if(typeof data69 === "string"){if(func1(data69) > 200){const err144 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/maxLength",keyword:"maxLength",params:{limit: 200},message:"must NOT have more than 200 characters"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;}if(func1(data69) < 1){const err145 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err145];}else {vErrors.push(err145);}errors++;}}else {const err146 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err146];}else {vErrors.push(err146);}errors++;}}if(data67.revision !== undefined){let data70 = data67.revision;if(!(((typeof data70 == "number") && (!(data70 % 1) && !isNaN(data70))) && (isFinite(data70)))){const err147 = {instancePath:instancePath+"/payload/revision",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/revision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err147];}else {vErrors.push(err147);}errors++;}if((typeof data70 == "number") && (isFinite(data70))){if(data70 < 1 || isNaN(data70)){const err148 = {instancePath:instancePath+"/payload/revision",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/revision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err148];}else {vErrors.push(err148);}errors++;}}}if(data67.source !== undefined){let data71 = data67.source;if(!((data71 === "harness_reported") || (data71 === "runner_verified"))){const err149 = {instancePath:instancePath+"/payload/source",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/source/enum",keyword:"enum",params:{allowedValues: schema175.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err149];}else {vErrors.push(err149);}errors++;}}if(data67.complete !== undefined){if(typeof data67.complete !== "boolean"){const err150 = {instancePath:instancePath+"/payload/complete",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/complete/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err150];}else {vErrors.push(err150);}errors++;}}if(data67.files !== undefined){let data73 = data67.files;if(Array.isArray(data73)){if(data73.length > 2000){const err151 = {instancePath:instancePath+"/payload/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/maxItems",keyword:"maxItems",params:{limit: 2000},message:"must NOT have more than 2000 items"};if(vErrors === null){vErrors = [err151];}else {vErrors.push(err151);}errors++;}const len1 = data73.length;for(let i1=0; i1 1024){const err160 = {instancePath:instancePath+"/payload/files/" + i1+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err160];}else {vErrors.push(err160);}errors++;}if(func1(data75) < 1){const err161 = {instancePath:instancePath+"/payload/files/" + i1+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err161];}else {vErrors.push(err161);}errors++;}if(!pattern70.test(data75)){const err162 = {instancePath:instancePath+"/payload/files/" + i1+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err162];}else {vErrors.push(err162);}errors++;}}else {const err163 = {instancePath:instancePath+"/payload/files/" + i1+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err163];}else {vErrors.push(err163);}errors++;}}if(data74.operation !== undefined){let data76 = data74.operation;if(!(((((data76 === "create") || (data76 === "modify")) || (data76 === "delete")) || (data76 === "rename")) || (data76 === "mode_change"))){const err164 = {instancePath:instancePath+"/payload/files/" + i1+"/operation",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/operation/enum",keyword:"enum",params:{allowedValues: schema175.properties.files.items.properties.operation.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err164];}else {vErrors.push(err164);}errors++;}}if(data74.previousPath !== undefined){let data77 = data74.previousPath;const _errs206 = errors;let valid74 = false;let passing2 = null;const _errs207 = errors;if(data77 !== null){const err165 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/0/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err165];}else {vErrors.push(err165);}errors++;}var _valid21 = _errs207 === errors;if(_valid21){valid74 = true;passing2 = 0;}const _errs209 = errors;if(typeof data77 === "string"){if(func1(data77) > 1024){const err166 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err166];}else {vErrors.push(err166);}errors++;}if(func1(data77) < 1){const err167 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err167];}else {vErrors.push(err167);}errors++;}if(!pattern70.test(data77)){const err168 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}}else {const err169 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}var _valid21 = _errs209 === errors;if(_valid21 && valid74){valid74 = false;passing2 = [passing2, 1];}else {if(_valid21){valid74 = true;passing2 = 1;}}if(!valid74){const err170 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf",keyword:"oneOf",params:{passingSchemas: passing2},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}else {errors = _errs206;if(vErrors !== null){if(_errs206){vErrors.length = _errs206;}else {vErrors = null;}}}}if(data74.additions !== undefined){let data78 = data74.additions;if((!(((typeof data78 == "number") && (!(data78 % 1) && !isNaN(data78))) && (isFinite(data78)))) && (data78 !== null)){const err171 = {instancePath:instancePath+"/payload/files/" + i1+"/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/additions/type",keyword:"type",params:{type: schema175.properties.files.items.properties.additions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}if((typeof data78 == "number") && (isFinite(data78))){if(data78 < 0 || isNaN(data78)){const err172 = {instancePath:instancePath+"/payload/files/" + i1+"/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}}}if(data74.deletions !== undefined){let data79 = data74.deletions;if((!(((typeof data79 == "number") && (!(data79 % 1) && !isNaN(data79))) && (isFinite(data79)))) && (data79 !== null)){const err173 = {instancePath:instancePath+"/payload/files/" + i1+"/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/deletions/type",keyword:"type",params:{type: schema175.properties.files.items.properties.deletions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}if((typeof data79 == "number") && (isFinite(data79))){if(data79 < 0 || isNaN(data79)){const err174 = {instancePath:instancePath+"/payload/files/" + i1+"/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}}}if(data74.binary !== undefined){if(typeof data74.binary !== "boolean"){const err175 = {instancePath:instancePath+"/payload/files/" + i1+"/binary",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/binary/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}}if(data74.diff !== undefined){let data81 = data74.diff;if((typeof data81 !== "string") && (data81 !== null)){const err176 = {instancePath:instancePath+"/payload/files/" + i1+"/diff",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/diff/type",keyword:"type",params:{type: schema175.properties.files.items.properties.diff.type},message:"must be string,null"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if(typeof data81 === "string"){if(func1(data81) > 262144){const err177 = {instancePath:instancePath+"/payload/files/" + i1+"/diff",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/diff/maxLength",keyword:"maxLength",params:{limit: 262144},message:"must NOT have more than 262144 characters"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}}}}else {const err178 = {instancePath:instancePath+"/payload/files/" + i1,schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err178];}else {vErrors.push(err178);}errors++;}}}else {const err179 = {instancePath:instancePath+"/payload/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err179];}else {vErrors.push(err179);}errors++;}}if(data67.totals !== undefined){let data82 = data67.totals;if(data82 && typeof data82 == "object" && !Array.isArray(data82)){if(data82.files === undefined){const err180 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err180];}else {vErrors.push(err180);}errors++;}if(data82.additions === undefined){const err181 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "additions"},message:"must have required property '"+"additions"+"'"};if(vErrors === null){vErrors = [err181];}else {vErrors.push(err181);}errors++;}if(data82.deletions === undefined){const err182 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "deletions"},message:"must have required property '"+"deletions"+"'"};if(vErrors === null){vErrors = [err182];}else {vErrors.push(err182);}errors++;}for(const key6 in data82){if(!(((key6 === "files") || (key6 === "additions")) || (key6 === "deletions"))){const err183 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key6},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err183];}else {vErrors.push(err183);}errors++;}}if(data82.files !== undefined){let data83 = data82.files;if(!(((typeof data83 == "number") && (!(data83 % 1) && !isNaN(data83))) && (isFinite(data83)))){const err184 = {instancePath:instancePath+"/payload/totals/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/files/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err184];}else {vErrors.push(err184);}errors++;}if((typeof data83 == "number") && (isFinite(data83))){if(data83 < 0 || isNaN(data83)){const err185 = {instancePath:instancePath+"/payload/totals/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/files/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err185];}else {vErrors.push(err185);}errors++;}}}if(data82.additions !== undefined){let data84 = data82.additions;if((!(((typeof data84 == "number") && (!(data84 % 1) && !isNaN(data84))) && (isFinite(data84)))) && (data84 !== null)){const err186 = {instancePath:instancePath+"/payload/totals/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/additions/type",keyword:"type",params:{type: schema175.properties.totals.properties.additions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err186];}else {vErrors.push(err186);}errors++;}if((typeof data84 == "number") && (isFinite(data84))){if(data84 < 0 || isNaN(data84)){const err187 = {instancePath:instancePath+"/payload/totals/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err187];}else {vErrors.push(err187);}errors++;}}}if(data82.deletions !== undefined){let data85 = data82.deletions;if((!(((typeof data85 == "number") && (!(data85 % 1) && !isNaN(data85))) && (isFinite(data85)))) && (data85 !== null)){const err188 = {instancePath:instancePath+"/payload/totals/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/deletions/type",keyword:"type",params:{type: schema175.properties.totals.properties.deletions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err188];}else {vErrors.push(err188);}errors++;}if((typeof data85 == "number") && (isFinite(data85))){if(data85 < 0 || isNaN(data85)){const err189 = {instancePath:instancePath+"/payload/totals/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err189];}else {vErrors.push(err189);}errors++;}}}}else {const err190 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err190];}else {vErrors.push(err190);}errors++;}}if(data67.patchArtifactRef !== undefined){let data86 = data67.patchArtifactRef;if((typeof data86 !== "string") && (data86 !== null)){const err191 = {instancePath:instancePath+"/payload/patchArtifactRef",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/patchArtifactRef/type",keyword:"type",params:{type: schema175.properties.patchArtifactRef.type},message:"must be string,null"};if(vErrors === null){vErrors = [err191];}else {vErrors.push(err191);}errors++;}if(typeof data86 === "string"){if(func1(data86) > 2048){const err192 = {instancePath:instancePath+"/payload/patchArtifactRef",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/patchArtifactRef/maxLength",keyword:"maxLength",params:{limit: 2048},message:"must NOT have more than 2048 characters"};if(vErrors === null){vErrors = [err192];}else {vErrors.push(err192);}errors++;}}}}else {const err193 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err193];}else {vErrors.push(err193);}errors++;}if(data67 && typeof data67 == "object" && !Array.isArray(data67)){if(data67.complete !== undefined){if(true !== data67.complete){const err194 = {instancePath:instancePath+"/payload/complete",schemaPath:"#/allOf/18/then/properties/payload/allOf/1/properties/complete/const",keyword:"const",params:{allowedValue: true},message:"must be equal to constant"};if(vErrors === null){vErrors = [err194];}else {vErrors.push(err194);}errors++;}}}else {const err195 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/18/then/properties/payload/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err195];}else {vErrors.push(err195);}errors++;}}}var _valid20 = _errs183 === errors;valid65 = _valid20;if(valid65){var props18 = {};props18.payload = true;props18.eventType = true;}}if(!valid65){const err196 = {instancePath,schemaPath:"#/allOf/18/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err196];}else {vErrors.push(err196);}errors++;}if(props0 !== true && props18 !== undefined){if(props18 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props18);}}const _errs234 = errors;let valid77 = true;const _errs235 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data88 = data.eventType;if(!((data88 === "semantic_tool.input") || (data88 === "mcp_app.tool_input"))){const err197 = {};if(vErrors === null){vErrors = [err197];}else {vErrors.push(err197);}errors++;}}}var _valid22 = _errs235 === errors;errors = _errs234;if(vErrors !== null){if(_errs234){vErrors.length = _errs234;}else {vErrors = null;}}if(_valid22){const _errs237 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data89 = data.payload;if(data89 && typeof data89 == "object" && !Array.isArray(data89)){if(data89.semantic_tool !== undefined){let data90 = data89.semantic_tool;if(!(validate94(data90, {instancePath:instancePath+"/payload/semantic_tool",parentData:data89,parentDataProperty:"semantic_tool",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate94.errors : vErrors.concat(validate94.errors);errors = vErrors.length;}if(data90 && typeof data90 == "object" && !Array.isArray(data90)){if(data90.phase !== undefined){if("input" !== data90.phase){const err198 = {instancePath:instancePath+"/payload/semantic_tool/phase",schemaPath:"#/allOf/19/then/properties/payload/properties/semantic_tool/allOf/1/properties/phase/const",keyword:"const",params:{allowedValue: "input"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err198];}else {vErrors.push(err198);}errors++;}}}else {const err199 = {instancePath:instancePath+"/payload/semantic_tool",schemaPath:"#/allOf/19/then/properties/payload/properties/semantic_tool/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err199];}else {vErrors.push(err199);}errors++;}}}else {const err200 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/19/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err200];}else {vErrors.push(err200);}errors++;}}}var _valid22 = _errs237 === errors;valid77 = _valid22;if(valid77){var props19 = {};props19.payload = true;props19.eventType = true;}}if(!valid77){const err201 = {instancePath,schemaPath:"#/allOf/19/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err201];}else {vErrors.push(err201);}errors++;}if(props0 !== true && props19 !== undefined){if(props19 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props19);}}const _errs247 = errors;let valid83 = true;const _errs248 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data92 = data.eventType;if(!((data92 === "semantic_tool.result") || (data92 === "mcp_app.tool_result"))){const err202 = {};if(vErrors === null){vErrors = [err202];}else {vErrors.push(err202);}errors++;}}}var _valid23 = _errs248 === errors;errors = _errs247;if(vErrors !== null){if(_errs247){vErrors.length = _errs247;}else {vErrors = null;}}if(_valid23){const _errs250 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data93 = data.payload;if(data93 && typeof data93 == "object" && !Array.isArray(data93)){if(data93.semantic_tool !== undefined){let data94 = data93.semantic_tool;if(!(validate94(data94, {instancePath:instancePath+"/payload/semantic_tool",parentData:data93,parentDataProperty:"semantic_tool",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate94.errors : vErrors.concat(validate94.errors);errors = vErrors.length;}if(data94 && typeof data94 == "object" && !Array.isArray(data94)){if(data94.phase !== undefined){if("result" !== data94.phase){const err203 = {instancePath:instancePath+"/payload/semantic_tool/phase",schemaPath:"#/allOf/20/then/properties/payload/properties/semantic_tool/allOf/1/properties/phase/const",keyword:"const",params:{allowedValue: "result"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err203];}else {vErrors.push(err203);}errors++;}}}else {const err204 = {instancePath:instancePath+"/payload/semantic_tool",schemaPath:"#/allOf/20/then/properties/payload/properties/semantic_tool/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err204];}else {vErrors.push(err204);}errors++;}}}else {const err205 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/20/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err205];}else {vErrors.push(err205);}errors++;}}}var _valid23 = _errs250 === errors;valid83 = _valid23;if(valid83){var props20 = {};props20.payload = true;props20.eventType = true;}}if(!valid83){const err206 = {instancePath,schemaPath:"#/allOf/20/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err206];}else {vErrors.push(err206);}errors++;}if(props0 !== true && props20 !== undefined){if(props20 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props20);}}const _errs260 = errors;let valid89 = true;const _errs261 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("semantic_tool.reconciled" !== data.eventType){const err207 = {};if(vErrors === null){vErrors = [err207];}else {vErrors.push(err207);}errors++;}}}var _valid24 = _errs261 === errors;errors = _errs260;if(vErrors !== null){if(_errs260){vErrors.length = _errs260;}else {vErrors = null;}}if(_valid24){const _errs263 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data97 = data.payload;if(data97 && typeof data97 == "object" && !Array.isArray(data97)){if(data97.semantic_tool !== undefined){let data98 = data97.semantic_tool;if(!(validate94(data98, {instancePath:instancePath+"/payload/semantic_tool",parentData:data97,parentDataProperty:"semantic_tool",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate94.errors : vErrors.concat(validate94.errors);errors = vErrors.length;}if(data98 && typeof data98 == "object" && !Array.isArray(data98)){if(data98.phase !== undefined){if("reconciled" !== data98.phase){const err208 = {instancePath:instancePath+"/payload/semantic_tool/phase",schemaPath:"#/allOf/21/then/properties/payload/properties/semantic_tool/allOf/1/properties/phase/const",keyword:"const",params:{allowedValue: "reconciled"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err208];}else {vErrors.push(err208);}errors++;}}}else {const err209 = {instancePath:instancePath+"/payload/semantic_tool",schemaPath:"#/allOf/21/then/properties/payload/properties/semantic_tool/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err209];}else {vErrors.push(err209);}errors++;}}}else {const err210 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/21/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err210];}else {vErrors.push(err210);}errors++;}}}var _valid24 = _errs263 === errors;valid89 = _valid24;if(valid89){var props21 = {};props21.payload = true;props21.eventType = true;}}if(!valid89){const err211 = {instancePath,schemaPath:"#/allOf/21/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err211];}else {vErrors.push(err211);}errors++;}if(props0 !== true && props21 !== undefined){if(props21 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props21);}}const _errs273 = errors;let valid95 = true;const _errs274 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("run.terminal" !== data.eventType){const err212 = {};if(vErrors === null){vErrors = [err212];}else {vErrors.push(err212);}errors++;}}}var _valid25 = _errs274 === errors;errors = _errs273;if(vErrors !== null){if(_errs273){vErrors.length = _errs273;}else {vErrors = null;}}if(_valid25){const _errs276 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate106(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate106.errors : vErrors.concat(validate106.errors);errors = vErrors.length;}}}var _valid25 = _errs276 === errors;valid95 = _valid25;if(valid95){var props22 = {};props22.payload = true;props22.eventType = true;}}if(!valid95){const err213 = {instancePath,schemaPath:"#/allOf/22/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err213];}else {vErrors.push(err213);}errors++;}if(props0 !== true && props22 !== undefined){if(props22 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props22);}}const _errs279 = errors;let valid98 = true;const _errs280 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("run.result.proposed" !== data.eventType){const err214 = {};if(vErrors === null){vErrors = [err214];}else {vErrors.push(err214);}errors++;}}}var _valid26 = _errs280 === errors;errors = _errs279;if(vErrors !== null){if(_errs279){vErrors.length = _errs279;}else {vErrors = null;}}if(_valid26){const _errs282 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data103 = data.payload;const _errs287 = errors;let valid103 = true;const _errs288 = errors;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.reportedWorkDisposition !== undefined){if("blocked" !== data103.reportedWorkDisposition){const err215 = {};if(vErrors === null){vErrors = [err215];}else {vErrors.push(err215);}errors++;}}}var _valid27 = _errs288 === errors;errors = _errs287;if(vErrors !== null){if(_errs287){vErrors.length = _errs287;}else {vErrors = null;}}if(_valid27){const _errs290 = errors;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.blocker === undefined){const err216 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/0/then/required",keyword:"required",params:{missingProperty: "blocker"},message:"must have required property '"+"blocker"+"'"};if(vErrors === null){vErrors = [err216];}else {vErrors.push(err216);}errors++;}}var _valid27 = _errs290 === errors;valid103 = _valid27;}if(!valid103){const err217 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err217];}else {vErrors.push(err217);}errors++;}const _errs292 = errors;let valid105 = true;const _errs293 = errors;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.reportedWorkDisposition !== undefined){if("yielded" !== data103.reportedWorkDisposition){const err218 = {};if(vErrors === null){vErrors = [err218];}else {vErrors.push(err218);}errors++;}}}var _valid28 = _errs293 === errors;errors = _errs292;if(vErrors !== null){if(_errs292){vErrors.length = _errs292;}else {vErrors = null;}}if(_valid28){const _errs295 = errors;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.continuation === undefined){const err219 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/1/then/required",keyword:"required",params:{missingProperty: "continuation"},message:"must have required property '"+"continuation"+"'"};if(vErrors === null){vErrors = [err219];}else {vErrors.push(err219);}errors++;}}var _valid28 = _errs295 === errors;valid105 = _valid28;}if(!valid105){const err220 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err220];}else {vErrors.push(err220);}errors++;}if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.schema === undefined){const err221 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err221];}else {vErrors.push(err221);}errors++;}if(data103.reportedWorkDisposition === undefined){const err222 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "reportedWorkDisposition"},message:"must have required property '"+"reportedWorkDisposition"+"'"};if(vErrors === null){vErrors = [err222];}else {vErrors.push(err222);}errors++;}if(data103.summary === undefined){const err223 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err223];}else {vErrors.push(err223);}errors++;}if(data103.completionClaim === undefined){const err224 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "completionClaim"},message:"must have required property '"+"completionClaim"+"'"};if(vErrors === null){vErrors = [err224];}else {vErrors.push(err224);}errors++;}if(data103.evidence === undefined){const err225 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "evidence"},message:"must have required property '"+"evidence"+"'"};if(vErrors === null){vErrors = [err225];}else {vErrors.push(err225);}errors++;}if(data103.verification === undefined){const err226 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "verification"},message:"must have required property '"+"verification"+"'"};if(vErrors === null){vErrors = [err226];}else {vErrors.push(err226);}errors++;}if(data103.attentionRequests === undefined){const err227 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "attentionRequests"},message:"must have required property '"+"attentionRequests"+"'"};if(vErrors === null){vErrors = [err227];}else {vErrors.push(err227);}errors++;}if(data103.artifacts === undefined){const err228 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "artifacts"},message:"must have required property '"+"artifacts"+"'"};if(vErrors === null){vErrors = [err228];}else {vErrors.push(err228);}errors++;}if(data103.schema !== undefined){if("paperclip.run_result.v1" !== data103.schema){const err229 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.run_result.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err229];}else {vErrors.push(err229);}errors++;}}if(data103.reportedWorkDisposition !== undefined){let data107 = data103.reportedWorkDisposition;if(!((((data107 === "done") || (data107 === "blocked")) || (data107 === "needs_review")) || (data107 === "yielded"))){const err230 = {instancePath:instancePath+"/payload/reportedWorkDisposition",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/reportedWorkDisposition/enum",keyword:"enum",params:{allowedValues: schema202.properties.reportedWorkDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err230];}else {vErrors.push(err230);}errors++;}}if(data103.summary !== undefined){let data108 = data103.summary;if(typeof data108 === "string"){if(func1(data108) > 12000){const err231 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err231];}else {vErrors.push(err231);}errors++;}if(func1(data108) < 1){const err232 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err232];}else {vErrors.push(err232);}errors++;}}else {const err233 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}}if(data103.completionClaim !== undefined){let data109 = data103.completionClaim;if(data109 && typeof data109 == "object" && !Array.isArray(data109)){if(data109.contractRevision === undefined){const err234 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "contractRevision"},message:"must have required property '"+"contractRevision"+"'"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}if(data109.objectiveSatisfied === undefined){const err235 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "objectiveSatisfied"},message:"must have required property '"+"objectiveSatisfied"+"'"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}if(data109.criteria === undefined){const err236 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "criteria"},message:"must have required property '"+"criteria"+"'"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}if(data109.remainingWork === undefined){const err237 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "remainingWork"},message:"must have required property '"+"remainingWork"+"'"};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}if(data109.contractRevision !== undefined){let data110 = data109.contractRevision;if(typeof data110 === "string"){if(func1(data110) < 1){const err238 = {instancePath:instancePath+"/payload/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err238];}else {vErrors.push(err238);}errors++;}}else {const err239 = {instancePath:instancePath+"/payload/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err239];}else {vErrors.push(err239);}errors++;}}if(data109.objectiveSatisfied !== undefined){if(typeof data109.objectiveSatisfied !== "boolean"){const err240 = {instancePath:instancePath+"/payload/completionClaim/objectiveSatisfied",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/objectiveSatisfied/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err240];}else {vErrors.push(err240);}errors++;}}if(data109.criteria !== undefined){let data112 = data109.criteria;if(Array.isArray(data112)){const len2 = data112.length;for(let i2=0; i2 160){const err329 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err329];}else {vErrors.push(err329);}errors++;}if(func1(data155) < 1){const err330 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err330];}else {vErrors.push(err330);}errors++;}}else {const err331 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err331];}else {vErrors.push(err331);}errors++;}}if(data.sourceSeq !== undefined){let data156 = data.sourceSeq;if(!(((typeof data156 == "number") && (!(data156 % 1) && !isNaN(data156))) && (isFinite(data156)))){const err332 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err332];}else {vErrors.push(err332);}errors++;}if((typeof data156 == "number") && (isFinite(data156))){if(data156 > 9007199254740991 || isNaN(data156)){const err333 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9007199254740991},message:"must be <= 9007199254740991"};if(vErrors === null){vErrors = [err333];}else {vErrors.push(err333);}errors++;}if(data156 < 1 || isNaN(data156)){const err334 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err334];}else {vErrors.push(err334);}errors++;}}}if(data.sourceInstanceId !== undefined){let data157 = data.sourceInstanceId;if(typeof data157 === "string"){if(func1(data157) > 160){const err335 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err335];}else {vErrors.push(err335);}errors++;}if(func1(data157) < 1){const err336 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err336];}else {vErrors.push(err336);}errors++;}}else {const err337 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err337];}else {vErrors.push(err337);}errors++;}}if(data.sourceKind !== undefined){let data158 = data.sourceKind;if(!((data158 === "runner") || (data158 === "control_plane"))){const err338 = {instancePath:instancePath+"/sourceKind",schemaPath:"#/properties/sourceKind/enum",keyword:"enum",params:{allowedValues: schema50.properties.sourceKind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err338];}else {vErrors.push(err338);}errors++;}}if(data.runId !== undefined){let data159 = data.runId;if(typeof data159 === "string"){if(func1(data159) > 160){const err339 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err339];}else {vErrors.push(err339);}errors++;}if(func1(data159) < 1){const err340 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err340];}else {vErrors.push(err340);}errors++;}}else {const err341 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err341];}else {vErrors.push(err341);}errors++;}}if(data.normalizedSessionId !== undefined){let data160 = data.normalizedSessionId;if(typeof data160 === "string"){if(func1(data160) > 160){const err342 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err342];}else {vErrors.push(err342);}errors++;}if(func1(data160) < 1){const err343 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err343];}else {vErrors.push(err343);}errors++;}}else {const err344 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err344];}else {vErrors.push(err344);}errors++;}}if(data.turnId !== undefined){let data161 = data.turnId;if(typeof data161 === "string"){if(func1(data161) > 240){const err345 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err345];}else {vErrors.push(err345);}errors++;}if(func1(data161) < 1){const err346 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err346];}else {vErrors.push(err346);}errors++;}}else {const err347 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err347];}else {vErrors.push(err347);}errors++;}}if(data.itemId !== undefined){let data162 = data.itemId;if(typeof data162 === "string"){if(func1(data162) > 240){const err348 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err348];}else {vErrors.push(err348);}errors++;}if(func1(data162) < 1){const err349 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err349];}else {vErrors.push(err349);}errors++;}}else {const err350 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err350];}else {vErrors.push(err350);}errors++;}}if(data.eventType !== undefined){let data163 = data.eventType;if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((data163 === "runner.connected") || (data163 === "runner.reconnected")) || (data163 === "runner.reconciled")) || (data163 === "runner.disconnected")) || (data163 === "runner.draining")) || (data163 === "runner.backpressure")) || (data163 === "runner.suspending")) || (data163 === "runner.suspended")) || (data163 === "runner.stopped")) || (data163 === "runner.diagnostic")) || (data163 === "runtime.phase.changed")) || (data163 === "sandbox.metric")) || (data163 === "workspace.ready")) || (data163 === "workspace.change.updated")) || (data163 === "workspace.diff.recorded")) || (data163 === "workspace.file.referenced")) || (data163 === "harness.starting")) || (data163 === "harness.ready")) || (data163 === "harness.exited")) || (data163 === "harness.diagnostic")) || (data163 === "plan.updated")) || (data163 === "tool.execution.started")) || (data163 === "tool.execution.progressed")) || (data163 === "tool.execution.completed")) || (data163 === "research.started")) || (data163 === "research.progressed")) || (data163 === "research.completed")) || (data163 === "delegation.started")) || (data163 === "delegation.updated")) || (data163 === "delegation.completed")) || (data163 === "model.route.changed")) || (data163 === "model.verification.updated")) || (data163 === "context.compacted")) || (data163 === "artifact.viewed")) || (data163 === "artifact.generated")) || (data163 === "review.mode.changed")) || (data163 === "hook.started")) || (data163 === "hook.completed")) || (data163 === "memory.citation.referenced")) || (data163 === "safety.review.started")) || (data163 === "safety.review.completed")) || (data163 === "terminal.input.sent")) || (data163 === "wait.started")) || (data163 === "wait.completed")) || (data163 === "provider.notice.recorded")) || (data163 === "session.starting")) || (data163 === "session.started")) || (data163 === "session.resuming")) || (data163 === "session.resumed")) || (data163 === "session.reconciled")) || (data163 === "session.updated")) || (data163 === "session.closed")) || (data163 === "session.failed")) || (data163 === "turn.submitted")) || (data163 === "turn.accepted")) || (data163 === "turn.started")) || (data163 === "turn.completed")) || (data163 === "turn.failed")) || (data163 === "turn.interrupted")) || (data163 === "turn.cancelled")) || (data163 === "item.started")) || (data163 === "item.delta")) || (data163 === "item.completed")) || (data163 === "item.failed")) || (data163 === "usage.reported")) || (data163 === "semantic_tool.input")) || (data163 === "semantic_tool.result")) || (data163 === "semantic_tool.reconciled")) || (data163 === "mcp_app.discovered")) || (data163 === "mcp_app.resource.resolved")) || (data163 === "mcp_app.initializing")) || (data163 === "mcp_app.ready")) || (data163 === "mcp_app.tool_input")) || (data163 === "mcp_app.tool_result")) || (data163 === "mcp_app.action.requested")) || (data163 === "mcp_app.action.resolved")) || (data163 === "mcp_app.host_context.changed")) || (data163 === "mcp_app.failed")) || (data163 === "mcp_app.teardown")) || (data163 === "runtime_request.created")) || (data163 === "runtime_request.resolved")) || (data163 === "runtime_request.expired")) || (data163 === "runtime_request.cancelled")) || (data163 === "interaction.request.proposed")) || (data163 === "interaction.request.materialized")) || (data163 === "interaction.request.rejected")) || (data163 === "interaction.response.progressed")) || (data163 === "interaction.response.resolved")) || (data163 === "interaction.response.delivered")) || (data163 === "run.attached")) || (data163 === "run.detached")) || (data163 === "run.result.proposed")) || (data163 === "run.result.accepted")) || (data163 === "run.result.rejected")) || (data163 === "attention.request.proposed")) || (data163 === "attention.request.routed")) || (data163 === "attention.request.resolved")) || (data163 === "attention.request.expired")) || (data163 === "attention.request.superseded")) || (data163 === "work.assessment.recorded")) || (data163 === "issue.status.decision.recorded")) || (data163 === "issue.status.decision.applied")) || (data163 === "issue.status.decision.rejected")) || (data163 === "issue.status.decision.superseded")) || (data163 === "run.terminal"))){const err351 = {instancePath:instancePath+"/eventType",schemaPath:"#/properties/eventType/enum",keyword:"enum",params:{allowedValues: schema50.properties.eventType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err351];}else {vErrors.push(err351);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err352 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err352];}else {vErrors.push(err352);}errors++;}}if(data.priority !== undefined){let data165 = data.priority;if(!(((data165 === 0) || (data165 === 1)) || (data165 === 2))){const err353 = {instancePath:instancePath+"/priority",schemaPath:"#/properties/priority/enum",keyword:"enum",params:{allowedValues: schema50.properties.priority.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err353];}else {vErrors.push(err353);}errors++;}}if(data.emittedAt !== undefined){let data166 = data.emittedAt;if(typeof data166 === "string"){if(!(formats0.test(data166))){const err354 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err354];}else {vErrors.push(err354);}errors++;}}else {const err355 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err355];}else {vErrors.push(err355);}errors++;}}if(data.observedAt !== undefined){let data167 = data.observedAt;if(typeof data167 === "string"){if(!(formats0.test(data167))){const err356 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err356];}else {vErrors.push(err356);}errors++;}}else {const err357 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err357];}else {vErrors.push(err357);}errors++;}}if(data.payload !== undefined){let data168 = data.payload;if(data168 && typeof data168 == "object" && !Array.isArray(data168)){if(data168.channel !== undefined){let data169 = data168.channel;if(!(((((data169 === "progress") || (data169 === "final")) || (data169 === "summary")) || (data169 === "detail")) || (data169 === "unknown"))){const err358 = {instancePath:instancePath+"/payload/channel",schemaPath:"#/properties/payload/properties/channel/enum",keyword:"enum",params:{allowedValues: schema50.properties.payload.properties.channel.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err358];}else {vErrors.push(err358);}errors++;}}if(data168.providerPhase !== undefined){if(typeof data168.providerPhase !== "string"){const err359 = {instancePath:instancePath+"/payload/providerPhase",schemaPath:"#/properties/payload/properties/providerPhase/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err359];}else {vErrors.push(err359);}errors++;}}if(data168.providerMethod !== undefined){if(typeof data168.providerMethod !== "string"){const err360 = {instancePath:instancePath+"/payload/providerMethod",schemaPath:"#/properties/payload/properties/providerMethod/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err360];}else {vErrors.push(err360);}errors++;}}}else {const err361 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err361];}else {vErrors.push(err361);}errors++;}}if(data.debug !== undefined){let data172 = data.debug;if(data172 && typeof data172 == "object" && !Array.isArray(data172)){}else {const err362 = {instancePath:instancePath+"/debug",schemaPath:"#/properties/debug/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err362];}else {vErrors.push(err362);}errors++;}}}else {const err363 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err363];}else {vErrors.push(err363);}errors++;}validate28.errors = vErrors;return errors === 0;}validate28.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema203 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v2/event.schema.json","title":"PRP v2 native event","type":"object","required":["schema","sourceEventId","sourceSeq","sourceInstanceId","sourceKind","runId","eventType","schemaVersion","priority","emittedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.event.v2"},"sourceEventId":{"type":"string","minLength":1,"maxLength":160},"sourceSeq":{"type":"integer","minimum":1,"maximum":9007199254740991},"sourceInstanceId":{"type":"string","minLength":1,"maxLength":160},"sourceKind":{"enum":["runner","control_plane"]},"runId":{"type":"string","minLength":1,"maxLength":160},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":160},"turnId":{"type":"string","minLength":1,"maxLength":160},"itemId":{"type":"string","minLength":1,"maxLength":160},"eventType":{"enum":["runner.connected","runner.reconnected","runner.reconciled","runner.disconnected","runner.draining","runner.suspending","runner.suspended","runner.stopped","runner.diagnostic","runtime.phase.changed","sandbox.metric","workspace.ready","workspace.change.updated","workspace.diff.recorded","workspace.file.referenced","harness.starting","harness.ready","harness.exited","harness.diagnostic","plan.updated","tool.execution.started","tool.execution.progressed","tool.execution.completed","research.started","research.progressed","research.completed","delegation.started","delegation.updated","delegation.completed","model.route.changed","model.verification.updated","context.compacted","artifact.viewed","artifact.generated","review.mode.changed","hook.started","hook.completed","memory.citation.referenced","safety.review.started","safety.review.completed","terminal.input.sent","wait.started","wait.completed","provider.notice.recorded","session.starting","session.started","session.resuming","session.resumed","session.reconciled","session.updated","session.closed","session.failed","session.capabilities.updated","session.goal.snapshot","session.goal.updated","session.goal.cleared","turn.submitted","turn.accepted","turn.started","turn.completed","turn.failed","turn.interrupted","turn.cancelled","item.started","item.delta","item.completed","item.failed","usage.reported","semantic_tool.input","semantic_tool.result","mcp_app.discovered","mcp_app.resource.resolved","mcp_app.initializing","mcp_app.ready","mcp_app.tool_input","mcp_app.tool_result","mcp_app.action.requested","mcp_app.action.resolved","mcp_app.host_context.changed","mcp_app.failed","mcp_app.teardown","runtime_request.created","runtime_request.resolved","runtime_request.expired","runtime_request.cancelled","interaction.request.proposed","interaction.request.materialized","interaction.request.rejected","interaction.response.progressed","interaction.response.resolved","interaction.response.delivered","run.attached","run.detached","run.result.proposed","run.result.accepted","run.result.rejected","attention.request.proposed","attention.request.routed","attention.request.resolved","attention.request.expired","attention.request.superseded","work.assessment.recorded","issue.status.decision.recorded","issue.status.decision.applied","issue.status.decision.rejected","issue.status.decision.superseded","run.terminal"]},"schemaVersion":{"const":2},"priority":{"enum":[0,1,2]},"emittedAt":{"type":"string","format":"date-time"},"observedAt":{"type":"string","format":"date-time"},"payload":{"type":"object","additionalProperties":true},"debug":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"eventType":{"enum":["session.goal.snapshot","session.goal.updated"]}}},"then":{"properties":{"payload":{"type":"object","required":["goal"],"properties":{"goal":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot"},{"type":"null"}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"const":"session.capabilities.updated"}}},"then":{"properties":{"payload":{"type":"object","required":["sessionGoals"],"properties":{"sessionGoals":{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"},"turnControls":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls"}},"additionalProperties":true}}}}],"additionalProperties":true};const schema204 = {"type":"object","required":["objective","status","tokenBudget","tokensUsed","elapsedSeconds","iterations","lastReason","createdAt","updatedAt","completedAt","workingNow"],"properties":{"objective":{"type":"string","minLength":1,"maxLength":4000},"status":{"enum":["active","paused","blocked","limited","usage_limited","budget_limited","complete"]},"tokenBudget":{"type":["integer","null"],"minimum":1},"tokensUsed":{"type":["integer","null"],"minimum":0},"elapsedSeconds":{"type":["number","null"],"minimum":0},"iterations":{"type":["integer","null"],"minimum":0},"lastReason":{"type":["string","null"],"maxLength":4000},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"completedAt":{"type":["string","null"],"format":"date-time"},"workingNow":{"type":"boolean"}},"additionalProperties":true};const schema207 = {"type":"object","required":["steering","queuedFollowUp"],"properties":{"steering":{"type":"boolean"},"queuedFollowUp":{"type":"boolean"}},"additionalProperties":false};function validate111(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v2/event.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate111.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data0 = data.eventType;if(!((data0 === "session.goal.snapshot") || (data0 === "session.goal.updated"))){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data1 = data.payload;if(data1 && typeof data1 == "object" && !Array.isArray(data1)){if(data1.goal === undefined){const err1 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/required",keyword:"required",params:{missingProperty: "goal"},message:"must have required property '"+"goal"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data1.goal !== undefined){let data2 = data1.goal;const _errs10 = errors;let valid5 = false;let passing0 = null;const _errs11 = errors;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.objective === undefined){const err2 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "objective"},message:"must have required property '"+"objective"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data2.status === undefined){const err3 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data2.tokenBudget === undefined){const err4 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "tokenBudget"},message:"must have required property '"+"tokenBudget"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data2.tokensUsed === undefined){const err5 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "tokensUsed"},message:"must have required property '"+"tokensUsed"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data2.elapsedSeconds === undefined){const err6 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "elapsedSeconds"},message:"must have required property '"+"elapsedSeconds"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data2.iterations === undefined){const err7 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "iterations"},message:"must have required property '"+"iterations"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data2.lastReason === undefined){const err8 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "lastReason"},message:"must have required property '"+"lastReason"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data2.createdAt === undefined){const err9 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "createdAt"},message:"must have required property '"+"createdAt"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data2.updatedAt === undefined){const err10 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "updatedAt"},message:"must have required property '"+"updatedAt"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data2.completedAt === undefined){const err11 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "completedAt"},message:"must have required property '"+"completedAt"+"'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data2.workingNow === undefined){const err12 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "workingNow"},message:"must have required property '"+"workingNow"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.objective !== undefined){let data3 = data2.objective;if(typeof data3 === "string"){if(func1(data3) > 4000){const err13 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data3) < 1){const err14 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data2.status !== undefined){let data4 = data2.status;if(!(((((((data4 === "active") || (data4 === "paused")) || (data4 === "blocked")) || (data4 === "limited")) || (data4 === "usage_limited")) || (data4 === "budget_limited")) || (data4 === "complete"))){const err16 = {instancePath:instancePath+"/payload/goal/status",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/status/enum",keyword:"enum",params:{allowedValues: schema204.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data2.tokenBudget !== undefined){let data5 = data2.tokenBudget;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err17 = {instancePath:instancePath+"/payload/goal/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokenBudget/type",keyword:"type",params:{type: schema204.properties.tokenBudget.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 1 || isNaN(data5)){const err18 = {instancePath:instancePath+"/payload/goal/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokenBudget/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}if(data2.tokensUsed !== undefined){let data6 = data2.tokensUsed;if((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (data6 !== null)){const err19 = {instancePath:instancePath+"/payload/goal/tokensUsed",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokensUsed/type",keyword:"type",params:{type: schema204.properties.tokensUsed.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err20 = {instancePath:instancePath+"/payload/goal/tokensUsed",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokensUsed/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data2.elapsedSeconds !== undefined){let data7 = data2.elapsedSeconds;if((!((typeof data7 == "number") && (isFinite(data7)))) && (data7 !== null)){const err21 = {instancePath:instancePath+"/payload/goal/elapsedSeconds",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/elapsedSeconds/type",keyword:"type",params:{type: schema204.properties.elapsedSeconds.type},message:"must be number,null"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 < 0 || isNaN(data7)){const err22 = {instancePath:instancePath+"/payload/goal/elapsedSeconds",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/elapsedSeconds/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}if(data2.iterations !== undefined){let data8 = data2.iterations;if((!(((typeof data8 == "number") && (!(data8 % 1) && !isNaN(data8))) && (isFinite(data8)))) && (data8 !== null)){const err23 = {instancePath:instancePath+"/payload/goal/iterations",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/iterations/type",keyword:"type",params:{type: schema204.properties.iterations.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if((typeof data8 == "number") && (isFinite(data8))){if(data8 < 0 || isNaN(data8)){const err24 = {instancePath:instancePath+"/payload/goal/iterations",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/iterations/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}if(data2.lastReason !== undefined){let data9 = data2.lastReason;if((typeof data9 !== "string") && (data9 !== null)){const err25 = {instancePath:instancePath+"/payload/goal/lastReason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/lastReason/type",keyword:"type",params:{type: schema204.properties.lastReason.type},message:"must be string,null"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(typeof data9 === "string"){if(func1(data9) > 4000){const err26 = {instancePath:instancePath+"/payload/goal/lastReason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/lastReason/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}if(data2.createdAt !== undefined){let data10 = data2.createdAt;if((typeof data10 !== "string") && (data10 !== null)){const err27 = {instancePath:instancePath+"/payload/goal/createdAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/createdAt/type",keyword:"type",params:{type: schema204.properties.createdAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(typeof data10 === "string"){if(!(formats0.test(data10))){const err28 = {instancePath:instancePath+"/payload/goal/createdAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/createdAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}if(data2.updatedAt !== undefined){let data11 = data2.updatedAt;if((typeof data11 !== "string") && (data11 !== null)){const err29 = {instancePath:instancePath+"/payload/goal/updatedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/updatedAt/type",keyword:"type",params:{type: schema204.properties.updatedAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(typeof data11 === "string"){if(!(formats0.test(data11))){const err30 = {instancePath:instancePath+"/payload/goal/updatedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/updatedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}}if(data2.completedAt !== undefined){let data12 = data2.completedAt;if((typeof data12 !== "string") && (data12 !== null)){const err31 = {instancePath:instancePath+"/payload/goal/completedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/completedAt/type",keyword:"type",params:{type: schema204.properties.completedAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(typeof data12 === "string"){if(!(formats0.test(data12))){const err32 = {instancePath:instancePath+"/payload/goal/completedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/completedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}if(data2.workingNow !== undefined){if(typeof data2.workingNow !== "boolean"){const err33 = {instancePath:instancePath+"/payload/goal/workingNow",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/workingNow/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}else {const err34 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}var _valid1 = _errs11 === errors;if(_valid1){valid5 = true;passing0 = 0;}const _errs36 = errors;if(data2 !== null){const err35 = {instancePath:instancePath+"/payload/goal",schemaPath:"#/allOf/0/then/properties/payload/properties/goal/oneOf/1/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}var _valid1 = _errs36 === errors;if(_valid1 && valid5){valid5 = false;passing0 = [passing0, 1];}else {if(_valid1){valid5 = true;passing0 = 1;}}if(!valid5){const err36 = {instancePath:instancePath+"/payload/goal",schemaPath:"#/allOf/0/then/properties/payload/properties/goal/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}else {errors = _errs10;if(vErrors !== null){if(_errs10){vErrors.length = _errs10;}else {vErrors = null;}}}}}else {const err37 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}}var _valid0 = _errs5 === errors;valid1 = _valid0;if(valid1){var props1 = {};props1.payload = true;props1.eventType = true;}}if(!valid1){const err38 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}const _errs39 = errors;let valid8 = true;const _errs40 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("session.capabilities.updated" !== data.eventType){const err39 = {};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}}var _valid2 = _errs40 === errors;errors = _errs39;if(vErrors !== null){if(_errs39){vErrors.length = _errs39;}else {vErrors = null;}}if(_valid2){const _errs42 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data15 = data.payload;if(data15 && typeof data15 == "object" && !Array.isArray(data15)){if(data15.sessionGoals === undefined){const err40 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "sessionGoals"},message:"must have required property '"+"sessionGoals"+"'"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if(data15.sessionGoals !== undefined){let data16 = data15.sessionGoals;if(data16 && typeof data16 == "object" && !Array.isArray(data16)){if(data16.availability === undefined){const err41 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "availability"},message:"must have required property '"+"availability"+"'"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data16.actions === undefined){const err42 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "actions"},message:"must have required property '"+"actions"+"'"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if(data16.autonomousUpdates === undefined){const err43 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "autonomousUpdates"},message:"must have required property '"+"autonomousUpdates"+"'"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}if(data16.persistentAcrossResume === undefined){const err44 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "persistentAcrossResume"},message:"must have required property '"+"persistentAcrossResume"+"'"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}if(data16.maxObjectiveChars === undefined){const err45 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "maxObjectiveChars"},message:"must have required property '"+"maxObjectiveChars"+"'"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(data16.tokenBudgetControl === undefined){const err46 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "tokenBudgetControl"},message:"must have required property '"+"tokenBudgetControl"+"'"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(data16.usageReporting === undefined){const err47 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "usageReporting"},message:"must have required property '"+"usageReporting"+"'"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}if(data16.availability !== undefined){let data17 = data16.availability;if(!(((data17 === "available") || (data17 === "unsupported")) || (data17 === "policy_disabled"))){const err48 = {instancePath:instancePath+"/payload/sessionGoals/availability",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/availability/enum",keyword:"enum",params:{allowedValues: schema44.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}if(data16.actions !== undefined){let data18 = data16.actions;if(Array.isArray(data18)){const len0 = data18.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data18[i1], data18[j0])){const err50 = {instancePath:instancePath+"/payload/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;break outer0;}}}}}else {const err51 = {instancePath:instancePath+"/payload/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data16.autonomousUpdates !== undefined){if(typeof data16.autonomousUpdates !== "boolean"){const err52 = {instancePath:instancePath+"/payload/sessionGoals/autonomousUpdates",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/autonomousUpdates/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}if(data16.persistentAcrossResume !== undefined){if(typeof data16.persistentAcrossResume !== "boolean"){const err53 = {instancePath:instancePath+"/payload/sessionGoals/persistentAcrossResume",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/persistentAcrossResume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}if(data16.maxObjectiveChars !== undefined){let data22 = data16.maxObjectiveChars;if(!(((typeof data22 == "number") && (!(data22 % 1) && !isNaN(data22))) && (isFinite(data22)))){const err54 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}if((typeof data22 == "number") && (isFinite(data22))){if(data22 > 4000 || isNaN(data22)){const err55 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/maximum",keyword:"maximum",params:{comparison: "<=", limit: 4000},message:"must be <= 4000"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}if(data22 < 1 || isNaN(data22)){const err56 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}}if(data16.tokenBudgetControl !== undefined){if(typeof data16.tokenBudgetControl !== "boolean"){const err57 = {instancePath:instancePath+"/payload/sessionGoals/tokenBudgetControl",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/tokenBudgetControl/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}if(data16.usageReporting !== undefined){if(typeof data16.usageReporting !== "boolean"){const err58 = {instancePath:instancePath+"/payload/sessionGoals/usageReporting",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/usageReporting/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}if(data16.reasonCode !== undefined){let data25 = data16.reasonCode;if(typeof data25 === "string"){if(func1(data25) > 160){const err59 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}if(func1(data25) < 1){const err60 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}else {const err61 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}if(data16.reason !== undefined){let data26 = data16.reason;if(typeof data26 === "string"){if(func1(data26) > 1000){const err62 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(func1(data26) < 1){const err63 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}else {const err64 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}}else {const err65 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}if(data15.turnControls !== undefined){let data27 = data15.turnControls;if(data27 && typeof data27 == "object" && !Array.isArray(data27)){if(data27.steering === undefined){const err66 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/required",keyword:"required",params:{missingProperty: "steering"},message:"must have required property '"+"steering"+"'"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}if(data27.queuedFollowUp === undefined){const err67 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/required",keyword:"required",params:{missingProperty: "queuedFollowUp"},message:"must have required property '"+"queuedFollowUp"+"'"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}for(const key0 in data27){if(!((key0 === "steering") || (key0 === "queuedFollowUp"))){const err68 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}if(data27.steering !== undefined){if(typeof data27.steering !== "boolean"){const err69 = {instancePath:instancePath+"/payload/turnControls/steering",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/properties/steering/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}if(data27.queuedFollowUp !== undefined){if(typeof data27.queuedFollowUp !== "boolean"){const err70 = {instancePath:instancePath+"/payload/turnControls/queuedFollowUp",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/properties/queuedFollowUp/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}}}else {const err71 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}}else {const err72 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}}}var _valid2 = _errs42 === errors;valid8 = _valid2;if(valid8){var props2 = {};props2.payload = true;props2.eventType = true;}}if(!valid8){const err73 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}if(props1 !== true && props2 !== undefined){if(props2 === true){props1 = true;}else {props1 = props1 || {};Object.assign(props1, props2);}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err74 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}if(data.sourceEventId === undefined){const err75 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceEventId"},message:"must have required property '"+"sourceEventId"+"'"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}if(data.sourceSeq === undefined){const err76 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceSeq"},message:"must have required property '"+"sourceSeq"+"'"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}if(data.sourceInstanceId === undefined){const err77 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceInstanceId"},message:"must have required property '"+"sourceInstanceId"+"'"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(data.sourceKind === undefined){const err78 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceKind"},message:"must have required property '"+"sourceKind"+"'"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}if(data.runId === undefined){const err79 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}if(data.eventType === undefined){const err80 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "eventType"},message:"must have required property '"+"eventType"+"'"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}if(data.schemaVersion === undefined){const err81 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}if(data.priority === undefined){const err82 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "priority"},message:"must have required property '"+"priority"+"'"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(data.emittedAt === undefined){const err83 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "emittedAt"},message:"must have required property '"+"emittedAt"+"'"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}if(data.payload === undefined){const err84 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "payload"},message:"must have required property '"+"payload"+"'"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}if(data.schema !== undefined){if("paperclip.prp.event.v2" !== data.schema){const err85 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.event.v2"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}}if(data.sourceEventId !== undefined){let data31 = data.sourceEventId;if(typeof data31 === "string"){if(func1(data31) > 160){const err86 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}if(func1(data31) < 1){const err87 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}else {const err88 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}}if(data.sourceSeq !== undefined){let data32 = data.sourceSeq;if(!(((typeof data32 == "number") && (!(data32 % 1) && !isNaN(data32))) && (isFinite(data32)))){const err89 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}if((typeof data32 == "number") && (isFinite(data32))){if(data32 > 9007199254740991 || isNaN(data32)){const err90 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9007199254740991},message:"must be <= 9007199254740991"};if(vErrors === null){vErrors = [err90];}else {vErrors.push(err90);}errors++;}if(data32 < 1 || isNaN(data32)){const err91 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}}if(data.sourceInstanceId !== undefined){let data33 = data.sourceInstanceId;if(typeof data33 === "string"){if(func1(data33) > 160){const err92 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}if(func1(data33) < 1){const err93 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}}else {const err94 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}}if(data.sourceKind !== undefined){let data34 = data.sourceKind;if(!((data34 === "runner") || (data34 === "control_plane"))){const err95 = {instancePath:instancePath+"/sourceKind",schemaPath:"#/properties/sourceKind/enum",keyword:"enum",params:{allowedValues: schema203.properties.sourceKind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}}if(data.runId !== undefined){let data35 = data.runId;if(typeof data35 === "string"){if(func1(data35) > 160){const err96 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}if(func1(data35) < 1){const err97 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}}else {const err98 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}if(data.normalizedSessionId !== undefined){let data36 = data.normalizedSessionId;if(typeof data36 === "string"){if(func1(data36) > 160){const err99 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}if(func1(data36) < 1){const err100 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}}else {const err101 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}if(data.turnId !== undefined){let data37 = data.turnId;if(typeof data37 === "string"){if(func1(data37) > 160){const err102 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}if(func1(data37) < 1){const err103 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}}else {const err104 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}}if(data.itemId !== undefined){let data38 = data.itemId;if(typeof data38 === "string"){if(func1(data38) > 160){const err105 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}if(func1(data38) < 1){const err106 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}}else {const err107 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}}if(data.eventType !== undefined){let data39 = data.eventType;if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((data39 === "runner.connected") || (data39 === "runner.reconnected")) || (data39 === "runner.reconciled")) || (data39 === "runner.disconnected")) || (data39 === "runner.draining")) || (data39 === "runner.suspending")) || (data39 === "runner.suspended")) || (data39 === "runner.stopped")) || (data39 === "runner.diagnostic")) || (data39 === "runtime.phase.changed")) || (data39 === "sandbox.metric")) || (data39 === "workspace.ready")) || (data39 === "workspace.change.updated")) || (data39 === "workspace.diff.recorded")) || (data39 === "workspace.file.referenced")) || (data39 === "harness.starting")) || (data39 === "harness.ready")) || (data39 === "harness.exited")) || (data39 === "harness.diagnostic")) || (data39 === "plan.updated")) || (data39 === "tool.execution.started")) || (data39 === "tool.execution.progressed")) || (data39 === "tool.execution.completed")) || (data39 === "research.started")) || (data39 === "research.progressed")) || (data39 === "research.completed")) || (data39 === "delegation.started")) || (data39 === "delegation.updated")) || (data39 === "delegation.completed")) || (data39 === "model.route.changed")) || (data39 === "model.verification.updated")) || (data39 === "context.compacted")) || (data39 === "artifact.viewed")) || (data39 === "artifact.generated")) || (data39 === "review.mode.changed")) || (data39 === "hook.started")) || (data39 === "hook.completed")) || (data39 === "memory.citation.referenced")) || (data39 === "safety.review.started")) || (data39 === "safety.review.completed")) || (data39 === "terminal.input.sent")) || (data39 === "wait.started")) || (data39 === "wait.completed")) || (data39 === "provider.notice.recorded")) || (data39 === "session.starting")) || (data39 === "session.started")) || (data39 === "session.resuming")) || (data39 === "session.resumed")) || (data39 === "session.reconciled")) || (data39 === "session.updated")) || (data39 === "session.closed")) || (data39 === "session.failed")) || (data39 === "session.capabilities.updated")) || (data39 === "session.goal.snapshot")) || (data39 === "session.goal.updated")) || (data39 === "session.goal.cleared")) || (data39 === "turn.submitted")) || (data39 === "turn.accepted")) || (data39 === "turn.started")) || (data39 === "turn.completed")) || (data39 === "turn.failed")) || (data39 === "turn.interrupted")) || (data39 === "turn.cancelled")) || (data39 === "item.started")) || (data39 === "item.delta")) || (data39 === "item.completed")) || (data39 === "item.failed")) || (data39 === "usage.reported")) || (data39 === "semantic_tool.input")) || (data39 === "semantic_tool.result")) || (data39 === "mcp_app.discovered")) || (data39 === "mcp_app.resource.resolved")) || (data39 === "mcp_app.initializing")) || (data39 === "mcp_app.ready")) || (data39 === "mcp_app.tool_input")) || (data39 === "mcp_app.tool_result")) || (data39 === "mcp_app.action.requested")) || (data39 === "mcp_app.action.resolved")) || (data39 === "mcp_app.host_context.changed")) || (data39 === "mcp_app.failed")) || (data39 === "mcp_app.teardown")) || (data39 === "runtime_request.created")) || (data39 === "runtime_request.resolved")) || (data39 === "runtime_request.expired")) || (data39 === "runtime_request.cancelled")) || (data39 === "interaction.request.proposed")) || (data39 === "interaction.request.materialized")) || (data39 === "interaction.request.rejected")) || (data39 === "interaction.response.progressed")) || (data39 === "interaction.response.resolved")) || (data39 === "interaction.response.delivered")) || (data39 === "run.attached")) || (data39 === "run.detached")) || (data39 === "run.result.proposed")) || (data39 === "run.result.accepted")) || (data39 === "run.result.rejected")) || (data39 === "attention.request.proposed")) || (data39 === "attention.request.routed")) || (data39 === "attention.request.resolved")) || (data39 === "attention.request.expired")) || (data39 === "attention.request.superseded")) || (data39 === "work.assessment.recorded")) || (data39 === "issue.status.decision.recorded")) || (data39 === "issue.status.decision.applied")) || (data39 === "issue.status.decision.rejected")) || (data39 === "issue.status.decision.superseded")) || (data39 === "run.terminal"))){const err108 = {instancePath:instancePath+"/eventType",schemaPath:"#/properties/eventType/enum",keyword:"enum",params:{allowedValues: schema203.properties.eventType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}if(data.schemaVersion !== undefined){if(2 !== data.schemaVersion){const err109 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 2},message:"must be equal to constant"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}}if(data.priority !== undefined){let data41 = data.priority;if(!(((data41 === 0) || (data41 === 1)) || (data41 === 2))){const err110 = {instancePath:instancePath+"/priority",schemaPath:"#/properties/priority/enum",keyword:"enum",params:{allowedValues: schema203.properties.priority.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}if(data.emittedAt !== undefined){let data42 = data.emittedAt;if(typeof data42 === "string"){if(!(formats0.test(data42))){const err111 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}else {const err112 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}}if(data.observedAt !== undefined){let data43 = data.observedAt;if(typeof data43 === "string"){if(!(formats0.test(data43))){const err113 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}else {const err114 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}if(data.payload !== undefined){let data44 = data.payload;if(data44 && typeof data44 == "object" && !Array.isArray(data44)){}else {const err115 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}}if(data.debug !== undefined){let data45 = data.debug;if(data45 && typeof data45 == "object" && !Array.isArray(data45)){}else {const err116 = {instancePath:instancePath+"/debug",schemaPath:"#/properties/debug/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}}else {const err117 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}validate111.errors = vErrors;return errors === 0;}validate111.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema209 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v3/event.schema.json","title":"PRP v3 native event","type":"object","required":["schema","sourceEventId","sourceSeq","sourceInstanceId","sourceKind","runId","eventType","schemaVersion","priority","emittedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.event.v3"},"sourceEventId":{"type":"string","minLength":1,"maxLength":160},"sourceSeq":{"type":"integer","minimum":1,"maximum":9007199254740991},"sourceInstanceId":{"type":"string","minLength":1,"maxLength":160},"sourceKind":{"enum":["runner","control_plane"]},"runId":{"type":"string","minLength":1,"maxLength":160},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":160},"turnId":{"type":"string","minLength":1,"maxLength":160},"itemId":{"type":"string","minLength":1,"maxLength":160},"eventType":{"enum":["runner.connected","runner.reconnected","runner.reconciled","runner.disconnected","runner.draining","runner.suspending","runner.suspended","runner.stopped","runner.diagnostic","runtime.phase.changed","sandbox.metric","workspace.ready","workspace.change.updated","workspace.diff.recorded","workspace.file.referenced","harness.starting","harness.ready","harness.exited","harness.diagnostic","plan.updated","tool.execution.started","tool.execution.progressed","tool.execution.completed","research.started","research.progressed","research.completed","delegation.started","delegation.updated","delegation.completed","model.route.changed","model.verification.updated","context.compacted","artifact.viewed","artifact.generated","review.mode.changed","hook.started","hook.completed","memory.citation.referenced","safety.review.started","safety.review.completed","terminal.input.sent","wait.started","wait.completed","provider.notice.recorded","session.starting","session.started","session.resuming","session.resumed","session.reconciled","session.updated","session.closed","session.failed","session.capabilities.updated","session.goal.snapshot","session.goal.updated","session.goal.cleared","turn.submitted","turn.accepted","turn.started","turn.completed","turn.failed","turn.interrupted","turn.cancelled","item.started","item.delta","item.completed","item.failed","usage.reported","semantic_tool.input","semantic_tool.result","mcp_app.discovered","mcp_app.resource.resolved","mcp_app.initializing","mcp_app.ready","mcp_app.tool_input","mcp_app.tool_result","mcp_app.action.requested","mcp_app.action.resolved","mcp_app.host_context.changed","mcp_app.failed","mcp_app.teardown","runtime_request.created","runtime_request.resolved","runtime_request.expired","runtime_request.cancelled","interaction.request.proposed","interaction.request.materialized","interaction.request.rejected","interaction.response.progressed","interaction.response.resolved","interaction.response.delivered","run.attached","run.detached","run.result.proposed","run.result.accepted","run.result.rejected","attention.request.proposed","attention.request.routed","attention.request.resolved","attention.request.expired","attention.request.superseded","work.assessment.recorded","issue.status.decision.recorded","issue.status.decision.applied","issue.status.decision.rejected","issue.status.decision.superseded","run.terminal","external_provider.dispatch_requested","external_provider.operation_settled"]},"schemaVersion":{"const":3},"priority":{"enum":[0,1,2]},"emittedAt":{"type":"string","format":"date-time"},"observedAt":{"type":"string","format":"date-time"},"payload":{"type":"object","additionalProperties":true},"debug":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"eventType":{"enum":["session.goal.snapshot","session.goal.updated"]}},"type":"object"},"then":{"properties":{"payload":{"type":"object","required":["goal"],"properties":{"goal":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot"},{"type":"null"}]}},"additionalProperties":true}},"type":"object"}},{"if":{"properties":{"eventType":{"const":"session.capabilities.updated"}},"type":"object"},"then":{"properties":{"payload":{"type":"object","required":["sessionGoals"],"properties":{"sessionGoals":{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"},"turnControls":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls"}},"additionalProperties":true}},"type":"object"}},{"if":{"properties":{"eventType":{"const":"external_provider.dispatch_requested"}},"type":"object"},"then":{"properties":{"payload":{"oneOf":[{"type":"object","properties":{"binding":{"type":"object","properties":{"companyId":{"type":"string","minLength":1,"maxLength":240},"agentId":{"type":"string","minLength":1,"maxLength":240},"bindingId":{"type":"string","minLength":1,"maxLength":240},"runId":{"type":"string","minLength":1,"maxLength":240},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":240},"turnId":{"type":"string","minLength":1,"maxLength":240},"bindingGeneration":{"type":"integer","minimum":1},"assignmentRevision":{"type":"integer","minimum":1}},"additionalProperties":false,"required":["companyId","agentId","bindingId","runId","normalizedSessionId","turnId","bindingGeneration","assignmentRevision"]},"text":{"type":"string","minLength":1,"maxLength":1048576},"instructions":{"type":"string","maxLength":1048576},"acceptByUnixMs":{"type":"integer","minimum":1},"expiresAtUnixMs":{"type":"integer","minimum":1},"completionContract":{"type":"object","properties":{"revision":{"type":"string","minLength":1},"criterionIds":{"type":"array","minItems":1,"maxItems":256,"uniqueItems":true,"items":{"type":"string","minLength":1}}},"required":["revision","criterionIds"],"additionalProperties":false},"tools":{"type":"array","items":{"type":"object"},"maxItems":512}},"additionalProperties":false,"required":["binding","text","instructions","acceptByUnixMs","expiresAtUnixMs","completionContract","tools"]},{"type":"object","properties":{"binding":{"type":"object","properties":{"companyId":{"type":"string","minLength":1,"maxLength":240},"agentId":{"type":"string","minLength":1,"maxLength":240},"bindingId":{"type":"string","minLength":1,"maxLength":240},"runId":{"type":"string","minLength":1,"maxLength":240},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":240},"turnId":{"type":"string","minLength":1,"maxLength":240},"bindingGeneration":{"type":"integer","minimum":1},"assignmentRevision":{"type":"integer","minimum":1}},"additionalProperties":false,"required":["companyId","agentId","bindingId","runId","normalizedSessionId","turnId","bindingGeneration","assignmentRevision"]},"kind":{"const":"authority_revoked"},"reason":{"type":"string"},"externalStopConfirmed":{"const":false}},"required":["binding","kind","externalStopConfirmed"],"additionalProperties":false}]}},"type":"object"}},{"if":{"properties":{"eventType":{"const":"external_provider.operation_settled"}},"type":"object"},"then":{"properties":{"payload":{"type":"object","properties":{"binding":{"type":"object","properties":{"companyId":{"type":"string","minLength":1,"maxLength":240},"agentId":{"type":"string","minLength":1,"maxLength":240},"bindingId":{"type":"string","minLength":1,"maxLength":240},"runId":{"type":"string","minLength":1,"maxLength":240},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":240},"turnId":{"type":"string","minLength":1,"maxLength":240},"bindingGeneration":{"type":"integer","minimum":1},"assignmentRevision":{"type":"integer","minimum":1}},"additionalProperties":false,"required":["companyId","agentId","bindingId","runId","normalizedSessionId","turnId","bindingGeneration","assignmentRevision"]},"requestId":{"type":"string","minLength":1,"maxLength":240},"outcome":{"type":"object"}},"required":["binding","requestId","outcome"],"additionalProperties":false}},"type":"object"}}],"additionalProperties":true};function validate114(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v3/event.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate114.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(errors === _errs3){if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data0 = data.eventType;if(!((data0 === "session.goal.snapshot") || (data0 === "session.goal.updated"))){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}else {const err1 = {};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs6 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data1 = data.payload;if(data1 && typeof data1 == "object" && !Array.isArray(data1)){if(data1.goal === undefined){const err2 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/required",keyword:"required",params:{missingProperty: "goal"},message:"must have required property '"+"goal"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data1.goal !== undefined){let data2 = data1.goal;const _errs12 = errors;let valid5 = false;let passing0 = null;const _errs13 = errors;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.objective === undefined){const err3 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "objective"},message:"must have required property '"+"objective"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data2.status === undefined){const err4 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data2.tokenBudget === undefined){const err5 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "tokenBudget"},message:"must have required property '"+"tokenBudget"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data2.tokensUsed === undefined){const err6 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "tokensUsed"},message:"must have required property '"+"tokensUsed"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data2.elapsedSeconds === undefined){const err7 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "elapsedSeconds"},message:"must have required property '"+"elapsedSeconds"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data2.iterations === undefined){const err8 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "iterations"},message:"must have required property '"+"iterations"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data2.lastReason === undefined){const err9 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "lastReason"},message:"must have required property '"+"lastReason"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data2.createdAt === undefined){const err10 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "createdAt"},message:"must have required property '"+"createdAt"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data2.updatedAt === undefined){const err11 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "updatedAt"},message:"must have required property '"+"updatedAt"+"'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data2.completedAt === undefined){const err12 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "completedAt"},message:"must have required property '"+"completedAt"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.workingNow === undefined){const err13 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "workingNow"},message:"must have required property '"+"workingNow"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data2.objective !== undefined){let data3 = data2.objective;if(typeof data3 === "string"){if(func1(data3) > 4000){const err14 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(func1(data3) < 1){const err15 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data2.status !== undefined){let data4 = data2.status;if(!(((((((data4 === "active") || (data4 === "paused")) || (data4 === "blocked")) || (data4 === "limited")) || (data4 === "usage_limited")) || (data4 === "budget_limited")) || (data4 === "complete"))){const err17 = {instancePath:instancePath+"/payload/goal/status",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/status/enum",keyword:"enum",params:{allowedValues: schema204.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data2.tokenBudget !== undefined){let data5 = data2.tokenBudget;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err18 = {instancePath:instancePath+"/payload/goal/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokenBudget/type",keyword:"type",params:{type: schema204.properties.tokenBudget.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 1 || isNaN(data5)){const err19 = {instancePath:instancePath+"/payload/goal/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokenBudget/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}if(data2.tokensUsed !== undefined){let data6 = data2.tokensUsed;if((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (data6 !== null)){const err20 = {instancePath:instancePath+"/payload/goal/tokensUsed",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokensUsed/type",keyword:"type",params:{type: schema204.properties.tokensUsed.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err21 = {instancePath:instancePath+"/payload/goal/tokensUsed",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokensUsed/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}if(data2.elapsedSeconds !== undefined){let data7 = data2.elapsedSeconds;if((!((typeof data7 == "number") && (isFinite(data7)))) && (data7 !== null)){const err22 = {instancePath:instancePath+"/payload/goal/elapsedSeconds",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/elapsedSeconds/type",keyword:"type",params:{type: schema204.properties.elapsedSeconds.type},message:"must be number,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 < 0 || isNaN(data7)){const err23 = {instancePath:instancePath+"/payload/goal/elapsedSeconds",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/elapsedSeconds/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}if(data2.iterations !== undefined){let data8 = data2.iterations;if((!(((typeof data8 == "number") && (!(data8 % 1) && !isNaN(data8))) && (isFinite(data8)))) && (data8 !== null)){const err24 = {instancePath:instancePath+"/payload/goal/iterations",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/iterations/type",keyword:"type",params:{type: schema204.properties.iterations.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if((typeof data8 == "number") && (isFinite(data8))){if(data8 < 0 || isNaN(data8)){const err25 = {instancePath:instancePath+"/payload/goal/iterations",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/iterations/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}if(data2.lastReason !== undefined){let data9 = data2.lastReason;if((typeof data9 !== "string") && (data9 !== null)){const err26 = {instancePath:instancePath+"/payload/goal/lastReason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/lastReason/type",keyword:"type",params:{type: schema204.properties.lastReason.type},message:"must be string,null"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(typeof data9 === "string"){if(func1(data9) > 4000){const err27 = {instancePath:instancePath+"/payload/goal/lastReason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/lastReason/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}}if(data2.createdAt !== undefined){let data10 = data2.createdAt;if((typeof data10 !== "string") && (data10 !== null)){const err28 = {instancePath:instancePath+"/payload/goal/createdAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/createdAt/type",keyword:"type",params:{type: schema204.properties.createdAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(typeof data10 === "string"){if(!(formats0.test(data10))){const err29 = {instancePath:instancePath+"/payload/goal/createdAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/createdAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}}if(data2.updatedAt !== undefined){let data11 = data2.updatedAt;if((typeof data11 !== "string") && (data11 !== null)){const err30 = {instancePath:instancePath+"/payload/goal/updatedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/updatedAt/type",keyword:"type",params:{type: schema204.properties.updatedAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(typeof data11 === "string"){if(!(formats0.test(data11))){const err31 = {instancePath:instancePath+"/payload/goal/updatedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/updatedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}}if(data2.completedAt !== undefined){let data12 = data2.completedAt;if((typeof data12 !== "string") && (data12 !== null)){const err32 = {instancePath:instancePath+"/payload/goal/completedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/completedAt/type",keyword:"type",params:{type: schema204.properties.completedAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(typeof data12 === "string"){if(!(formats0.test(data12))){const err33 = {instancePath:instancePath+"/payload/goal/completedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/completedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}if(data2.workingNow !== undefined){if(typeof data2.workingNow !== "boolean"){const err34 = {instancePath:instancePath+"/payload/goal/workingNow",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/workingNow/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}else {const err35 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}var _valid1 = _errs13 === errors;if(_valid1){valid5 = true;passing0 = 0;}const _errs38 = errors;if(data2 !== null){const err36 = {instancePath:instancePath+"/payload/goal",schemaPath:"#/allOf/0/then/properties/payload/properties/goal/oneOf/1/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}var _valid1 = _errs38 === errors;if(_valid1 && valid5){valid5 = false;passing0 = [passing0, 1];}else {if(_valid1){valid5 = true;passing0 = 1;}}if(!valid5){const err37 = {instancePath:instancePath+"/payload/goal",schemaPath:"#/allOf/0/then/properties/payload/properties/goal/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}else {errors = _errs12;if(vErrors !== null){if(_errs12){vErrors.length = _errs12;}else {vErrors = null;}}}}}else {const err38 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}}else {const err39 = {instancePath,schemaPath:"#/allOf/0/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}var _valid0 = _errs6 === errors;valid1 = _valid0;if(valid1){var props1 = {};props1.payload = true;props1.eventType = true;}}if(!valid1){const err40 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}const _errs41 = errors;let valid8 = true;const _errs42 = errors;if(errors === _errs42){if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("session.capabilities.updated" !== data.eventType){const err41 = {};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}}else {const err42 = {};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}var _valid2 = _errs42 === errors;errors = _errs41;if(vErrors !== null){if(_errs41){vErrors.length = _errs41;}else {vErrors = null;}}if(_valid2){const _errs45 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data15 = data.payload;if(data15 && typeof data15 == "object" && !Array.isArray(data15)){if(data15.sessionGoals === undefined){const err43 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "sessionGoals"},message:"must have required property '"+"sessionGoals"+"'"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}if(data15.sessionGoals !== undefined){let data16 = data15.sessionGoals;if(data16 && typeof data16 == "object" && !Array.isArray(data16)){if(data16.availability === undefined){const err44 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "availability"},message:"must have required property '"+"availability"+"'"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}if(data16.actions === undefined){const err45 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "actions"},message:"must have required property '"+"actions"+"'"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(data16.autonomousUpdates === undefined){const err46 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "autonomousUpdates"},message:"must have required property '"+"autonomousUpdates"+"'"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(data16.persistentAcrossResume === undefined){const err47 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "persistentAcrossResume"},message:"must have required property '"+"persistentAcrossResume"+"'"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}if(data16.maxObjectiveChars === undefined){const err48 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "maxObjectiveChars"},message:"must have required property '"+"maxObjectiveChars"+"'"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}if(data16.tokenBudgetControl === undefined){const err49 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "tokenBudgetControl"},message:"must have required property '"+"tokenBudgetControl"+"'"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}if(data16.usageReporting === undefined){const err50 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "usageReporting"},message:"must have required property '"+"usageReporting"+"'"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}if(data16.availability !== undefined){let data17 = data16.availability;if(!(((data17 === "available") || (data17 === "unsupported")) || (data17 === "policy_disabled"))){const err51 = {instancePath:instancePath+"/payload/sessionGoals/availability",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/availability/enum",keyword:"enum",params:{allowedValues: schema44.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data16.actions !== undefined){let data18 = data16.actions;if(Array.isArray(data18)){const len0 = data18.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data18[i1], data18[j0])){const err53 = {instancePath:instancePath+"/payload/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;break outer0;}}}}}else {const err54 = {instancePath:instancePath+"/payload/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}if(data16.autonomousUpdates !== undefined){if(typeof data16.autonomousUpdates !== "boolean"){const err55 = {instancePath:instancePath+"/payload/sessionGoals/autonomousUpdates",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/autonomousUpdates/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data16.persistentAcrossResume !== undefined){if(typeof data16.persistentAcrossResume !== "boolean"){const err56 = {instancePath:instancePath+"/payload/sessionGoals/persistentAcrossResume",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/persistentAcrossResume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}if(data16.maxObjectiveChars !== undefined){let data22 = data16.maxObjectiveChars;if(!(((typeof data22 == "number") && (!(data22 % 1) && !isNaN(data22))) && (isFinite(data22)))){const err57 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}if((typeof data22 == "number") && (isFinite(data22))){if(data22 > 4000 || isNaN(data22)){const err58 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/maximum",keyword:"maximum",params:{comparison: "<=", limit: 4000},message:"must be <= 4000"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}if(data22 < 1 || isNaN(data22)){const err59 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}}if(data16.tokenBudgetControl !== undefined){if(typeof data16.tokenBudgetControl !== "boolean"){const err60 = {instancePath:instancePath+"/payload/sessionGoals/tokenBudgetControl",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/tokenBudgetControl/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}if(data16.usageReporting !== undefined){if(typeof data16.usageReporting !== "boolean"){const err61 = {instancePath:instancePath+"/payload/sessionGoals/usageReporting",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/usageReporting/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}if(data16.reasonCode !== undefined){let data25 = data16.reasonCode;if(typeof data25 === "string"){if(func1(data25) > 160){const err62 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(func1(data25) < 1){const err63 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}else {const err64 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}if(data16.reason !== undefined){let data26 = data16.reason;if(typeof data26 === "string"){if(func1(data26) > 1000){const err65 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}if(func1(data26) < 1){const err66 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}}else {const err67 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}}}else {const err68 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}if(data15.turnControls !== undefined){let data27 = data15.turnControls;if(data27 && typeof data27 == "object" && !Array.isArray(data27)){if(data27.steering === undefined){const err69 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/required",keyword:"required",params:{missingProperty: "steering"},message:"must have required property '"+"steering"+"'"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}if(data27.queuedFollowUp === undefined){const err70 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/required",keyword:"required",params:{missingProperty: "queuedFollowUp"},message:"must have required property '"+"queuedFollowUp"+"'"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}for(const key0 in data27){if(!((key0 === "steering") || (key0 === "queuedFollowUp"))){const err71 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}if(data27.steering !== undefined){if(typeof data27.steering !== "boolean"){const err72 = {instancePath:instancePath+"/payload/turnControls/steering",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/properties/steering/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}}if(data27.queuedFollowUp !== undefined){if(typeof data27.queuedFollowUp !== "boolean"){const err73 = {instancePath:instancePath+"/payload/turnControls/queuedFollowUp",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/properties/queuedFollowUp/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}}}else {const err74 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}}}else {const err75 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}}}else {const err76 = {instancePath,schemaPath:"#/allOf/1/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}var _valid2 = _errs45 === errors;valid8 = _valid2;if(valid8){var props2 = {};props2.payload = true;props2.eventType = true;}}if(!valid8){const err77 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(props1 !== true && props2 !== undefined){if(props2 === true){props1 = true;}else {props1 = props1 || {};Object.assign(props1, props2);}}const _errs81 = errors;let valid19 = true;const _errs82 = errors;if(errors === _errs82){if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("external_provider.dispatch_requested" !== data.eventType){const err78 = {};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}}}else {const err79 = {};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}}var _valid3 = _errs82 === errors;errors = _errs81;if(vErrors !== null){if(_errs81){vErrors.length = _errs81;}else {vErrors = null;}}if(_valid3){const _errs85 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data31 = data.payload;const _errs88 = errors;let valid22 = false;let passing1 = null;const _errs89 = errors;if(data31 && typeof data31 == "object" && !Array.isArray(data31)){if(data31.binding === undefined){const err80 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "binding"},message:"must have required property '"+"binding"+"'"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}if(data31.text === undefined){const err81 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "text"},message:"must have required property '"+"text"+"'"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}if(data31.instructions === undefined){const err82 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "instructions"},message:"must have required property '"+"instructions"+"'"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(data31.acceptByUnixMs === undefined){const err83 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "acceptByUnixMs"},message:"must have required property '"+"acceptByUnixMs"+"'"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}if(data31.expiresAtUnixMs === undefined){const err84 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "expiresAtUnixMs"},message:"must have required property '"+"expiresAtUnixMs"+"'"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}if(data31.completionContract === undefined){const err85 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "completionContract"},message:"must have required property '"+"completionContract"+"'"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}if(data31.tools === undefined){const err86 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "tools"},message:"must have required property '"+"tools"+"'"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}for(const key1 in data31){if(!(((((((key1 === "binding") || (key1 === "text")) || (key1 === "instructions")) || (key1 === "acceptByUnixMs")) || (key1 === "expiresAtUnixMs")) || (key1 === "completionContract")) || (key1 === "tools"))){const err87 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}if(data31.binding !== undefined){let data32 = data31.binding;if(data32 && typeof data32 == "object" && !Array.isArray(data32)){if(data32.companyId === undefined){const err88 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "companyId"},message:"must have required property '"+"companyId"+"'"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}if(data32.agentId === undefined){const err89 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "agentId"},message:"must have required property '"+"agentId"+"'"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}if(data32.bindingId === undefined){const err90 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err90];}else {vErrors.push(err90);}errors++;}if(data32.runId === undefined){const err91 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}if(data32.normalizedSessionId === undefined){const err92 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}if(data32.turnId === undefined){const err93 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}if(data32.bindingGeneration === undefined){const err94 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "bindingGeneration"},message:"must have required property '"+"bindingGeneration"+"'"};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}if(data32.assignmentRevision === undefined){const err95 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "assignmentRevision"},message:"must have required property '"+"assignmentRevision"+"'"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}for(const key2 in data32){if(!((((((((key2 === "companyId") || (key2 === "agentId")) || (key2 === "bindingId")) || (key2 === "runId")) || (key2 === "normalizedSessionId")) || (key2 === "turnId")) || (key2 === "bindingGeneration")) || (key2 === "assignmentRevision"))){const err96 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}}if(data32.companyId !== undefined){let data33 = data32.companyId;if(typeof data33 === "string"){if(func1(data33) > 240){const err97 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/companyId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}if(func1(data33) < 1){const err98 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/companyId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}else {const err99 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/companyId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}}if(data32.agentId !== undefined){let data34 = data32.agentId;if(typeof data34 === "string"){if(func1(data34) > 240){const err100 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/agentId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}if(func1(data34) < 1){const err101 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/agentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}else {const err102 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/agentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}if(data32.bindingId !== undefined){let data35 = data32.bindingId;if(typeof data35 === "string"){if(func1(data35) > 240){const err103 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}if(func1(data35) < 1){const err104 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}}else {const err105 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}}if(data32.runId !== undefined){let data36 = data32.runId;if(typeof data36 === "string"){if(func1(data36) > 240){const err106 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/runId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}if(func1(data36) < 1){const err107 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}}else {const err108 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}if(data32.normalizedSessionId !== undefined){let data37 = data32.normalizedSessionId;if(typeof data37 === "string"){if(func1(data37) > 240){const err109 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}if(func1(data37) < 1){const err110 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}else {const err111 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}if(data32.turnId !== undefined){let data38 = data32.turnId;if(typeof data38 === "string"){if(func1(data38) > 240){const err112 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}if(func1(data38) < 1){const err113 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}else {const err114 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}if(data32.bindingGeneration !== undefined){let data39 = data32.bindingGeneration;if(!(((typeof data39 == "number") && (!(data39 % 1) && !isNaN(data39))) && (isFinite(data39)))){const err115 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingGeneration/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}if((typeof data39 == "number") && (isFinite(data39))){if(data39 < 1 || isNaN(data39)){const err116 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingGeneration/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}}if(data32.assignmentRevision !== undefined){let data40 = data32.assignmentRevision;if(!(((typeof data40 == "number") && (!(data40 % 1) && !isNaN(data40))) && (isFinite(data40)))){const err117 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/assignmentRevision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}if((typeof data40 == "number") && (isFinite(data40))){if(data40 < 1 || isNaN(data40)){const err118 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/assignmentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}}}}else {const err119 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}}if(data31.text !== undefined){let data41 = data31.text;if(typeof data41 === "string"){if(func1(data41) > 1048576){const err120 = {instancePath:instancePath+"/payload/text",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/text/maxLength",keyword:"maxLength",params:{limit: 1048576},message:"must NOT have more than 1048576 characters"};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}if(func1(data41) < 1){const err121 = {instancePath:instancePath+"/payload/text",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/text/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}else {const err122 = {instancePath:instancePath+"/payload/text",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/text/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}}if(data31.instructions !== undefined){let data42 = data31.instructions;if(typeof data42 === "string"){if(func1(data42) > 1048576){const err123 = {instancePath:instancePath+"/payload/instructions",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/instructions/maxLength",keyword:"maxLength",params:{limit: 1048576},message:"must NOT have more than 1048576 characters"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}else {const err124 = {instancePath:instancePath+"/payload/instructions",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/instructions/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}}if(data31.acceptByUnixMs !== undefined){let data43 = data31.acceptByUnixMs;if(!(((typeof data43 == "number") && (!(data43 % 1) && !isNaN(data43))) && (isFinite(data43)))){const err125 = {instancePath:instancePath+"/payload/acceptByUnixMs",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/acceptByUnixMs/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}if((typeof data43 == "number") && (isFinite(data43))){if(data43 < 1 || isNaN(data43)){const err126 = {instancePath:instancePath+"/payload/acceptByUnixMs",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/acceptByUnixMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}}}if(data31.expiresAtUnixMs !== undefined){let data44 = data31.expiresAtUnixMs;if(!(((typeof data44 == "number") && (!(data44 % 1) && !isNaN(data44))) && (isFinite(data44)))){const err127 = {instancePath:instancePath+"/payload/expiresAtUnixMs",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/expiresAtUnixMs/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;}if((typeof data44 == "number") && (isFinite(data44))){if(data44 < 1 || isNaN(data44)){const err128 = {instancePath:instancePath+"/payload/expiresAtUnixMs",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/expiresAtUnixMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}}}if(data31.completionContract !== undefined){let data45 = data31.completionContract;if(data45 && typeof data45 == "object" && !Array.isArray(data45)){if(data45.revision === undefined){const err129 = {instancePath:instancePath+"/payload/completionContract",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}if(data45.criterionIds === undefined){const err130 = {instancePath:instancePath+"/payload/completionContract",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/required",keyword:"required",params:{missingProperty: "criterionIds"},message:"must have required property '"+"criterionIds"+"'"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}for(const key3 in data45){if(!((key3 === "revision") || (key3 === "criterionIds"))){const err131 = {instancePath:instancePath+"/payload/completionContract",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}}if(data45.revision !== undefined){let data46 = data45.revision;if(typeof data46 === "string"){if(func1(data46) < 1){const err132 = {instancePath:instancePath+"/payload/completionContract/revision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/revision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;}}else {const err133 = {instancePath:instancePath+"/payload/completionContract/revision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/revision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err133];}else {vErrors.push(err133);}errors++;}}if(data45.criterionIds !== undefined){let data47 = data45.criterionIds;if(Array.isArray(data47)){if(data47.length > 256){const err134 = {instancePath:instancePath+"/payload/completionContract/criterionIds",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/criterionIds/maxItems",keyword:"maxItems",params:{limit: 256},message:"must NOT have more than 256 items"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}if(data47.length < 1){const err135 = {instancePath:instancePath+"/payload/completionContract/criterionIds",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/criterionIds/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}const len1 = data47.length;for(let i2=0; i2 1){const indices0 = {};for(;i3--;){let item0 = data47[i3];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j1 = indices0[item0];const err138 = {instancePath:instancePath+"/payload/completionContract/criterionIds",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/criterionIds/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;break;}indices0[item0] = i3;}}}else {const err139 = {instancePath:instancePath+"/payload/completionContract/criterionIds",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/criterionIds/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}}}else {const err140 = {instancePath:instancePath+"/payload/completionContract",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}}if(data31.tools !== undefined){let data49 = data31.tools;if(Array.isArray(data49)){if(data49.length > 512){const err141 = {instancePath:instancePath+"/payload/tools",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/tools/maxItems",keyword:"maxItems",params:{limit: 512},message:"must NOT have more than 512 items"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}const len2 = data49.length;for(let i4=0; i4 240){const err158 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/companyId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err158];}else {vErrors.push(err158);}errors++;}if(func1(data52) < 1){const err159 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/companyId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err159];}else {vErrors.push(err159);}errors++;}}else {const err160 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/companyId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err160];}else {vErrors.push(err160);}errors++;}}if(data51.agentId !== undefined){let data53 = data51.agentId;if(typeof data53 === "string"){if(func1(data53) > 240){const err161 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/agentId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err161];}else {vErrors.push(err161);}errors++;}if(func1(data53) < 1){const err162 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/agentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err162];}else {vErrors.push(err162);}errors++;}}else {const err163 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/agentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err163];}else {vErrors.push(err163);}errors++;}}if(data51.bindingId !== undefined){let data54 = data51.bindingId;if(typeof data54 === "string"){if(func1(data54) > 240){const err164 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err164];}else {vErrors.push(err164);}errors++;}if(func1(data54) < 1){const err165 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err165];}else {vErrors.push(err165);}errors++;}}else {const err166 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err166];}else {vErrors.push(err166);}errors++;}}if(data51.runId !== undefined){let data55 = data51.runId;if(typeof data55 === "string"){if(func1(data55) > 240){const err167 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/runId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err167];}else {vErrors.push(err167);}errors++;}if(func1(data55) < 1){const err168 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}}else {const err169 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}}if(data51.normalizedSessionId !== undefined){let data56 = data51.normalizedSessionId;if(typeof data56 === "string"){if(func1(data56) > 240){const err170 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}if(func1(data56) < 1){const err171 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}}else {const err172 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}}if(data51.turnId !== undefined){let data57 = data51.turnId;if(typeof data57 === "string"){if(func1(data57) > 240){const err173 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}if(func1(data57) < 1){const err174 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}}else {const err175 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}}if(data51.bindingGeneration !== undefined){let data58 = data51.bindingGeneration;if(!(((typeof data58 == "number") && (!(data58 % 1) && !isNaN(data58))) && (isFinite(data58)))){const err176 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingGeneration/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if((typeof data58 == "number") && (isFinite(data58))){if(data58 < 1 || isNaN(data58)){const err177 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingGeneration/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}}}if(data51.assignmentRevision !== undefined){let data59 = data51.assignmentRevision;if(!(((typeof data59 == "number") && (!(data59 % 1) && !isNaN(data59))) && (isFinite(data59)))){const err178 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/assignmentRevision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err178];}else {vErrors.push(err178);}errors++;}if((typeof data59 == "number") && (isFinite(data59))){if(data59 < 1 || isNaN(data59)){const err179 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/assignmentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err179];}else {vErrors.push(err179);}errors++;}}}}else {const err180 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err180];}else {vErrors.push(err180);}errors++;}}if(data31.kind !== undefined){if("authority_revoked" !== data31.kind){const err181 = {instancePath:instancePath+"/payload/kind",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/kind/const",keyword:"const",params:{allowedValue: "authority_revoked"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err181];}else {vErrors.push(err181);}errors++;}}if(data31.reason !== undefined){if(typeof data31.reason !== "string"){const err182 = {instancePath:instancePath+"/payload/reason",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err182];}else {vErrors.push(err182);}errors++;}}if(data31.externalStopConfirmed !== undefined){if(false !== data31.externalStopConfirmed){const err183 = {instancePath:instancePath+"/payload/externalStopConfirmed",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/externalStopConfirmed/const",keyword:"const",params:{allowedValue: false},message:"must be equal to constant"};if(vErrors === null){vErrors = [err183];}else {vErrors.push(err183);}errors++;}}}else {const err184 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err184];}else {vErrors.push(err184);}errors++;}var _valid4 = _errs132 === errors;if(_valid4 && valid22){valid22 = false;passing1 = [passing1, 1];}else {if(_valid4){valid22 = true;passing1 = 1;if(props3 !== true){props3 = true;}}}if(!valid22){const err185 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf",keyword:"oneOf",params:{passingSchemas: passing1},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err185];}else {vErrors.push(err185);}errors++;}else {errors = _errs88;if(vErrors !== null){if(_errs88){vErrors.length = _errs88;}else {vErrors = null;}}}}}else {const err186 = {instancePath,schemaPath:"#/allOf/2/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err186];}else {vErrors.push(err186);}errors++;}var _valid3 = _errs85 === errors;valid19 = _valid3;if(valid19){var props4 = {};props4.payload = true;props4.eventType = true;}}if(!valid19){const err187 = {instancePath,schemaPath:"#/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err187];}else {vErrors.push(err187);}errors++;}if(props1 !== true && props4 !== undefined){if(props4 === true){props1 = true;}else {props1 = props1 || {};Object.assign(props1, props4);}}const _errs159 = errors;let valid33 = true;const _errs160 = errors;if(errors === _errs160){if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("external_provider.operation_settled" !== data.eventType){const err188 = {};if(vErrors === null){vErrors = [err188];}else {vErrors.push(err188);}errors++;}}}else {const err189 = {};if(vErrors === null){vErrors = [err189];}else {vErrors.push(err189);}errors++;}}var _valid5 = _errs160 === errors;errors = _errs159;if(vErrors !== null){if(_errs159){vErrors.length = _errs159;}else {vErrors = null;}}if(_valid5){const _errs163 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data64 = data.payload;if(data64 && typeof data64 == "object" && !Array.isArray(data64)){if(data64.binding === undefined){const err190 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/required",keyword:"required",params:{missingProperty: "binding"},message:"must have required property '"+"binding"+"'"};if(vErrors === null){vErrors = [err190];}else {vErrors.push(err190);}errors++;}if(data64.requestId === undefined){const err191 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/required",keyword:"required",params:{missingProperty: "requestId"},message:"must have required property '"+"requestId"+"'"};if(vErrors === null){vErrors = [err191];}else {vErrors.push(err191);}errors++;}if(data64.outcome === undefined){const err192 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/required",keyword:"required",params:{missingProperty: "outcome"},message:"must have required property '"+"outcome"+"'"};if(vErrors === null){vErrors = [err192];}else {vErrors.push(err192);}errors++;}for(const key6 in data64){if(!(((key6 === "binding") || (key6 === "requestId")) || (key6 === "outcome"))){const err193 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key6},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err193];}else {vErrors.push(err193);}errors++;}}if(data64.binding !== undefined){let data65 = data64.binding;if(data65 && typeof data65 == "object" && !Array.isArray(data65)){if(data65.companyId === undefined){const err194 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "companyId"},message:"must have required property '"+"companyId"+"'"};if(vErrors === null){vErrors = [err194];}else {vErrors.push(err194);}errors++;}if(data65.agentId === undefined){const err195 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "agentId"},message:"must have required property '"+"agentId"+"'"};if(vErrors === null){vErrors = [err195];}else {vErrors.push(err195);}errors++;}if(data65.bindingId === undefined){const err196 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err196];}else {vErrors.push(err196);}errors++;}if(data65.runId === undefined){const err197 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err197];}else {vErrors.push(err197);}errors++;}if(data65.normalizedSessionId === undefined){const err198 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err198];}else {vErrors.push(err198);}errors++;}if(data65.turnId === undefined){const err199 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err199];}else {vErrors.push(err199);}errors++;}if(data65.bindingGeneration === undefined){const err200 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "bindingGeneration"},message:"must have required property '"+"bindingGeneration"+"'"};if(vErrors === null){vErrors = [err200];}else {vErrors.push(err200);}errors++;}if(data65.assignmentRevision === undefined){const err201 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "assignmentRevision"},message:"must have required property '"+"assignmentRevision"+"'"};if(vErrors === null){vErrors = [err201];}else {vErrors.push(err201);}errors++;}for(const key7 in data65){if(!((((((((key7 === "companyId") || (key7 === "agentId")) || (key7 === "bindingId")) || (key7 === "runId")) || (key7 === "normalizedSessionId")) || (key7 === "turnId")) || (key7 === "bindingGeneration")) || (key7 === "assignmentRevision"))){const err202 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key7},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err202];}else {vErrors.push(err202);}errors++;}}if(data65.companyId !== undefined){let data66 = data65.companyId;if(typeof data66 === "string"){if(func1(data66) > 240){const err203 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/companyId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err203];}else {vErrors.push(err203);}errors++;}if(func1(data66) < 1){const err204 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/companyId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err204];}else {vErrors.push(err204);}errors++;}}else {const err205 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/companyId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err205];}else {vErrors.push(err205);}errors++;}}if(data65.agentId !== undefined){let data67 = data65.agentId;if(typeof data67 === "string"){if(func1(data67) > 240){const err206 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/agentId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err206];}else {vErrors.push(err206);}errors++;}if(func1(data67) < 1){const err207 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/agentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err207];}else {vErrors.push(err207);}errors++;}}else {const err208 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/agentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err208];}else {vErrors.push(err208);}errors++;}}if(data65.bindingId !== undefined){let data68 = data65.bindingId;if(typeof data68 === "string"){if(func1(data68) > 240){const err209 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err209];}else {vErrors.push(err209);}errors++;}if(func1(data68) < 1){const err210 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err210];}else {vErrors.push(err210);}errors++;}}else {const err211 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err211];}else {vErrors.push(err211);}errors++;}}if(data65.runId !== undefined){let data69 = data65.runId;if(typeof data69 === "string"){if(func1(data69) > 240){const err212 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/runId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err212];}else {vErrors.push(err212);}errors++;}if(func1(data69) < 1){const err213 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err213];}else {vErrors.push(err213);}errors++;}}else {const err214 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err214];}else {vErrors.push(err214);}errors++;}}if(data65.normalizedSessionId !== undefined){let data70 = data65.normalizedSessionId;if(typeof data70 === "string"){if(func1(data70) > 240){const err215 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err215];}else {vErrors.push(err215);}errors++;}if(func1(data70) < 1){const err216 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err216];}else {vErrors.push(err216);}errors++;}}else {const err217 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err217];}else {vErrors.push(err217);}errors++;}}if(data65.turnId !== undefined){let data71 = data65.turnId;if(typeof data71 === "string"){if(func1(data71) > 240){const err218 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err218];}else {vErrors.push(err218);}errors++;}if(func1(data71) < 1){const err219 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err219];}else {vErrors.push(err219);}errors++;}}else {const err220 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err220];}else {vErrors.push(err220);}errors++;}}if(data65.bindingGeneration !== undefined){let data72 = data65.bindingGeneration;if(!(((typeof data72 == "number") && (!(data72 % 1) && !isNaN(data72))) && (isFinite(data72)))){const err221 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingGeneration/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err221];}else {vErrors.push(err221);}errors++;}if((typeof data72 == "number") && (isFinite(data72))){if(data72 < 1 || isNaN(data72)){const err222 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingGeneration/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err222];}else {vErrors.push(err222);}errors++;}}}if(data65.assignmentRevision !== undefined){let data73 = data65.assignmentRevision;if(!(((typeof data73 == "number") && (!(data73 % 1) && !isNaN(data73))) && (isFinite(data73)))){const err223 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/assignmentRevision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err223];}else {vErrors.push(err223);}errors++;}if((typeof data73 == "number") && (isFinite(data73))){if(data73 < 1 || isNaN(data73)){const err224 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/assignmentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err224];}else {vErrors.push(err224);}errors++;}}}}else {const err225 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err225];}else {vErrors.push(err225);}errors++;}}if(data64.requestId !== undefined){let data74 = data64.requestId;if(typeof data74 === "string"){if(func1(data74) > 240){const err226 = {instancePath:instancePath+"/payload/requestId",schemaPath:"#/allOf/3/then/properties/payload/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err226];}else {vErrors.push(err226);}errors++;}if(func1(data74) < 1){const err227 = {instancePath:instancePath+"/payload/requestId",schemaPath:"#/allOf/3/then/properties/payload/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err227];}else {vErrors.push(err227);}errors++;}}else {const err228 = {instancePath:instancePath+"/payload/requestId",schemaPath:"#/allOf/3/then/properties/payload/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err228];}else {vErrors.push(err228);}errors++;}}if(data64.outcome !== undefined){let data75 = data64.outcome;if(!(data75 && typeof data75 == "object" && !Array.isArray(data75))){const err229 = {instancePath:instancePath+"/payload/outcome",schemaPath:"#/allOf/3/then/properties/payload/properties/outcome/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err229];}else {vErrors.push(err229);}errors++;}}}else {const err230 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err230];}else {vErrors.push(err230);}errors++;}}}else {const err231 = {instancePath,schemaPath:"#/allOf/3/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err231];}else {vErrors.push(err231);}errors++;}var _valid5 = _errs163 === errors;valid33 = _valid5;if(valid33){var props5 = {};props5.payload = true;props5.eventType = true;}}if(!valid33){const err232 = {instancePath,schemaPath:"#/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err232];}else {vErrors.push(err232);}errors++;}if(props1 !== true && props5 !== undefined){if(props5 === true){props1 = true;}else {props1 = props1 || {};Object.assign(props1, props5);}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err233 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}if(data.sourceEventId === undefined){const err234 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceEventId"},message:"must have required property '"+"sourceEventId"+"'"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}if(data.sourceSeq === undefined){const err235 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceSeq"},message:"must have required property '"+"sourceSeq"+"'"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}if(data.sourceInstanceId === undefined){const err236 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceInstanceId"},message:"must have required property '"+"sourceInstanceId"+"'"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}if(data.sourceKind === undefined){const err237 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceKind"},message:"must have required property '"+"sourceKind"+"'"};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}if(data.runId === undefined){const err238 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err238];}else {vErrors.push(err238);}errors++;}if(data.eventType === undefined){const err239 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "eventType"},message:"must have required property '"+"eventType"+"'"};if(vErrors === null){vErrors = [err239];}else {vErrors.push(err239);}errors++;}if(data.schemaVersion === undefined){const err240 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err240];}else {vErrors.push(err240);}errors++;}if(data.priority === undefined){const err241 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "priority"},message:"must have required property '"+"priority"+"'"};if(vErrors === null){vErrors = [err241];}else {vErrors.push(err241);}errors++;}if(data.emittedAt === undefined){const err242 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "emittedAt"},message:"must have required property '"+"emittedAt"+"'"};if(vErrors === null){vErrors = [err242];}else {vErrors.push(err242);}errors++;}if(data.payload === undefined){const err243 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "payload"},message:"must have required property '"+"payload"+"'"};if(vErrors === null){vErrors = [err243];}else {vErrors.push(err243);}errors++;}if(data.schema !== undefined){if("paperclip.prp.event.v3" !== data.schema){const err244 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.event.v3"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err244];}else {vErrors.push(err244);}errors++;}}if(data.sourceEventId !== undefined){let data77 = data.sourceEventId;if(typeof data77 === "string"){if(func1(data77) > 160){const err245 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err245];}else {vErrors.push(err245);}errors++;}if(func1(data77) < 1){const err246 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err246];}else {vErrors.push(err246);}errors++;}}else {const err247 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err247];}else {vErrors.push(err247);}errors++;}}if(data.sourceSeq !== undefined){let data78 = data.sourceSeq;if(!(((typeof data78 == "number") && (!(data78 % 1) && !isNaN(data78))) && (isFinite(data78)))){const err248 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err248];}else {vErrors.push(err248);}errors++;}if((typeof data78 == "number") && (isFinite(data78))){if(data78 > 9007199254740991 || isNaN(data78)){const err249 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9007199254740991},message:"must be <= 9007199254740991"};if(vErrors === null){vErrors = [err249];}else {vErrors.push(err249);}errors++;}if(data78 < 1 || isNaN(data78)){const err250 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err250];}else {vErrors.push(err250);}errors++;}}}if(data.sourceInstanceId !== undefined){let data79 = data.sourceInstanceId;if(typeof data79 === "string"){if(func1(data79) > 160){const err251 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err251];}else {vErrors.push(err251);}errors++;}if(func1(data79) < 1){const err252 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err252];}else {vErrors.push(err252);}errors++;}}else {const err253 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err253];}else {vErrors.push(err253);}errors++;}}if(data.sourceKind !== undefined){let data80 = data.sourceKind;if(!((data80 === "runner") || (data80 === "control_plane"))){const err254 = {instancePath:instancePath+"/sourceKind",schemaPath:"#/properties/sourceKind/enum",keyword:"enum",params:{allowedValues: schema209.properties.sourceKind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err254];}else {vErrors.push(err254);}errors++;}}if(data.runId !== undefined){let data81 = data.runId;if(typeof data81 === "string"){if(func1(data81) > 160){const err255 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err255];}else {vErrors.push(err255);}errors++;}if(func1(data81) < 1){const err256 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err256];}else {vErrors.push(err256);}errors++;}}else {const err257 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err257];}else {vErrors.push(err257);}errors++;}}if(data.normalizedSessionId !== undefined){let data82 = data.normalizedSessionId;if(typeof data82 === "string"){if(func1(data82) > 160){const err258 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err258];}else {vErrors.push(err258);}errors++;}if(func1(data82) < 1){const err259 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err259];}else {vErrors.push(err259);}errors++;}}else {const err260 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err260];}else {vErrors.push(err260);}errors++;}}if(data.turnId !== undefined){let data83 = data.turnId;if(typeof data83 === "string"){if(func1(data83) > 160){const err261 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err261];}else {vErrors.push(err261);}errors++;}if(func1(data83) < 1){const err262 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err262];}else {vErrors.push(err262);}errors++;}}else {const err263 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err263];}else {vErrors.push(err263);}errors++;}}if(data.itemId !== undefined){let data84 = data.itemId;if(typeof data84 === "string"){if(func1(data84) > 160){const err264 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err264];}else {vErrors.push(err264);}errors++;}if(func1(data84) < 1){const err265 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err265];}else {vErrors.push(err265);}errors++;}}else {const err266 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err266];}else {vErrors.push(err266);}errors++;}}if(data.eventType !== undefined){let data85 = data.eventType;if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((data85 === "runner.connected") || (data85 === "runner.reconnected")) || (data85 === "runner.reconciled")) || (data85 === "runner.disconnected")) || (data85 === "runner.draining")) || (data85 === "runner.suspending")) || (data85 === "runner.suspended")) || (data85 === "runner.stopped")) || (data85 === "runner.diagnostic")) || (data85 === "runtime.phase.changed")) || (data85 === "sandbox.metric")) || (data85 === "workspace.ready")) || (data85 === "workspace.change.updated")) || (data85 === "workspace.diff.recorded")) || (data85 === "workspace.file.referenced")) || (data85 === "harness.starting")) || (data85 === "harness.ready")) || (data85 === "harness.exited")) || (data85 === "harness.diagnostic")) || (data85 === "plan.updated")) || (data85 === "tool.execution.started")) || (data85 === "tool.execution.progressed")) || (data85 === "tool.execution.completed")) || (data85 === "research.started")) || (data85 === "research.progressed")) || (data85 === "research.completed")) || (data85 === "delegation.started")) || (data85 === "delegation.updated")) || (data85 === "delegation.completed")) || (data85 === "model.route.changed")) || (data85 === "model.verification.updated")) || (data85 === "context.compacted")) || (data85 === "artifact.viewed")) || (data85 === "artifact.generated")) || (data85 === "review.mode.changed")) || (data85 === "hook.started")) || (data85 === "hook.completed")) || (data85 === "memory.citation.referenced")) || (data85 === "safety.review.started")) || (data85 === "safety.review.completed")) || (data85 === "terminal.input.sent")) || (data85 === "wait.started")) || (data85 === "wait.completed")) || (data85 === "provider.notice.recorded")) || (data85 === "session.starting")) || (data85 === "session.started")) || (data85 === "session.resuming")) || (data85 === "session.resumed")) || (data85 === "session.reconciled")) || (data85 === "session.updated")) || (data85 === "session.closed")) || (data85 === "session.failed")) || (data85 === "session.capabilities.updated")) || (data85 === "session.goal.snapshot")) || (data85 === "session.goal.updated")) || (data85 === "session.goal.cleared")) || (data85 === "turn.submitted")) || (data85 === "turn.accepted")) || (data85 === "turn.started")) || (data85 === "turn.completed")) || (data85 === "turn.failed")) || (data85 === "turn.interrupted")) || (data85 === "turn.cancelled")) || (data85 === "item.started")) || (data85 === "item.delta")) || (data85 === "item.completed")) || (data85 === "item.failed")) || (data85 === "usage.reported")) || (data85 === "semantic_tool.input")) || (data85 === "semantic_tool.result")) || (data85 === "mcp_app.discovered")) || (data85 === "mcp_app.resource.resolved")) || (data85 === "mcp_app.initializing")) || (data85 === "mcp_app.ready")) || (data85 === "mcp_app.tool_input")) || (data85 === "mcp_app.tool_result")) || (data85 === "mcp_app.action.requested")) || (data85 === "mcp_app.action.resolved")) || (data85 === "mcp_app.host_context.changed")) || (data85 === "mcp_app.failed")) || (data85 === "mcp_app.teardown")) || (data85 === "runtime_request.created")) || (data85 === "runtime_request.resolved")) || (data85 === "runtime_request.expired")) || (data85 === "runtime_request.cancelled")) || (data85 === "interaction.request.proposed")) || (data85 === "interaction.request.materialized")) || (data85 === "interaction.request.rejected")) || (data85 === "interaction.response.progressed")) || (data85 === "interaction.response.resolved")) || (data85 === "interaction.response.delivered")) || (data85 === "run.attached")) || (data85 === "run.detached")) || (data85 === "run.result.proposed")) || (data85 === "run.result.accepted")) || (data85 === "run.result.rejected")) || (data85 === "attention.request.proposed")) || (data85 === "attention.request.routed")) || (data85 === "attention.request.resolved")) || (data85 === "attention.request.expired")) || (data85 === "attention.request.superseded")) || (data85 === "work.assessment.recorded")) || (data85 === "issue.status.decision.recorded")) || (data85 === "issue.status.decision.applied")) || (data85 === "issue.status.decision.rejected")) || (data85 === "issue.status.decision.superseded")) || (data85 === "run.terminal")) || (data85 === "external_provider.dispatch_requested")) || (data85 === "external_provider.operation_settled"))){const err267 = {instancePath:instancePath+"/eventType",schemaPath:"#/properties/eventType/enum",keyword:"enum",params:{allowedValues: schema209.properties.eventType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err267];}else {vErrors.push(err267);}errors++;}}if(data.schemaVersion !== undefined){if(3 !== data.schemaVersion){const err268 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 3},message:"must be equal to constant"};if(vErrors === null){vErrors = [err268];}else {vErrors.push(err268);}errors++;}}if(data.priority !== undefined){let data87 = data.priority;if(!(((data87 === 0) || (data87 === 1)) || (data87 === 2))){const err269 = {instancePath:instancePath+"/priority",schemaPath:"#/properties/priority/enum",keyword:"enum",params:{allowedValues: schema209.properties.priority.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err269];}else {vErrors.push(err269);}errors++;}}if(data.emittedAt !== undefined){let data88 = data.emittedAt;if(typeof data88 === "string"){if(!(formats0.test(data88))){const err270 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err270];}else {vErrors.push(err270);}errors++;}}else {const err271 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err271];}else {vErrors.push(err271);}errors++;}}if(data.observedAt !== undefined){let data89 = data.observedAt;if(typeof data89 === "string"){if(!(formats0.test(data89))){const err272 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err272];}else {vErrors.push(err272);}errors++;}}else {const err273 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err273];}else {vErrors.push(err273);}errors++;}}if(data.payload !== undefined){let data90 = data.payload;if(data90 && typeof data90 == "object" && !Array.isArray(data90)){}else {const err274 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err274];}else {vErrors.push(err274);}errors++;}}if(data.debug !== undefined){let data91 = data.debug;if(data91 && typeof data91 == "object" && !Array.isArray(data91)){}else {const err275 = {instancePath:instancePath+"/debug",schemaPath:"#/properties/debug/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err275];}else {vErrors.push(err275);}errors++;}}}else {const err276 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err276];}else {vErrors.push(err276);}errors++;}validate114.errors = vErrors;return errors === 0;}validate114.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema213 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/request.schema.json","title":"PRP request","oneOf":[{"type":"object","required":["schema","requestKind","requestId","type","status","prompt","choices","origin","turnId"],"properties":{"schema":{"const":"paperclip.runtime_request.v2"},"requestKind":{"const":"permission_approval"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"const":"permission"},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","minItems":1,"maxItems":4,"items":{"type":"object","required":["key","label"],"properties":{"key":{"enum":["accept","accept_for_session","decline","cancel"]},"label":{"type":"string","minLength":1,"maxLength":500}},"additionalProperties":false},"allOf":[{"contains":{"type":"object","required":["key"],"properties":{"key":{"const":"accept"}}},"minContains":0,"maxContains":1},{"contains":{"type":"object","required":["key"],"properties":{"key":{"const":"accept_for_session"}}},"minContains":0,"maxContains":1},{"contains":{"type":"object","required":["key"],"properties":{"key":{"const":"decline"}}},"minContains":0,"maxContains":1},{"contains":{"type":"object","required":["key"],"properties":{"key":{"const":"cancel"}}},"minContains":0,"maxContains":1}]},"details":{"type":"object","additionalProperties":true},"origin":{"type":"object","required":["adapter"],"properties":{"adapter":{"type":"string","minLength":1,"maxLength":160},"provider":{"type":"string","minLength":1,"maxLength":160},"method":{"type":"string","minLength":1,"maxLength":500}},"additionalProperties":false},"turnId":{"type":"string","minLength":1,"maxLength":240},"itemId":{"type":["string","null"],"minLength":1,"maxLength":240}},"additionalProperties":false},{"type":"object","required":["schema","requestKind","requestId","type","status","prompt","input"],"properties":{"schema":{"const":"paperclip.runtime_request.v2"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"const":"input"},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"input":{"$ref":"https://paperclip.dev/schemas/prp/v1/question-set.schema.json"},"origin":{"type":"object","required":["adapter"],"properties":{"adapter":{"type":"string","minLength":1,"maxLength":160},"provider":{"type":"string","minLength":1,"maxLength":160},"method":{"type":"string","minLength":1,"maxLength":500}},"additionalProperties":false},"turnId":{"type":"string","minLength":1,"maxLength":240},"itemId":{"type":"string","minLength":1,"maxLength":240}},"additionalProperties":false},{"type":"object","required":["schema","requestKind","requestId","type","status","prompt"],"properties":{"schema":{"const":"paperclip.runtime_request.v1"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"enum":["permission","input"]},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","items":{"type":"object","required":["key","label"],"properties":{"key":{"type":"string","minLength":1},"label":{"type":"string","minLength":1}},"additionalProperties":true}}},"additionalProperties":true},{"type":"object","required":["schema","requestKind","requestId","kind","blocking","payload"],"properties":{"schema":{"const":"paperclip.interaction_request.v1"},"requestKind":{"const":"issue_thread"},"requestId":{"type":"string","minLength":1,"maxLength":160},"kind":{"enum":["request_confirmation","request_checkbox_confirmation","request_item_verdicts","ask_user_questions","suggest_tasks"]},"blocking":{"type":"boolean"},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true}]};const schema214 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/question-set.schema.json","title":"Paperclip question set","type":"object","required":["schema","questions"],"properties":{"schema":{"const":"paperclip.question_set.v1"},"title":{"type":"string","maxLength":1000},"description":{"type":"string","maxLength":100000},"submitLabel":{"type":"string","maxLength":200},"questions":{"type":"array","minItems":1,"maxItems":64,"items":{"$ref":"#/$defs/question"}}},"$defs":{"option":{"type":"object","required":["id","label"],"properties":{"id":{"type":"string","minLength":1,"maxLength":160},"label":{"type":"string","minLength":1,"maxLength":1000},"description":{"type":"string","maxLength":4000},"recommended":{"type":"boolean"}},"additionalProperties":false},"customAnswer":{"type":"object","required":["enabled"],"properties":{"enabled":{"const":true},"label":{"type":"string","maxLength":1000},"placeholder":{"type":"string","maxLength":1000}},"additionalProperties":false},"textValidation":{"type":"object","properties":{"minLength":{"type":"integer","minimum":0,"maximum":100000},"maxLength":{"type":"integer","minimum":0,"maximum":100000},"pattern":{"type":"string","maxLength":1000},"inputType":{"enum":["text","number","integer"]},"minimum":{"type":"number"},"maximum":{"type":"number"}},"additionalProperties":false},"question":{"type":"object","required":["id","prompt","required","answerMode"],"properties":{"id":{"type":"string","minLength":1,"maxLength":160},"header":{"type":"string","maxLength":1000},"prompt":{"type":"string","minLength":1,"maxLength":4000},"helpText":{"type":"string","maxLength":4000},"required":{"type":"boolean"},"answerMode":{"enum":["single_select","multi_select","text"]},"options":{"type":"array","maxItems":128,"items":{"$ref":"#/$defs/option"}},"customAnswer":{"$ref":"#/$defs/customAnswer"},"textValidation":{"$ref":"#/$defs/textValidation"}},"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"properties":{"options":{"type":"array","maxItems":0}},"not":{"required":["customAnswer"]}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"required":["options"],"properties":{"options":{"type":"array","minItems":1}}}}],"additionalProperties":false}},"additionalProperties":false};const schema215 = {"type":"object","required":["id","prompt","required","answerMode"],"properties":{"id":{"type":"string","minLength":1,"maxLength":160},"header":{"type":"string","maxLength":1000},"prompt":{"type":"string","minLength":1,"maxLength":4000},"helpText":{"type":"string","maxLength":4000},"required":{"type":"boolean"},"answerMode":{"enum":["single_select","multi_select","text"]},"options":{"type":"array","maxItems":128,"items":{"$ref":"#/$defs/option"}},"customAnswer":{"$ref":"#/$defs/customAnswer"},"textValidation":{"$ref":"#/$defs/textValidation"}},"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"properties":{"options":{"type":"array","maxItems":0}},"not":{"required":["customAnswer"]}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"required":["options"],"properties":{"options":{"type":"array","minItems":1}}}}],"additionalProperties":false};const schema216 = {"type":"object","required":["id","label"],"properties":{"id":{"type":"string","minLength":1,"maxLength":160},"label":{"type":"string","minLength":1,"maxLength":1000},"description":{"type":"string","maxLength":4000},"recommended":{"type":"boolean"}},"additionalProperties":false};const schema217 = {"type":"object","required":["enabled"],"properties":{"enabled":{"const":true},"label":{"type":"string","maxLength":1000},"placeholder":{"type":"string","maxLength":1000}},"additionalProperties":false};const schema218 = {"type":"object","properties":{"minLength":{"type":"integer","minimum":0,"maximum":100000},"maxLength":{"type":"integer","minimum":0,"maximum":100000},"pattern":{"type":"string","maxLength":1000},"inputType":{"enum":["text","number","integer"]},"minimum":{"type":"number"},"maximum":{"type":"number"}},"additionalProperties":false};function validate118(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate118.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((data.answerMode === undefined) && (missing0 = "answerMode")){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}else {if(data.answerMode !== undefined){if("text" !== data.answerMode){const err1 = {};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;const _errs6 = errors;const _errs7 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing1;if((data.customAnswer === undefined) && (missing1 = "customAnswer")){const err2 = {};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}var valid3 = _errs7 === errors;if(valid3){const err3 = {instancePath,schemaPath:"#/allOf/0/then/not",keyword:"not",params:{},message:"must NOT be valid"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}else {errors = _errs6;if(vErrors !== null){if(_errs6){vErrors.length = _errs6;}else {vErrors = null;}}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.options !== undefined){let data1 = data.options;if(Array.isArray(data1)){if(data1.length > 0){const err4 = {instancePath:instancePath+"/options",schemaPath:"#/allOf/0/then/properties/options/maxItems",keyword:"maxItems",params:{limit: 0},message:"must NOT have more than 0 items"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {const err5 = {instancePath:instancePath+"/options",schemaPath:"#/allOf/0/then/properties/options/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}}var _valid0 = _errs5 === errors;valid1 = _valid0;if(valid1){var props0 = {};props0.options = true;props0.answerMode = true;}}if(!valid1){const err6 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}const _errs11 = errors;let valid5 = true;const _errs12 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing2;if((data.answerMode === undefined) && (missing2 = "answerMode")){const err7 = {};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}else {if(data.answerMode !== undefined){let data2 = data.answerMode;if(!((data2 === "single_select") || (data2 === "multi_select"))){const err8 = {};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}}var _valid1 = _errs12 === errors;errors = _errs11;if(vErrors !== null){if(_errs11){vErrors.length = _errs11;}else {vErrors = null;}}if(_valid1){const _errs14 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.options === undefined){const err9 = {instancePath,schemaPath:"#/allOf/1/then/required",keyword:"required",params:{missingProperty: "options"},message:"must have required property '"+"options"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.options !== undefined){let data3 = data.options;if(Array.isArray(data3)){if(data3.length < 1){const err10 = {instancePath:instancePath+"/options",schemaPath:"#/allOf/1/then/properties/options/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/options",schemaPath:"#/allOf/1/then/properties/options/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}}var _valid1 = _errs14 === errors;valid5 = _valid1;if(valid5){var props1 = {};props1.options = true;props1.answerMode = true;}}if(!valid5){const err12 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(props0 !== true && props1 !== undefined){if(props1 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props1);}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.id === undefined){const err13 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data.prompt === undefined){const err14 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "prompt"},message:"must have required property '"+"prompt"+"'"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(data.required === undefined){const err15 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "required"},message:"must have required property '"+"required"+"'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data.answerMode === undefined){const err16 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "answerMode"},message:"must have required property '"+"answerMode"+"'"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}for(const key0 in data){if(!(func66.call(schema215.properties, key0))){const err17 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.id !== undefined){let data4 = data.id;if(typeof data4 === "string"){if(func1(data4) > 160){const err18 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(func1(data4) < 1){const err19 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}else {const err20 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.header !== undefined){let data5 = data.header;if(typeof data5 === "string"){if(func1(data5) > 1000){const err21 = {instancePath:instancePath+"/header",schemaPath:"#/properties/header/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}else {const err22 = {instancePath:instancePath+"/header",schemaPath:"#/properties/header/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.prompt !== undefined){let data6 = data.prompt;if(typeof data6 === "string"){if(func1(data6) > 4000){const err23 = {instancePath:instancePath+"/prompt",schemaPath:"#/properties/prompt/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(func1(data6) < 1){const err24 = {instancePath:instancePath+"/prompt",schemaPath:"#/properties/prompt/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}else {const err25 = {instancePath:instancePath+"/prompt",schemaPath:"#/properties/prompt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.helpText !== undefined){let data7 = data.helpText;if(typeof data7 === "string"){if(func1(data7) > 4000){const err26 = {instancePath:instancePath+"/helpText",schemaPath:"#/properties/helpText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}else {const err27 = {instancePath:instancePath+"/helpText",schemaPath:"#/properties/helpText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}if(data.required !== undefined){if(typeof data.required !== "boolean"){const err28 = {instancePath:instancePath+"/required",schemaPath:"#/properties/required/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data.answerMode !== undefined){let data9 = data.answerMode;if(!(((data9 === "single_select") || (data9 === "multi_select")) || (data9 === "text"))){const err29 = {instancePath:instancePath+"/answerMode",schemaPath:"#/properties/answerMode/enum",keyword:"enum",params:{allowedValues: schema215.properties.answerMode.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data.options !== undefined){let data10 = data.options;if(Array.isArray(data10)){if(data10.length > 128){const err30 = {instancePath:instancePath+"/options",schemaPath:"#/properties/options/maxItems",keyword:"maxItems",params:{limit: 128},message:"must NOT have more than 128 items"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}const len0 = data10.length;for(let i0=0; i0 160){const err34 = {instancePath:instancePath+"/options/" + i0+"/id",schemaPath:"#/$defs/option/properties/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(func1(data12) < 1){const err35 = {instancePath:instancePath+"/options/" + i0+"/id",schemaPath:"#/$defs/option/properties/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}else {const err36 = {instancePath:instancePath+"/options/" + i0+"/id",schemaPath:"#/$defs/option/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data11.label !== undefined){let data13 = data11.label;if(typeof data13 === "string"){if(func1(data13) > 1000){const err37 = {instancePath:instancePath+"/options/" + i0+"/label",schemaPath:"#/$defs/option/properties/label/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(func1(data13) < 1){const err38 = {instancePath:instancePath+"/options/" + i0+"/label",schemaPath:"#/$defs/option/properties/label/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}else {const err39 = {instancePath:instancePath+"/options/" + i0+"/label",schemaPath:"#/$defs/option/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}if(data11.description !== undefined){let data14 = data11.description;if(typeof data14 === "string"){if(func1(data14) > 4000){const err40 = {instancePath:instancePath+"/options/" + i0+"/description",schemaPath:"#/$defs/option/properties/description/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}else {const err41 = {instancePath:instancePath+"/options/" + i0+"/description",schemaPath:"#/$defs/option/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}if(data11.recommended !== undefined){if(typeof data11.recommended !== "boolean"){const err42 = {instancePath:instancePath+"/options/" + i0+"/recommended",schemaPath:"#/$defs/option/properties/recommended/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}else {const err43 = {instancePath:instancePath+"/options/" + i0,schemaPath:"#/$defs/option/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}}else {const err44 = {instancePath:instancePath+"/options",schemaPath:"#/properties/options/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data.customAnswer !== undefined){let data16 = data.customAnswer;if(data16 && typeof data16 == "object" && !Array.isArray(data16)){if(data16.enabled === undefined){const err45 = {instancePath:instancePath+"/customAnswer",schemaPath:"#/$defs/customAnswer/required",keyword:"required",params:{missingProperty: "enabled"},message:"must have required property '"+"enabled"+"'"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}for(const key2 in data16){if(!(((key2 === "enabled") || (key2 === "label")) || (key2 === "placeholder"))){const err46 = {instancePath:instancePath+"/customAnswer",schemaPath:"#/$defs/customAnswer/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}if(data16.enabled !== undefined){if(true !== data16.enabled){const err47 = {instancePath:instancePath+"/customAnswer/enabled",schemaPath:"#/$defs/customAnswer/properties/enabled/const",keyword:"const",params:{allowedValue: true},message:"must be equal to constant"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data16.label !== undefined){let data18 = data16.label;if(typeof data18 === "string"){if(func1(data18) > 1000){const err48 = {instancePath:instancePath+"/customAnswer/label",schemaPath:"#/$defs/customAnswer/properties/label/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}else {const err49 = {instancePath:instancePath+"/customAnswer/label",schemaPath:"#/$defs/customAnswer/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}if(data16.placeholder !== undefined){let data19 = data16.placeholder;if(typeof data19 === "string"){if(func1(data19) > 1000){const err50 = {instancePath:instancePath+"/customAnswer/placeholder",schemaPath:"#/$defs/customAnswer/properties/placeholder/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}else {const err51 = {instancePath:instancePath+"/customAnswer/placeholder",schemaPath:"#/$defs/customAnswer/properties/placeholder/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}}else {const err52 = {instancePath:instancePath+"/customAnswer",schemaPath:"#/$defs/customAnswer/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}if(data.textValidation !== undefined){let data20 = data.textValidation;if(data20 && typeof data20 == "object" && !Array.isArray(data20)){for(const key3 in data20){if(!((((((key3 === "minLength") || (key3 === "maxLength")) || (key3 === "pattern")) || (key3 === "inputType")) || (key3 === "minimum")) || (key3 === "maximum"))){const err53 = {instancePath:instancePath+"/textValidation",schemaPath:"#/$defs/textValidation/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}if(data20.minLength !== undefined){let data21 = data20.minLength;if(!(((typeof data21 == "number") && (!(data21 % 1) && !isNaN(data21))) && (isFinite(data21)))){const err54 = {instancePath:instancePath+"/textValidation/minLength",schemaPath:"#/$defs/textValidation/properties/minLength/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}if((typeof data21 == "number") && (isFinite(data21))){if(data21 > 100000 || isNaN(data21)){const err55 = {instancePath:instancePath+"/textValidation/minLength",schemaPath:"#/$defs/textValidation/properties/minLength/maximum",keyword:"maximum",params:{comparison: "<=", limit: 100000},message:"must be <= 100000"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}if(data21 < 0 || isNaN(data21)){const err56 = {instancePath:instancePath+"/textValidation/minLength",schemaPath:"#/$defs/textValidation/properties/minLength/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}}if(data20.maxLength !== undefined){let data22 = data20.maxLength;if(!(((typeof data22 == "number") && (!(data22 % 1) && !isNaN(data22))) && (isFinite(data22)))){const err57 = {instancePath:instancePath+"/textValidation/maxLength",schemaPath:"#/$defs/textValidation/properties/maxLength/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}if((typeof data22 == "number") && (isFinite(data22))){if(data22 > 100000 || isNaN(data22)){const err58 = {instancePath:instancePath+"/textValidation/maxLength",schemaPath:"#/$defs/textValidation/properties/maxLength/maximum",keyword:"maximum",params:{comparison: "<=", limit: 100000},message:"must be <= 100000"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}if(data22 < 0 || isNaN(data22)){const err59 = {instancePath:instancePath+"/textValidation/maxLength",schemaPath:"#/$defs/textValidation/properties/maxLength/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}}if(data20.pattern !== undefined){let data23 = data20.pattern;if(typeof data23 === "string"){if(func1(data23) > 1000){const err60 = {instancePath:instancePath+"/textValidation/pattern",schemaPath:"#/$defs/textValidation/properties/pattern/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}else {const err61 = {instancePath:instancePath+"/textValidation/pattern",schemaPath:"#/$defs/textValidation/properties/pattern/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}if(data20.inputType !== undefined){let data24 = data20.inputType;if(!(((data24 === "text") || (data24 === "number")) || (data24 === "integer"))){const err62 = {instancePath:instancePath+"/textValidation/inputType",schemaPath:"#/$defs/textValidation/properties/inputType/enum",keyword:"enum",params:{allowedValues: schema218.properties.inputType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}}if(data20.minimum !== undefined){let data25 = data20.minimum;if(!((typeof data25 == "number") && (isFinite(data25)))){const err63 = {instancePath:instancePath+"/textValidation/minimum",schemaPath:"#/$defs/textValidation/properties/minimum/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}if(data20.maximum !== undefined){let data26 = data20.maximum;if(!((typeof data26 == "number") && (isFinite(data26)))){const err64 = {instancePath:instancePath+"/textValidation/maximum",schemaPath:"#/$defs/textValidation/properties/maximum/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}}else {const err65 = {instancePath:instancePath+"/textValidation",schemaPath:"#/$defs/textValidation/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}}else {const err66 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}validate118.errors = vErrors;return errors === 0;}validate118.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate117(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/question-set.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate117.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.questions === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "questions"},message:"must have required property '"+"questions"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "title")) || (key0 === "description")) || (key0 === "submitLabel")) || (key0 === "questions"))){const err2 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.schema !== undefined){if("paperclip.question_set.v1" !== data.schema){const err3 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.question_set.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.title !== undefined){let data1 = data.title;if(typeof data1 === "string"){if(func1(data1) > 1000){const err4 = {instancePath:instancePath+"/title",schemaPath:"#/properties/title/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {const err5 = {instancePath:instancePath+"/title",schemaPath:"#/properties/title/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.description !== undefined){let data2 = data.description;if(typeof data2 === "string"){if(func1(data2) > 100000){const err6 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 100000},message:"must NOT have more than 100000 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {const err7 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.submitLabel !== undefined){let data3 = data.submitLabel;if(typeof data3 === "string"){if(func1(data3) > 200){const err8 = {instancePath:instancePath+"/submitLabel",schemaPath:"#/properties/submitLabel/maxLength",keyword:"maxLength",params:{limit: 200},message:"must NOT have more than 200 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/submitLabel",schemaPath:"#/properties/submitLabel/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.questions !== undefined){let data4 = data.questions;if(Array.isArray(data4)){if(data4.length > 64){const err10 = {instancePath:instancePath+"/questions",schemaPath:"#/properties/questions/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data4.length < 1){const err11 = {instancePath:instancePath+"/questions",schemaPath:"#/properties/questions/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}const len0 = data4.length;for(let i0=0; i0 160){const err12 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/0/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(func1(data2) < 1){const err13 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/0/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}else {const err14 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/0/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.type !== undefined){if("permission" !== data.type){const err15 = {instancePath:instancePath+"/type",schemaPath:"#/oneOf/0/properties/type/const",keyword:"const",params:{allowedValue: "permission"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.status !== undefined){let data4 = data.status;if(!((((data4 === "pending") || (data4 === "resolved")) || (data4 === "expired")) || (data4 === "cancelled"))){const err16 = {instancePath:instancePath+"/status",schemaPath:"#/oneOf/0/properties/status/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[0].properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.prompt !== undefined){let data5 = data.prompt;if(typeof data5 === "string"){if(func1(data5) > 4000){const err17 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/0/properties/prompt/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data5) < 1){const err18 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/0/properties/prompt/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/0/properties/prompt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.choices !== undefined){let data6 = data.choices;if(Array.isArray(data6)){const _errs15 = errors;const len0 = data6.length;let valid3 = true;if(data6.length > 0){let count0 = 0;for(let i0=0; i0 1){valid3 = false;break;}if(count0 >= 0){valid3 = true;}}}}if(!valid3){const err23 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/allOf/0/contains",keyword:"contains",params:{minContains: 0, maxContains: 1},message:"must contain at least 0 and no more than 1 valid item(s)"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}else {errors = _errs15;if(vErrors !== null){if(_errs15){vErrors.length = _errs15;}else {vErrors = null;}}}}if(Array.isArray(data6)){const _errs20 = errors;const len1 = data6.length;let valid5 = true;if(data6.length > 0){let count1 = 0;for(let i1=0; i1 1){valid5 = false;break;}if(count1 >= 0){valid5 = true;}}}}if(!valid5){const err27 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/allOf/1/contains",keyword:"contains",params:{minContains: 0, maxContains: 1},message:"must contain at least 0 and no more than 1 valid item(s)"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}else {errors = _errs20;if(vErrors !== null){if(_errs20){vErrors.length = _errs20;}else {vErrors = null;}}}}if(Array.isArray(data6)){const _errs25 = errors;const len2 = data6.length;let valid7 = true;if(data6.length > 0){let count2 = 0;for(let i2=0; i2 1){valid7 = false;break;}if(count2 >= 0){valid7 = true;}}}}if(!valid7){const err31 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/allOf/2/contains",keyword:"contains",params:{minContains: 0, maxContains: 1},message:"must contain at least 0 and no more than 1 valid item(s)"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}else {errors = _errs25;if(vErrors !== null){if(_errs25){vErrors.length = _errs25;}else {vErrors = null;}}}}if(Array.isArray(data6)){const _errs30 = errors;const len3 = data6.length;let valid9 = true;if(data6.length > 0){let count3 = 0;for(let i3=0; i3 1){valid9 = false;break;}if(count3 >= 0){valid9 = true;}}}}if(!valid9){const err35 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/allOf/3/contains",keyword:"contains",params:{minContains: 0, maxContains: 1},message:"must contain at least 0 and no more than 1 valid item(s)"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}else {errors = _errs30;if(vErrors !== null){if(_errs30){vErrors.length = _errs30;}else {vErrors = null;}}}}if(Array.isArray(data6)){if(data6.length > 4){const err36 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/maxItems",keyword:"maxItems",params:{limit: 4},message:"must NOT have more than 4 items"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}if(data6.length < 1){const err37 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}const len4 = data6.length;for(let i4=0; i4 500){const err42 = {instancePath:instancePath+"/choices/" + i4+"/label",schemaPath:"#/oneOf/0/properties/choices/items/properties/label/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if(func1(data17) < 1){const err43 = {instancePath:instancePath+"/choices/" + i4+"/label",schemaPath:"#/oneOf/0/properties/choices/items/properties/label/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}else {const err44 = {instancePath:instancePath+"/choices/" + i4+"/label",schemaPath:"#/oneOf/0/properties/choices/items/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}}else {const err45 = {instancePath:instancePath+"/choices/" + i4,schemaPath:"#/oneOf/0/properties/choices/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}}else {const err46 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}if(data.details !== undefined){let data18 = data.details;if(data18 && typeof data18 == "object" && !Array.isArray(data18)){}else {const err47 = {instancePath:instancePath+"/details",schemaPath:"#/oneOf/0/properties/details/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data.origin !== undefined){let data19 = data.origin;if(data19 && typeof data19 == "object" && !Array.isArray(data19)){if(data19.adapter === undefined){const err48 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/0/properties/origin/required",keyword:"required",params:{missingProperty: "adapter"},message:"must have required property '"+"adapter"+"'"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}for(const key2 in data19){if(!(((key2 === "adapter") || (key2 === "provider")) || (key2 === "method"))){const err49 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/0/properties/origin/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}if(data19.adapter !== undefined){let data20 = data19.adapter;if(typeof data20 === "string"){if(func1(data20) > 160){const err50 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/0/properties/origin/properties/adapter/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}if(func1(data20) < 1){const err51 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/0/properties/origin/properties/adapter/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}else {const err52 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/0/properties/origin/properties/adapter/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}if(data19.provider !== undefined){let data21 = data19.provider;if(typeof data21 === "string"){if(func1(data21) > 160){const err53 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/0/properties/origin/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}if(func1(data21) < 1){const err54 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/0/properties/origin/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}else {const err55 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/0/properties/origin/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data19.method !== undefined){let data22 = data19.method;if(typeof data22 === "string"){if(func1(data22) > 500){const err56 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/0/properties/origin/properties/method/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}if(func1(data22) < 1){const err57 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/0/properties/origin/properties/method/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}else {const err58 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/0/properties/origin/properties/method/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}}else {const err59 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/0/properties/origin/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data.turnId !== undefined){let data23 = data.turnId;if(typeof data23 === "string"){if(func1(data23) > 240){const err60 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/0/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}if(func1(data23) < 1){const err61 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/0/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}else {const err62 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/0/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}}if(data.itemId !== undefined){let data24 = data.itemId;if((typeof data24 !== "string") && (data24 !== null)){const err63 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/0/properties/itemId/type",keyword:"type",params:{type: schema213.oneOf[0].properties.itemId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}if(typeof data24 === "string"){if(func1(data24) > 240){const err64 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/0/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}if(func1(data24) < 1){const err65 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/0/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}}}else {const err66 = {instancePath,schemaPath:"#/oneOf/0/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}var _valid0 = _errs1 === errors;if(_valid0){valid0 = true;passing0 = 0;var props0 = true;}const _errs56 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err67 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}if(data.requestKind === undefined){const err68 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "requestKind"},message:"must have required property '"+"requestKind"+"'"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}if(data.requestId === undefined){const err69 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "requestId"},message:"must have required property '"+"requestId"+"'"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}if(data.type === undefined){const err70 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "type"},message:"must have required property '"+"type"+"'"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if(data.status === undefined){const err71 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}if(data.prompt === undefined){const err72 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "prompt"},message:"must have required property '"+"prompt"+"'"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(data.input === undefined){const err73 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "input"},message:"must have required property '"+"input"+"'"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}for(const key3 in data){if(!(func66.call(schema213.oneOf[1].properties, key3))){const err74 = {instancePath,schemaPath:"#/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}}if(data.schema !== undefined){if("paperclip.runtime_request.v2" !== data.schema){const err75 = {instancePath:instancePath+"/schema",schemaPath:"#/oneOf/1/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.runtime_request.v2"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}}if(data.requestKind !== undefined){if("runtime" !== data.requestKind){const err76 = {instancePath:instancePath+"/requestKind",schemaPath:"#/oneOf/1/properties/requestKind/const",keyword:"const",params:{allowedValue: "runtime"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}}if(data.requestId !== undefined){let data27 = data.requestId;if(typeof data27 === "string"){if(func1(data27) > 160){const err77 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/1/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(func1(data27) < 1){const err78 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/1/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}}else {const err79 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/1/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}}if(data.type !== undefined){if("input" !== data.type){const err80 = {instancePath:instancePath+"/type",schemaPath:"#/oneOf/1/properties/type/const",keyword:"const",params:{allowedValue: "input"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}}if(data.status !== undefined){let data29 = data.status;if(!((((data29 === "pending") || (data29 === "resolved")) || (data29 === "expired")) || (data29 === "cancelled"))){const err81 = {instancePath:instancePath+"/status",schemaPath:"#/oneOf/1/properties/status/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[1].properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}}if(data.prompt !== undefined){let data30 = data.prompt;if(typeof data30 === "string"){if(func1(data30) > 4000){const err82 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/1/properties/prompt/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(func1(data30) < 1){const err83 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/1/properties/prompt/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}}else {const err84 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/1/properties/prompt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}}if(data.input !== undefined){if(!(validate117(data.input, {instancePath:instancePath+"/input",parentData:data,parentDataProperty:"input",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate117.errors : vErrors.concat(validate117.errors);errors = vErrors.length;}}if(data.origin !== undefined){let data32 = data.origin;if(data32 && typeof data32 == "object" && !Array.isArray(data32)){if(data32.adapter === undefined){const err85 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/1/properties/origin/required",keyword:"required",params:{missingProperty: "adapter"},message:"must have required property '"+"adapter"+"'"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}for(const key4 in data32){if(!(((key4 === "adapter") || (key4 === "provider")) || (key4 === "method"))){const err86 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/1/properties/origin/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}}if(data32.adapter !== undefined){let data33 = data32.adapter;if(typeof data33 === "string"){if(func1(data33) > 160){const err87 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/1/properties/origin/properties/adapter/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}if(func1(data33) < 1){const err88 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/1/properties/origin/properties/adapter/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}}else {const err89 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/1/properties/origin/properties/adapter/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}}if(data32.provider !== undefined){let data34 = data32.provider;if(typeof data34 === "string"){if(func1(data34) > 160){const err90 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/1/properties/origin/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err90];}else {vErrors.push(err90);}errors++;}if(func1(data34) < 1){const err91 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/1/properties/origin/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}else {const err92 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/1/properties/origin/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}}if(data32.method !== undefined){let data35 = data32.method;if(typeof data35 === "string"){if(func1(data35) > 500){const err93 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/1/properties/origin/properties/method/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}if(func1(data35) < 1){const err94 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/1/properties/origin/properties/method/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}}else {const err95 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/1/properties/origin/properties/method/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}}}else {const err96 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/1/properties/origin/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}}if(data.turnId !== undefined){let data36 = data.turnId;if(typeof data36 === "string"){if(func1(data36) > 240){const err97 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/1/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}if(func1(data36) < 1){const err98 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/1/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}else {const err99 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/1/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}}if(data.itemId !== undefined){let data37 = data.itemId;if(typeof data37 === "string"){if(func1(data37) > 240){const err100 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/1/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}if(func1(data37) < 1){const err101 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/1/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}else {const err102 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/1/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}}else {const err103 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}var _valid0 = _errs56 === errors;if(_valid0 && valid0){valid0 = false;passing0 = [passing0, 1];}else {if(_valid0){valid0 = true;passing0 = 1;if(props0 !== true){props0 = true;}}const _errs81 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err104 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}if(data.requestKind === undefined){const err105 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "requestKind"},message:"must have required property '"+"requestKind"+"'"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}if(data.requestId === undefined){const err106 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "requestId"},message:"must have required property '"+"requestId"+"'"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}if(data.type === undefined){const err107 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "type"},message:"must have required property '"+"type"+"'"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}if(data.status === undefined){const err108 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}if(data.prompt === undefined){const err109 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "prompt"},message:"must have required property '"+"prompt"+"'"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}if(data.schema !== undefined){if("paperclip.runtime_request.v1" !== data.schema){const err110 = {instancePath:instancePath+"/schema",schemaPath:"#/oneOf/2/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.runtime_request.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}if(data.requestKind !== undefined){if("runtime" !== data.requestKind){const err111 = {instancePath:instancePath+"/requestKind",schemaPath:"#/oneOf/2/properties/requestKind/const",keyword:"const",params:{allowedValue: "runtime"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}if(data.requestId !== undefined){let data40 = data.requestId;if(typeof data40 === "string"){if(func1(data40) > 160){const err112 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/2/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}if(func1(data40) < 1){const err113 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/2/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}else {const err114 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/2/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}if(data.type !== undefined){let data41 = data.type;if(!((data41 === "permission") || (data41 === "input"))){const err115 = {instancePath:instancePath+"/type",schemaPath:"#/oneOf/2/properties/type/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[2].properties.type.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}}if(data.status !== undefined){let data42 = data.status;if(!((((data42 === "pending") || (data42 === "resolved")) || (data42 === "expired")) || (data42 === "cancelled"))){const err116 = {instancePath:instancePath+"/status",schemaPath:"#/oneOf/2/properties/status/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[2].properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}if(data.prompt !== undefined){let data43 = data.prompt;if(typeof data43 === "string"){if(func1(data43) > 4000){const err117 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/2/properties/prompt/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}if(func1(data43) < 1){const err118 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/2/properties/prompt/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}}else {const err119 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/2/properties/prompt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}}if(data.choices !== undefined){let data44 = data.choices;if(Array.isArray(data44)){const len5 = data44.length;for(let i5=0; i5 160){const err137 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/3/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err137];}else {vErrors.push(err137);}errors++;}if(func1(data50) < 1){const err138 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/3/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}}else {const err139 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/3/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}}if(data.kind !== undefined){let data51 = data.kind;if(!(((((data51 === "request_confirmation") || (data51 === "request_checkbox_confirmation")) || (data51 === "request_item_verdicts")) || (data51 === "ask_user_questions")) || (data51 === "suggest_tasks"))){const err140 = {instancePath:instancePath+"/kind",schemaPath:"#/oneOf/3/properties/kind/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[3].properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}}if(data.blocking !== undefined){if(typeof data.blocking !== "boolean"){const err141 = {instancePath:instancePath+"/blocking",schemaPath:"#/oneOf/3/properties/blocking/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}}if(data.payload !== undefined){let data53 = data.payload;if(data53 && typeof data53 == "object" && !Array.isArray(data53)){}else {const err142 = {instancePath:instancePath+"/payload",schemaPath:"#/oneOf/3/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err142];}else {vErrors.push(err142);}errors++;}}}else {const err143 = {instancePath,schemaPath:"#/oneOf/3/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}var _valid0 = _errs101 === errors;if(_valid0 && valid0){valid0 = false;passing0 = [passing0, 3];}else {if(_valid0){valid0 = true;passing0 = 3;if(props0 !== true){props0 = true;}}}}}if(!valid0){const err144 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;}else {errors = _errs0;if(vErrors !== null){if(_errs0){vErrors.length = _errs0;}else {vErrors = null;}}}validate116.errors = vErrors;evaluated0.props = props0;return errors === 0;}validate116.evaluated = {"dynamicProps":true,"dynamicItems":false};const pattern12 = new RegExp("^[a-z][a-z0-9_.-]*$", "u");const pattern15 = new RegExp("^sha256:[0-9a-f]{64}$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/fixture.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.fixtureVersion === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "fixtureVersion"},message:"must have required property '"+"fixtureVersion"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.protocolVersion === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "protocolVersion"},message:"must have required property '"+"protocolVersion"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.name === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "name"},message:"must have required property '"+"name"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.description === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "description"},message:"must have required property '"+"description"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.identity === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "identity"},message:"must have required property '"+"identity"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.capabilities === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "capabilities"},message:"must have required property '"+"capabilities"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.commands === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "commands"},message:"must have required property '"+"commands"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.events === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "events"},message:"must have required property '"+"events"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.result === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "result"},message:"must have required property '"+"result"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.schema !== undefined){if("paperclip.prp.fixture.v1" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.fixture.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.fixtureVersion !== undefined){if(1 !== data.fixtureVersion){const err11 = {instancePath:instancePath+"/fixtureVersion",schemaPath:"#/properties/fixtureVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.protocolVersion !== undefined){let data2 = data.protocolVersion;if(!(((data2 === 1) || (data2 === 2)) || (data2 === 3))){const err12 = {instancePath:instancePath+"/protocolVersion",schemaPath:"#/properties/protocolVersion/enum",keyword:"enum",params:{allowedValues: schema31.properties.protocolVersion.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.name !== undefined){let data3 = data.name;if(typeof data3 === "string"){if(func1(data3) > 120){const err13 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/maxLength",keyword:"maxLength",params:{limit: 120},message:"must NOT have more than 120 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data3) < 1){const err14 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.description !== undefined){let data4 = data.description;if(typeof data4 === "string"){if(func1(data4) > 1000){const err16 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data4) < 1){const err17 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}else {const err18 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.identity !== undefined){if(!(validate21(data.identity, {instancePath:instancePath+"/identity",parentData:data,parentDataProperty:"identity",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate21.errors : vErrors.concat(validate21.errors);errors = vErrors.length;}}if(data.capabilities !== undefined){let data6 = data.capabilities;const _errs11 = errors;let valid1 = false;let passing0 = null;const _errs12 = errors;if(data6 && typeof data6 == "object" && !Array.isArray(data6)){if(data6.schema === undefined){const err19 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data6.sessionReusePolicy === undefined){const err20 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "sessionReusePolicy"},message:"must have required property '"+"sessionReusePolicy"+"'"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data6.driver === undefined){const err21 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "driver"},message:"must have required property '"+"driver"+"'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data6.steer === undefined){const err22 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "steer"},message:"must have required property '"+"steer"+"'"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(data6.interrupt === undefined){const err23 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "interrupt"},message:"must have required property '"+"interrupt"+"'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data6.resume === undefined){const err24 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "resume"},message:"must have required property '"+"resume"+"'"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(data6.runtimeRequests === undefined){const err25 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "runtimeRequests"},message:"must have required property '"+"runtimeRequests"+"'"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(data6.structuredResult === undefined){const err26 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "structuredResult"},message:"must have required property '"+"structuredResult"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data6.typedEvents === undefined){const err27 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "typedEvents"},message:"must have required property '"+"typedEvents"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data6.schema !== undefined){if("paperclip.prp.capabilities.v1" !== data6.schema){const err28 = {instancePath:instancePath+"/capabilities/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.capabilities.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data6.sessionReusePolicy !== undefined){let data8 = data6.sessionReusePolicy;if(!(((data8 === "new_per_run") || (data8 === "reuse_per_issue")) || (data8 === "reuse_per_workspace"))){const err29 = {instancePath:instancePath+"/capabilities/sessionReusePolicy",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/sessionReusePolicy/enum",keyword:"enum",params:{allowedValues: schema41.properties.sessionReusePolicy.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data6.driver !== undefined){let data9 = data6.driver;if(data9 && typeof data9 == "object" && !Array.isArray(data9)){if(data9.kind === undefined){const err30 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data9.version === undefined){const err31 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(data9.kind !== undefined){let data10 = data9.kind;if(typeof data10 === "string"){if(func1(data10) > 80){const err32 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(func1(data10) < 1){const err33 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}else {const err34 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data9.version !== undefined){let data11 = data9.version;if(typeof data11 === "string"){if(func1(data11) > 80){const err35 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(func1(data11) < 1){const err36 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}else {const err37 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}}else {const err38 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data6.steer !== undefined){if(typeof data6.steer !== "boolean"){const err39 = {instancePath:instancePath+"/capabilities/steer",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/steer/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}if(data6.interrupt !== undefined){if(typeof data6.interrupt !== "boolean"){const err40 = {instancePath:instancePath+"/capabilities/interrupt",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/interrupt/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}if(data6.resume !== undefined){if(typeof data6.resume !== "boolean"){const err41 = {instancePath:instancePath+"/capabilities/resume",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/resume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}if(data6.runtimeRequests !== undefined){if(typeof data6.runtimeRequests !== "boolean"){const err42 = {instancePath:instancePath+"/capabilities/runtimeRequests",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/runtimeRequests/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}if(data6.structuredResult !== undefined){if(typeof data6.structuredResult !== "boolean"){const err43 = {instancePath:instancePath+"/capabilities/structuredResult",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/structuredResult/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}if(data6.typedEvents !== undefined){if(typeof data6.typedEvents !== "boolean"){const err44 = {instancePath:instancePath+"/capabilities/typedEvents",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEvents/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data6.typedEventFamilies !== undefined){let data18 = data6.typedEventFamilies;if(Array.isArray(data18)){if(data18.length > 64){const err45 = {instancePath:instancePath+"/capabilities/typedEventFamilies",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}const len0 = data18.length;for(let i0=0; i0 160){const err51 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/family",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/family/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}if(func1(data20) < 1){const err52 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/family",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/family/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}if(!pattern12.test(data20)){const err53 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/family",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/family/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.-]*$"+"\""};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}else {const err54 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/family",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/family/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}if(data19.version !== undefined){if(1 !== data19.version){const err55 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data19.availability !== undefined){let data22 = data19.availability;if(!(((data22 === "available") || (data22 === "unsupported")) || (data22 === "policy_disabled"))){const err56 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/availability",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/availability/enum",keyword:"enum",params:{allowedValues: schema41.properties.typedEventFamilies.items.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}if(data19.detailLevel !== undefined){let data23 = data19.detailLevel;if(!((data23 === "summary") || (data23 === "structured"))){const err57 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/detailLevel",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/detailLevel/enum",keyword:"enum",params:{allowedValues: schema41.properties.typedEventFamilies.items.properties.detailLevel.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}}else {const err58 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}}else {const err59 = {instancePath:instancePath+"/capabilities/typedEventFamilies",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data6.semanticTools !== undefined){let data24 = data6.semanticTools;if(data24 && typeof data24 == "object" && !Array.isArray(data24)){if(data24.schema === undefined){const err60 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}if(data24.schemaVersion === undefined){const err61 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}if(data24.operations === undefined){const err62 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "operations"},message:"must have required property '"+"operations"+"'"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(data24.schema !== undefined){if("paperclip.prp.semantic_tools.v1" !== data24.schema){const err63 = {instancePath:instancePath+"/capabilities/semanticTools/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.semantic_tools.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}if(data24.schemaVersion !== undefined){if(1 !== data24.schemaVersion){const err64 = {instancePath:instancePath+"/capabilities/semanticTools/schemaVersion",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}if(data24.operations !== undefined){let data27 = data24.operations;if(Array.isArray(data27)){const len1 = data27.length;for(let i1=0; i1 160){const err69 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}if(func1(data29) < 1){const err70 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if(!pattern4.test(data29)){const err71 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}else {const err72 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}}if(data28.version !== undefined){if(1 !== data28.version){const err73 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}}if(data28.availability !== undefined){let data31 = data28.availability;if(!(((data31 === "available") || (data31 === "denied")) || (data31 === "unsupported"))){const err74 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/availability",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/availability/enum",keyword:"enum",params:{allowedValues: schema41.properties.semanticTools.properties.operations.items.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}}if(data28.redactionDisposition !== undefined){if("digest_only" !== data28.redactionDisposition){const err75 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/redactionDisposition",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/redactionDisposition/const",keyword:"const",params:{allowedValue: "digest_only"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}}if(data28.requiredClaims !== undefined){let data33 = data28.requiredClaims;if(Array.isArray(data33)){const len2 = data33.length;for(let i2=0; i2 160){const err76 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims/" + i2,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}if(func1(data34) < 1){const err77 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims/" + i2,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}}else {const err78 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims/" + i2,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}}let i3 = data33.length;let j0;if(i3 > 1){const indices0 = {};for(;i3--;){let item0 = data33[i3];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j0 = indices0[item0];const err79 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;break;}indices0[item0] = i3;}}}else {const err80 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}}if(data28.reasonCode !== undefined){let data35 = data28.reasonCode;if(typeof data35 === "string"){if(func1(data35) > 160){const err81 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}if(func1(data35) < 1){const err82 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(!pattern14.test(data35)){const err83 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}}else {const err84 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}}}else {const err85 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}}}else {const err86 = {instancePath:instancePath+"/capabilities/semanticTools/operations",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}}}else {const err87 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}if(data6.unsupported !== undefined){let data36 = data6.unsupported;if(Array.isArray(data36)){const len3 = data36.length;for(let i4=0; i4 1){const indices1 = {};for(;i5--;){let item1 = data36[i5];if(typeof item1 !== "string"){continue;}if(typeof indices1[item1] == "number"){j1 = indices1[item1];const err90 = {instancePath:instancePath+"/capabilities/unsupported",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/unsupported/uniqueItems",keyword:"uniqueItems",params:{i: i5, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i5+" are identical)"};if(vErrors === null){vErrors = [err90];}else {vErrors.push(err90);}errors++;break;}indices1[item1] = i5;}}}else {const err91 = {instancePath:instancePath+"/capabilities/unsupported",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/unsupported/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}}else {const err92 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}var _valid0 = _errs12 === errors;if(_valid0){valid1 = true;passing0 = 0;var props0 = true;}const _errs72 = errors;if(!(validate23(data6, {instancePath:instancePath+"/capabilities",parentData:data,parentDataProperty:"capabilities",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate23.errors : vErrors.concat(validate23.errors);errors = vErrors.length;}var _valid0 = _errs72 === errors;if(_valid0 && valid1){valid1 = false;passing0 = [passing0, 1];}else {if(_valid0){valid1 = true;passing0 = 1;if(props0 !== true){props0 = true;}}const _errs73 = errors;if(!(validate26(data6, {instancePath:instancePath+"/capabilities",parentData:data,parentDataProperty:"capabilities",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate26.errors : vErrors.concat(validate26.errors);errors = vErrors.length;}var _valid0 = _errs73 === errors;if(_valid0 && valid1){valid1 = false;passing0 = [passing0, 2];}else {if(_valid0){valid1 = true;passing0 = 2;if(props0 !== true){props0 = true;}}}}if(!valid1){const err93 = {instancePath:instancePath+"/capabilities",schemaPath:"#/properties/capabilities/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}else {errors = _errs11;if(vErrors !== null){if(_errs11){vErrors.length = _errs11;}else {vErrors = null;}}}}if(data.commands !== undefined){let data38 = data.commands;if(Array.isArray(data38)){const len4 = data38.length;for(let i6=0; i6 160){const err101 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/commandId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}if(func1(data41) < 1){const err102 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/commandId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}else {const err103 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/commandId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}}if(data39.controllerSeq !== undefined){let data42 = data39.controllerSeq;if(!(((typeof data42 == "number") && (!(data42 % 1) && !isNaN(data42))) && (isFinite(data42)))){const err104 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/controllerSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}if((typeof data42 == "number") && (isFinite(data42))){if(data42 < 1 || isNaN(data42)){const err105 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/controllerSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}}}if(data39.type !== undefined){let data43 = data39.type;if(!((((((((((((((((((data43 === "run.prepare") || (data43 === "run.attach")) || (data43 === "session.open")) || (data43 === "turn.start")) || (data43 === "turn.steer")) || (data43 === "turn.interrupt")) || (data43 === "turn.stop")) || (data43 === "request.resolve")) || (data43 === "interaction.receipt")) || (data43 === "semantic_tool.result")) || (data43 === "session.snapshot")) || (data43 === "session.close")) || (data43 === "session.budget.increase")) || (data43 === "session.destroy")) || (data43 === "run.cancel")) || (data43 === "runner.drain")) || (data43 === "runner.suspend")) || (data43 === "runner.shutdown"))){const err106 = {instancePath:instancePath+"/commands/" + i6+"/type",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/type/enum",keyword:"enum",params:{allowedValues: schema47.properties.type.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}}if(data39.issuedAt !== undefined){let data44 = data39.issuedAt;if(typeof data44 === "string"){if(!(formats0.test(data44))){const err107 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/issuedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}}else {const err108 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/issuedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}if(data39.deadlineAt !== undefined){let data45 = data39.deadlineAt;if(typeof data45 === "string"){if(!(formats0.test(data45))){const err109 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/deadlineAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}}else {const err110 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/deadlineAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}if(data39.precondition !== undefined){let data46 = data39.precondition;if(data46 && typeof data46 == "object" && !Array.isArray(data46)){if(data46.runnerState !== undefined){let data47 = data46.runnerState;if(Array.isArray(data47)){const len5 = data47.length;for(let i7=0; i7 160){const err122 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}if(func1(data57) < 1){const err123 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}else {const err124 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}}if(data56.objective !== undefined){let data58 = data56.objective;if(typeof data58 === "string"){if(func1(data58) > 4000){const err125 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/objective/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}if(func1(data58) < 1){const err126 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/objective/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}}else {const err127 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/objective/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;}}if(data56.status !== undefined){let data59 = data56.status;if(!((data59 === "active") || (data59 === "paused"))){const err128 = {instancePath:instancePath+"/commands/" + i6+"/payload/status",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/status/enum",keyword:"enum",params:{allowedValues: schema48.allOf[0].then.properties.payload.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}}if(data56.tokenBudget !== undefined){let data60 = data56.tokenBudget;if((!(((typeof data60 == "number") && (!(data60 % 1) && !isNaN(data60))) && (isFinite(data60)))) && (data60 !== null)){const err129 = {instancePath:instancePath+"/commands/" + i6+"/payload/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/tokenBudget/type",keyword:"type",params:{type: schema48.allOf[0].then.properties.payload.properties.tokenBudget.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}if((typeof data60 == "number") && (isFinite(data60))){if(data60 < 1 || isNaN(data60)){const err130 = {instancePath:instancePath+"/commands/" + i6+"/payload/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/tokenBudget/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}}}}else {const err131 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}}}var _valid2 = _errs119 === errors;valid32 = _valid2;if(valid32){var props2 = {};props2.payload = true;props2.type = true;}}if(!valid32){const err132 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;}if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.schema === undefined){const err133 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err133];}else {vErrors.push(err133);}errors++;}if(data39.commandId === undefined){const err134 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "commandId"},message:"must have required property '"+"commandId"+"'"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}if(data39.controllerSeq === undefined){const err135 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "controllerSeq"},message:"must have required property '"+"controllerSeq"+"'"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}if(data39.type === undefined){const err136 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "type"},message:"must have required property '"+"type"+"'"};if(vErrors === null){vErrors = [err136];}else {vErrors.push(err136);}errors++;}if(data39.issuedAt === undefined){const err137 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "issuedAt"},message:"must have required property '"+"issuedAt"+"'"};if(vErrors === null){vErrors = [err137];}else {vErrors.push(err137);}errors++;}if(data39.payload === undefined){const err138 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "payload"},message:"must have required property '"+"payload"+"'"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}if(data39.schema !== undefined){if("paperclip.prp.command.v2" !== data39.schema){const err139 = {instancePath:instancePath+"/commands/" + i6+"/schema",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.command.v2"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}}if(data39.commandId !== undefined){let data62 = data39.commandId;if(typeof data62 === "string"){if(func1(data62) > 160){const err140 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/commandId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}if(func1(data62) < 1){const err141 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/commandId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}}else {const err142 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/commandId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err142];}else {vErrors.push(err142);}errors++;}}if(data39.controllerSeq !== undefined){let data63 = data39.controllerSeq;if(!(((typeof data63 == "number") && (!(data63 % 1) && !isNaN(data63))) && (isFinite(data63)))){const err143 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/controllerSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}if((typeof data63 == "number") && (isFinite(data63))){if(data63 < 1 || isNaN(data63)){const err144 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/controllerSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;}}}if(data39.type !== undefined){let data64 = data39.type;if(!(((((((((((((((((((((data64 === "run.prepare") || (data64 === "run.attach")) || (data64 === "session.open")) || (data64 === "turn.start")) || (data64 === "turn.steer")) || (data64 === "turn.interrupt")) || (data64 === "turn.stop")) || (data64 === "request.resolve")) || (data64 === "interaction.receipt")) || (data64 === "semantic_tool.result")) || (data64 === "session.snapshot")) || (data64 === "session.close")) || (data64 === "session.budget.increase")) || (data64 === "session.destroy")) || (data64 === "run.cancel")) || (data64 === "runner.drain")) || (data64 === "runner.suspend")) || (data64 === "runner.shutdown")) || (data64 === "session.goal.get")) || (data64 === "session.goal.set")) || (data64 === "session.goal.clear"))){const err145 = {instancePath:instancePath+"/commands/" + i6+"/type",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/type/enum",keyword:"enum",params:{allowedValues: schema48.properties.type.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err145];}else {vErrors.push(err145);}errors++;}}if(data39.issuedAt !== undefined){let data65 = data39.issuedAt;if(typeof data65 === "string"){if(!(formats0.test(data65))){const err146 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/issuedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err146];}else {vErrors.push(err146);}errors++;}}else {const err147 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/issuedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err147];}else {vErrors.push(err147);}errors++;}}if(data39.deadlineAt !== undefined){let data66 = data39.deadlineAt;if(typeof data66 === "string"){if(!(formats0.test(data66))){const err148 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/deadlineAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err148];}else {vErrors.push(err148);}errors++;}}else {const err149 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/deadlineAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err149];}else {vErrors.push(err149);}errors++;}}if(data39.precondition !== undefined){let data67 = data39.precondition;if(data67 && typeof data67 == "object" && !Array.isArray(data67)){if(data67.runnerState !== undefined){let data68 = data67.runnerState;if(Array.isArray(data68)){const len8 = data68.length;for(let i10=0; i10 160){const err162 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err162];}else {vErrors.push(err162);}errors++;}if(func1(data78) < 1){const err163 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err163];}else {vErrors.push(err163);}errors++;}}else {const err164 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err164];}else {vErrors.push(err164);}errors++;}}if(data77.objective !== undefined){let data79 = data77.objective;if(typeof data79 === "string"){if(func1(data79) > 4000){const err165 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/objective/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err165];}else {vErrors.push(err165);}errors++;}if(func1(data79) < 1){const err166 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/objective/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err166];}else {vErrors.push(err166);}errors++;}}else {const err167 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/objective/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err167];}else {vErrors.push(err167);}errors++;}}if(data77.status !== undefined){let data80 = data77.status;if(!((data80 === "active") || (data80 === "paused"))){const err168 = {instancePath:instancePath+"/commands/" + i6+"/payload/status",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/status/enum",keyword:"enum",params:{allowedValues: schema49.allOf[0].then.properties.payload.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}}if(data77.tokenBudget !== undefined){let data81 = data77.tokenBudget;if((!(((typeof data81 == "number") && (!(data81 % 1) && !isNaN(data81))) && (isFinite(data81)))) && (data81 !== null)){const err169 = {instancePath:instancePath+"/commands/" + i6+"/payload/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/tokenBudget/type",keyword:"type",params:{type: schema49.allOf[0].then.properties.payload.properties.tokenBudget.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}if((typeof data81 == "number") && (isFinite(data81))){if(data81 < 1 || isNaN(data81)){const err170 = {instancePath:instancePath+"/commands/" + i6+"/payload/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/tokenBudget/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}}}}else {const err171 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}}}else {const err172 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}var _valid3 = _errs169 === errors;valid46 = _valid3;if(valid46){var props3 = {};props3.payload = true;props3.type = true;}}if(!valid46){const err173 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}const _errs182 = errors;let valid50 = true;const _errs183 = errors;if(errors === _errs183){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){if("external_provider.operation" !== data39.type){const err174 = {};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}}}else {const err175 = {};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}}var _valid4 = _errs183 === errors;errors = _errs182;if(vErrors !== null){if(_errs182){vErrors.length = _errs182;}else {vErrors = null;}}if(_valid4){const _errs186 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data83 = data39.payload;if(data83 && typeof data83 == "object" && !Array.isArray(data83)){if(data83.requestId === undefined){const err176 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "requestId"},message:"must have required property '"+"requestId"+"'"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if(data83.bindingId === undefined){const err177 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}if(data83.runId === undefined){const err178 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err178];}else {vErrors.push(err178);}errors++;}if(data83.normalizedSessionId === undefined){const err179 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err179];}else {vErrors.push(err179);}errors++;}if(data83.turnId === undefined){const err180 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err180];}else {vErrors.push(err180);}errors++;}if(data83.bindingGeneration === undefined){const err181 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "bindingGeneration"},message:"must have required property '"+"bindingGeneration"+"'"};if(vErrors === null){vErrors = [err181];}else {vErrors.push(err181);}errors++;}if(data83.assignmentRevision === undefined){const err182 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "assignmentRevision"},message:"must have required property '"+"assignmentRevision"+"'"};if(vErrors === null){vErrors = [err182];}else {vErrors.push(err182);}errors++;}if(data83.digest === undefined){const err183 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "digest"},message:"must have required property '"+"digest"+"'"};if(vErrors === null){vErrors = [err183];}else {vErrors.push(err183);}errors++;}if(data83.action === undefined){const err184 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "action"},message:"must have required property '"+"action"+"'"};if(vErrors === null){vErrors = [err184];}else {vErrors.push(err184);}errors++;}if(data83.input === undefined){const err185 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "input"},message:"must have required property '"+"input"+"'"};if(vErrors === null){vErrors = [err185];}else {vErrors.push(err185);}errors++;}for(const key1 in data83){if(!(func66.call(schema49.allOf[1].then.properties.payload.properties, key1))){const err186 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err186];}else {vErrors.push(err186);}errors++;}}if(data83.requestId !== undefined){let data84 = data83.requestId;if(typeof data84 === "string"){if(func1(data84) > 240){const err187 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err187];}else {vErrors.push(err187);}errors++;}if(func1(data84) < 1){const err188 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err188];}else {vErrors.push(err188);}errors++;}}else {const err189 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err189];}else {vErrors.push(err189);}errors++;}}if(data83.bindingId !== undefined){let data85 = data83.bindingId;if(typeof data85 === "string"){if(func1(data85) > 240){const err190 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err190];}else {vErrors.push(err190);}errors++;}if(func1(data85) < 1){const err191 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err191];}else {vErrors.push(err191);}errors++;}}else {const err192 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err192];}else {vErrors.push(err192);}errors++;}}if(data83.runId !== undefined){let data86 = data83.runId;if(typeof data86 === "string"){if(func1(data86) > 240){const err193 = {instancePath:instancePath+"/commands/" + i6+"/payload/runId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/runId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err193];}else {vErrors.push(err193);}errors++;}if(func1(data86) < 1){const err194 = {instancePath:instancePath+"/commands/" + i6+"/payload/runId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err194];}else {vErrors.push(err194);}errors++;}}else {const err195 = {instancePath:instancePath+"/commands/" + i6+"/payload/runId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err195];}else {vErrors.push(err195);}errors++;}}if(data83.normalizedSessionId !== undefined){let data87 = data83.normalizedSessionId;if(typeof data87 === "string"){if(func1(data87) > 240){const err196 = {instancePath:instancePath+"/commands/" + i6+"/payload/normalizedSessionId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err196];}else {vErrors.push(err196);}errors++;}if(func1(data87) < 1){const err197 = {instancePath:instancePath+"/commands/" + i6+"/payload/normalizedSessionId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err197];}else {vErrors.push(err197);}errors++;}}else {const err198 = {instancePath:instancePath+"/commands/" + i6+"/payload/normalizedSessionId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err198];}else {vErrors.push(err198);}errors++;}}if(data83.turnId !== undefined){let data88 = data83.turnId;if(typeof data88 === "string"){if(func1(data88) > 240){const err199 = {instancePath:instancePath+"/commands/" + i6+"/payload/turnId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err199];}else {vErrors.push(err199);}errors++;}if(func1(data88) < 1){const err200 = {instancePath:instancePath+"/commands/" + i6+"/payload/turnId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err200];}else {vErrors.push(err200);}errors++;}}else {const err201 = {instancePath:instancePath+"/commands/" + i6+"/payload/turnId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err201];}else {vErrors.push(err201);}errors++;}}if(data83.bindingGeneration !== undefined){let data89 = data83.bindingGeneration;if(!(((typeof data89 == "number") && (!(data89 % 1) && !isNaN(data89))) && (isFinite(data89)))){const err202 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingGeneration",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingGeneration/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err202];}else {vErrors.push(err202);}errors++;}if((typeof data89 == "number") && (isFinite(data89))){if(data89 < 1 || isNaN(data89)){const err203 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingGeneration",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingGeneration/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err203];}else {vErrors.push(err203);}errors++;}}}if(data83.assignmentRevision !== undefined){let data90 = data83.assignmentRevision;if(!(((typeof data90 == "number") && (!(data90 % 1) && !isNaN(data90))) && (isFinite(data90)))){const err204 = {instancePath:instancePath+"/commands/" + i6+"/payload/assignmentRevision",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/assignmentRevision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err204];}else {vErrors.push(err204);}errors++;}if((typeof data90 == "number") && (isFinite(data90))){if(data90 < 1 || isNaN(data90)){const err205 = {instancePath:instancePath+"/commands/" + i6+"/payload/assignmentRevision",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/assignmentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err205];}else {vErrors.push(err205);}errors++;}}}if(data83.digest !== undefined){let data91 = data83.digest;if(typeof data91 === "string"){if(!pattern15.test(data91)){const err206 = {instancePath:instancePath+"/commands/" + i6+"/payload/digest",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/digest/pattern",keyword:"pattern",params:{pattern: "^sha256:[0-9a-f]{64}$"},message:"must match pattern \""+"^sha256:[0-9a-f]{64}$"+"\""};if(vErrors === null){vErrors = [err206];}else {vErrors.push(err206);}errors++;}}else {const err207 = {instancePath:instancePath+"/commands/" + i6+"/payload/digest",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/digest/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err207];}else {vErrors.push(err207);}errors++;}}if(data83.action !== undefined){let data92 = data83.action;if(!((((data92 === "accept") || (data92 === "tool")) || (data92 === "progress")) || (data92 === "finish"))){const err208 = {instancePath:instancePath+"/commands/" + i6+"/payload/action",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/action/enum",keyword:"enum",params:{allowedValues: schema49.allOf[1].then.properties.payload.properties.action.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err208];}else {vErrors.push(err208);}errors++;}}if(data83.input !== undefined){let data93 = data83.input;if(!(data93 && typeof data93 == "object" && !Array.isArray(data93))){const err209 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err209];}else {vErrors.push(err209);}errors++;}}}else {const err210 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err210];}else {vErrors.push(err210);}errors++;}}}else {const err211 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err211];}else {vErrors.push(err211);}errors++;}var _valid4 = _errs186 === errors;valid50 = _valid4;if(valid50){var props4 = {};props4.payload = true;props4.type = true;}}if(!valid50){const err212 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err212];}else {vErrors.push(err212);}errors++;}if(props3 !== true && props4 !== undefined){if(props4 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props4);}}const _errs211 = errors;let valid54 = true;const _errs212 = errors;if(errors === _errs212){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){const _errs214 = errors;if("external_provider.operation" !== data39.type){const err213 = {};if(vErrors === null){vErrors = [err213];}else {vErrors.push(err213);}errors++;}var valid55 = _errs214 === errors;}else {var valid55 = true;}if(valid55){if(data39.payload !== undefined){let data95 = data39.payload;const _errs215 = errors;if(errors === _errs215){if(data95 && typeof data95 == "object" && !Array.isArray(data95)){if(data95.action !== undefined){if("accept" !== data95.action){const err214 = {};if(vErrors === null){vErrors = [err214];}else {vErrors.push(err214);}errors++;}}}else {const err215 = {};if(vErrors === null){vErrors = [err215];}else {vErrors.push(err215);}errors++;}}var valid55 = _errs215 === errors;}else {var valid55 = true;}}}else {const err216 = {};if(vErrors === null){vErrors = [err216];}else {vErrors.push(err216);}errors++;}}var _valid5 = _errs212 === errors;errors = _errs211;if(vErrors !== null){if(_errs211){vErrors.length = _errs211;}else {vErrors = null;}}if(_valid5){const _errs218 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data97 = data39.payload;if(data97 && typeof data97 == "object" && !Array.isArray(data97)){if(data97.input !== undefined){let data98 = data97.input;if(data98 && typeof data98 == "object" && !Array.isArray(data98)){for(const key2 in data98){const err217 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/then/properties/payload/properties/input/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err217];}else {vErrors.push(err217);}errors++;}}else {const err218 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err218];}else {vErrors.push(err218);}errors++;}}}else {const err219 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err219];}else {vErrors.push(err219);}errors++;}}}else {const err220 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err220];}else {vErrors.push(err220);}errors++;}var _valid5 = _errs218 === errors;valid54 = _valid5;if(valid54){var props5 = {};props5.payload = true;props5.type = true;}}if(!valid54){const err221 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err221];}else {vErrors.push(err221);}errors++;}if(props3 !== true && props5 !== undefined){if(props5 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props5);}}const _errs226 = errors;let valid59 = true;const _errs227 = errors;if(errors === _errs227){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){const _errs229 = errors;if("external_provider.operation" !== data39.type){const err222 = {};if(vErrors === null){vErrors = [err222];}else {vErrors.push(err222);}errors++;}var valid60 = _errs229 === errors;}else {var valid60 = true;}if(valid60){if(data39.payload !== undefined){let data100 = data39.payload;const _errs230 = errors;if(errors === _errs230){if(data100 && typeof data100 == "object" && !Array.isArray(data100)){if(data100.action !== undefined){if("tool" !== data100.action){const err223 = {};if(vErrors === null){vErrors = [err223];}else {vErrors.push(err223);}errors++;}}}else {const err224 = {};if(vErrors === null){vErrors = [err224];}else {vErrors.push(err224);}errors++;}}var valid60 = _errs230 === errors;}else {var valid60 = true;}}}else {const err225 = {};if(vErrors === null){vErrors = [err225];}else {vErrors.push(err225);}errors++;}}var _valid6 = _errs227 === errors;errors = _errs226;if(vErrors !== null){if(_errs226){vErrors.length = _errs226;}else {vErrors = null;}}if(_valid6){const _errs233 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data102 = data39.payload;if(data102 && typeof data102 == "object" && !Array.isArray(data102)){if(data102.input !== undefined){let data103 = data102.input;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.name === undefined){const err226 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/required",keyword:"required",params:{missingProperty: "name"},message:"must have required property '"+"name"+"'"};if(vErrors === null){vErrors = [err226];}else {vErrors.push(err226);}errors++;}if(data103.arguments === undefined){const err227 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/required",keyword:"required",params:{missingProperty: "arguments"},message:"must have required property '"+"arguments"+"'"};if(vErrors === null){vErrors = [err227];}else {vErrors.push(err227);}errors++;}for(const key3 in data103){if(!((key3 === "name") || (key3 === "arguments"))){const err228 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err228];}else {vErrors.push(err228);}errors++;}}if(data103.name !== undefined){let data104 = data103.name;if(typeof data104 === "string"){if(func1(data104) > 160){const err229 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/name",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/properties/name/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err229];}else {vErrors.push(err229);}errors++;}if(func1(data104) < 1){const err230 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/name",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err230];}else {vErrors.push(err230);}errors++;}}else {const err231 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/name",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err231];}else {vErrors.push(err231);}errors++;}}if(data103.arguments !== undefined){let data105 = data103.arguments;if(!(data105 && typeof data105 == "object" && !Array.isArray(data105))){const err232 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/arguments",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/properties/arguments/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err232];}else {vErrors.push(err232);}errors++;}}}else {const err233 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}}}else {const err234 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}}}else {const err235 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}var _valid6 = _errs233 === errors;valid59 = _valid6;if(valid59){var props6 = {};props6.payload = true;props6.type = true;}}if(!valid59){const err236 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}if(props3 !== true && props6 !== undefined){if(props6 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props6);}}const _errs245 = errors;let valid65 = true;const _errs246 = errors;if(errors === _errs246){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){const _errs248 = errors;if("external_provider.operation" !== data39.type){const err237 = {};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}var valid66 = _errs248 === errors;}else {var valid66 = true;}if(valid66){if(data39.payload !== undefined){let data107 = data39.payload;const _errs249 = errors;if(errors === _errs249){if(data107 && typeof data107 == "object" && !Array.isArray(data107)){if(data107.action !== undefined){if("progress" !== data107.action){const err238 = {};if(vErrors === null){vErrors = [err238];}else {vErrors.push(err238);}errors++;}}}else {const err239 = {};if(vErrors === null){vErrors = [err239];}else {vErrors.push(err239);}errors++;}}var valid66 = _errs249 === errors;}else {var valid66 = true;}}}else {const err240 = {};if(vErrors === null){vErrors = [err240];}else {vErrors.push(err240);}errors++;}}var _valid7 = _errs246 === errors;errors = _errs245;if(vErrors !== null){if(_errs245){vErrors.length = _errs245;}else {vErrors = null;}}if(_valid7){const _errs252 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data109 = data39.payload;if(data109 && typeof data109 == "object" && !Array.isArray(data109)){if(data109.input !== undefined){let data110 = data109.input;if(data110 && typeof data110 == "object" && !Array.isArray(data110)){if(data110.text === undefined){const err241 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/required",keyword:"required",params:{missingProperty: "text"},message:"must have required property '"+"text"+"'"};if(vErrors === null){vErrors = [err241];}else {vErrors.push(err241);}errors++;}for(const key4 in data110){if(!(key4 === "text")){const err242 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err242];}else {vErrors.push(err242);}errors++;}}if(data110.text !== undefined){let data111 = data110.text;if(typeof data111 === "string"){if(func1(data111) > 12000){const err243 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/text",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/properties/text/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err243];}else {vErrors.push(err243);}errors++;}if(func1(data111) < 1){const err244 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/text",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/properties/text/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err244];}else {vErrors.push(err244);}errors++;}}else {const err245 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/text",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/properties/text/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err245];}else {vErrors.push(err245);}errors++;}}}else {const err246 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err246];}else {vErrors.push(err246);}errors++;}}}else {const err247 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err247];}else {vErrors.push(err247);}errors++;}}}else {const err248 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err248];}else {vErrors.push(err248);}errors++;}var _valid7 = _errs252 === errors;valid65 = _valid7;if(valid65){var props7 = {};props7.payload = true;props7.type = true;}}if(!valid65){const err249 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err249];}else {vErrors.push(err249);}errors++;}if(props3 !== true && props7 !== undefined){if(props7 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props7);}}const _errs262 = errors;let valid71 = true;const _errs263 = errors;if(errors === _errs263){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){const _errs265 = errors;if("external_provider.operation" !== data39.type){const err250 = {};if(vErrors === null){vErrors = [err250];}else {vErrors.push(err250);}errors++;}var valid72 = _errs265 === errors;}else {var valid72 = true;}if(valid72){if(data39.payload !== undefined){let data113 = data39.payload;const _errs266 = errors;if(errors === _errs266){if(data113 && typeof data113 == "object" && !Array.isArray(data113)){if(data113.action !== undefined){if("finish" !== data113.action){const err251 = {};if(vErrors === null){vErrors = [err251];}else {vErrors.push(err251);}errors++;}}}else {const err252 = {};if(vErrors === null){vErrors = [err252];}else {vErrors.push(err252);}errors++;}}var valid72 = _errs266 === errors;}else {var valid72 = true;}}}else {const err253 = {};if(vErrors === null){vErrors = [err253];}else {vErrors.push(err253);}errors++;}}var _valid8 = _errs263 === errors;errors = _errs262;if(vErrors !== null){if(_errs262){vErrors.length = _errs262;}else {vErrors = null;}}if(_valid8){const _errs269 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data115 = data39.payload;if(data115 && typeof data115 == "object" && !Array.isArray(data115)){if(data115.input !== undefined){let data116 = data115.input;if(data116 && typeof data116 == "object" && !Array.isArray(data116)){if(data116.result === undefined){const err254 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/properties/input/required",keyword:"required",params:{missingProperty: "result"},message:"must have required property '"+"result"+"'"};if(vErrors === null){vErrors = [err254];}else {vErrors.push(err254);}errors++;}for(const key5 in data116){if(!(key5 === "result")){const err255 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/properties/input/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key5},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err255];}else {vErrors.push(err255);}errors++;}}if(data116.result !== undefined){let data117 = data116.result;if(!(data117 && typeof data117 == "object" && !Array.isArray(data117))){const err256 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/result",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/properties/input/properties/result/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err256];}else {vErrors.push(err256);}errors++;}}}else {const err257 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err257];}else {vErrors.push(err257);}errors++;}}}else {const err258 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err258];}else {vErrors.push(err258);}errors++;}}}else {const err259 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err259];}else {vErrors.push(err259);}errors++;}var _valid8 = _errs269 === errors;valid71 = _valid8;if(valid71){var props8 = {};props8.payload = true;props8.type = true;}}if(!valid71){const err260 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err260];}else {vErrors.push(err260);}errors++;}if(props3 !== true && props8 !== undefined){if(props8 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props8);}}if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.schema === undefined){const err261 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err261];}else {vErrors.push(err261);}errors++;}if(data39.commandId === undefined){const err262 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "commandId"},message:"must have required property '"+"commandId"+"'"};if(vErrors === null){vErrors = [err262];}else {vErrors.push(err262);}errors++;}if(data39.controllerSeq === undefined){const err263 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "controllerSeq"},message:"must have required property '"+"controllerSeq"+"'"};if(vErrors === null){vErrors = [err263];}else {vErrors.push(err263);}errors++;}if(data39.type === undefined){const err264 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "type"},message:"must have required property '"+"type"+"'"};if(vErrors === null){vErrors = [err264];}else {vErrors.push(err264);}errors++;}if(data39.issuedAt === undefined){const err265 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "issuedAt"},message:"must have required property '"+"issuedAt"+"'"};if(vErrors === null){vErrors = [err265];}else {vErrors.push(err265);}errors++;}if(data39.payload === undefined){const err266 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "payload"},message:"must have required property '"+"payload"+"'"};if(vErrors === null){vErrors = [err266];}else {vErrors.push(err266);}errors++;}if(data39.schema !== undefined){if("paperclip.prp.command.v3" !== data39.schema){const err267 = {instancePath:instancePath+"/commands/" + i6+"/schema",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.command.v3"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err267];}else {vErrors.push(err267);}errors++;}}if(data39.commandId !== undefined){let data119 = data39.commandId;if(typeof data119 === "string"){if(func1(data119) > 160){const err268 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/commandId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err268];}else {vErrors.push(err268);}errors++;}if(func1(data119) < 1){const err269 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/commandId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err269];}else {vErrors.push(err269);}errors++;}}else {const err270 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/commandId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err270];}else {vErrors.push(err270);}errors++;}}if(data39.controllerSeq !== undefined){let data120 = data39.controllerSeq;if(!(((typeof data120 == "number") && (!(data120 % 1) && !isNaN(data120))) && (isFinite(data120)))){const err271 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/controllerSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err271];}else {vErrors.push(err271);}errors++;}if((typeof data120 == "number") && (isFinite(data120))){if(data120 < 1 || isNaN(data120)){const err272 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/controllerSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err272];}else {vErrors.push(err272);}errors++;}}}if(data39.type !== undefined){let data121 = data39.type;if(!((((((((((((((((((((((data121 === "run.prepare") || (data121 === "run.attach")) || (data121 === "session.open")) || (data121 === "turn.start")) || (data121 === "turn.steer")) || (data121 === "turn.interrupt")) || (data121 === "turn.stop")) || (data121 === "request.resolve")) || (data121 === "interaction.receipt")) || (data121 === "semantic_tool.result")) || (data121 === "session.snapshot")) || (data121 === "session.close")) || (data121 === "session.budget.increase")) || (data121 === "session.destroy")) || (data121 === "run.cancel")) || (data121 === "runner.drain")) || (data121 === "runner.suspend")) || (data121 === "runner.shutdown")) || (data121 === "session.goal.get")) || (data121 === "session.goal.set")) || (data121 === "session.goal.clear")) || (data121 === "external_provider.operation"))){const err273 = {instancePath:instancePath+"/commands/" + i6+"/type",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/type/enum",keyword:"enum",params:{allowedValues: schema49.properties.type.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err273];}else {vErrors.push(err273);}errors++;}}if(data39.issuedAt !== undefined){let data122 = data39.issuedAt;if(typeof data122 === "string"){if(!(formats0.test(data122))){const err274 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/issuedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err274];}else {vErrors.push(err274);}errors++;}}else {const err275 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/issuedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err275];}else {vErrors.push(err275);}errors++;}}if(data39.deadlineAt !== undefined){let data123 = data39.deadlineAt;if(typeof data123 === "string"){if(!(formats0.test(data123))){const err276 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/deadlineAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err276];}else {vErrors.push(err276);}errors++;}}else {const err277 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/deadlineAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err277];}else {vErrors.push(err277);}errors++;}}if(data39.precondition !== undefined){let data124 = data39.precondition;if(data124 && typeof data124 == "object" && !Array.isArray(data124)){if(data124.runnerState !== undefined){let data125 = data124.runnerState;if(Array.isArray(data125)){const len11 = data125.length;for(let i13=0; i13 12000){const err310 = {instancePath:instancePath+"/result/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err310];}else {vErrors.push(err310);}errors++;}if(func1(data142) < 1){const err311 = {instancePath:instancePath+"/result/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err311];}else {vErrors.push(err311);}errors++;}}else {const err312 = {instancePath:instancePath+"/result/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err312];}else {vErrors.push(err312);}errors++;}}if(data137.completionClaim !== undefined){let data143 = data137.completionClaim;if(data143 && typeof data143 == "object" && !Array.isArray(data143)){if(data143.contractRevision === undefined){const err313 = {instancePath:instancePath+"/result/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "contractRevision"},message:"must have required property '"+"contractRevision"+"'"};if(vErrors === null){vErrors = [err313];}else {vErrors.push(err313);}errors++;}if(data143.objectiveSatisfied === undefined){const err314 = {instancePath:instancePath+"/result/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "objectiveSatisfied"},message:"must have required property '"+"objectiveSatisfied"+"'"};if(vErrors === null){vErrors = [err314];}else {vErrors.push(err314);}errors++;}if(data143.criteria === undefined){const err315 = {instancePath:instancePath+"/result/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "criteria"},message:"must have required property '"+"criteria"+"'"};if(vErrors === null){vErrors = [err315];}else {vErrors.push(err315);}errors++;}if(data143.remainingWork === undefined){const err316 = {instancePath:instancePath+"/result/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "remainingWork"},message:"must have required property '"+"remainingWork"+"'"};if(vErrors === null){vErrors = [err316];}else {vErrors.push(err316);}errors++;}if(data143.contractRevision !== undefined){let data144 = data143.contractRevision;if(typeof data144 === "string"){if(func1(data144) < 1){const err317 = {instancePath:instancePath+"/result/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err317];}else {vErrors.push(err317);}errors++;}}else {const err318 = {instancePath:instancePath+"/result/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err318];}else {vErrors.push(err318);}errors++;}}if(data143.objectiveSatisfied !== undefined){if(typeof data143.objectiveSatisfied !== "boolean"){const err319 = {instancePath:instancePath+"/result/completionClaim/objectiveSatisfied",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/objectiveSatisfied/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err319];}else {vErrors.push(err319);}errors++;}}if(data143.criteria !== undefined){let data146 = data143.criteria;if(Array.isArray(data146)){const len16 = data146.length;for(let i18=0; i18 12000){const err16 = {instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data4) < 1){const err17 = {instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}else {const err18 = {instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.completionClaim !== undefined){let data5 = data.completionClaim;if(data5 && typeof data5 == "object" && !Array.isArray(data5)){if(data5.contractRevision === undefined){const err19 = {instancePath:instancePath+"/completionClaim",schemaPath:"#/properties/completionClaim/required",keyword:"required",params:{missingProperty: "contractRevision"},message:"must have required property '"+"contractRevision"+"'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data5.objectiveSatisfied === undefined){const err20 = {instancePath:instancePath+"/completionClaim",schemaPath:"#/properties/completionClaim/required",keyword:"required",params:{missingProperty: "objectiveSatisfied"},message:"must have required property '"+"objectiveSatisfied"+"'"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data5.criteria === undefined){const err21 = {instancePath:instancePath+"/completionClaim",schemaPath:"#/properties/completionClaim/required",keyword:"required",params:{missingProperty: "criteria"},message:"must have required property '"+"criteria"+"'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data5.remainingWork === undefined){const err22 = {instancePath:instancePath+"/completionClaim",schemaPath:"#/properties/completionClaim/required",keyword:"required",params:{missingProperty: "remainingWork"},message:"must have required property '"+"remainingWork"+"'"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(data5.contractRevision !== undefined){let data6 = data5.contractRevision;if(typeof data6 === "string"){if(func1(data6) < 1){const err23 = {instancePath:instancePath+"/completionClaim/contractRevision",schemaPath:"#/properties/completionClaim/properties/contractRevision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/completionClaim/contractRevision",schemaPath:"#/properties/completionClaim/properties/contractRevision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data5.objectiveSatisfied !== undefined){if(typeof data5.objectiveSatisfied !== "boolean"){const err25 = {instancePath:instancePath+"/completionClaim/objectiveSatisfied",schemaPath:"#/properties/completionClaim/properties/objectiveSatisfied/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data5.criteria !== undefined){let data8 = data5.criteria;if(Array.isArray(data8)){const len0 = data8.length;for(let i0=0; i0 160){const err9 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data1) < 1){const err10 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data1)){const err11 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.issueId !== undefined){let data2 = data.issueId;if(typeof data2 === "string"){if(func1(data2) > 160){const err13 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data2) < 1){const err14 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(!pattern4.test(data2)){const err15 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.runId !== undefined){let data3 = data.runId;if(typeof data3 === "string"){if(func1(data3) > 160){const err17 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data3) < 1){const err18 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(!pattern4.test(data3)){const err19 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}else {const err20 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.environmentLeaseId !== undefined){let data4 = data.environmentLeaseId;if(typeof data4 === "string"){if(func1(data4) > 160){const err21 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data4) < 1){const err22 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data4)){const err23 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.runnerInstanceId !== undefined){let data5 = data.runnerInstanceId;if(typeof data5 === "string"){if(func1(data5) > 160){const err25 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(func1(data5) < 1){const err26 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(!pattern4.test(data5)){const err27 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}else {const err28 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data.normalizedSessionId !== undefined){let data6 = data.normalizedSessionId;if(typeof data6 === "string"){if(func1(data6) > 160){const err29 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(func1(data6) < 1){const err30 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(!pattern4.test(data6)){const err31 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}else {const err32 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}if(data.driverSessionId !== undefined){let data7 = data.driverSessionId;if(typeof data7 === "string"){if(func1(data7) > 160){const err33 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(func1(data7) < 1){const err34 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(!pattern4.test(data7)){const err35 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}else {const err36 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.providerSessionId !== undefined){let data8 = data.providerSessionId;if(typeof data8 === "string"){if(func1(data8) > 160){const err37 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(func1(data8) < 1){const err38 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(!pattern4.test(data8)){const err39 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}else {const err40 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}}else {const err41 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}validate21.errors = vErrors;return errors === 0;}validate21.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json","title":"PRP v2 negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents","sessionGoals"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v2"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"sessionGoals":{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema44 = {"type":"object","required":["availability","actions","autonomousUpdates","persistentAcrossResume","maxObjectiveChars","tokenBudgetControl","usageReporting"],"properties":{"availability":{"enum":["available","unsupported","policy_disabled"]},"actions":{"type":"array","items":{"enum":["set","pause","resume","clear"]},"uniqueItems":true},"autonomousUpdates":{"type":"boolean"},"persistentAcrossResume":{"type":"boolean"},"maxObjectiveChars":{"type":"integer","minimum":1,"maximum":4000},"tokenBudgetControl":{"type":"boolean"},"usageReporting":{"type":"boolean"},"reasonCode":{"type":"string","minLength":1,"maxLength":160},"reason":{"type":"string","minLength":1,"maxLength":1000}},"additionalProperties":true};const func0 = typeof equalModule === "function" ? equalModule : equalModule.default;function validate23(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v2/capabilities.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate23.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.sessionReusePolicy === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sessionReusePolicy"},message:"must have required property '"+"sessionReusePolicy"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.driver === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "driver"},message:"must have required property '"+"driver"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.steer === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "steer"},message:"must have required property '"+"steer"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.interrupt === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "interrupt"},message:"must have required property '"+"interrupt"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.resume === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "resume"},message:"must have required property '"+"resume"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.runtimeRequests === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runtimeRequests"},message:"must have required property '"+"runtimeRequests"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.structuredResult === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "structuredResult"},message:"must have required property '"+"structuredResult"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.typedEvents === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "typedEvents"},message:"must have required property '"+"typedEvents"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.sessionGoals === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sessionGoals"},message:"must have required property '"+"sessionGoals"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.schema !== undefined){if("paperclip.prp.capabilities.v2" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.capabilities.v2"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.sessionReusePolicy !== undefined){let data1 = data.sessionReusePolicy;if(!(((data1 === "new_per_run") || (data1 === "reuse_per_issue")) || (data1 === "reuse_per_workspace"))){const err11 = {instancePath:instancePath+"/sessionReusePolicy",schemaPath:"#/properties/sessionReusePolicy/enum",keyword:"enum",params:{allowedValues: schema42.properties.sessionReusePolicy.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.driver !== undefined){let data2 = data.driver;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.kind === undefined){const err12 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.version === undefined){const err13 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data2.kind !== undefined){let data3 = data2.kind;if(typeof data3 === "string"){if(func1(data3) > 80){const err14 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(func1(data3) < 1){const err15 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data2.version !== undefined){let data4 = data2.version;if(typeof data4 === "string"){if(func1(data4) > 80){const err17 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data4) < 1){const err18 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}else {const err20 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.steer !== undefined){if(typeof data.steer !== "boolean"){const err21 = {instancePath:instancePath+"/steer",schemaPath:"#/properties/steer/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.interrupt !== undefined){if(typeof data.interrupt !== "boolean"){const err22 = {instancePath:instancePath+"/interrupt",schemaPath:"#/properties/interrupt/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.resume !== undefined){if(typeof data.resume !== "boolean"){const err23 = {instancePath:instancePath+"/resume",schemaPath:"#/properties/resume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}if(data.runtimeRequests !== undefined){if(typeof data.runtimeRequests !== "boolean"){const err24 = {instancePath:instancePath+"/runtimeRequests",schemaPath:"#/properties/runtimeRequests/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.structuredResult !== undefined){if(typeof data.structuredResult !== "boolean"){const err25 = {instancePath:instancePath+"/structuredResult",schemaPath:"#/properties/structuredResult/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.typedEvents !== undefined){if(typeof data.typedEvents !== "boolean"){const err26 = {instancePath:instancePath+"/typedEvents",schemaPath:"#/properties/typedEvents/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data.sessionGoals !== undefined){let data11 = data.sessionGoals;if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.availability === undefined){const err27 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "availability"},message:"must have required property '"+"availability"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data11.actions === undefined){const err28 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "actions"},message:"must have required property '"+"actions"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data11.autonomousUpdates === undefined){const err29 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "autonomousUpdates"},message:"must have required property '"+"autonomousUpdates"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data11.persistentAcrossResume === undefined){const err30 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "persistentAcrossResume"},message:"must have required property '"+"persistentAcrossResume"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data11.maxObjectiveChars === undefined){const err31 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "maxObjectiveChars"},message:"must have required property '"+"maxObjectiveChars"+"'"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(data11.tokenBudgetControl === undefined){const err32 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "tokenBudgetControl"},message:"must have required property '"+"tokenBudgetControl"+"'"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(data11.usageReporting === undefined){const err33 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "usageReporting"},message:"must have required property '"+"usageReporting"+"'"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(data11.availability !== undefined){let data12 = data11.availability;if(!(((data12 === "available") || (data12 === "unsupported")) || (data12 === "policy_disabled"))){const err34 = {instancePath:instancePath+"/sessionGoals/availability",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/availability/enum",keyword:"enum",params:{allowedValues: schema44.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data11.actions !== undefined){let data13 = data11.actions;if(Array.isArray(data13)){const len0 = data13.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data13[i1], data13[j0])){const err36 = {instancePath:instancePath+"/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;break outer0;}}}}}else {const err37 = {instancePath:instancePath+"/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data11.autonomousUpdates !== undefined){if(typeof data11.autonomousUpdates !== "boolean"){const err38 = {instancePath:instancePath+"/sessionGoals/autonomousUpdates",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/autonomousUpdates/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data11.persistentAcrossResume !== undefined){if(typeof data11.persistentAcrossResume !== "boolean"){const err39 = {instancePath:instancePath+"/sessionGoals/persistentAcrossResume",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/persistentAcrossResume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}if(data11.maxObjectiveChars !== undefined){let data17 = data11.maxObjectiveChars;if(!(((typeof data17 == "number") && (!(data17 % 1) && !isNaN(data17))) && (isFinite(data17)))){const err40 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if((typeof data17 == "number") && (isFinite(data17))){if(data17 > 4000 || isNaN(data17)){const err41 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/maximum",keyword:"maximum",params:{comparison: "<=", limit: 4000},message:"must be <= 4000"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data17 < 1 || isNaN(data17)){const err42 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}if(data11.tokenBudgetControl !== undefined){if(typeof data11.tokenBudgetControl !== "boolean"){const err43 = {instancePath:instancePath+"/sessionGoals/tokenBudgetControl",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/tokenBudgetControl/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}if(data11.usageReporting !== undefined){if(typeof data11.usageReporting !== "boolean"){const err44 = {instancePath:instancePath+"/sessionGoals/usageReporting",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/usageReporting/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data11.reasonCode !== undefined){let data20 = data11.reasonCode;if(typeof data20 === "string"){if(func1(data20) > 160){const err45 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(func1(data20) < 1){const err46 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}else {const err47 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data11.reason !== undefined){let data21 = data11.reason;if(typeof data21 === "string"){if(func1(data21) > 1000){const err48 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}if(func1(data21) < 1){const err49 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data.unsupported !== undefined){let data22 = data.unsupported;if(Array.isArray(data22)){const len1 = data22.length;for(let i2=0; i2 1){const indices0 = {};for(;i3--;){let item0 = data22[i3];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j1 = indices0[item0];const err54 = {instancePath:instancePath+"/unsupported",schemaPath:"#/properties/unsupported/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;break;}indices0[item0] = i3;}}}else {const err55 = {instancePath:instancePath+"/unsupported",schemaPath:"#/properties/unsupported/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}}else {const err56 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}validate23.errors = vErrors;return errors === 0;}validate23.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v3/capabilities.schema.json","title":"PRP v3 negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents","sessionGoals"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v3"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"sessionGoals":{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true},"externalProvider":{"type":"boolean"}},"additionalProperties":true};function validate26(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v3/capabilities.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate26.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.sessionReusePolicy === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sessionReusePolicy"},message:"must have required property '"+"sessionReusePolicy"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.driver === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "driver"},message:"must have required property '"+"driver"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.steer === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "steer"},message:"must have required property '"+"steer"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.interrupt === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "interrupt"},message:"must have required property '"+"interrupt"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.resume === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "resume"},message:"must have required property '"+"resume"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.runtimeRequests === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runtimeRequests"},message:"must have required property '"+"runtimeRequests"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.structuredResult === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "structuredResult"},message:"must have required property '"+"structuredResult"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.typedEvents === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "typedEvents"},message:"must have required property '"+"typedEvents"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.sessionGoals === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sessionGoals"},message:"must have required property '"+"sessionGoals"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.schema !== undefined){if("paperclip.prp.capabilities.v3" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.capabilities.v3"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.sessionReusePolicy !== undefined){let data1 = data.sessionReusePolicy;if(!(((data1 === "new_per_run") || (data1 === "reuse_per_issue")) || (data1 === "reuse_per_workspace"))){const err11 = {instancePath:instancePath+"/sessionReusePolicy",schemaPath:"#/properties/sessionReusePolicy/enum",keyword:"enum",params:{allowedValues: schema45.properties.sessionReusePolicy.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.driver !== undefined){let data2 = data.driver;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.kind === undefined){const err12 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.version === undefined){const err13 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data2.kind !== undefined){let data3 = data2.kind;if(typeof data3 === "string"){if(func1(data3) > 80){const err14 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(func1(data3) < 1){const err15 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/driver/kind",schemaPath:"#/properties/driver/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data2.version !== undefined){let data4 = data2.version;if(typeof data4 === "string"){if(func1(data4) > 80){const err17 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data4) < 1){const err18 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/driver/version",schemaPath:"#/properties/driver/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}else {const err20 = {instancePath:instancePath+"/driver",schemaPath:"#/properties/driver/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.steer !== undefined){if(typeof data.steer !== "boolean"){const err21 = {instancePath:instancePath+"/steer",schemaPath:"#/properties/steer/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.interrupt !== undefined){if(typeof data.interrupt !== "boolean"){const err22 = {instancePath:instancePath+"/interrupt",schemaPath:"#/properties/interrupt/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.resume !== undefined){if(typeof data.resume !== "boolean"){const err23 = {instancePath:instancePath+"/resume",schemaPath:"#/properties/resume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}if(data.runtimeRequests !== undefined){if(typeof data.runtimeRequests !== "boolean"){const err24 = {instancePath:instancePath+"/runtimeRequests",schemaPath:"#/properties/runtimeRequests/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.structuredResult !== undefined){if(typeof data.structuredResult !== "boolean"){const err25 = {instancePath:instancePath+"/structuredResult",schemaPath:"#/properties/structuredResult/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.typedEvents !== undefined){if(typeof data.typedEvents !== "boolean"){const err26 = {instancePath:instancePath+"/typedEvents",schemaPath:"#/properties/typedEvents/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data.sessionGoals !== undefined){let data11 = data.sessionGoals;if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.availability === undefined){const err27 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "availability"},message:"must have required property '"+"availability"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data11.actions === undefined){const err28 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "actions"},message:"must have required property '"+"actions"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data11.autonomousUpdates === undefined){const err29 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "autonomousUpdates"},message:"must have required property '"+"autonomousUpdates"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data11.persistentAcrossResume === undefined){const err30 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "persistentAcrossResume"},message:"must have required property '"+"persistentAcrossResume"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data11.maxObjectiveChars === undefined){const err31 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "maxObjectiveChars"},message:"must have required property '"+"maxObjectiveChars"+"'"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(data11.tokenBudgetControl === undefined){const err32 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "tokenBudgetControl"},message:"must have required property '"+"tokenBudgetControl"+"'"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(data11.usageReporting === undefined){const err33 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "usageReporting"},message:"must have required property '"+"usageReporting"+"'"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(data11.availability !== undefined){let data12 = data11.availability;if(!(((data12 === "available") || (data12 === "unsupported")) || (data12 === "policy_disabled"))){const err34 = {instancePath:instancePath+"/sessionGoals/availability",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/availability/enum",keyword:"enum",params:{allowedValues: schema44.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data11.actions !== undefined){let data13 = data11.actions;if(Array.isArray(data13)){const len0 = data13.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data13[i1], data13[j0])){const err36 = {instancePath:instancePath+"/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;break outer0;}}}}}else {const err37 = {instancePath:instancePath+"/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data11.autonomousUpdates !== undefined){if(typeof data11.autonomousUpdates !== "boolean"){const err38 = {instancePath:instancePath+"/sessionGoals/autonomousUpdates",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/autonomousUpdates/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data11.persistentAcrossResume !== undefined){if(typeof data11.persistentAcrossResume !== "boolean"){const err39 = {instancePath:instancePath+"/sessionGoals/persistentAcrossResume",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/persistentAcrossResume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}if(data11.maxObjectiveChars !== undefined){let data17 = data11.maxObjectiveChars;if(!(((typeof data17 == "number") && (!(data17 % 1) && !isNaN(data17))) && (isFinite(data17)))){const err40 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if((typeof data17 == "number") && (isFinite(data17))){if(data17 > 4000 || isNaN(data17)){const err41 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/maximum",keyword:"maximum",params:{comparison: "<=", limit: 4000},message:"must be <= 4000"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data17 < 1 || isNaN(data17)){const err42 = {instancePath:instancePath+"/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}if(data11.tokenBudgetControl !== undefined){if(typeof data11.tokenBudgetControl !== "boolean"){const err43 = {instancePath:instancePath+"/sessionGoals/tokenBudgetControl",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/tokenBudgetControl/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}if(data11.usageReporting !== undefined){if(typeof data11.usageReporting !== "boolean"){const err44 = {instancePath:instancePath+"/sessionGoals/usageReporting",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/usageReporting/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data11.reasonCode !== undefined){let data20 = data11.reasonCode;if(typeof data20 === "string"){if(func1(data20) > 160){const err45 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(func1(data20) < 1){const err46 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}else {const err47 = {instancePath:instancePath+"/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data11.reason !== undefined){let data21 = data11.reason;if(typeof data21 === "string"){if(func1(data21) > 1000){const err48 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}if(func1(data21) < 1){const err49 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {instancePath:instancePath+"/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath:instancePath+"/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data.unsupported !== undefined){let data22 = data.unsupported;if(Array.isArray(data22)){const len1 = data22.length;for(let i2=0; i2 1){const indices0 = {};for(;i3--;){let item0 = data22[i3];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j1 = indices0[item0];const err54 = {instancePath:instancePath+"/unsupported",schemaPath:"#/properties/unsupported/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;break;}indices0[item0] = i3;}}}else {const err55 = {instancePath:instancePath+"/unsupported",schemaPath:"#/properties/unsupported/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data.externalProvider !== undefined){if(typeof data.externalProvider !== "boolean"){const err56 = {instancePath:instancePath+"/externalProvider",schemaPath:"#/properties/externalProvider/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}}else {const err57 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}validate26.errors = vErrors;return errors === 0;}validate26.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema50 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/event.schema.json","title":"PRP native event","type":"object","required":["schema","sourceEventId","sourceSeq","sourceInstanceId","sourceKind","runId","eventType","schemaVersion","priority","emittedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.event.v1"},"sourceEventId":{"type":"string","minLength":1,"maxLength":160},"sourceSeq":{"type":"integer","minimum":1,"maximum":9007199254740991},"sourceInstanceId":{"type":"string","minLength":1,"maxLength":160},"sourceKind":{"enum":["runner","control_plane"]},"runId":{"type":"string","minLength":1,"maxLength":160},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":160},"turnId":{"type":"string","minLength":1,"maxLength":240},"itemId":{"type":"string","minLength":1,"maxLength":240},"eventType":{"enum":["runner.connected","runner.reconnected","runner.reconciled","runner.disconnected","runner.draining","runner.backpressure","runner.suspending","runner.suspended","runner.stopped","runner.diagnostic","runtime.phase.changed","sandbox.metric","workspace.ready","workspace.change.updated","workspace.diff.recorded","workspace.file.referenced","harness.starting","harness.ready","harness.exited","harness.diagnostic","plan.updated","tool.execution.started","tool.execution.progressed","tool.execution.completed","research.started","research.progressed","research.completed","delegation.started","delegation.updated","delegation.completed","model.route.changed","model.verification.updated","context.compacted","artifact.viewed","artifact.generated","review.mode.changed","hook.started","hook.completed","memory.citation.referenced","safety.review.started","safety.review.completed","terminal.input.sent","wait.started","wait.completed","provider.notice.recorded","session.starting","session.started","session.resuming","session.resumed","session.reconciled","session.updated","session.closed","session.failed","turn.submitted","turn.accepted","turn.started","turn.completed","turn.failed","turn.interrupted","turn.cancelled","item.started","item.delta","item.completed","item.failed","usage.reported","semantic_tool.input","semantic_tool.result","semantic_tool.reconciled","mcp_app.discovered","mcp_app.resource.resolved","mcp_app.initializing","mcp_app.ready","mcp_app.tool_input","mcp_app.tool_result","mcp_app.action.requested","mcp_app.action.resolved","mcp_app.host_context.changed","mcp_app.failed","mcp_app.teardown","runtime_request.created","runtime_request.resolved","runtime_request.expired","runtime_request.cancelled","interaction.request.proposed","interaction.request.materialized","interaction.request.rejected","interaction.response.progressed","interaction.response.resolved","interaction.response.delivered","run.attached","run.detached","run.result.proposed","run.result.accepted","run.result.rejected","attention.request.proposed","attention.request.routed","attention.request.resolved","attention.request.expired","attention.request.superseded","work.assessment.recorded","issue.status.decision.recorded","issue.status.decision.applied","issue.status.decision.rejected","issue.status.decision.superseded","run.terminal"]},"schemaVersion":{"const":1},"priority":{"enum":[0,1,2]},"emittedAt":{"type":"string","format":"date-time"},"observedAt":{"type":"string","format":"date-time"},"payload":{"type":"object","properties":{"channel":{"enum":["progress","final","summary","detail","unknown"]},"providerPhase":{"type":"string"},"providerMethod":{"type":"string"}},"additionalProperties":true},"debug":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"eventType":{"const":"plan.updated"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/plan"}}}},{"if":{"properties":{"eventType":{"enum":["tool.execution.started","tool.execution.progressed","tool.execution.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/toolExecution"}}}},{"if":{"properties":{"eventType":{"enum":["research.started","research.progressed","research.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/research"}}}},{"if":{"properties":{"eventType":{"enum":["delegation.started","delegation.updated","delegation.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/delegation"}}}},{"if":{"properties":{"eventType":{"const":"model.route.changed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelRoute"}}}},{"if":{"properties":{"eventType":{"const":"model.verification.updated"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelVerification"}}}},{"if":{"properties":{"eventType":{"const":"context.compacted"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/contextCompacted"}}}},{"if":{"properties":{"eventType":{"const":"artifact.viewed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactViewed"}}}},{"if":{"properties":{"eventType":{"const":"artifact.generated"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactGenerated"}}}},{"if":{"properties":{"eventType":{"const":"review.mode.changed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/reviewMode"}}}},{"if":{"properties":{"eventType":{"enum":["hook.started","hook.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/hook"}}}},{"if":{"properties":{"eventType":{"const":"memory.citation.referenced"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/memoryCitation"}}}},{"if":{"properties":{"eventType":{"enum":["safety.review.started","safety.review.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/safetyReview"}}}},{"if":{"properties":{"eventType":{"const":"terminal.input.sent"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/terminalInput"}}}},{"if":{"properties":{"eventType":{"enum":["wait.started","wait.completed"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/wait"}}}},{"if":{"properties":{"eventType":{"const":"provider.notice.recorded"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/providerNotice"}}}},{"if":{"properties":{"eventType":{"const":"workspace.file.referenced"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json"}}}},{"if":{"properties":{"eventType":{"enum":["workspace.change.updated","workspace.diff.recorded"]}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json"}}}},{"if":{"properties":{"eventType":{"const":"workspace.diff.recorded"}}},"then":{"properties":{"payload":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json"},{"type":"object","properties":{"complete":{"const":true}}}]}}}},{"if":{"properties":{"eventType":{"enum":["semantic_tool.input","mcp_app.tool_input"]}}},"then":{"properties":{"payload":{"type":"object","properties":{"semantic_tool":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"},{"type":"object","properties":{"phase":{"const":"input"}}}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"enum":["semantic_tool.result","mcp_app.tool_result"]}}},"then":{"properties":{"payload":{"type":"object","properties":{"semantic_tool":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"},{"type":"object","properties":{"phase":{"const":"result"}}}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"const":"semantic_tool.reconciled"}}},"then":{"properties":{"payload":{"type":"object","properties":{"semantic_tool":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"},{"type":"object","properties":{"phase":{"const":"reconciled"}}}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"const":"run.terminal"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/terminal.schema.json"}}}},{"if":{"properties":{"eventType":{"const":"run.result.proposed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}}}}],"additionalProperties":true};const schema174 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json","title":"Paperclip workspace file reference","type":"object","required":["schema","referenceId","source","path","displayName","mediaType","presentation","line","preview","previewTruncated","contentDigest"],"properties":{"schema":{"const":"paperclip.workspace.file_reference.v1"},"referenceId":{"type":"string","minLength":1,"maxLength":240},"source":{"enum":["harness_reported","runner_verified"]},"path":{"type":"string","minLength":1,"maxLength":1024,"pattern":"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},"displayName":{"type":"string","minLength":1,"maxLength":255},"mediaType":{"type":["string","null"],"maxLength":160},"presentation":{"enum":["document","code","image","generic"]},"line":{"type":["integer","null"],"minimum":1},"preview":{"type":["string","null"],"maxLength":262144},"previewTruncated":{"type":"boolean"},"contentDigest":{"oneOf":[{"type":"null"},{"type":"string","pattern":"^sha256:[a-f0-9]{64}$"}]}},"additionalProperties":false};const schema175 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json","title":"Paperclip workspace diff","type":"object","required":["schema","changeSetId","revision","source","complete","files","totals","patchArtifactRef"],"properties":{"schema":{"const":"paperclip.workspace.diff.v1"},"changeSetId":{"type":"string","minLength":1,"maxLength":200},"revision":{"type":"integer","minimum":1},"source":{"enum":["harness_reported","runner_verified"]},"complete":{"type":"boolean"},"files":{"type":"array","maxItems":2000,"items":{"type":"object","required":["path","operation","previousPath","additions","deletions","binary","diff"],"properties":{"path":{"type":"string","minLength":1,"maxLength":1024,"pattern":"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},"operation":{"enum":["create","modify","delete","rename","mode_change"]},"previousPath":{"oneOf":[{"type":"null"},{"type":"string","minLength":1,"maxLength":1024,"pattern":"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"}]},"additions":{"type":["integer","null"],"minimum":0},"deletions":{"type":["integer","null"],"minimum":0},"binary":{"type":"boolean"},"diff":{"type":["string","null"],"maxLength":262144}},"additionalProperties":false}},"totals":{"type":"object","required":["files","additions","deletions"],"properties":{"files":{"type":"integer","minimum":0},"additions":{"type":["integer","null"],"minimum":0},"deletions":{"type":["integer","null"],"minimum":0}},"additionalProperties":false},"patchArtifactRef":{"type":["string","null"],"maxLength":2048}},"additionalProperties":false};const schema52 = {"type":"object","description":"A complete provider-authored within-turn checklist snapshot. Consumers replace the prior snapshot with the same planId in PRP sourceSeq order; this is not Paperclip's durable Plan document.","required":["schema","planId","revision","steps","complete","syncStatus"],"properties":{"schema":{"const":"paperclip.plan.updated.v1"},"planId":{"$ref":"#/$defs/id"},"revision":{"type":"integer","minimum":1},"explanation":{"$ref":"#/$defs/nullableShortText"},"steps":{"type":"array","maxItems":256,"items":{"type":"object","required":["stepId","body","status"],"properties":{"stepId":{"$ref":"#/$defs/id"},"body":{"$ref":"#/$defs/shortText"},"status":{"enum":["pending","in_progress","completed","blocked"]}},"additionalProperties":false}},"complete":{"type":"boolean"},"syncStatus":{"description":"Legacy compatibility field. Within-turn checklist snapshots use not_applicable.","enum":["streaming","pending","synchronized","conflict","not_applicable"]},"documentRevision":{"description":"Legacy compatibility field. Within-turn checklist snapshots use null.","type":["integer","null"],"minimum":1}},"additionalProperties":false};const schema53 = {"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};const schema54 = {"type":["string","null"],"maxLength":4000};const schema56 = {"type":"string","maxLength":4000};function validate62(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate62.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.planId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "planId"},message:"must have required property '"+"planId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.revision === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.steps === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "steps"},message:"must have required property '"+"steps"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.complete === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "complete"},message:"must have required property '"+"complete"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.syncStatus === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "syncStatus"},message:"must have required property '"+"syncStatus"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((((key0 === "schema") || (key0 === "planId")) || (key0 === "revision")) || (key0 === "explanation")) || (key0 === "steps")) || (key0 === "complete")) || (key0 === "syncStatus")) || (key0 === "documentRevision"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.plan.updated.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.plan.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.planId !== undefined){let data1 = data.planId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/planId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/planId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/planId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/planId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.revision !== undefined){let data2 = data.revision;if(!(((typeof data2 == "number") && (!(data2 % 1) && !isNaN(data2))) && (isFinite(data2)))){const err12 = {instancePath:instancePath+"/revision",schemaPath:"#/properties/revision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if((typeof data2 == "number") && (isFinite(data2))){if(data2 < 1 || isNaN(data2)){const err13 = {instancePath:instancePath+"/revision",schemaPath:"#/properties/revision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}if(data.explanation !== undefined){let data3 = data.explanation;if((typeof data3 !== "string") && (data3 !== null)){const err14 = {instancePath:instancePath+"/explanation",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(typeof data3 === "string"){if(func1(data3) > 4000){const err15 = {instancePath:instancePath+"/explanation",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}if(data.steps !== undefined){let data4 = data.steps;if(Array.isArray(data4)){if(data4.length > 256){const err16 = {instancePath:instancePath+"/steps",schemaPath:"#/properties/steps/maxItems",keyword:"maxItems",params:{limit: 256},message:"must NOT have more than 256 items"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}const len0 = data4.length;for(let i0=0; i0 160){const err21 = {instancePath:instancePath+"/steps/" + i0+"/stepId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data6) < 1){const err22 = {instancePath:instancePath+"/steps/" + i0+"/stepId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data6)){const err23 = {instancePath:instancePath+"/steps/" + i0+"/stepId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/steps/" + i0+"/stepId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data5.body !== undefined){let data7 = data5.body;if(typeof data7 === "string"){if(func1(data7) > 4000){const err25 = {instancePath:instancePath+"/steps/" + i0+"/body",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}else {const err26 = {instancePath:instancePath+"/steps/" + i0+"/body",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data5.status !== undefined){let data8 = data5.status;if(!((((data8 === "pending") || (data8 === "in_progress")) || (data8 === "completed")) || (data8 === "blocked"))){const err27 = {instancePath:instancePath+"/steps/" + i0+"/status",schemaPath:"#/properties/steps/items/properties/status/enum",keyword:"enum",params:{allowedValues: schema52.properties.steps.items.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}}else {const err28 = {instancePath:instancePath+"/steps/" + i0,schemaPath:"#/properties/steps/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}else {const err29 = {instancePath:instancePath+"/steps",schemaPath:"#/properties/steps/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data.complete !== undefined){if(typeof data.complete !== "boolean"){const err30 = {instancePath:instancePath+"/complete",schemaPath:"#/properties/complete/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}if(data.syncStatus !== undefined){let data10 = data.syncStatus;if(!(((((data10 === "streaming") || (data10 === "pending")) || (data10 === "synchronized")) || (data10 === "conflict")) || (data10 === "not_applicable"))){const err31 = {instancePath:instancePath+"/syncStatus",schemaPath:"#/properties/syncStatus/enum",keyword:"enum",params:{allowedValues: schema52.properties.syncStatus.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data.documentRevision !== undefined){let data11 = data.documentRevision;if((!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))) && (data11 !== null)){const err32 = {instancePath:instancePath+"/documentRevision",schemaPath:"#/properties/documentRevision/type",keyword:"type",params:{type: schema52.properties.documentRevision.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if((typeof data11 == "number") && (isFinite(data11))){if(data11 < 1 || isNaN(data11)){const err33 = {instancePath:instancePath+"/documentRevision",schemaPath:"#/properties/documentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}}else {const err34 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}validate62.errors = vErrors;return errors === 0;}validate62.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema57 = {"type":"object","required":["schema","executionId","transport","operation","status","output","outputBytes","outputTruncated","outputDigest"],"properties":{"schema":{"const":"paperclip.tool.execution.v1"},"executionId":{"$ref":"#/$defs/id"},"transport":{"enum":["process","mcp","dynamic","builtin"]},"operation":{"enum":["read","search","list","execute","edit","unknown"]},"name":{"$ref":"#/$defs/nullableShortText"},"target":{"$ref":"#/$defs/safePath"},"namespace":{"type":["string","null"],"maxLength":240},"readOnly":{"type":["boolean","null"]},"inputUpdated":{"type":"boolean"},"status":{"enum":["running","completed","failed","cancelled","interrupted"]},"durationMs":{"type":["integer","null"],"minimum":0},"exitCode":{"type":["integer","null"]},"progress":{"$ref":"#/$defs/nullableShortText"},"output":{"type":["string","null"],"maxLength":65536},"outputBytes":{"type":"integer","minimum":0},"outputTruncated":{"type":"boolean"},"outputDigest":{"type":["string","null"],"pattern":"^sha256:[a-f0-9]{64}$"}},"additionalProperties":false};const schema60 = {"type":["string","null"],"maxLength":4096,"pattern":"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"};const pattern19 = new RegExp("^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$", "u");const pattern20 = new RegExp("^sha256:[a-f0-9]{64}$", "u");function validate64(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate64.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.executionId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "executionId"},message:"must have required property '"+"executionId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.transport === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "transport"},message:"must have required property '"+"transport"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.operation === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "operation"},message:"must have required property '"+"operation"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.status === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.output === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "output"},message:"must have required property '"+"output"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.outputBytes === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "outputBytes"},message:"must have required property '"+"outputBytes"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.outputTruncated === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "outputTruncated"},message:"must have required property '"+"outputTruncated"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.outputDigest === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "outputDigest"},message:"must have required property '"+"outputDigest"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}for(const key0 in data){if(!(func66.call(schema57.properties, key0))){const err9 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.schema !== undefined){if("paperclip.tool.execution.v1" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.tool.execution.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.executionId !== undefined){let data1 = data.executionId;if(typeof data1 === "string"){if(func1(data1) > 160){const err11 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(func1(data1) < 1){const err12 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(!pattern4.test(data1)){const err13 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}else {const err14 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.transport !== undefined){let data2 = data.transport;if(!((((data2 === "process") || (data2 === "mcp")) || (data2 === "dynamic")) || (data2 === "builtin"))){const err15 = {instancePath:instancePath+"/transport",schemaPath:"#/properties/transport/enum",keyword:"enum",params:{allowedValues: schema57.properties.transport.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.operation !== undefined){let data3 = data.operation;if(!((((((data3 === "read") || (data3 === "search")) || (data3 === "list")) || (data3 === "execute")) || (data3 === "edit")) || (data3 === "unknown"))){const err16 = {instancePath:instancePath+"/operation",schemaPath:"#/properties/operation/enum",keyword:"enum",params:{allowedValues: schema57.properties.operation.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.name !== undefined){let data4 = data.name;if((typeof data4 !== "string") && (data4 !== null)){const err17 = {instancePath:instancePath+"/name",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 4000){const err18 = {instancePath:instancePath+"/name",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}if(data.target !== undefined){let data5 = data.target;if((typeof data5 !== "string") && (data5 !== null)){const err19 = {instancePath:instancePath+"/target",schemaPath:"#/$defs/safePath/type",keyword:"type",params:{type: schema60.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(typeof data5 === "string"){if(func1(data5) > 4096){const err20 = {instancePath:instancePath+"/target",schemaPath:"#/$defs/safePath/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(!pattern19.test(data5)){const err21 = {instancePath:instancePath+"/target",schemaPath:"#/$defs/safePath/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"+"\""};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}if(data.namespace !== undefined){let data6 = data.namespace;if((typeof data6 !== "string") && (data6 !== null)){const err22 = {instancePath:instancePath+"/namespace",schemaPath:"#/properties/namespace/type",keyword:"type",params:{type: schema57.properties.namespace.type},message:"must be string,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(typeof data6 === "string"){if(func1(data6) > 240){const err23 = {instancePath:instancePath+"/namespace",schemaPath:"#/properties/namespace/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}if(data.readOnly !== undefined){let data7 = data.readOnly;if((typeof data7 !== "boolean") && (data7 !== null)){const err24 = {instancePath:instancePath+"/readOnly",schemaPath:"#/properties/readOnly/type",keyword:"type",params:{type: schema57.properties.readOnly.type},message:"must be boolean,null"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.inputUpdated !== undefined){if(typeof data.inputUpdated !== "boolean"){const err25 = {instancePath:instancePath+"/inputUpdated",schemaPath:"#/properties/inputUpdated/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.status !== undefined){let data9 = data.status;if(!(((((data9 === "running") || (data9 === "completed")) || (data9 === "failed")) || (data9 === "cancelled")) || (data9 === "interrupted"))){const err26 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema57.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data.durationMs !== undefined){let data10 = data.durationMs;if((!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))) && (data10 !== null)){const err27 = {instancePath:instancePath+"/durationMs",schemaPath:"#/properties/durationMs/type",keyword:"type",params:{type: schema57.properties.durationMs.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if((typeof data10 == "number") && (isFinite(data10))){if(data10 < 0 || isNaN(data10)){const err28 = {instancePath:instancePath+"/durationMs",schemaPath:"#/properties/durationMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}if(data.exitCode !== undefined){let data11 = data.exitCode;if((!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))) && (data11 !== null)){const err29 = {instancePath:instancePath+"/exitCode",schemaPath:"#/properties/exitCode/type",keyword:"type",params:{type: schema57.properties.exitCode.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data.progress !== undefined){let data12 = data.progress;if((typeof data12 !== "string") && (data12 !== null)){const err30 = {instancePath:instancePath+"/progress",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(typeof data12 === "string"){if(func1(data12) > 4000){const err31 = {instancePath:instancePath+"/progress",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}}if(data.output !== undefined){let data13 = data.output;if((typeof data13 !== "string") && (data13 !== null)){const err32 = {instancePath:instancePath+"/output",schemaPath:"#/properties/output/type",keyword:"type",params:{type: schema57.properties.output.type},message:"must be string,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(typeof data13 === "string"){if(func1(data13) > 65536){const err33 = {instancePath:instancePath+"/output",schemaPath:"#/properties/output/maxLength",keyword:"maxLength",params:{limit: 65536},message:"must NOT have more than 65536 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}if(data.outputBytes !== undefined){let data14 = data.outputBytes;if(!(((typeof data14 == "number") && (!(data14 % 1) && !isNaN(data14))) && (isFinite(data14)))){const err34 = {instancePath:instancePath+"/outputBytes",schemaPath:"#/properties/outputBytes/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if((typeof data14 == "number") && (isFinite(data14))){if(data14 < 0 || isNaN(data14)){const err35 = {instancePath:instancePath+"/outputBytes",schemaPath:"#/properties/outputBytes/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}}if(data.outputTruncated !== undefined){if(typeof data.outputTruncated !== "boolean"){const err36 = {instancePath:instancePath+"/outputTruncated",schemaPath:"#/properties/outputTruncated/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.outputDigest !== undefined){let data16 = data.outputDigest;if((typeof data16 !== "string") && (data16 !== null)){const err37 = {instancePath:instancePath+"/outputDigest",schemaPath:"#/properties/outputDigest/type",keyword:"type",params:{type: schema57.properties.outputDigest.type},message:"must be string,null"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(typeof data16 === "string"){if(!pattern20.test(data16)){const err38 = {instancePath:instancePath+"/outputDigest",schemaPath:"#/properties/outputDigest/pattern",keyword:"pattern",params:{pattern: "^sha256:[a-f0-9]{64}$"},message:"must match pattern \""+"^sha256:[a-f0-9]{64}$"+"\""};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}}}else {const err39 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}validate64.errors = vErrors;return errors === 0;}validate64.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema62 = {"type":"object","required":["schema","researchId","action","status","sources"],"properties":{"schema":{"const":"paperclip.research.v1"},"researchId":{"$ref":"#/$defs/id"},"action":{"enum":["search","open_page","find_in_page","other"]},"status":{"enum":["running","completed","failed","cancelled"]},"query":{"$ref":"#/$defs/nullableShortText"},"url":{"$ref":"#/$defs/safeUrl"},"pattern":{"$ref":"#/$defs/nullableShortText"},"sources":{"type":"array","maxItems":64,"items":{"type":"object","required":["sourceId","title","url","snippet"],"properties":{"sourceId":{"$ref":"#/$defs/id"},"title":{"$ref":"#/$defs/shortText"},"url":{"$ref":"#/$defs/safeUrl"},"snippet":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false}}},"additionalProperties":false};const schema65 = {"type":["string","null"],"maxLength":8192,"pattern":"^https?://"};const pattern22 = new RegExp("^https?://", "u");function validate66(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate66.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.researchId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "researchId"},message:"must have required property '"+"researchId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.action === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "action"},message:"must have required property '"+"action"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.status === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.sources === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sources"},message:"must have required property '"+"sources"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!((((((((key0 === "schema") || (key0 === "researchId")) || (key0 === "action")) || (key0 === "status")) || (key0 === "query")) || (key0 === "url")) || (key0 === "pattern")) || (key0 === "sources"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.research.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.research.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.researchId !== undefined){let data1 = data.researchId;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/researchId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/researchId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/researchId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/researchId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.action !== undefined){let data2 = data.action;if(!((((data2 === "search") || (data2 === "open_page")) || (data2 === "find_in_page")) || (data2 === "other"))){const err11 = {instancePath:instancePath+"/action",schemaPath:"#/properties/action/enum",keyword:"enum",params:{allowedValues: schema62.properties.action.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.status !== undefined){let data3 = data.status;if(!((((data3 === "running") || (data3 === "completed")) || (data3 === "failed")) || (data3 === "cancelled"))){const err12 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema62.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.query !== undefined){let data4 = data.query;if((typeof data4 !== "string") && (data4 !== null)){const err13 = {instancePath:instancePath+"/query",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 4000){const err14 = {instancePath:instancePath+"/query",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}if(data.url !== undefined){let data5 = data.url;if((typeof data5 !== "string") && (data5 !== null)){const err15 = {instancePath:instancePath+"/url",schemaPath:"#/$defs/safeUrl/type",keyword:"type",params:{type: schema65.type},message:"must be string,null"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(typeof data5 === "string"){if(func1(data5) > 8192){const err16 = {instancePath:instancePath+"/url",schemaPath:"#/$defs/safeUrl/maxLength",keyword:"maxLength",params:{limit: 8192},message:"must NOT have more than 8192 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(!pattern22.test(data5)){const err17 = {instancePath:instancePath+"/url",schemaPath:"#/$defs/safeUrl/pattern",keyword:"pattern",params:{pattern: "^https?://"},message:"must match pattern \""+"^https?://"+"\""};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}if(data.pattern !== undefined){let data6 = data.pattern;if((typeof data6 !== "string") && (data6 !== null)){const err18 = {instancePath:instancePath+"/pattern",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(typeof data6 === "string"){if(func1(data6) > 4000){const err19 = {instancePath:instancePath+"/pattern",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}if(data.sources !== undefined){let data7 = data.sources;if(Array.isArray(data7)){if(data7.length > 64){const err20 = {instancePath:instancePath+"/sources",schemaPath:"#/properties/sources/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}const len0 = data7.length;for(let i0=0; i0 160){const err26 = {instancePath:instancePath+"/sources/" + i0+"/sourceId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(func1(data9) < 1){const err27 = {instancePath:instancePath+"/sources/" + i0+"/sourceId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(!pattern4.test(data9)){const err28 = {instancePath:instancePath+"/sources/" + i0+"/sourceId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}else {const err29 = {instancePath:instancePath+"/sources/" + i0+"/sourceId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data8.title !== undefined){let data10 = data8.title;if(typeof data10 === "string"){if(func1(data10) > 4000){const err30 = {instancePath:instancePath+"/sources/" + i0+"/title",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}else {const err31 = {instancePath:instancePath+"/sources/" + i0+"/title",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data8.url !== undefined){let data11 = data8.url;if((typeof data11 !== "string") && (data11 !== null)){const err32 = {instancePath:instancePath+"/sources/" + i0+"/url",schemaPath:"#/$defs/safeUrl/type",keyword:"type",params:{type: schema65.type},message:"must be string,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(typeof data11 === "string"){if(func1(data11) > 8192){const err33 = {instancePath:instancePath+"/sources/" + i0+"/url",schemaPath:"#/$defs/safeUrl/maxLength",keyword:"maxLength",params:{limit: 8192},message:"must NOT have more than 8192 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(!pattern22.test(data11)){const err34 = {instancePath:instancePath+"/sources/" + i0+"/url",schemaPath:"#/$defs/safeUrl/pattern",keyword:"pattern",params:{pattern: "^https?://"},message:"must match pattern \""+"^https?://"+"\""};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}if(data8.snippet !== undefined){let data12 = data8.snippet;if((typeof data12 !== "string") && (data12 !== null)){const err35 = {instancePath:instancePath+"/sources/" + i0+"/snippet",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(typeof data12 === "string"){if(func1(data12) > 4000){const err36 = {instancePath:instancePath+"/sources/" + i0+"/snippet",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}}}else {const err37 = {instancePath:instancePath+"/sources/" + i0,schemaPath:"#/properties/sources/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}}else {const err38 = {instancePath:instancePath+"/sources",schemaPath:"#/properties/sources/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}}else {const err39 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}validate66.errors = vErrors;return errors === 0;}validate66.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema71 = {"type":"object","required":["schema","delegationId","action","status","children"],"properties":{"schema":{"const":"paperclip.delegation.v1"},"delegationId":{"$ref":"#/$defs/id"},"action":{"enum":["spawn","message","resume","wait","close"]},"status":{"enum":["running","waiting","completed","failed","interrupted","closed"]},"children":{"type":"array","maxItems":64,"items":{"type":"object","required":["childId","role","model","status","summary","activitySummary"],"properties":{"childId":{"$ref":"#/$defs/id"},"role":{"type":["string","null"],"maxLength":160},"model":{"type":["string","null"],"maxLength":240},"status":{"enum":["running","waiting","completed","failed","interrupted","closed"]},"summary":{"$ref":"#/$defs/nullableShortText"},"activitySummary":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false}}},"additionalProperties":false};function validate68(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate68.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.delegationId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "delegationId"},message:"must have required property '"+"delegationId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.action === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "action"},message:"must have required property '"+"action"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.status === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.children === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "children"},message:"must have required property '"+"children"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "delegationId")) || (key0 === "action")) || (key0 === "status")) || (key0 === "children"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.delegation.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.delegation.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.delegationId !== undefined){let data1 = data.delegationId;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/delegationId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/delegationId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/delegationId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/delegationId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.action !== undefined){let data2 = data.action;if(!(((((data2 === "spawn") || (data2 === "message")) || (data2 === "resume")) || (data2 === "wait")) || (data2 === "close"))){const err11 = {instancePath:instancePath+"/action",schemaPath:"#/properties/action/enum",keyword:"enum",params:{allowedValues: schema71.properties.action.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.status !== undefined){let data3 = data.status;if(!((((((data3 === "running") || (data3 === "waiting")) || (data3 === "completed")) || (data3 === "failed")) || (data3 === "interrupted")) || (data3 === "closed"))){const err12 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema71.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.children !== undefined){let data4 = data.children;if(Array.isArray(data4)){if(data4.length > 64){const err13 = {instancePath:instancePath+"/children",schemaPath:"#/properties/children/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}const len0 = data4.length;for(let i0=0; i0 160){const err21 = {instancePath:instancePath+"/children/" + i0+"/childId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data6) < 1){const err22 = {instancePath:instancePath+"/children/" + i0+"/childId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data6)){const err23 = {instancePath:instancePath+"/children/" + i0+"/childId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/children/" + i0+"/childId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data5.role !== undefined){let data7 = data5.role;if((typeof data7 !== "string") && (data7 !== null)){const err25 = {instancePath:instancePath+"/children/" + i0+"/role",schemaPath:"#/properties/children/items/properties/role/type",keyword:"type",params:{type: schema71.properties.children.items.properties.role.type},message:"must be string,null"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(typeof data7 === "string"){if(func1(data7) > 160){const err26 = {instancePath:instancePath+"/children/" + i0+"/role",schemaPath:"#/properties/children/items/properties/role/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}if(data5.model !== undefined){let data8 = data5.model;if((typeof data8 !== "string") && (data8 !== null)){const err27 = {instancePath:instancePath+"/children/" + i0+"/model",schemaPath:"#/properties/children/items/properties/model/type",keyword:"type",params:{type: schema71.properties.children.items.properties.model.type},message:"must be string,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(typeof data8 === "string"){if(func1(data8) > 240){const err28 = {instancePath:instancePath+"/children/" + i0+"/model",schemaPath:"#/properties/children/items/properties/model/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}if(data5.status !== undefined){let data9 = data5.status;if(!((((((data9 === "running") || (data9 === "waiting")) || (data9 === "completed")) || (data9 === "failed")) || (data9 === "interrupted")) || (data9 === "closed"))){const err29 = {instancePath:instancePath+"/children/" + i0+"/status",schemaPath:"#/properties/children/items/properties/status/enum",keyword:"enum",params:{allowedValues: schema71.properties.children.items.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data5.summary !== undefined){let data10 = data5.summary;if((typeof data10 !== "string") && (data10 !== null)){const err30 = {instancePath:instancePath+"/children/" + i0+"/summary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(typeof data10 === "string"){if(func1(data10) > 4000){const err31 = {instancePath:instancePath+"/children/" + i0+"/summary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}}if(data5.activitySummary !== undefined){let data11 = data5.activitySummary;if((typeof data11 !== "string") && (data11 !== null)){const err32 = {instancePath:instancePath+"/children/" + i0+"/activitySummary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(typeof data11 === "string"){if(func1(data11) > 4000){const err33 = {instancePath:instancePath+"/children/" + i0+"/activitySummary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}}else {const err34 = {instancePath:instancePath+"/children/" + i0,schemaPath:"#/properties/children/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}else {const err35 = {instancePath:instancePath+"/children",schemaPath:"#/properties/children/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}}else {const err36 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}validate68.errors = vErrors;return errors === 0;}validate68.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema76 = {"type":"object","required":["schema","routeId","provider","requestedModel","fromModel","effectiveModel","reason"],"properties":{"schema":{"const":"paperclip.model.route_changed.v1"},"routeId":{"$ref":"#/$defs/id"},"provider":{"type":"string","minLength":1,"maxLength":160},"requestedModel":{"type":"string","minLength":1,"maxLength":240},"fromModel":{"type":["string","null"],"maxLength":240},"effectiveModel":{"type":"string","minLength":1,"maxLength":240},"reason":{"$ref":"#/$defs/shortText"}},"additionalProperties":false};function validate70(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate70.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.routeId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "routeId"},message:"must have required property '"+"routeId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.provider === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.requestedModel === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "requestedModel"},message:"must have required property '"+"requestedModel"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.fromModel === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "fromModel"},message:"must have required property '"+"fromModel"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.effectiveModel === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "effectiveModel"},message:"must have required property '"+"effectiveModel"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.reason === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reason"},message:"must have required property '"+"reason"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}for(const key0 in data){if(!(((((((key0 === "schema") || (key0 === "routeId")) || (key0 === "provider")) || (key0 === "requestedModel")) || (key0 === "fromModel")) || (key0 === "effectiveModel")) || (key0 === "reason"))){const err7 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.schema !== undefined){if("paperclip.model.route_changed.v1" !== data.schema){const err8 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.model.route_changed.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.routeId !== undefined){let data1 = data.routeId;if(typeof data1 === "string"){if(func1(data1) > 160){const err9 = {instancePath:instancePath+"/routeId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data1) < 1){const err10 = {instancePath:instancePath+"/routeId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data1)){const err11 = {instancePath:instancePath+"/routeId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/routeId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.provider !== undefined){let data2 = data.provider;if(typeof data2 === "string"){if(func1(data2) > 160){const err13 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data2) < 1){const err14 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.requestedModel !== undefined){let data3 = data.requestedModel;if(typeof data3 === "string"){if(func1(data3) > 240){const err16 = {instancePath:instancePath+"/requestedModel",schemaPath:"#/properties/requestedModel/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data3) < 1){const err17 = {instancePath:instancePath+"/requestedModel",schemaPath:"#/properties/requestedModel/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}else {const err18 = {instancePath:instancePath+"/requestedModel",schemaPath:"#/properties/requestedModel/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.fromModel !== undefined){let data4 = data.fromModel;if((typeof data4 !== "string") && (data4 !== null)){const err19 = {instancePath:instancePath+"/fromModel",schemaPath:"#/properties/fromModel/type",keyword:"type",params:{type: schema76.properties.fromModel.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 240){const err20 = {instancePath:instancePath+"/fromModel",schemaPath:"#/properties/fromModel/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data.effectiveModel !== undefined){let data5 = data.effectiveModel;if(typeof data5 === "string"){if(func1(data5) > 240){const err21 = {instancePath:instancePath+"/effectiveModel",schemaPath:"#/properties/effectiveModel/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data5) < 1){const err22 = {instancePath:instancePath+"/effectiveModel",schemaPath:"#/properties/effectiveModel/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}else {const err23 = {instancePath:instancePath+"/effectiveModel",schemaPath:"#/properties/effectiveModel/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}if(data.reason !== undefined){let data6 = data.reason;if(typeof data6 === "string"){if(func1(data6) > 4000){const err24 = {instancePath:instancePath+"/reason",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}else {const err25 = {instancePath:instancePath+"/reason",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}else {const err26 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}validate70.errors = vErrors;return errors === 0;}validate70.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema79 = {"type":"object","required":["schema","verificationId","status","classes","buffering","summary"],"properties":{"schema":{"const":"paperclip.model.verification.v1"},"verificationId":{"$ref":"#/$defs/id"},"status":{"enum":["running","completed","failed"]},"classes":{"type":"array","maxItems":32,"items":{"type":"string","maxLength":160}},"buffering":{"type":"boolean"},"summary":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate72(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate72.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.verificationId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "verificationId"},message:"must have required property '"+"verificationId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.status === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.classes === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "classes"},message:"must have required property '"+"classes"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.buffering === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "buffering"},message:"must have required property '"+"buffering"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.summary === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "verificationId")) || (key0 === "status")) || (key0 === "classes")) || (key0 === "buffering")) || (key0 === "summary"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.model.verification.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.model.verification.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.verificationId !== undefined){let data1 = data.verificationId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/verificationId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/verificationId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/verificationId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/verificationId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.status !== undefined){let data2 = data.status;if(!(((data2 === "running") || (data2 === "completed")) || (data2 === "failed"))){const err12 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema79.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.classes !== undefined){let data3 = data.classes;if(Array.isArray(data3)){if(data3.length > 32){const err13 = {instancePath:instancePath+"/classes",schemaPath:"#/properties/classes/maxItems",keyword:"maxItems",params:{limit: 32},message:"must NOT have more than 32 items"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}const len0 = data3.length;for(let i0=0; i0 160){const err14 = {instancePath:instancePath+"/classes/" + i0,schemaPath:"#/properties/classes/items/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/classes/" + i0,schemaPath:"#/properties/classes/items/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}else {const err16 = {instancePath:instancePath+"/classes",schemaPath:"#/properties/classes/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.buffering !== undefined){if(typeof data.buffering !== "boolean"){const err17 = {instancePath:instancePath+"/buffering",schemaPath:"#/properties/buffering/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.summary !== undefined){let data6 = data.summary;if((typeof data6 !== "string") && (data6 !== null)){const err18 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(typeof data6 === "string"){if(func1(data6) > 4000){const err19 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}}else {const err20 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}validate72.errors = vErrors;return errors === 0;}validate72.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema82 = {"type":"object","required":["schema","compactionId","reason","preTokens","postTokens","sameSession"],"properties":{"schema":{"const":"paperclip.context.compacted.v1"},"compactionId":{"$ref":"#/$defs/id"},"reason":{"enum":["context_window","manual","provider","unknown"]},"preTokens":{"type":["integer","null"],"minimum":0},"postTokens":{"type":["integer","null"],"minimum":0},"sameSession":{"type":"boolean"}},"additionalProperties":false};function validate74(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate74.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.compactionId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "compactionId"},message:"must have required property '"+"compactionId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.reason === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reason"},message:"must have required property '"+"reason"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.preTokens === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "preTokens"},message:"must have required property '"+"preTokens"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.postTokens === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "postTokens"},message:"must have required property '"+"postTokens"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.sameSession === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sameSession"},message:"must have required property '"+"sameSession"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "compactionId")) || (key0 === "reason")) || (key0 === "preTokens")) || (key0 === "postTokens")) || (key0 === "sameSession"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.context.compacted.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.context.compacted.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.compactionId !== undefined){let data1 = data.compactionId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/compactionId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/compactionId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/compactionId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/compactionId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.reason !== undefined){let data2 = data.reason;if(!((((data2 === "context_window") || (data2 === "manual")) || (data2 === "provider")) || (data2 === "unknown"))){const err12 = {instancePath:instancePath+"/reason",schemaPath:"#/properties/reason/enum",keyword:"enum",params:{allowedValues: schema82.properties.reason.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.preTokens !== undefined){let data3 = data.preTokens;if((!(((typeof data3 == "number") && (!(data3 % 1) && !isNaN(data3))) && (isFinite(data3)))) && (data3 !== null)){const err13 = {instancePath:instancePath+"/preTokens",schemaPath:"#/properties/preTokens/type",keyword:"type",params:{type: schema82.properties.preTokens.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if((typeof data3 == "number") && (isFinite(data3))){if(data3 < 0 || isNaN(data3)){const err14 = {instancePath:instancePath+"/preTokens",schemaPath:"#/properties/preTokens/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}if(data.postTokens !== undefined){let data4 = data.postTokens;if((!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))) && (data4 !== null)){const err15 = {instancePath:instancePath+"/postTokens",schemaPath:"#/properties/postTokens/type",keyword:"type",params:{type: schema82.properties.postTokens.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if((typeof data4 == "number") && (isFinite(data4))){if(data4 < 0 || isNaN(data4)){const err16 = {instancePath:instancePath+"/postTokens",schemaPath:"#/properties/postTokens/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}if(data.sameSession !== undefined){if(typeof data.sameSession !== "boolean"){const err17 = {instancePath:instancePath+"/sameSession",schemaPath:"#/properties/sameSession/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}else {const err18 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}validate74.errors = vErrors;return errors === 0;}validate74.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema84 = {"type":"object","required":["schema","artifactId","reference","mediaType","title"],"properties":{"schema":{"const":"paperclip.artifact.viewed.v1"},"artifactId":{"$ref":"#/$defs/id"},"reference":{"$ref":"#/$defs/safePath"},"mediaType":{"type":["string","null"],"maxLength":160},"title":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate76(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate76.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.artifactId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "artifactId"},message:"must have required property '"+"artifactId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.reference === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reference"},message:"must have required property '"+"reference"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.mediaType === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "mediaType"},message:"must have required property '"+"mediaType"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.title === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "title"},message:"must have required property '"+"title"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "artifactId")) || (key0 === "reference")) || (key0 === "mediaType")) || (key0 === "title"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.artifact.viewed.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.artifact.viewed.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.artifactId !== undefined){let data1 = data.artifactId;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.reference !== undefined){let data2 = data.reference;if((typeof data2 !== "string") && (data2 !== null)){const err11 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/type",keyword:"type",params:{type: schema60.type},message:"must be string,null"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(typeof data2 === "string"){if(func1(data2) > 4096){const err12 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(!pattern19.test(data2)){const err13 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"+"\""};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}if(data.mediaType !== undefined){let data3 = data.mediaType;if((typeof data3 !== "string") && (data3 !== null)){const err14 = {instancePath:instancePath+"/mediaType",schemaPath:"#/properties/mediaType/type",keyword:"type",params:{type: schema84.properties.mediaType.type},message:"must be string,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(typeof data3 === "string"){if(func1(data3) > 160){const err15 = {instancePath:instancePath+"/mediaType",schemaPath:"#/properties/mediaType/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}if(data.title !== undefined){let data4 = data.title;if((typeof data4 !== "string") && (data4 !== null)){const err16 = {instancePath:instancePath+"/title",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 4000){const err17 = {instancePath:instancePath+"/title",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}}else {const err18 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}validate76.errors = vErrors;return errors === 0;}validate76.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema88 = {"type":"object","required":["schema","artifactId","status","reference","mediaType","registered","failure"],"properties":{"schema":{"const":"paperclip.artifact.generated.v1"},"artifactId":{"$ref":"#/$defs/id"},"status":{"enum":["running","completed","failed"]},"reference":{"$ref":"#/$defs/safePath"},"mediaType":{"type":["string","null"],"maxLength":160},"registered":{"type":"boolean"},"transparentBackground":{"type":["boolean","null"]},"failure":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate78(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate78.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.artifactId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "artifactId"},message:"must have required property '"+"artifactId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.status === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.reference === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reference"},message:"must have required property '"+"reference"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.mediaType === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "mediaType"},message:"must have required property '"+"mediaType"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.registered === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "registered"},message:"must have required property '"+"registered"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.failure === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "failure"},message:"must have required property '"+"failure"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}for(const key0 in data){if(!((((((((key0 === "schema") || (key0 === "artifactId")) || (key0 === "status")) || (key0 === "reference")) || (key0 === "mediaType")) || (key0 === "registered")) || (key0 === "transparentBackground")) || (key0 === "failure"))){const err7 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.schema !== undefined){if("paperclip.artifact.generated.v1" !== data.schema){const err8 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.artifact.generated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.artifactId !== undefined){let data1 = data.artifactId;if(typeof data1 === "string"){if(func1(data1) > 160){const err9 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data1) < 1){const err10 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data1)){const err11 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/artifactId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.status !== undefined){let data2 = data.status;if(!(((data2 === "running") || (data2 === "completed")) || (data2 === "failed"))){const err13 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema88.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.reference !== undefined){let data3 = data.reference;if((typeof data3 !== "string") && (data3 !== null)){const err14 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/type",keyword:"type",params:{type: schema60.type},message:"must be string,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(typeof data3 === "string"){if(func1(data3) > 4096){const err15 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(!pattern19.test(data3)){const err16 = {instancePath:instancePath+"/reference",schemaPath:"#/$defs/safePath/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$"+"\""};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}if(data.mediaType !== undefined){let data4 = data.mediaType;if((typeof data4 !== "string") && (data4 !== null)){const err17 = {instancePath:instancePath+"/mediaType",schemaPath:"#/properties/mediaType/type",keyword:"type",params:{type: schema88.properties.mediaType.type},message:"must be string,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(typeof data4 === "string"){if(func1(data4) > 160){const err18 = {instancePath:instancePath+"/mediaType",schemaPath:"#/properties/mediaType/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}if(data.registered !== undefined){if(typeof data.registered !== "boolean"){const err19 = {instancePath:instancePath+"/registered",schemaPath:"#/properties/registered/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.transparentBackground !== undefined){let data6 = data.transparentBackground;if((typeof data6 !== "boolean") && (data6 !== null)){const err20 = {instancePath:instancePath+"/transparentBackground",schemaPath:"#/properties/transparentBackground/type",keyword:"type",params:{type: schema88.properties.transparentBackground.type},message:"must be boolean,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.failure !== undefined){let data7 = data.failure;if((typeof data7 !== "string") && (data7 !== null)){const err21 = {instancePath:instancePath+"/failure",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(typeof data7 === "string"){if(func1(data7) > 4000){const err22 = {instancePath:instancePath+"/failure",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}}else {const err23 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}validate78.errors = vErrors;return errors === 0;}validate78.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema92 = {"type":"object","required":["schema","reviewId","state","scope"],"properties":{"schema":{"const":"paperclip.review.mode_changed.v1"},"reviewId":{"$ref":"#/$defs/id"},"state":{"enum":["entered","exited"]},"scope":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate80(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate80.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.reviewId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reviewId"},message:"must have required property '"+"reviewId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.state === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "state"},message:"must have required property '"+"state"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.scope === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "scope"},message:"must have required property '"+"scope"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}for(const key0 in data){if(!((((key0 === "schema") || (key0 === "reviewId")) || (key0 === "state")) || (key0 === "scope"))){const err4 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.schema !== undefined){if("paperclip.review.mode_changed.v1" !== data.schema){const err5 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.review.mode_changed.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.reviewId !== undefined){let data1 = data.reviewId;if(typeof data1 === "string"){if(func1(data1) > 160){const err6 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(func1(data1) < 1){const err7 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(!pattern4.test(data1)){const err8 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.state !== undefined){let data2 = data.state;if(!((data2 === "entered") || (data2 === "exited"))){const err10 = {instancePath:instancePath+"/state",schemaPath:"#/properties/state/enum",keyword:"enum",params:{allowedValues: schema92.properties.state.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.scope !== undefined){let data3 = data.scope;if((typeof data3 !== "string") && (data3 !== null)){const err11 = {instancePath:instancePath+"/scope",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(typeof data3 === "string"){if(func1(data3) > 4000){const err12 = {instancePath:instancePath+"/scope",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}}else {const err13 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}validate80.errors = vErrors;return errors === 0;}validate80.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema95 = {"type":"object","required":["schema","hookId","event","scope","status","blocking","durationMs","summary"],"properties":{"schema":{"const":"paperclip.hook.v1"},"hookId":{"$ref":"#/$defs/id"},"event":{"type":"string","minLength":1,"maxLength":160},"scope":{"type":"string","minLength":1,"maxLength":160},"status":{"enum":["running","completed","failed","cancelled"]},"blocking":{"type":"boolean"},"durationMs":{"type":["integer","null"],"minimum":0},"summary":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate82(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate82.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.hookId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "hookId"},message:"must have required property '"+"hookId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.event === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "event"},message:"must have required property '"+"event"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.scope === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "scope"},message:"must have required property '"+"scope"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.status === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.blocking === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "blocking"},message:"must have required property '"+"blocking"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.durationMs === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "durationMs"},message:"must have required property '"+"durationMs"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.summary === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}for(const key0 in data){if(!((((((((key0 === "schema") || (key0 === "hookId")) || (key0 === "event")) || (key0 === "scope")) || (key0 === "status")) || (key0 === "blocking")) || (key0 === "durationMs")) || (key0 === "summary"))){const err8 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.schema !== undefined){if("paperclip.hook.v1" !== data.schema){const err9 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.hook.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.hookId !== undefined){let data1 = data.hookId;if(typeof data1 === "string"){if(func1(data1) > 160){const err10 = {instancePath:instancePath+"/hookId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(func1(data1) < 1){const err11 = {instancePath:instancePath+"/hookId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(!pattern4.test(data1)){const err12 = {instancePath:instancePath+"/hookId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {const err13 = {instancePath:instancePath+"/hookId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.event !== undefined){let data2 = data.event;if(typeof data2 === "string"){if(func1(data2) > 160){const err14 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(func1(data2) < 1){const err15 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.scope !== undefined){let data3 = data.scope;if(typeof data3 === "string"){if(func1(data3) > 160){const err17 = {instancePath:instancePath+"/scope",schemaPath:"#/properties/scope/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data3) < 1){const err18 = {instancePath:instancePath+"/scope",schemaPath:"#/properties/scope/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/scope",schemaPath:"#/properties/scope/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.status !== undefined){let data4 = data.status;if(!((((data4 === "running") || (data4 === "completed")) || (data4 === "failed")) || (data4 === "cancelled"))){const err20 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema95.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.blocking !== undefined){if(typeof data.blocking !== "boolean"){const err21 = {instancePath:instancePath+"/blocking",schemaPath:"#/properties/blocking/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.durationMs !== undefined){let data6 = data.durationMs;if((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (data6 !== null)){const err22 = {instancePath:instancePath+"/durationMs",schemaPath:"#/properties/durationMs/type",keyword:"type",params:{type: schema95.properties.durationMs.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err23 = {instancePath:instancePath+"/durationMs",schemaPath:"#/properties/durationMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}if(data.summary !== undefined){let data7 = data.summary;if((typeof data7 !== "string") && (data7 !== null)){const err24 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(typeof data7 === "string"){if(func1(data7) > 4000){const err25 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}}else {const err26 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}validate82.errors = vErrors;return errors === 0;}validate82.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema98 = {"type":"object","required":["schema","citationId","messageItemId","label","available","reference"],"properties":{"schema":{"const":"paperclip.memory.citation.v1"},"citationId":{"$ref":"#/$defs/id"},"messageItemId":{"$ref":"#/$defs/id"},"label":{"$ref":"#/$defs/shortText"},"available":{"type":"boolean"},"reference":{"type":["string","null"],"maxLength":4096}},"additionalProperties":false};function validate84(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate84.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.citationId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "citationId"},message:"must have required property '"+"citationId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.messageItemId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "messageItemId"},message:"must have required property '"+"messageItemId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.label === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "label"},message:"must have required property '"+"label"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.available === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "available"},message:"must have required property '"+"available"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.reference === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reference"},message:"must have required property '"+"reference"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "citationId")) || (key0 === "messageItemId")) || (key0 === "label")) || (key0 === "available")) || (key0 === "reference"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.memory.citation.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.memory.citation.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.citationId !== undefined){let data1 = data.citationId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/citationId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/citationId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/citationId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/citationId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.messageItemId !== undefined){let data2 = data.messageItemId;if(typeof data2 === "string"){if(func1(data2) > 160){const err12 = {instancePath:instancePath+"/messageItemId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(func1(data2) < 1){const err13 = {instancePath:instancePath+"/messageItemId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(!pattern4.test(data2)){const err14 = {instancePath:instancePath+"/messageItemId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/messageItemId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.label !== undefined){let data3 = data.label;if(typeof data3 === "string"){if(func1(data3) > 4000){const err16 = {instancePath:instancePath+"/label",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}else {const err17 = {instancePath:instancePath+"/label",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.available !== undefined){if(typeof data.available !== "boolean"){const err18 = {instancePath:instancePath+"/available",schemaPath:"#/properties/available/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.reference !== undefined){let data5 = data.reference;if((typeof data5 !== "string") && (data5 !== null)){const err19 = {instancePath:instancePath+"/reference",schemaPath:"#/properties/reference/type",keyword:"type",params:{type: schema98.properties.reference.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(typeof data5 === "string"){if(func1(data5) > 4096){const err20 = {instancePath:instancePath+"/reference",schemaPath:"#/properties/reference/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}}else {const err21 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}validate84.errors = vErrors;return errors === 0;}validate84.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema102 = {"type":"object","required":["schema","reviewId","targetExecutionId","status","decision","summary"],"properties":{"schema":{"const":"paperclip.safety.review.v1"},"reviewId":{"$ref":"#/$defs/id"},"targetExecutionId":{"anyOf":[{"$ref":"#/$defs/id"},{"type":"null"}]},"status":{"enum":["running","completed","failed"]},"decision":{"enum":["pending","allowed","denied","user_required","unknown"]},"summary":{"$ref":"#/$defs/nullableShortText"}},"additionalProperties":false};function validate86(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate86.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.reviewId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reviewId"},message:"must have required property '"+"reviewId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.targetExecutionId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "targetExecutionId"},message:"must have required property '"+"targetExecutionId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.status === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.decision === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "decision"},message:"must have required property '"+"decision"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.summary === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "reviewId")) || (key0 === "targetExecutionId")) || (key0 === "status")) || (key0 === "decision")) || (key0 === "summary"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.safety.review.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.safety.review.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.reviewId !== undefined){let data1 = data.reviewId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/reviewId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.targetExecutionId !== undefined){let data2 = data.targetExecutionId;const _errs7 = errors;let valid2 = false;const _errs8 = errors;if(typeof data2 === "string"){if(func1(data2) > 160){const err12 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(func1(data2) < 1){const err13 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(!pattern4.test(data2)){const err14 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}var _valid0 = _errs8 === errors;valid2 = valid2 || _valid0;const _errs11 = errors;if(data2 !== null){const err16 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/properties/targetExecutionId/anyOf/1/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}var _valid0 = _errs11 === errors;valid2 = valid2 || _valid0;if(!valid2){const err17 = {instancePath:instancePath+"/targetExecutionId",schemaPath:"#/properties/targetExecutionId/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}else {errors = _errs7;if(vErrors !== null){if(_errs7){vErrors.length = _errs7;}else {vErrors = null;}}}}if(data.status !== undefined){let data3 = data.status;if(!(((data3 === "running") || (data3 === "completed")) || (data3 === "failed"))){const err18 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema102.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.decision !== undefined){let data4 = data.decision;if(!(((((data4 === "pending") || (data4 === "allowed")) || (data4 === "denied")) || (data4 === "user_required")) || (data4 === "unknown"))){const err19 = {instancePath:instancePath+"/decision",schemaPath:"#/properties/decision/enum",keyword:"enum",params:{allowedValues: schema102.properties.decision.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.summary !== undefined){let data5 = data.summary;if((typeof data5 !== "string") && (data5 !== null)){const err20 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/type",keyword:"type",params:{type: schema54.type},message:"must be string,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(typeof data5 === "string"){if(func1(data5) > 4000){const err21 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/nullableShortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}}else {const err22 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}validate86.errors = vErrors;return errors === 0;}validate86.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema106 = {"type":"object","required":["schema","executionId","origin","inputClass","byteCount"],"properties":{"schema":{"const":"paperclip.terminal.input_sent.v1"},"executionId":{"$ref":"#/$defs/id"},"origin":{"enum":["agent","user","system"]},"inputClass":{"enum":["text","control","eof"]},"byteCount":{"type":"integer","minimum":0}},"additionalProperties":false};function validate88(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate88.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.executionId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "executionId"},message:"must have required property '"+"executionId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.origin === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "origin"},message:"must have required property '"+"origin"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.inputClass === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "inputClass"},message:"must have required property '"+"inputClass"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.byteCount === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "byteCount"},message:"must have required property '"+"byteCount"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "executionId")) || (key0 === "origin")) || (key0 === "inputClass")) || (key0 === "byteCount"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.terminal.input_sent.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.terminal.input_sent.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.executionId !== undefined){let data1 = data.executionId;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/executionId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.origin !== undefined){let data2 = data.origin;if(!(((data2 === "agent") || (data2 === "user")) || (data2 === "system"))){const err11 = {instancePath:instancePath+"/origin",schemaPath:"#/properties/origin/enum",keyword:"enum",params:{allowedValues: schema106.properties.origin.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.inputClass !== undefined){let data3 = data.inputClass;if(!(((data3 === "text") || (data3 === "control")) || (data3 === "eof"))){const err12 = {instancePath:instancePath+"/inputClass",schemaPath:"#/properties/inputClass/enum",keyword:"enum",params:{allowedValues: schema106.properties.inputClass.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.byteCount !== undefined){let data4 = data.byteCount;if(!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))){const err13 = {instancePath:instancePath+"/byteCount",schemaPath:"#/properties/byteCount/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if((typeof data4 == "number") && (isFinite(data4))){if(data4 < 0 || isNaN(data4)){const err14 = {instancePath:instancePath+"/byteCount",schemaPath:"#/properties/byteCount/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}}else {const err15 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}validate88.errors = vErrors;return errors === 0;}validate88.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema108 = {"type":"object","required":["schema","waitId","reason","status","plannedDurationMs","elapsedDurationMs"],"properties":{"schema":{"const":"paperclip.wait.v1"},"waitId":{"$ref":"#/$defs/id"},"reason":{"enum":["timer","provider","rate_limit","unknown"]},"status":{"enum":["running","completed","interrupted","cancelled"]},"plannedDurationMs":{"type":["integer","null"],"minimum":0},"elapsedDurationMs":{"type":["integer","null"],"minimum":0}},"additionalProperties":false};function validate90(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate90.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.waitId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "waitId"},message:"must have required property '"+"waitId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.reason === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reason"},message:"must have required property '"+"reason"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.status === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.plannedDurationMs === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "plannedDurationMs"},message:"must have required property '"+"plannedDurationMs"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.elapsedDurationMs === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "elapsedDurationMs"},message:"must have required property '"+"elapsedDurationMs"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}for(const key0 in data){if(!((((((key0 === "schema") || (key0 === "waitId")) || (key0 === "reason")) || (key0 === "status")) || (key0 === "plannedDurationMs")) || (key0 === "elapsedDurationMs"))){const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.schema !== undefined){if("paperclip.wait.v1" !== data.schema){const err7 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.wait.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.waitId !== undefined){let data1 = data.waitId;if(typeof data1 === "string"){if(func1(data1) > 160){const err8 = {instancePath:instancePath+"/waitId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(func1(data1) < 1){const err9 = {instancePath:instancePath+"/waitId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(!pattern4.test(data1)){const err10 = {instancePath:instancePath+"/waitId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/waitId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.reason !== undefined){let data2 = data.reason;if(!((((data2 === "timer") || (data2 === "provider")) || (data2 === "rate_limit")) || (data2 === "unknown"))){const err12 = {instancePath:instancePath+"/reason",schemaPath:"#/properties/reason/enum",keyword:"enum",params:{allowedValues: schema108.properties.reason.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.status !== undefined){let data3 = data.status;if(!((((data3 === "running") || (data3 === "completed")) || (data3 === "interrupted")) || (data3 === "cancelled"))){const err13 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema108.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.plannedDurationMs !== undefined){let data4 = data.plannedDurationMs;if((!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))) && (data4 !== null)){const err14 = {instancePath:instancePath+"/plannedDurationMs",schemaPath:"#/properties/plannedDurationMs/type",keyword:"type",params:{type: schema108.properties.plannedDurationMs.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if((typeof data4 == "number") && (isFinite(data4))){if(data4 < 0 || isNaN(data4)){const err15 = {instancePath:instancePath+"/plannedDurationMs",schemaPath:"#/properties/plannedDurationMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}if(data.elapsedDurationMs !== undefined){let data5 = data.elapsedDurationMs;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err16 = {instancePath:instancePath+"/elapsedDurationMs",schemaPath:"#/properties/elapsedDurationMs/type",keyword:"type",params:{type: schema108.properties.elapsedDurationMs.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 0 || isNaN(data5)){const err17 = {instancePath:instancePath+"/elapsedDurationMs",schemaPath:"#/properties/elapsedDurationMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}}else {const err18 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}validate90.errors = vErrors;return errors === 0;}validate90.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema110 = {"type":"object","required":["schema","noticeId","severity","category","scope","recoverable","userActionable","summary"],"properties":{"schema":{"const":"paperclip.provider.notice.v1"},"noticeId":{"$ref":"#/$defs/id"},"severity":{"enum":["info","warning","error"]},"category":{"type":"string","minLength":1,"maxLength":160},"scope":{"enum":["turn","session","environment","account"]},"recoverable":{"type":"boolean"},"userActionable":{"type":"boolean"},"summary":{"$ref":"#/$defs/shortText"},"provenance":{"type":"object","required":["method","eventType","sessionId","turnId"],"properties":{"method":{"type":"string","maxLength":160},"eventType":{"type":"string","maxLength":160},"sessionId":{"type":"string","maxLength":240},"turnId":{"type":"string","maxLength":240},"agentId":{"type":"string","maxLength":240},"timestamp":{"type":"string","maxLength":80}},"additionalProperties":false},"details":{"type":"array","maxItems":64,"items":{"type":"object","required":["name","value"],"properties":{"name":{"type":"string","minLength":1,"maxLength":160},"value":{"type":"string","maxLength":4000}},"additionalProperties":false}}},"additionalProperties":false};function validate92(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate92.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.noticeId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "noticeId"},message:"must have required property '"+"noticeId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.severity === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "severity"},message:"must have required property '"+"severity"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.category === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "category"},message:"must have required property '"+"category"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.scope === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "scope"},message:"must have required property '"+"scope"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.recoverable === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "recoverable"},message:"must have required property '"+"recoverable"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.userActionable === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "userActionable"},message:"must have required property '"+"userActionable"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.summary === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}for(const key0 in data){if(!(func66.call(schema110.properties, key0))){const err8 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.schema !== undefined){if("paperclip.provider.notice.v1" !== data.schema){const err9 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.provider.notice.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.noticeId !== undefined){let data1 = data.noticeId;if(typeof data1 === "string"){if(func1(data1) > 160){const err10 = {instancePath:instancePath+"/noticeId",schemaPath:"#/$defs/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(func1(data1) < 1){const err11 = {instancePath:instancePath+"/noticeId",schemaPath:"#/$defs/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(!pattern4.test(data1)){const err12 = {instancePath:instancePath+"/noticeId",schemaPath:"#/$defs/id/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {const err13 = {instancePath:instancePath+"/noticeId",schemaPath:"#/$defs/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.severity !== undefined){let data2 = data.severity;if(!(((data2 === "info") || (data2 === "warning")) || (data2 === "error"))){const err14 = {instancePath:instancePath+"/severity",schemaPath:"#/properties/severity/enum",keyword:"enum",params:{allowedValues: schema110.properties.severity.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.category !== undefined){let data3 = data.category;if(typeof data3 === "string"){if(func1(data3) > 160){const err15 = {instancePath:instancePath+"/category",schemaPath:"#/properties/category/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(func1(data3) < 1){const err16 = {instancePath:instancePath+"/category",schemaPath:"#/properties/category/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}else {const err17 = {instancePath:instancePath+"/category",schemaPath:"#/properties/category/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.scope !== undefined){let data4 = data.scope;if(!((((data4 === "turn") || (data4 === "session")) || (data4 === "environment")) || (data4 === "account"))){const err18 = {instancePath:instancePath+"/scope",schemaPath:"#/properties/scope/enum",keyword:"enum",params:{allowedValues: schema110.properties.scope.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.recoverable !== undefined){if(typeof data.recoverable !== "boolean"){const err19 = {instancePath:instancePath+"/recoverable",schemaPath:"#/properties/recoverable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.userActionable !== undefined){if(typeof data.userActionable !== "boolean"){const err20 = {instancePath:instancePath+"/userActionable",schemaPath:"#/properties/userActionable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.summary !== undefined){let data7 = data.summary;if(typeof data7 === "string"){if(func1(data7) > 4000){const err21 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/shortText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}else {const err22 = {instancePath:instancePath+"/summary",schemaPath:"#/$defs/shortText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.provenance !== undefined){let data8 = data.provenance;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.method === undefined){const err23 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/required",keyword:"required",params:{missingProperty: "method"},message:"must have required property '"+"method"+"'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data8.eventType === undefined){const err24 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/required",keyword:"required",params:{missingProperty: "eventType"},message:"must have required property '"+"eventType"+"'"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(data8.sessionId === undefined){const err25 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/required",keyword:"required",params:{missingProperty: "sessionId"},message:"must have required property '"+"sessionId"+"'"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(data8.turnId === undefined){const err26 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}for(const key1 in data8){if(!((((((key1 === "method") || (key1 === "eventType")) || (key1 === "sessionId")) || (key1 === "turnId")) || (key1 === "agentId")) || (key1 === "timestamp"))){const err27 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}if(data8.method !== undefined){let data9 = data8.method;if(typeof data9 === "string"){if(func1(data9) > 160){const err28 = {instancePath:instancePath+"/provenance/method",schemaPath:"#/properties/provenance/properties/method/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}else {const err29 = {instancePath:instancePath+"/provenance/method",schemaPath:"#/properties/provenance/properties/method/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data8.eventType !== undefined){let data10 = data8.eventType;if(typeof data10 === "string"){if(func1(data10) > 160){const err30 = {instancePath:instancePath+"/provenance/eventType",schemaPath:"#/properties/provenance/properties/eventType/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}else {const err31 = {instancePath:instancePath+"/provenance/eventType",schemaPath:"#/properties/provenance/properties/eventType/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data8.sessionId !== undefined){let data11 = data8.sessionId;if(typeof data11 === "string"){if(func1(data11) > 240){const err32 = {instancePath:instancePath+"/provenance/sessionId",schemaPath:"#/properties/provenance/properties/sessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}else {const err33 = {instancePath:instancePath+"/provenance/sessionId",schemaPath:"#/properties/provenance/properties/sessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}if(data8.turnId !== undefined){let data12 = data8.turnId;if(typeof data12 === "string"){if(func1(data12) > 240){const err34 = {instancePath:instancePath+"/provenance/turnId",schemaPath:"#/properties/provenance/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}else {const err35 = {instancePath:instancePath+"/provenance/turnId",schemaPath:"#/properties/provenance/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}if(data8.agentId !== undefined){let data13 = data8.agentId;if(typeof data13 === "string"){if(func1(data13) > 240){const err36 = {instancePath:instancePath+"/provenance/agentId",schemaPath:"#/properties/provenance/properties/agentId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}else {const err37 = {instancePath:instancePath+"/provenance/agentId",schemaPath:"#/properties/provenance/properties/agentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data8.timestamp !== undefined){let data14 = data8.timestamp;if(typeof data14 === "string"){if(func1(data14) > 80){const err38 = {instancePath:instancePath+"/provenance/timestamp",schemaPath:"#/properties/provenance/properties/timestamp/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}else {const err39 = {instancePath:instancePath+"/provenance/timestamp",schemaPath:"#/properties/provenance/properties/timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}}else {const err40 = {instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}if(data.details !== undefined){let data15 = data.details;if(Array.isArray(data15)){if(data15.length > 64){const err41 = {instancePath:instancePath+"/details",schemaPath:"#/properties/details/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}const len0 = data15.length;for(let i0=0; i0 160){const err45 = {instancePath:instancePath+"/details/" + i0+"/name",schemaPath:"#/properties/details/items/properties/name/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(func1(data17) < 1){const err46 = {instancePath:instancePath+"/details/" + i0+"/name",schemaPath:"#/properties/details/items/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}else {const err47 = {instancePath:instancePath+"/details/" + i0+"/name",schemaPath:"#/properties/details/items/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data16.value !== undefined){let data18 = data16.value;if(typeof data18 === "string"){if(func1(data18) > 4000){const err48 = {instancePath:instancePath+"/details/" + i0+"/value",schemaPath:"#/properties/details/items/properties/value/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}else {const err49 = {instancePath:instancePath+"/details/" + i0+"/value",schemaPath:"#/properties/details/items/properties/value/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}}else {const err50 = {instancePath:instancePath+"/details/" + i0,schemaPath:"#/properties/details/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath:instancePath+"/details",schemaPath:"#/properties/details/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}}else {const err52 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}validate92.errors = vErrors;return errors === 0;}validate92.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema177 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json","title":"PRP provider-neutral semantic tool envelope","type":"object","required":["schema","schemaVersion","phase","operationId","callId","correlation","idempotencyKey","content"],"properties":{"schema":{"const":"paperclip.prp.semantic_tool.v1"},"schemaVersion":{"const":1},"phase":{"enum":["input","result","reconciled"]},"operationId":{"$ref":"#/$defs/stableId"},"callId":{"$ref":"#/$defs/stableId"},"correlation":{"type":"object","required":["runId","normalizedSessionId","turnId","itemId"],"properties":{"runId":{"$ref":"#/$defs/stableId"},"normalizedSessionId":{"$ref":"#/$defs/stableId"},"turnId":{"$ref":"#/$defs/stableId"},"itemId":{"$ref":"#/$defs/stableId"},"requestId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true},"idempotencyKey":{"type":["string","null"],"minLength":1,"maxLength":240},"content":{"$ref":"#/$defs/safeContent"},"outcome":{"enum":["succeeded","denied","conflict","duplicate","unavailable","failed"]},"code":{"$ref":"#/$defs/stableCode"},"retryable":{"type":"boolean"},"authorizationBoundary":{"enum":["company","actor","active_task","grant","governed_action","lock","revision"]},"operationReceiptId":{"$ref":"#/$defs/stableId"},"auditReceiptId":{"$ref":"#/$defs/stableId"},"currentRevision":{"oneOf":[{"type":"integer","minimum":0},{"$ref":"#/$defs/stableId"}]},"duplicateOfReceiptId":{"$ref":"#/$defs/stableId"},"artifactRefs":{"type":"array","items":{"allOf":[{"$ref":"#/$defs/safeReference"},{"type":"object","properties":{"kind":{"enum":["artifact","work_product"]}}}]},"uniqueItems":true},"targets":{"type":"array","items":{"$ref":"#/$defs/immutableTarget"},"uniqueItems":true},"causalRefs":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"allOf":[{"if":{"properties":{"phase":{"enum":["result","reconciled"]}},"required":["phase"]},"then":{"required":["outcome","code","retryable","authorizationBoundary","operationReceiptId"]}}],"additionalProperties":true,"$defs":{"stableId":{"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"stableCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"},"safeContent":{"type":"object","required":["digest","redactionDisposition","references"],"properties":{"digest":{"type":"string","pattern":"^sha256:[a-f0-9]{64}$"},"redactionDisposition":{"enum":["digest_only","allowlisted_references","redacted"]},"references":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"additionalProperties":true},"safeReference":{"type":"object","required":["kind","id"],"properties":{"kind":{"enum":["task","document_revision","interaction","approval","decision","artifact","work_product","wake","monitor","audit","operation"]},"id":{"$ref":"#/$defs/stableId"}},"additionalProperties":true},"immutableTarget":{"type":"object","required":["kind","id","immutable"],"properties":{"kind":{"enum":["document_revision","interaction","approval","decision"]},"id":{"$ref":"#/$defs/stableId"},"immutable":{"const":true},"revisionId":{"$ref":"#/$defs/stableId"},"decisionId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true}}};const schema178 = {"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};const schema188 = {"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"};const pattern14 = new RegExp("^[a-z][a-z0-9_.:-]*$", "u");const schema185 = {"type":"object","required":["digest","redactionDisposition","references"],"properties":{"digest":{"type":"string","pattern":"^sha256:[a-f0-9]{64}$"},"redactionDisposition":{"enum":["digest_only","allowlisted_references","redacted"]},"references":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"additionalProperties":true};const schema186 = {"type":"object","required":["kind","id"],"properties":{"kind":{"enum":["task","document_revision","interaction","approval","decision","artifact","work_product","wake","monitor","audit","operation"]},"id":{"$ref":"#/$defs/stableId"}},"additionalProperties":true};function validate96(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate96.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.kind === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.id === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.kind !== undefined){let data0 = data.kind;if(!(((((((((((data0 === "task") || (data0 === "document_revision")) || (data0 === "interaction")) || (data0 === "approval")) || (data0 === "decision")) || (data0 === "artifact")) || (data0 === "work_product")) || (data0 === "wake")) || (data0 === "monitor")) || (data0 === "audit")) || (data0 === "operation"))){const err2 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema186.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.id !== undefined){let data1 = data.id;if(typeof data1 === "string"){if(func1(data1) > 240){const err3 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(func1(data1) < 1){const err4 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(!pattern4.test(data1)){const err5 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}else {const err6 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}else {const err7 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}validate96.errors = vErrors;return errors === 0;}validate96.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate95(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate95.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.digest === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "digest"},message:"must have required property '"+"digest"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.redactionDisposition === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "redactionDisposition"},message:"must have required property '"+"redactionDisposition"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.references === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "references"},message:"must have required property '"+"references"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.digest !== undefined){let data0 = data.digest;if(typeof data0 === "string"){if(!pattern20.test(data0)){const err3 = {instancePath:instancePath+"/digest",schemaPath:"#/properties/digest/pattern",keyword:"pattern",params:{pattern: "^sha256:[a-f0-9]{64}$"},message:"must match pattern \""+"^sha256:[a-f0-9]{64}$"+"\""};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}else {const err4 = {instancePath:instancePath+"/digest",schemaPath:"#/properties/digest/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.redactionDisposition !== undefined){let data1 = data.redactionDisposition;if(!(((data1 === "digest_only") || (data1 === "allowlisted_references")) || (data1 === "redacted"))){const err5 = {instancePath:instancePath+"/redactionDisposition",schemaPath:"#/properties/redactionDisposition/enum",keyword:"enum",params:{allowedValues: schema185.properties.redactionDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.references !== undefined){let data2 = data.references;if(Array.isArray(data2)){const len0 = data2.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data2[i1], data2[j0])){const err6 = {instancePath:instancePath+"/references",schemaPath:"#/properties/references/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;break outer0;}}}}}else {const err7 = {instancePath:instancePath+"/references",schemaPath:"#/properties/references/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}}else {const err8 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}validate95.errors = vErrors;return errors === 0;}validate95.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema193 = {"type":"object","required":["kind","id","immutable"],"properties":{"kind":{"enum":["document_revision","interaction","approval","decision"]},"id":{"$ref":"#/$defs/stableId"},"immutable":{"const":true},"revisionId":{"$ref":"#/$defs/stableId"},"decisionId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true};function validate100(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate100.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.kind === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.id === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.immutable === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "immutable"},message:"must have required property '"+"immutable"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.kind !== undefined){let data0 = data.kind;if(!((((data0 === "document_revision") || (data0 === "interaction")) || (data0 === "approval")) || (data0 === "decision"))){const err3 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema193.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.id !== undefined){let data1 = data.id;if(typeof data1 === "string"){if(func1(data1) > 240){const err4 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(func1(data1) < 1){const err5 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(!pattern4.test(data1)){const err6 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {const err7 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.immutable !== undefined){if(true !== data.immutable){const err8 = {instancePath:instancePath+"/immutable",schemaPath:"#/properties/immutable/const",keyword:"const",params:{allowedValue: true},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.revisionId !== undefined){let data3 = data.revisionId;if(typeof data3 === "string"){if(func1(data3) > 240){const err9 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data3) < 1){const err10 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data3)){const err11 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.decisionId !== undefined){let data4 = data.decisionId;if(typeof data4 === "string"){if(func1(data4) > 240){const err13 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data4) < 1){const err14 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(!pattern4.test(data4)){const err15 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}else {const err17 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}validate100.errors = vErrors;return errors === 0;}validate100.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate94(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate94.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((data.phase === undefined) && (missing0 = "phase")){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}else {if(data.phase !== undefined){let data0 = data.phase;if(!((data0 === "result") || (data0 === "reconciled"))){const err1 = {};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.outcome === undefined){const err2 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "outcome"},message:"must have required property '"+"outcome"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.code === undefined){const err3 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "code"},message:"must have required property '"+"code"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.retryable === undefined){const err4 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "retryable"},message:"must have required property '"+"retryable"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.authorizationBoundary === undefined){const err5 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "authorizationBoundary"},message:"must have required property '"+"authorizationBoundary"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.operationReceiptId === undefined){const err6 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "operationReceiptId"},message:"must have required property '"+"operationReceiptId"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}var _valid0 = _errs5 === errors;valid1 = _valid0;}if(!valid1){const err7 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.schemaVersion === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.phase === undefined){const err10 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "phase"},message:"must have required property '"+"phase"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data.operationId === undefined){const err11 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "operationId"},message:"must have required property '"+"operationId"+"'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data.callId === undefined){const err12 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "callId"},message:"must have required property '"+"callId"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data.correlation === undefined){const err13 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "correlation"},message:"must have required property '"+"correlation"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data.idempotencyKey === undefined){const err14 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "idempotencyKey"},message:"must have required property '"+"idempotencyKey"+"'"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(data.content === undefined){const err15 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "content"},message:"must have required property '"+"content"+"'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data.schema !== undefined){if("paperclip.prp.semantic_tool.v1" !== data.schema){const err16 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.semantic_tool.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err17 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.phase !== undefined){let data3 = data.phase;if(!(((data3 === "input") || (data3 === "result")) || (data3 === "reconciled"))){const err18 = {instancePath:instancePath+"/phase",schemaPath:"#/properties/phase/enum",keyword:"enum",params:{allowedValues: schema177.properties.phase.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.operationId !== undefined){let data4 = data.operationId;if(typeof data4 === "string"){if(func1(data4) > 240){const err19 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(func1(data4) < 1){const err20 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(!pattern4.test(data4)){const err21 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}else {const err22 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.callId !== undefined){let data5 = data.callId;if(typeof data5 === "string"){if(func1(data5) > 240){const err23 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(func1(data5) < 1){const err24 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(!pattern4.test(data5)){const err25 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}else {const err26 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}if(data.correlation !== undefined){let data6 = data.correlation;if(data6 && typeof data6 == "object" && !Array.isArray(data6)){if(data6.runId === undefined){const err27 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data6.normalizedSessionId === undefined){const err28 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data6.turnId === undefined){const err29 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data6.itemId === undefined){const err30 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "itemId"},message:"must have required property '"+"itemId"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data6.runId !== undefined){let data7 = data6.runId;if(typeof data7 === "string"){if(func1(data7) > 240){const err31 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(func1(data7) < 1){const err32 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(!pattern4.test(data7)){const err33 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}else {const err34 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data6.normalizedSessionId !== undefined){let data8 = data6.normalizedSessionId;if(typeof data8 === "string"){if(func1(data8) > 240){const err35 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(func1(data8) < 1){const err36 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}if(!pattern4.test(data8)){const err37 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}else {const err38 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data6.turnId !== undefined){let data9 = data6.turnId;if(typeof data9 === "string"){if(func1(data9) > 240){const err39 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}if(func1(data9) < 1){const err40 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if(!pattern4.test(data9)){const err41 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}else {const err42 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}if(data6.itemId !== undefined){let data10 = data6.itemId;if(typeof data10 === "string"){if(func1(data10) > 240){const err43 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}if(func1(data10) < 1){const err44 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}if(!pattern4.test(data10)){const err45 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}else {const err46 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}if(data6.requestId !== undefined){let data11 = data6.requestId;if(typeof data11 === "string"){if(func1(data11) > 240){const err47 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}if(func1(data11) < 1){const err48 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}if(!pattern4.test(data11)){const err49 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}else {const err50 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data.idempotencyKey !== undefined){let data12 = data.idempotencyKey;if((typeof data12 !== "string") && (data12 !== null)){const err52 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/type",keyword:"type",params:{type: schema177.properties.idempotencyKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}if(typeof data12 === "string"){if(func1(data12) > 240){const err53 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}if(func1(data12) < 1){const err54 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}}if(data.content !== undefined){if(!(validate95(data.content, {instancePath:instancePath+"/content",parentData:data,parentDataProperty:"content",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate95.errors : vErrors.concat(validate95.errors);errors = vErrors.length;}}if(data.outcome !== undefined){let data14 = data.outcome;if(!((((((data14 === "succeeded") || (data14 === "denied")) || (data14 === "conflict")) || (data14 === "duplicate")) || (data14 === "unavailable")) || (data14 === "failed"))){const err55 = {instancePath:instancePath+"/outcome",schemaPath:"#/properties/outcome/enum",keyword:"enum",params:{allowedValues: schema177.properties.outcome.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data.code !== undefined){let data15 = data.code;if(typeof data15 === "string"){if(func1(data15) > 160){const err56 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}if(func1(data15) < 1){const err57 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}if(!pattern14.test(data15)){const err58 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}else {const err59 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data.retryable !== undefined){if(typeof data.retryable !== "boolean"){const err60 = {instancePath:instancePath+"/retryable",schemaPath:"#/properties/retryable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}if(data.authorizationBoundary !== undefined){let data17 = data.authorizationBoundary;if(!(((((((data17 === "company") || (data17 === "actor")) || (data17 === "active_task")) || (data17 === "grant")) || (data17 === "governed_action")) || (data17 === "lock")) || (data17 === "revision"))){const err61 = {instancePath:instancePath+"/authorizationBoundary",schemaPath:"#/properties/authorizationBoundary/enum",keyword:"enum",params:{allowedValues: schema177.properties.authorizationBoundary.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}if(data.operationReceiptId !== undefined){let data18 = data.operationReceiptId;if(typeof data18 === "string"){if(func1(data18) > 240){const err62 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(func1(data18) < 1){const err63 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}if(!pattern4.test(data18)){const err64 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}else {const err65 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}if(data.auditReceiptId !== undefined){let data19 = data.auditReceiptId;if(typeof data19 === "string"){if(func1(data19) > 240){const err66 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}if(func1(data19) < 1){const err67 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}if(!pattern4.test(data19)){const err68 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}else {const err69 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}if(data.currentRevision !== undefined){let data20 = data.currentRevision;const _errs51 = errors;let valid15 = false;let passing0 = null;const _errs52 = errors;if(!(((typeof data20 == "number") && (!(data20 % 1) && !isNaN(data20))) && (isFinite(data20)))){const err70 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf/0/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if((typeof data20 == "number") && (isFinite(data20))){if(data20 < 0 || isNaN(data20)){const err71 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf/0/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}var _valid1 = _errs52 === errors;if(_valid1){valid15 = true;passing0 = 0;}const _errs54 = errors;if(typeof data20 === "string"){if(func1(data20) > 240){const err72 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(func1(data20) < 1){const err73 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}if(!pattern4.test(data20)){const err74 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}}else {const err75 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}var _valid1 = _errs54 === errors;if(_valid1 && valid15){valid15 = false;passing0 = [passing0, 1];}else {if(_valid1){valid15 = true;passing0 = 1;}}if(!valid15){const err76 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}else {errors = _errs51;if(vErrors !== null){if(_errs51){vErrors.length = _errs51;}else {vErrors = null;}}}}if(data.duplicateOfReceiptId !== undefined){let data21 = data.duplicateOfReceiptId;if(typeof data21 === "string"){if(func1(data21) > 240){const err77 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(func1(data21) < 1){const err78 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}if(!pattern4.test(data21)){const err79 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}}else {const err80 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}}if(data.artifactRefs !== undefined){let data22 = data.artifactRefs;if(Array.isArray(data22)){const len0 = data22.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data22[i1], data22[j0])){const err83 = {instancePath:instancePath+"/artifactRefs",schemaPath:"#/properties/artifactRefs/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;break outer0;}}}}}else {const err84 = {instancePath:instancePath+"/artifactRefs",schemaPath:"#/properties/artifactRefs/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}}if(data.targets !== undefined){let data25 = data.targets;if(Array.isArray(data25)){const len1 = data25.length;for(let i2=0; i2 1){outer1:for(;i3--;){for(j1 = i3; j1--;){if(func0(data25[i3], data25[j1])){const err85 = {instancePath:instancePath+"/targets",schemaPath:"#/properties/targets/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;break outer1;}}}}}else {const err86 = {instancePath:instancePath+"/targets",schemaPath:"#/properties/targets/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}}if(data.causalRefs !== undefined){let data27 = data.causalRefs;if(Array.isArray(data27)){const len2 = data27.length;for(let i4=0; i4 1){outer2:for(;i5--;){for(j2 = i5; j2--;){if(func0(data27[i5], data27[j2])){const err87 = {instancePath:instancePath+"/causalRefs",schemaPath:"#/properties/causalRefs/uniqueItems",keyword:"uniqueItems",params:{i: i5, j: j2},message:"must NOT have duplicate items (items ## "+j2+" and "+i5+" are identical)"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;break outer2;}}}}}else {const err88 = {instancePath:instancePath+"/causalRefs",schemaPath:"#/properties/causalRefs/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}}}else {const err89 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}validate94.errors = vErrors;return errors === 0;}validate94.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema197 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/terminal.schema.json","title":"PRP run terminal state","type":"object","required":["schema","turnTerminalState","runTerminalState","reportedWorkDisposition"],"properties":{"schema":{"const":"paperclip.prp.terminal.v1"},"turnTerminalState":{"enum":["completed","failed","interrupted","cancelled"]},"runTerminalState":{"enum":["succeeded","failed","cancelled"]},"reportedWorkDisposition":{"enum":["done","blocked","needs_review","yielded"]},"workAssessmentId":{"type":"string","minLength":1},"statusDecisionId":{"type":"string","minLength":1},"stopReason":{"$ref":"https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json"}},"additionalProperties":true};const schema198 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json","title":"PRP terminal stop reason receipt","type":"object","required":["schema","schemaVersion","receiptId","kind","code","retryable","decisionId","limitClass","aggregate"],"properties":{"schema":{"const":"paperclip.prp.stop_reason.v1"},"schemaVersion":{"const":1},"receiptId":{"$ref":"#/$defs/stableId"},"kind":{"enum":["budget","cost"]},"code":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"},"retryable":{"type":"boolean"},"decisionId":{"$ref":"#/$defs/stableId"},"limitClass":{"enum":["company_monthly","actor_monthly","project_monthly","run_cost","provider_quota"]},"aggregate":{"type":"object","required":["unit","observed","limit","window"],"properties":{"unit":{"enum":["cents","usd_micros","tokens"]},"observed":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0},"window":{"enum":["run","monthly_utc","provider_window"]}},"additionalProperties":true},"relatedReceiptIds":{"type":"array","items":{"$ref":"#/$defs/stableId"},"uniqueItems":true}},"additionalProperties":true,"$defs":{"stableId":{"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"}}};const schema199 = {"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};function validate107(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate107.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.schemaVersion === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.receiptId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "receiptId"},message:"must have required property '"+"receiptId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.kind === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.code === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "code"},message:"must have required property '"+"code"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.retryable === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "retryable"},message:"must have required property '"+"retryable"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.decisionId === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "decisionId"},message:"must have required property '"+"decisionId"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.limitClass === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "limitClass"},message:"must have required property '"+"limitClass"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.aggregate === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "aggregate"},message:"must have required property '"+"aggregate"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.schema !== undefined){if("paperclip.prp.stop_reason.v1" !== data.schema){const err9 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.stop_reason.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err10 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.receiptId !== undefined){let data2 = data.receiptId;if(typeof data2 === "string"){if(func1(data2) > 240){const err11 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(func1(data2) < 1){const err12 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(!pattern4.test(data2)){const err13 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}else {const err14 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.kind !== undefined){let data3 = data.kind;if(!((data3 === "budget") || (data3 === "cost"))){const err15 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema198.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.code !== undefined){let data4 = data.code;if(typeof data4 === "string"){if(func1(data4) > 160){const err16 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data4) < 1){const err17 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(!pattern14.test(data4)){const err18 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.retryable !== undefined){if(typeof data.retryable !== "boolean"){const err20 = {instancePath:instancePath+"/retryable",schemaPath:"#/properties/retryable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.decisionId !== undefined){let data6 = data.decisionId;if(typeof data6 === "string"){if(func1(data6) > 240){const err21 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data6) < 1){const err22 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data6)){const err23 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.limitClass !== undefined){let data7 = data.limitClass;if(!(((((data7 === "company_monthly") || (data7 === "actor_monthly")) || (data7 === "project_monthly")) || (data7 === "run_cost")) || (data7 === "provider_quota"))){const err25 = {instancePath:instancePath+"/limitClass",schemaPath:"#/properties/limitClass/enum",keyword:"enum",params:{allowedValues: schema198.properties.limitClass.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.aggregate !== undefined){let data8 = data.aggregate;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.unit === undefined){const err26 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "unit"},message:"must have required property '"+"unit"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data8.observed === undefined){const err27 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "observed"},message:"must have required property '"+"observed"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data8.limit === undefined){const err28 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "limit"},message:"must have required property '"+"limit"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data8.window === undefined){const err29 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "window"},message:"must have required property '"+"window"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data8.unit !== undefined){let data9 = data8.unit;if(!(((data9 === "cents") || (data9 === "usd_micros")) || (data9 === "tokens"))){const err30 = {instancePath:instancePath+"/aggregate/unit",schemaPath:"#/properties/aggregate/properties/unit/enum",keyword:"enum",params:{allowedValues: schema198.properties.aggregate.properties.unit.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}if(data8.observed !== undefined){let data10 = data8.observed;if(!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))){const err31 = {instancePath:instancePath+"/aggregate/observed",schemaPath:"#/properties/aggregate/properties/observed/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if((typeof data10 == "number") && (isFinite(data10))){if(data10 < 0 || isNaN(data10)){const err32 = {instancePath:instancePath+"/aggregate/observed",schemaPath:"#/properties/aggregate/properties/observed/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}if(data8.limit !== undefined){let data11 = data8.limit;if(!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))){const err33 = {instancePath:instancePath+"/aggregate/limit",schemaPath:"#/properties/aggregate/properties/limit/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if((typeof data11 == "number") && (isFinite(data11))){if(data11 < 0 || isNaN(data11)){const err34 = {instancePath:instancePath+"/aggregate/limit",schemaPath:"#/properties/aggregate/properties/limit/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}if(data8.window !== undefined){let data12 = data8.window;if(!(((data12 === "run") || (data12 === "monthly_utc")) || (data12 === "provider_window"))){const err35 = {instancePath:instancePath+"/aggregate/window",schemaPath:"#/properties/aggregate/properties/window/enum",keyword:"enum",params:{allowedValues: schema198.properties.aggregate.properties.window.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}}else {const err36 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.relatedReceiptIds !== undefined){let data13 = data.relatedReceiptIds;if(Array.isArray(data13)){const len0 = data13.length;for(let i0=0; i0 240){const err37 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(func1(data14) < 1){const err38 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(!pattern4.test(data14)){const err39 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}else {const err40 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}let i1 = data13.length;let j0;if(i1 > 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data13[i1], data13[j0])){const err41 = {instancePath:instancePath+"/relatedReceiptIds",schemaPath:"#/properties/relatedReceiptIds/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;break outer0;}}}}}else {const err42 = {instancePath:instancePath+"/relatedReceiptIds",schemaPath:"#/properties/relatedReceiptIds/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}else {const err43 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}validate107.errors = vErrors;return errors === 0;}validate107.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate106(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/terminal.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate106.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.turnTerminalState === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "turnTerminalState"},message:"must have required property '"+"turnTerminalState"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.runTerminalState === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runTerminalState"},message:"must have required property '"+"runTerminalState"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.reportedWorkDisposition === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reportedWorkDisposition"},message:"must have required property '"+"reportedWorkDisposition"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.schema !== undefined){if("paperclip.prp.terminal.v1" !== data.schema){const err4 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.terminal.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.turnTerminalState !== undefined){let data1 = data.turnTerminalState;if(!((((data1 === "completed") || (data1 === "failed")) || (data1 === "interrupted")) || (data1 === "cancelled"))){const err5 = {instancePath:instancePath+"/turnTerminalState",schemaPath:"#/properties/turnTerminalState/enum",keyword:"enum",params:{allowedValues: schema197.properties.turnTerminalState.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.runTerminalState !== undefined){let data2 = data.runTerminalState;if(!(((data2 === "succeeded") || (data2 === "failed")) || (data2 === "cancelled"))){const err6 = {instancePath:instancePath+"/runTerminalState",schemaPath:"#/properties/runTerminalState/enum",keyword:"enum",params:{allowedValues: schema197.properties.runTerminalState.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.reportedWorkDisposition !== undefined){let data3 = data.reportedWorkDisposition;if(!((((data3 === "done") || (data3 === "blocked")) || (data3 === "needs_review")) || (data3 === "yielded"))){const err7 = {instancePath:instancePath+"/reportedWorkDisposition",schemaPath:"#/properties/reportedWorkDisposition/enum",keyword:"enum",params:{allowedValues: schema197.properties.reportedWorkDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.workAssessmentId !== undefined){let data4 = data.workAssessmentId;if(typeof data4 === "string"){if(func1(data4) < 1){const err8 = {instancePath:instancePath+"/workAssessmentId",schemaPath:"#/properties/workAssessmentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/workAssessmentId",schemaPath:"#/properties/workAssessmentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.statusDecisionId !== undefined){let data5 = data.statusDecisionId;if(typeof data5 === "string"){if(func1(data5) < 1){const err10 = {instancePath:instancePath+"/statusDecisionId",schemaPath:"#/properties/statusDecisionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/statusDecisionId",schemaPath:"#/properties/statusDecisionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.stopReason !== undefined){if(!(validate107(data.stopReason, {instancePath:instancePath+"/stopReason",parentData:data,parentDataProperty:"stopReason",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate107.errors : vErrors.concat(validate107.errors);errors = vErrors.length;}}}else {const err12 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}validate106.errors = vErrors;return errors === 0;}validate106.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const pattern70 = new RegExp("^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "u");const formats0 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;function validate28(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/event.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate28.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("plan.updated" !== data.eventType){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate62(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate62.errors : vErrors.concat(validate62.errors);errors = vErrors.length;}}}var _valid0 = _errs5 === errors;valid1 = _valid0;if(valid1){var props0 = {};props0.payload = true;props0.eventType = true;}}if(!valid1){const err1 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}const _errs8 = errors;let valid4 = true;const _errs9 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data2 = data.eventType;if(!(((data2 === "tool.execution.started") || (data2 === "tool.execution.progressed")) || (data2 === "tool.execution.completed"))){const err2 = {};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}}var _valid1 = _errs9 === errors;errors = _errs8;if(vErrors !== null){if(_errs8){vErrors.length = _errs8;}else {vErrors = null;}}if(_valid1){const _errs11 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate64(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate64.errors : vErrors.concat(validate64.errors);errors = vErrors.length;}}}var _valid1 = _errs11 === errors;valid4 = _valid1;if(valid4){var props1 = {};props1.payload = true;props1.eventType = true;}}if(!valid4){const err3 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(props0 !== true && props1 !== undefined){if(props1 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props1);}}const _errs14 = errors;let valid7 = true;const _errs15 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data4 = data.eventType;if(!(((data4 === "research.started") || (data4 === "research.progressed")) || (data4 === "research.completed"))){const err4 = {};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}var _valid2 = _errs15 === errors;errors = _errs14;if(vErrors !== null){if(_errs14){vErrors.length = _errs14;}else {vErrors = null;}}if(_valid2){const _errs17 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate66(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate66.errors : vErrors.concat(validate66.errors);errors = vErrors.length;}}}var _valid2 = _errs17 === errors;valid7 = _valid2;if(valid7){var props2 = {};props2.payload = true;props2.eventType = true;}}if(!valid7){const err5 = {instancePath,schemaPath:"#/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(props0 !== true && props2 !== undefined){if(props2 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props2);}}const _errs20 = errors;let valid10 = true;const _errs21 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data6 = data.eventType;if(!(((data6 === "delegation.started") || (data6 === "delegation.updated")) || (data6 === "delegation.completed"))){const err6 = {};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}var _valid3 = _errs21 === errors;errors = _errs20;if(vErrors !== null){if(_errs20){vErrors.length = _errs20;}else {vErrors = null;}}if(_valid3){const _errs23 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate68(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate68.errors : vErrors.concat(validate68.errors);errors = vErrors.length;}}}var _valid3 = _errs23 === errors;valid10 = _valid3;if(valid10){var props3 = {};props3.payload = true;props3.eventType = true;}}if(!valid10){const err7 = {instancePath,schemaPath:"#/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(props0 !== true && props3 !== undefined){if(props3 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props3);}}const _errs26 = errors;let valid13 = true;const _errs27 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("model.route.changed" !== data.eventType){const err8 = {};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}var _valid4 = _errs27 === errors;errors = _errs26;if(vErrors !== null){if(_errs26){vErrors.length = _errs26;}else {vErrors = null;}}if(_valid4){const _errs29 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate70(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate70.errors : vErrors.concat(validate70.errors);errors = vErrors.length;}}}var _valid4 = _errs29 === errors;valid13 = _valid4;if(valid13){var props4 = {};props4.payload = true;props4.eventType = true;}}if(!valid13){const err9 = {instancePath,schemaPath:"#/allOf/4/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(props0 !== true && props4 !== undefined){if(props4 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props4);}}const _errs32 = errors;let valid16 = true;const _errs33 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("model.verification.updated" !== data.eventType){const err10 = {};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}}var _valid5 = _errs33 === errors;errors = _errs32;if(vErrors !== null){if(_errs32){vErrors.length = _errs32;}else {vErrors = null;}}if(_valid5){const _errs35 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate72(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate72.errors : vErrors.concat(validate72.errors);errors = vErrors.length;}}}var _valid5 = _errs35 === errors;valid16 = _valid5;if(valid16){var props5 = {};props5.payload = true;props5.eventType = true;}}if(!valid16){const err11 = {instancePath,schemaPath:"#/allOf/5/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(props0 !== true && props5 !== undefined){if(props5 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props5);}}const _errs38 = errors;let valid19 = true;const _errs39 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("context.compacted" !== data.eventType){const err12 = {};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}var _valid6 = _errs39 === errors;errors = _errs38;if(vErrors !== null){if(_errs38){vErrors.length = _errs38;}else {vErrors = null;}}if(_valid6){const _errs41 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate74(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate74.errors : vErrors.concat(validate74.errors);errors = vErrors.length;}}}var _valid6 = _errs41 === errors;valid19 = _valid6;if(valid19){var props6 = {};props6.payload = true;props6.eventType = true;}}if(!valid19){const err13 = {instancePath,schemaPath:"#/allOf/6/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(props0 !== true && props6 !== undefined){if(props6 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props6);}}const _errs44 = errors;let valid22 = true;const _errs45 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("artifact.viewed" !== data.eventType){const err14 = {};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}var _valid7 = _errs45 === errors;errors = _errs44;if(vErrors !== null){if(_errs44){vErrors.length = _errs44;}else {vErrors = null;}}if(_valid7){const _errs47 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate76(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate76.errors : vErrors.concat(validate76.errors);errors = vErrors.length;}}}var _valid7 = _errs47 === errors;valid22 = _valid7;if(valid22){var props7 = {};props7.payload = true;props7.eventType = true;}}if(!valid22){const err15 = {instancePath,schemaPath:"#/allOf/7/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(props0 !== true && props7 !== undefined){if(props7 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props7);}}const _errs50 = errors;let valid25 = true;const _errs51 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("artifact.generated" !== data.eventType){const err16 = {};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}var _valid8 = _errs51 === errors;errors = _errs50;if(vErrors !== null){if(_errs50){vErrors.length = _errs50;}else {vErrors = null;}}if(_valid8){const _errs53 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate78(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate78.errors : vErrors.concat(validate78.errors);errors = vErrors.length;}}}var _valid8 = _errs53 === errors;valid25 = _valid8;if(valid25){var props8 = {};props8.payload = true;props8.eventType = true;}}if(!valid25){const err17 = {instancePath,schemaPath:"#/allOf/8/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(props0 !== true && props8 !== undefined){if(props8 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props8);}}const _errs56 = errors;let valid28 = true;const _errs57 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("review.mode.changed" !== data.eventType){const err18 = {};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}var _valid9 = _errs57 === errors;errors = _errs56;if(vErrors !== null){if(_errs56){vErrors.length = _errs56;}else {vErrors = null;}}if(_valid9){const _errs59 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate80(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate80.errors : vErrors.concat(validate80.errors);errors = vErrors.length;}}}var _valid9 = _errs59 === errors;valid28 = _valid9;if(valid28){var props9 = {};props9.payload = true;props9.eventType = true;}}if(!valid28){const err19 = {instancePath,schemaPath:"#/allOf/9/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(props0 !== true && props9 !== undefined){if(props9 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props9);}}const _errs62 = errors;let valid31 = true;const _errs63 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data20 = data.eventType;if(!((data20 === "hook.started") || (data20 === "hook.completed"))){const err20 = {};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}var _valid10 = _errs63 === errors;errors = _errs62;if(vErrors !== null){if(_errs62){vErrors.length = _errs62;}else {vErrors = null;}}if(_valid10){const _errs65 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate82(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate82.errors : vErrors.concat(validate82.errors);errors = vErrors.length;}}}var _valid10 = _errs65 === errors;valid31 = _valid10;if(valid31){var props10 = {};props10.payload = true;props10.eventType = true;}}if(!valid31){const err21 = {instancePath,schemaPath:"#/allOf/10/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(props0 !== true && props10 !== undefined){if(props10 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props10);}}const _errs68 = errors;let valid34 = true;const _errs69 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("memory.citation.referenced" !== data.eventType){const err22 = {};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}var _valid11 = _errs69 === errors;errors = _errs68;if(vErrors !== null){if(_errs68){vErrors.length = _errs68;}else {vErrors = null;}}if(_valid11){const _errs71 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate84(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate84.errors : vErrors.concat(validate84.errors);errors = vErrors.length;}}}var _valid11 = _errs71 === errors;valid34 = _valid11;if(valid34){var props11 = {};props11.payload = true;props11.eventType = true;}}if(!valid34){const err23 = {instancePath,schemaPath:"#/allOf/11/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(props0 !== true && props11 !== undefined){if(props11 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props11);}}const _errs74 = errors;let valid37 = true;const _errs75 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data24 = data.eventType;if(!((data24 === "safety.review.started") || (data24 === "safety.review.completed"))){const err24 = {};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}var _valid12 = _errs75 === errors;errors = _errs74;if(vErrors !== null){if(_errs74){vErrors.length = _errs74;}else {vErrors = null;}}if(_valid12){const _errs77 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate86(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate86.errors : vErrors.concat(validate86.errors);errors = vErrors.length;}}}var _valid12 = _errs77 === errors;valid37 = _valid12;if(valid37){var props12 = {};props12.payload = true;props12.eventType = true;}}if(!valid37){const err25 = {instancePath,schemaPath:"#/allOf/12/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(props0 !== true && props12 !== undefined){if(props12 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props12);}}const _errs80 = errors;let valid40 = true;const _errs81 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("terminal.input.sent" !== data.eventType){const err26 = {};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}var _valid13 = _errs81 === errors;errors = _errs80;if(vErrors !== null){if(_errs80){vErrors.length = _errs80;}else {vErrors = null;}}if(_valid13){const _errs83 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate88(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate88.errors : vErrors.concat(validate88.errors);errors = vErrors.length;}}}var _valid13 = _errs83 === errors;valid40 = _valid13;if(valid40){var props13 = {};props13.payload = true;props13.eventType = true;}}if(!valid40){const err27 = {instancePath,schemaPath:"#/allOf/13/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(props0 !== true && props13 !== undefined){if(props13 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props13);}}const _errs86 = errors;let valid43 = true;const _errs87 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data28 = data.eventType;if(!((data28 === "wait.started") || (data28 === "wait.completed"))){const err28 = {};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}var _valid14 = _errs87 === errors;errors = _errs86;if(vErrors !== null){if(_errs86){vErrors.length = _errs86;}else {vErrors = null;}}if(_valid14){const _errs89 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate90(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate90.errors : vErrors.concat(validate90.errors);errors = vErrors.length;}}}var _valid14 = _errs89 === errors;valid43 = _valid14;if(valid43){var props14 = {};props14.payload = true;props14.eventType = true;}}if(!valid43){const err29 = {instancePath,schemaPath:"#/allOf/14/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(props0 !== true && props14 !== undefined){if(props14 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props14);}}const _errs92 = errors;let valid46 = true;const _errs93 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("provider.notice.recorded" !== data.eventType){const err30 = {};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}}var _valid15 = _errs93 === errors;errors = _errs92;if(vErrors !== null){if(_errs92){vErrors.length = _errs92;}else {vErrors = null;}}if(_valid15){const _errs95 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate92(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate92.errors : vErrors.concat(validate92.errors);errors = vErrors.length;}}}var _valid15 = _errs95 === errors;valid46 = _valid15;if(valid46){var props15 = {};props15.payload = true;props15.eventType = true;}}if(!valid46){const err31 = {instancePath,schemaPath:"#/allOf/15/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(props0 !== true && props15 !== undefined){if(props15 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props15);}}const _errs98 = errors;let valid49 = true;const _errs99 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("workspace.file.referenced" !== data.eventType){const err32 = {};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}var _valid16 = _errs99 === errors;errors = _errs98;if(vErrors !== null){if(_errs98){vErrors.length = _errs98;}else {vErrors = null;}}if(_valid16){const _errs101 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data33 = data.payload;if(data33 && typeof data33 == "object" && !Array.isArray(data33)){if(data33.schema === undefined){const err33 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(data33.referenceId === undefined){const err34 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "referenceId"},message:"must have required property '"+"referenceId"+"'"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(data33.source === undefined){const err35 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(data33.path === undefined){const err36 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "path"},message:"must have required property '"+"path"+"'"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}if(data33.displayName === undefined){const err37 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "displayName"},message:"must have required property '"+"displayName"+"'"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(data33.mediaType === undefined){const err38 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "mediaType"},message:"must have required property '"+"mediaType"+"'"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(data33.presentation === undefined){const err39 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "presentation"},message:"must have required property '"+"presentation"+"'"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}if(data33.line === undefined){const err40 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "line"},message:"must have required property '"+"line"+"'"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if(data33.preview === undefined){const err41 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "preview"},message:"must have required property '"+"preview"+"'"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data33.previewTruncated === undefined){const err42 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "previewTruncated"},message:"must have required property '"+"previewTruncated"+"'"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if(data33.contentDigest === undefined){const err43 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/required",keyword:"required",params:{missingProperty: "contentDigest"},message:"must have required property '"+"contentDigest"+"'"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}for(const key0 in data33){if(!(func66.call(schema174.properties, key0))){const err44 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data33.schema !== undefined){if("paperclip.workspace.file_reference.v1" !== data33.schema){const err45 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.workspace.file_reference.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}if(data33.referenceId !== undefined){let data35 = data33.referenceId;if(typeof data35 === "string"){if(func1(data35) > 240){const err46 = {instancePath:instancePath+"/payload/referenceId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/referenceId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(func1(data35) < 1){const err47 = {instancePath:instancePath+"/payload/referenceId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/referenceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}else {const err48 = {instancePath:instancePath+"/payload/referenceId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/referenceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}if(data33.source !== undefined){let data36 = data33.source;if(!((data36 === "harness_reported") || (data36 === "runner_verified"))){const err49 = {instancePath:instancePath+"/payload/source",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/source/enum",keyword:"enum",params:{allowedValues: schema174.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}if(data33.path !== undefined){let data37 = data33.path;if(typeof data37 === "string"){if(func1(data37) > 1024){const err50 = {instancePath:instancePath+"/payload/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/path/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}if(func1(data37) < 1){const err51 = {instancePath:instancePath+"/payload/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/path/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}if(!pattern70.test(data37)){const err52 = {instancePath:instancePath+"/payload/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/path/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}else {const err53 = {instancePath:instancePath+"/payload/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}if(data33.displayName !== undefined){let data38 = data33.displayName;if(typeof data38 === "string"){if(func1(data38) > 255){const err54 = {instancePath:instancePath+"/payload/displayName",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/displayName/maxLength",keyword:"maxLength",params:{limit: 255},message:"must NOT have more than 255 characters"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}if(func1(data38) < 1){const err55 = {instancePath:instancePath+"/payload/displayName",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/displayName/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}else {const err56 = {instancePath:instancePath+"/payload/displayName",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/displayName/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}if(data33.mediaType !== undefined){let data39 = data33.mediaType;if((typeof data39 !== "string") && (data39 !== null)){const err57 = {instancePath:instancePath+"/payload/mediaType",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/mediaType/type",keyword:"type",params:{type: schema174.properties.mediaType.type},message:"must be string,null"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}if(typeof data39 === "string"){if(func1(data39) > 160){const err58 = {instancePath:instancePath+"/payload/mediaType",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/mediaType/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}}if(data33.presentation !== undefined){let data40 = data33.presentation;if(!((((data40 === "document") || (data40 === "code")) || (data40 === "image")) || (data40 === "generic"))){const err59 = {instancePath:instancePath+"/payload/presentation",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/presentation/enum",keyword:"enum",params:{allowedValues: schema174.properties.presentation.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data33.line !== undefined){let data41 = data33.line;if((!(((typeof data41 == "number") && (!(data41 % 1) && !isNaN(data41))) && (isFinite(data41)))) && (data41 !== null)){const err60 = {instancePath:instancePath+"/payload/line",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/line/type",keyword:"type",params:{type: schema174.properties.line.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}if((typeof data41 == "number") && (isFinite(data41))){if(data41 < 1 || isNaN(data41)){const err61 = {instancePath:instancePath+"/payload/line",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/line/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}}if(data33.preview !== undefined){let data42 = data33.preview;if((typeof data42 !== "string") && (data42 !== null)){const err62 = {instancePath:instancePath+"/payload/preview",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/preview/type",keyword:"type",params:{type: schema174.properties.preview.type},message:"must be string,null"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(typeof data42 === "string"){if(func1(data42) > 262144){const err63 = {instancePath:instancePath+"/payload/preview",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/preview/maxLength",keyword:"maxLength",params:{limit: 262144},message:"must NOT have more than 262144 characters"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}}if(data33.previewTruncated !== undefined){if(typeof data33.previewTruncated !== "boolean"){const err64 = {instancePath:instancePath+"/payload/previewTruncated",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/previewTruncated/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}if(data33.contentDigest !== undefined){let data44 = data33.contentDigest;const _errs124 = errors;let valid54 = false;let passing0 = null;const _errs125 = errors;if(data44 !== null){const err65 = {instancePath:instancePath+"/payload/contentDigest",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/contentDigest/oneOf/0/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}var _valid17 = _errs125 === errors;if(_valid17){valid54 = true;passing0 = 0;}const _errs127 = errors;if(typeof data44 === "string"){if(!pattern20.test(data44)){const err66 = {instancePath:instancePath+"/payload/contentDigest",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/contentDigest/oneOf/1/pattern",keyword:"pattern",params:{pattern: "^sha256:[a-f0-9]{64}$"},message:"must match pattern \""+"^sha256:[a-f0-9]{64}$"+"\""};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}}else {const err67 = {instancePath:instancePath+"/payload/contentDigest",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/contentDigest/oneOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}var _valid17 = _errs127 === errors;if(_valid17 && valid54){valid54 = false;passing0 = [passing0, 1];}else {if(_valid17){valid54 = true;passing0 = 1;}}if(!valid54){const err68 = {instancePath:instancePath+"/payload/contentDigest",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/properties/contentDigest/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}else {errors = _errs124;if(vErrors !== null){if(_errs124){vErrors.length = _errs124;}else {vErrors = null;}}}}}else {const err69 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}}var _valid16 = _errs101 === errors;valid49 = _valid16;if(valid49){var props16 = {};props16.payload = true;props16.eventType = true;}}if(!valid49){const err70 = {instancePath,schemaPath:"#/allOf/16/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if(props0 !== true && props16 !== undefined){if(props16 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props16);}}const _errs130 = errors;let valid55 = true;const _errs131 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data45 = data.eventType;if(!((data45 === "workspace.change.updated") || (data45 === "workspace.diff.recorded"))){const err71 = {};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}}var _valid18 = _errs131 === errors;errors = _errs130;if(vErrors !== null){if(_errs130){vErrors.length = _errs130;}else {vErrors = null;}}if(_valid18){const _errs133 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data46 = data.payload;if(data46 && typeof data46 == "object" && !Array.isArray(data46)){if(data46.schema === undefined){const err72 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(data46.changeSetId === undefined){const err73 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "changeSetId"},message:"must have required property '"+"changeSetId"+"'"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}if(data46.revision === undefined){const err74 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}if(data46.source === undefined){const err75 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}if(data46.complete === undefined){const err76 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "complete"},message:"must have required property '"+"complete"+"'"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}if(data46.files === undefined){const err77 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(data46.totals === undefined){const err78 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "totals"},message:"must have required property '"+"totals"+"'"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}if(data46.patchArtifactRef === undefined){const err79 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "patchArtifactRef"},message:"must have required property '"+"patchArtifactRef"+"'"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}for(const key1 in data46){if(!((((((((key1 === "schema") || (key1 === "changeSetId")) || (key1 === "revision")) || (key1 === "source")) || (key1 === "complete")) || (key1 === "files")) || (key1 === "totals")) || (key1 === "patchArtifactRef"))){const err80 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}}if(data46.schema !== undefined){if("paperclip.workspace.diff.v1" !== data46.schema){const err81 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.workspace.diff.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}}if(data46.changeSetId !== undefined){let data48 = data46.changeSetId;if(typeof data48 === "string"){if(func1(data48) > 200){const err82 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/maxLength",keyword:"maxLength",params:{limit: 200},message:"must NOT have more than 200 characters"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(func1(data48) < 1){const err83 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}}else {const err84 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}}if(data46.revision !== undefined){let data49 = data46.revision;if(!(((typeof data49 == "number") && (!(data49 % 1) && !isNaN(data49))) && (isFinite(data49)))){const err85 = {instancePath:instancePath+"/payload/revision",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/revision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}if((typeof data49 == "number") && (isFinite(data49))){if(data49 < 1 || isNaN(data49)){const err86 = {instancePath:instancePath+"/payload/revision",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/revision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}}}if(data46.source !== undefined){let data50 = data46.source;if(!((data50 === "harness_reported") || (data50 === "runner_verified"))){const err87 = {instancePath:instancePath+"/payload/source",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/source/enum",keyword:"enum",params:{allowedValues: schema175.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}if(data46.complete !== undefined){if(typeof data46.complete !== "boolean"){const err88 = {instancePath:instancePath+"/payload/complete",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/complete/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}}if(data46.files !== undefined){let data52 = data46.files;if(Array.isArray(data52)){if(data52.length > 2000){const err89 = {instancePath:instancePath+"/payload/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/maxItems",keyword:"maxItems",params:{limit: 2000},message:"must NOT have more than 2000 items"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}const len0 = data52.length;for(let i0=0; i0 1024){const err98 = {instancePath:instancePath+"/payload/files/" + i0+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}if(func1(data54) < 1){const err99 = {instancePath:instancePath+"/payload/files/" + i0+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}if(!pattern70.test(data54)){const err100 = {instancePath:instancePath+"/payload/files/" + i0+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}}else {const err101 = {instancePath:instancePath+"/payload/files/" + i0+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}if(data53.operation !== undefined){let data55 = data53.operation;if(!(((((data55 === "create") || (data55 === "modify")) || (data55 === "delete")) || (data55 === "rename")) || (data55 === "mode_change"))){const err102 = {instancePath:instancePath+"/payload/files/" + i0+"/operation",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/operation/enum",keyword:"enum",params:{allowedValues: schema175.properties.files.items.properties.operation.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}if(data53.previousPath !== undefined){let data56 = data53.previousPath;const _errs155 = errors;let valid63 = false;let passing1 = null;const _errs156 = errors;if(data56 !== null){const err103 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/0/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}var _valid19 = _errs156 === errors;if(_valid19){valid63 = true;passing1 = 0;}const _errs158 = errors;if(typeof data56 === "string"){if(func1(data56) > 1024){const err104 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}if(func1(data56) < 1){const err105 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}if(!pattern70.test(data56)){const err106 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}}else {const err107 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}var _valid19 = _errs158 === errors;if(_valid19 && valid63){valid63 = false;passing1 = [passing1, 1];}else {if(_valid19){valid63 = true;passing1 = 1;}}if(!valid63){const err108 = {instancePath:instancePath+"/payload/files/" + i0+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf",keyword:"oneOf",params:{passingSchemas: passing1},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}else {errors = _errs155;if(vErrors !== null){if(_errs155){vErrors.length = _errs155;}else {vErrors = null;}}}}if(data53.additions !== undefined){let data57 = data53.additions;if((!(((typeof data57 == "number") && (!(data57 % 1) && !isNaN(data57))) && (isFinite(data57)))) && (data57 !== null)){const err109 = {instancePath:instancePath+"/payload/files/" + i0+"/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/additions/type",keyword:"type",params:{type: schema175.properties.files.items.properties.additions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}if((typeof data57 == "number") && (isFinite(data57))){if(data57 < 0 || isNaN(data57)){const err110 = {instancePath:instancePath+"/payload/files/" + i0+"/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}}if(data53.deletions !== undefined){let data58 = data53.deletions;if((!(((typeof data58 == "number") && (!(data58 % 1) && !isNaN(data58))) && (isFinite(data58)))) && (data58 !== null)){const err111 = {instancePath:instancePath+"/payload/files/" + i0+"/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/deletions/type",keyword:"type",params:{type: schema175.properties.files.items.properties.deletions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}if((typeof data58 == "number") && (isFinite(data58))){if(data58 < 0 || isNaN(data58)){const err112 = {instancePath:instancePath+"/payload/files/" + i0+"/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}}}if(data53.binary !== undefined){if(typeof data53.binary !== "boolean"){const err113 = {instancePath:instancePath+"/payload/files/" + i0+"/binary",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/binary/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}if(data53.diff !== undefined){let data60 = data53.diff;if((typeof data60 !== "string") && (data60 !== null)){const err114 = {instancePath:instancePath+"/payload/files/" + i0+"/diff",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/diff/type",keyword:"type",params:{type: schema175.properties.files.items.properties.diff.type},message:"must be string,null"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}if(typeof data60 === "string"){if(func1(data60) > 262144){const err115 = {instancePath:instancePath+"/payload/files/" + i0+"/diff",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/diff/maxLength",keyword:"maxLength",params:{limit: 262144},message:"must NOT have more than 262144 characters"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}}}}else {const err116 = {instancePath:instancePath+"/payload/files/" + i0,schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}}else {const err117 = {instancePath:instancePath+"/payload/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}}if(data46.totals !== undefined){let data61 = data46.totals;if(data61 && typeof data61 == "object" && !Array.isArray(data61)){if(data61.files === undefined){const err118 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}if(data61.additions === undefined){const err119 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "additions"},message:"must have required property '"+"additions"+"'"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}if(data61.deletions === undefined){const err120 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "deletions"},message:"must have required property '"+"deletions"+"'"};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}for(const key3 in data61){if(!(((key3 === "files") || (key3 === "additions")) || (key3 === "deletions"))){const err121 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}if(data61.files !== undefined){let data62 = data61.files;if(!(((typeof data62 == "number") && (!(data62 % 1) && !isNaN(data62))) && (isFinite(data62)))){const err122 = {instancePath:instancePath+"/payload/totals/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/files/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}if((typeof data62 == "number") && (isFinite(data62))){if(data62 < 0 || isNaN(data62)){const err123 = {instancePath:instancePath+"/payload/totals/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/files/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}}if(data61.additions !== undefined){let data63 = data61.additions;if((!(((typeof data63 == "number") && (!(data63 % 1) && !isNaN(data63))) && (isFinite(data63)))) && (data63 !== null)){const err124 = {instancePath:instancePath+"/payload/totals/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/additions/type",keyword:"type",params:{type: schema175.properties.totals.properties.additions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}if((typeof data63 == "number") && (isFinite(data63))){if(data63 < 0 || isNaN(data63)){const err125 = {instancePath:instancePath+"/payload/totals/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}}}if(data61.deletions !== undefined){let data64 = data61.deletions;if((!(((typeof data64 == "number") && (!(data64 % 1) && !isNaN(data64))) && (isFinite(data64)))) && (data64 !== null)){const err126 = {instancePath:instancePath+"/payload/totals/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/deletions/type",keyword:"type",params:{type: schema175.properties.totals.properties.deletions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}if((typeof data64 == "number") && (isFinite(data64))){if(data64 < 0 || isNaN(data64)){const err127 = {instancePath:instancePath+"/payload/totals/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;}}}}else {const err128 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}}if(data46.patchArtifactRef !== undefined){let data65 = data46.patchArtifactRef;if((typeof data65 !== "string") && (data65 !== null)){const err129 = {instancePath:instancePath+"/payload/patchArtifactRef",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/patchArtifactRef/type",keyword:"type",params:{type: schema175.properties.patchArtifactRef.type},message:"must be string,null"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}if(typeof data65 === "string"){if(func1(data65) > 2048){const err130 = {instancePath:instancePath+"/payload/patchArtifactRef",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/patchArtifactRef/maxLength",keyword:"maxLength",params:{limit: 2048},message:"must NOT have more than 2048 characters"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}}}}else {const err131 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}}}var _valid18 = _errs133 === errors;valid55 = _valid18;if(valid55){var props17 = {};props17.payload = true;props17.eventType = true;}}if(!valid55){const err132 = {instancePath,schemaPath:"#/allOf/17/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;}if(props0 !== true && props17 !== undefined){if(props17 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props17);}}const _errs180 = errors;let valid65 = true;const _errs181 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("workspace.diff.recorded" !== data.eventType){const err133 = {};if(vErrors === null){vErrors = [err133];}else {vErrors.push(err133);}errors++;}}}var _valid20 = _errs181 === errors;errors = _errs180;if(vErrors !== null){if(_errs180){vErrors.length = _errs180;}else {vErrors = null;}}if(_valid20){const _errs183 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data67 = data.payload;if(data67 && typeof data67 == "object" && !Array.isArray(data67)){if(data67.schema === undefined){const err134 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}if(data67.changeSetId === undefined){const err135 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "changeSetId"},message:"must have required property '"+"changeSetId"+"'"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}if(data67.revision === undefined){const err136 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err136];}else {vErrors.push(err136);}errors++;}if(data67.source === undefined){const err137 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err137];}else {vErrors.push(err137);}errors++;}if(data67.complete === undefined){const err138 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "complete"},message:"must have required property '"+"complete"+"'"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}if(data67.files === undefined){const err139 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}if(data67.totals === undefined){const err140 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "totals"},message:"must have required property '"+"totals"+"'"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}if(data67.patchArtifactRef === undefined){const err141 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/required",keyword:"required",params:{missingProperty: "patchArtifactRef"},message:"must have required property '"+"patchArtifactRef"+"'"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}for(const key4 in data67){if(!((((((((key4 === "schema") || (key4 === "changeSetId")) || (key4 === "revision")) || (key4 === "source")) || (key4 === "complete")) || (key4 === "files")) || (key4 === "totals")) || (key4 === "patchArtifactRef"))){const err142 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err142];}else {vErrors.push(err142);}errors++;}}if(data67.schema !== undefined){if("paperclip.workspace.diff.v1" !== data67.schema){const err143 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.workspace.diff.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}}if(data67.changeSetId !== undefined){let data69 = data67.changeSetId;if(typeof data69 === "string"){if(func1(data69) > 200){const err144 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/maxLength",keyword:"maxLength",params:{limit: 200},message:"must NOT have more than 200 characters"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;}if(func1(data69) < 1){const err145 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err145];}else {vErrors.push(err145);}errors++;}}else {const err146 = {instancePath:instancePath+"/payload/changeSetId",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/changeSetId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err146];}else {vErrors.push(err146);}errors++;}}if(data67.revision !== undefined){let data70 = data67.revision;if(!(((typeof data70 == "number") && (!(data70 % 1) && !isNaN(data70))) && (isFinite(data70)))){const err147 = {instancePath:instancePath+"/payload/revision",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/revision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err147];}else {vErrors.push(err147);}errors++;}if((typeof data70 == "number") && (isFinite(data70))){if(data70 < 1 || isNaN(data70)){const err148 = {instancePath:instancePath+"/payload/revision",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/revision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err148];}else {vErrors.push(err148);}errors++;}}}if(data67.source !== undefined){let data71 = data67.source;if(!((data71 === "harness_reported") || (data71 === "runner_verified"))){const err149 = {instancePath:instancePath+"/payload/source",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/source/enum",keyword:"enum",params:{allowedValues: schema175.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err149];}else {vErrors.push(err149);}errors++;}}if(data67.complete !== undefined){if(typeof data67.complete !== "boolean"){const err150 = {instancePath:instancePath+"/payload/complete",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/complete/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err150];}else {vErrors.push(err150);}errors++;}}if(data67.files !== undefined){let data73 = data67.files;if(Array.isArray(data73)){if(data73.length > 2000){const err151 = {instancePath:instancePath+"/payload/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/maxItems",keyword:"maxItems",params:{limit: 2000},message:"must NOT have more than 2000 items"};if(vErrors === null){vErrors = [err151];}else {vErrors.push(err151);}errors++;}const len1 = data73.length;for(let i1=0; i1 1024){const err160 = {instancePath:instancePath+"/payload/files/" + i1+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err160];}else {vErrors.push(err160);}errors++;}if(func1(data75) < 1){const err161 = {instancePath:instancePath+"/payload/files/" + i1+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err161];}else {vErrors.push(err161);}errors++;}if(!pattern70.test(data75)){const err162 = {instancePath:instancePath+"/payload/files/" + i1+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err162];}else {vErrors.push(err162);}errors++;}}else {const err163 = {instancePath:instancePath+"/payload/files/" + i1+"/path",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err163];}else {vErrors.push(err163);}errors++;}}if(data74.operation !== undefined){let data76 = data74.operation;if(!(((((data76 === "create") || (data76 === "modify")) || (data76 === "delete")) || (data76 === "rename")) || (data76 === "mode_change"))){const err164 = {instancePath:instancePath+"/payload/files/" + i1+"/operation",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/operation/enum",keyword:"enum",params:{allowedValues: schema175.properties.files.items.properties.operation.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err164];}else {vErrors.push(err164);}errors++;}}if(data74.previousPath !== undefined){let data77 = data74.previousPath;const _errs206 = errors;let valid74 = false;let passing2 = null;const _errs207 = errors;if(data77 !== null){const err165 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/0/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err165];}else {vErrors.push(err165);}errors++;}var _valid21 = _errs207 === errors;if(_valid21){valid74 = true;passing2 = 0;}const _errs209 = errors;if(typeof data77 === "string"){if(func1(data77) > 1024){const err166 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err166];}else {vErrors.push(err166);}errors++;}if(func1(data77) < 1){const err167 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err167];}else {vErrors.push(err167);}errors++;}if(!pattern70.test(data77)){const err168 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/pattern",keyword:"pattern",params:{pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"},message:"must match pattern \""+"^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"+"\""};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}}else {const err169 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}var _valid21 = _errs209 === errors;if(_valid21 && valid74){valid74 = false;passing2 = [passing2, 1];}else {if(_valid21){valid74 = true;passing2 = 1;}}if(!valid74){const err170 = {instancePath:instancePath+"/payload/files/" + i1+"/previousPath",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/previousPath/oneOf",keyword:"oneOf",params:{passingSchemas: passing2},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}else {errors = _errs206;if(vErrors !== null){if(_errs206){vErrors.length = _errs206;}else {vErrors = null;}}}}if(data74.additions !== undefined){let data78 = data74.additions;if((!(((typeof data78 == "number") && (!(data78 % 1) && !isNaN(data78))) && (isFinite(data78)))) && (data78 !== null)){const err171 = {instancePath:instancePath+"/payload/files/" + i1+"/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/additions/type",keyword:"type",params:{type: schema175.properties.files.items.properties.additions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}if((typeof data78 == "number") && (isFinite(data78))){if(data78 < 0 || isNaN(data78)){const err172 = {instancePath:instancePath+"/payload/files/" + i1+"/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}}}if(data74.deletions !== undefined){let data79 = data74.deletions;if((!(((typeof data79 == "number") && (!(data79 % 1) && !isNaN(data79))) && (isFinite(data79)))) && (data79 !== null)){const err173 = {instancePath:instancePath+"/payload/files/" + i1+"/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/deletions/type",keyword:"type",params:{type: schema175.properties.files.items.properties.deletions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}if((typeof data79 == "number") && (isFinite(data79))){if(data79 < 0 || isNaN(data79)){const err174 = {instancePath:instancePath+"/payload/files/" + i1+"/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}}}if(data74.binary !== undefined){if(typeof data74.binary !== "boolean"){const err175 = {instancePath:instancePath+"/payload/files/" + i1+"/binary",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/binary/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}}if(data74.diff !== undefined){let data81 = data74.diff;if((typeof data81 !== "string") && (data81 !== null)){const err176 = {instancePath:instancePath+"/payload/files/" + i1+"/diff",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/diff/type",keyword:"type",params:{type: schema175.properties.files.items.properties.diff.type},message:"must be string,null"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if(typeof data81 === "string"){if(func1(data81) > 262144){const err177 = {instancePath:instancePath+"/payload/files/" + i1+"/diff",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/properties/diff/maxLength",keyword:"maxLength",params:{limit: 262144},message:"must NOT have more than 262144 characters"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}}}}else {const err178 = {instancePath:instancePath+"/payload/files/" + i1,schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err178];}else {vErrors.push(err178);}errors++;}}}else {const err179 = {instancePath:instancePath+"/payload/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/files/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err179];}else {vErrors.push(err179);}errors++;}}if(data67.totals !== undefined){let data82 = data67.totals;if(data82 && typeof data82 == "object" && !Array.isArray(data82)){if(data82.files === undefined){const err180 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};if(vErrors === null){vErrors = [err180];}else {vErrors.push(err180);}errors++;}if(data82.additions === undefined){const err181 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "additions"},message:"must have required property '"+"additions"+"'"};if(vErrors === null){vErrors = [err181];}else {vErrors.push(err181);}errors++;}if(data82.deletions === undefined){const err182 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/required",keyword:"required",params:{missingProperty: "deletions"},message:"must have required property '"+"deletions"+"'"};if(vErrors === null){vErrors = [err182];}else {vErrors.push(err182);}errors++;}for(const key6 in data82){if(!(((key6 === "files") || (key6 === "additions")) || (key6 === "deletions"))){const err183 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key6},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err183];}else {vErrors.push(err183);}errors++;}}if(data82.files !== undefined){let data83 = data82.files;if(!(((typeof data83 == "number") && (!(data83 % 1) && !isNaN(data83))) && (isFinite(data83)))){const err184 = {instancePath:instancePath+"/payload/totals/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/files/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err184];}else {vErrors.push(err184);}errors++;}if((typeof data83 == "number") && (isFinite(data83))){if(data83 < 0 || isNaN(data83)){const err185 = {instancePath:instancePath+"/payload/totals/files",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/files/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err185];}else {vErrors.push(err185);}errors++;}}}if(data82.additions !== undefined){let data84 = data82.additions;if((!(((typeof data84 == "number") && (!(data84 % 1) && !isNaN(data84))) && (isFinite(data84)))) && (data84 !== null)){const err186 = {instancePath:instancePath+"/payload/totals/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/additions/type",keyword:"type",params:{type: schema175.properties.totals.properties.additions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err186];}else {vErrors.push(err186);}errors++;}if((typeof data84 == "number") && (isFinite(data84))){if(data84 < 0 || isNaN(data84)){const err187 = {instancePath:instancePath+"/payload/totals/additions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/additions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err187];}else {vErrors.push(err187);}errors++;}}}if(data82.deletions !== undefined){let data85 = data82.deletions;if((!(((typeof data85 == "number") && (!(data85 % 1) && !isNaN(data85))) && (isFinite(data85)))) && (data85 !== null)){const err188 = {instancePath:instancePath+"/payload/totals/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/deletions/type",keyword:"type",params:{type: schema175.properties.totals.properties.deletions.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err188];}else {vErrors.push(err188);}errors++;}if((typeof data85 == "number") && (isFinite(data85))){if(data85 < 0 || isNaN(data85)){const err189 = {instancePath:instancePath+"/payload/totals/deletions",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/properties/deletions/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err189];}else {vErrors.push(err189);}errors++;}}}}else {const err190 = {instancePath:instancePath+"/payload/totals",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/totals/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err190];}else {vErrors.push(err190);}errors++;}}if(data67.patchArtifactRef !== undefined){let data86 = data67.patchArtifactRef;if((typeof data86 !== "string") && (data86 !== null)){const err191 = {instancePath:instancePath+"/payload/patchArtifactRef",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/patchArtifactRef/type",keyword:"type",params:{type: schema175.properties.patchArtifactRef.type},message:"must be string,null"};if(vErrors === null){vErrors = [err191];}else {vErrors.push(err191);}errors++;}if(typeof data86 === "string"){if(func1(data86) > 2048){const err192 = {instancePath:instancePath+"/payload/patchArtifactRef",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/properties/patchArtifactRef/maxLength",keyword:"maxLength",params:{limit: 2048},message:"must NOT have more than 2048 characters"};if(vErrors === null){vErrors = [err192];}else {vErrors.push(err192);}errors++;}}}}else {const err193 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err193];}else {vErrors.push(err193);}errors++;}if(data67 && typeof data67 == "object" && !Array.isArray(data67)){if(data67.complete !== undefined){if(true !== data67.complete){const err194 = {instancePath:instancePath+"/payload/complete",schemaPath:"#/allOf/18/then/properties/payload/allOf/1/properties/complete/const",keyword:"const",params:{allowedValue: true},message:"must be equal to constant"};if(vErrors === null){vErrors = [err194];}else {vErrors.push(err194);}errors++;}}}else {const err195 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/18/then/properties/payload/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err195];}else {vErrors.push(err195);}errors++;}}}var _valid20 = _errs183 === errors;valid65 = _valid20;if(valid65){var props18 = {};props18.payload = true;props18.eventType = true;}}if(!valid65){const err196 = {instancePath,schemaPath:"#/allOf/18/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err196];}else {vErrors.push(err196);}errors++;}if(props0 !== true && props18 !== undefined){if(props18 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props18);}}const _errs234 = errors;let valid77 = true;const _errs235 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data88 = data.eventType;if(!((data88 === "semantic_tool.input") || (data88 === "mcp_app.tool_input"))){const err197 = {};if(vErrors === null){vErrors = [err197];}else {vErrors.push(err197);}errors++;}}}var _valid22 = _errs235 === errors;errors = _errs234;if(vErrors !== null){if(_errs234){vErrors.length = _errs234;}else {vErrors = null;}}if(_valid22){const _errs237 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data89 = data.payload;if(data89 && typeof data89 == "object" && !Array.isArray(data89)){if(data89.semantic_tool !== undefined){let data90 = data89.semantic_tool;if(!(validate94(data90, {instancePath:instancePath+"/payload/semantic_tool",parentData:data89,parentDataProperty:"semantic_tool",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate94.errors : vErrors.concat(validate94.errors);errors = vErrors.length;}if(data90 && typeof data90 == "object" && !Array.isArray(data90)){if(data90.phase !== undefined){if("input" !== data90.phase){const err198 = {instancePath:instancePath+"/payload/semantic_tool/phase",schemaPath:"#/allOf/19/then/properties/payload/properties/semantic_tool/allOf/1/properties/phase/const",keyword:"const",params:{allowedValue: "input"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err198];}else {vErrors.push(err198);}errors++;}}}else {const err199 = {instancePath:instancePath+"/payload/semantic_tool",schemaPath:"#/allOf/19/then/properties/payload/properties/semantic_tool/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err199];}else {vErrors.push(err199);}errors++;}}}else {const err200 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/19/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err200];}else {vErrors.push(err200);}errors++;}}}var _valid22 = _errs237 === errors;valid77 = _valid22;if(valid77){var props19 = {};props19.payload = true;props19.eventType = true;}}if(!valid77){const err201 = {instancePath,schemaPath:"#/allOf/19/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err201];}else {vErrors.push(err201);}errors++;}if(props0 !== true && props19 !== undefined){if(props19 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props19);}}const _errs247 = errors;let valid83 = true;const _errs248 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data92 = data.eventType;if(!((data92 === "semantic_tool.result") || (data92 === "mcp_app.tool_result"))){const err202 = {};if(vErrors === null){vErrors = [err202];}else {vErrors.push(err202);}errors++;}}}var _valid23 = _errs248 === errors;errors = _errs247;if(vErrors !== null){if(_errs247){vErrors.length = _errs247;}else {vErrors = null;}}if(_valid23){const _errs250 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data93 = data.payload;if(data93 && typeof data93 == "object" && !Array.isArray(data93)){if(data93.semantic_tool !== undefined){let data94 = data93.semantic_tool;if(!(validate94(data94, {instancePath:instancePath+"/payload/semantic_tool",parentData:data93,parentDataProperty:"semantic_tool",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate94.errors : vErrors.concat(validate94.errors);errors = vErrors.length;}if(data94 && typeof data94 == "object" && !Array.isArray(data94)){if(data94.phase !== undefined){if("result" !== data94.phase){const err203 = {instancePath:instancePath+"/payload/semantic_tool/phase",schemaPath:"#/allOf/20/then/properties/payload/properties/semantic_tool/allOf/1/properties/phase/const",keyword:"const",params:{allowedValue: "result"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err203];}else {vErrors.push(err203);}errors++;}}}else {const err204 = {instancePath:instancePath+"/payload/semantic_tool",schemaPath:"#/allOf/20/then/properties/payload/properties/semantic_tool/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err204];}else {vErrors.push(err204);}errors++;}}}else {const err205 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/20/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err205];}else {vErrors.push(err205);}errors++;}}}var _valid23 = _errs250 === errors;valid83 = _valid23;if(valid83){var props20 = {};props20.payload = true;props20.eventType = true;}}if(!valid83){const err206 = {instancePath,schemaPath:"#/allOf/20/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err206];}else {vErrors.push(err206);}errors++;}if(props0 !== true && props20 !== undefined){if(props20 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props20);}}const _errs260 = errors;let valid89 = true;const _errs261 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("semantic_tool.reconciled" !== data.eventType){const err207 = {};if(vErrors === null){vErrors = [err207];}else {vErrors.push(err207);}errors++;}}}var _valid24 = _errs261 === errors;errors = _errs260;if(vErrors !== null){if(_errs260){vErrors.length = _errs260;}else {vErrors = null;}}if(_valid24){const _errs263 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data97 = data.payload;if(data97 && typeof data97 == "object" && !Array.isArray(data97)){if(data97.semantic_tool !== undefined){let data98 = data97.semantic_tool;if(!(validate94(data98, {instancePath:instancePath+"/payload/semantic_tool",parentData:data97,parentDataProperty:"semantic_tool",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate94.errors : vErrors.concat(validate94.errors);errors = vErrors.length;}if(data98 && typeof data98 == "object" && !Array.isArray(data98)){if(data98.phase !== undefined){if("reconciled" !== data98.phase){const err208 = {instancePath:instancePath+"/payload/semantic_tool/phase",schemaPath:"#/allOf/21/then/properties/payload/properties/semantic_tool/allOf/1/properties/phase/const",keyword:"const",params:{allowedValue: "reconciled"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err208];}else {vErrors.push(err208);}errors++;}}}else {const err209 = {instancePath:instancePath+"/payload/semantic_tool",schemaPath:"#/allOf/21/then/properties/payload/properties/semantic_tool/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err209];}else {vErrors.push(err209);}errors++;}}}else {const err210 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/21/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err210];}else {vErrors.push(err210);}errors++;}}}var _valid24 = _errs263 === errors;valid89 = _valid24;if(valid89){var props21 = {};props21.payload = true;props21.eventType = true;}}if(!valid89){const err211 = {instancePath,schemaPath:"#/allOf/21/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err211];}else {vErrors.push(err211);}errors++;}if(props0 !== true && props21 !== undefined){if(props21 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props21);}}const _errs273 = errors;let valid95 = true;const _errs274 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("run.terminal" !== data.eventType){const err212 = {};if(vErrors === null){vErrors = [err212];}else {vErrors.push(err212);}errors++;}}}var _valid25 = _errs274 === errors;errors = _errs273;if(vErrors !== null){if(_errs273){vErrors.length = _errs273;}else {vErrors = null;}}if(_valid25){const _errs276 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate106(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate106.errors : vErrors.concat(validate106.errors);errors = vErrors.length;}}}var _valid25 = _errs276 === errors;valid95 = _valid25;if(valid95){var props22 = {};props22.payload = true;props22.eventType = true;}}if(!valid95){const err213 = {instancePath,schemaPath:"#/allOf/22/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err213];}else {vErrors.push(err213);}errors++;}if(props0 !== true && props22 !== undefined){if(props22 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props22);}}const _errs279 = errors;let valid98 = true;const _errs280 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("run.result.proposed" !== data.eventType){const err214 = {};if(vErrors === null){vErrors = [err214];}else {vErrors.push(err214);}errors++;}}}var _valid26 = _errs280 === errors;errors = _errs279;if(vErrors !== null){if(_errs279){vErrors.length = _errs279;}else {vErrors = null;}}if(_valid26){const _errs282 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data103 = data.payload;const _errs287 = errors;let valid103 = true;const _errs288 = errors;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.reportedWorkDisposition !== undefined){if("blocked" !== data103.reportedWorkDisposition){const err215 = {};if(vErrors === null){vErrors = [err215];}else {vErrors.push(err215);}errors++;}}}var _valid27 = _errs288 === errors;errors = _errs287;if(vErrors !== null){if(_errs287){vErrors.length = _errs287;}else {vErrors = null;}}if(_valid27){const _errs290 = errors;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.blocker === undefined){const err216 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/0/then/required",keyword:"required",params:{missingProperty: "blocker"},message:"must have required property '"+"blocker"+"'"};if(vErrors === null){vErrors = [err216];}else {vErrors.push(err216);}errors++;}}var _valid27 = _errs290 === errors;valid103 = _valid27;}if(!valid103){const err217 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err217];}else {vErrors.push(err217);}errors++;}const _errs292 = errors;let valid105 = true;const _errs293 = errors;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.reportedWorkDisposition !== undefined){if("yielded" !== data103.reportedWorkDisposition){const err218 = {};if(vErrors === null){vErrors = [err218];}else {vErrors.push(err218);}errors++;}}}var _valid28 = _errs293 === errors;errors = _errs292;if(vErrors !== null){if(_errs292){vErrors.length = _errs292;}else {vErrors = null;}}if(_valid28){const _errs295 = errors;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.continuation === undefined){const err219 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/1/then/required",keyword:"required",params:{missingProperty: "continuation"},message:"must have required property '"+"continuation"+"'"};if(vErrors === null){vErrors = [err219];}else {vErrors.push(err219);}errors++;}}var _valid28 = _errs295 === errors;valid105 = _valid28;}if(!valid105){const err220 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err220];}else {vErrors.push(err220);}errors++;}if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.schema === undefined){const err221 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err221];}else {vErrors.push(err221);}errors++;}if(data103.reportedWorkDisposition === undefined){const err222 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "reportedWorkDisposition"},message:"must have required property '"+"reportedWorkDisposition"+"'"};if(vErrors === null){vErrors = [err222];}else {vErrors.push(err222);}errors++;}if(data103.summary === undefined){const err223 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err223];}else {vErrors.push(err223);}errors++;}if(data103.completionClaim === undefined){const err224 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "completionClaim"},message:"must have required property '"+"completionClaim"+"'"};if(vErrors === null){vErrors = [err224];}else {vErrors.push(err224);}errors++;}if(data103.evidence === undefined){const err225 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "evidence"},message:"must have required property '"+"evidence"+"'"};if(vErrors === null){vErrors = [err225];}else {vErrors.push(err225);}errors++;}if(data103.verification === undefined){const err226 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "verification"},message:"must have required property '"+"verification"+"'"};if(vErrors === null){vErrors = [err226];}else {vErrors.push(err226);}errors++;}if(data103.attentionRequests === undefined){const err227 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "attentionRequests"},message:"must have required property '"+"attentionRequests"+"'"};if(vErrors === null){vErrors = [err227];}else {vErrors.push(err227);}errors++;}if(data103.artifacts === undefined){const err228 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "artifacts"},message:"must have required property '"+"artifacts"+"'"};if(vErrors === null){vErrors = [err228];}else {vErrors.push(err228);}errors++;}if(data103.schema !== undefined){if("paperclip.run_result.v1" !== data103.schema){const err229 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.run_result.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err229];}else {vErrors.push(err229);}errors++;}}if(data103.reportedWorkDisposition !== undefined){let data107 = data103.reportedWorkDisposition;if(!((((data107 === "done") || (data107 === "blocked")) || (data107 === "needs_review")) || (data107 === "yielded"))){const err230 = {instancePath:instancePath+"/payload/reportedWorkDisposition",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/reportedWorkDisposition/enum",keyword:"enum",params:{allowedValues: schema202.properties.reportedWorkDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err230];}else {vErrors.push(err230);}errors++;}}if(data103.summary !== undefined){let data108 = data103.summary;if(typeof data108 === "string"){if(func1(data108) > 12000){const err231 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err231];}else {vErrors.push(err231);}errors++;}if(func1(data108) < 1){const err232 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err232];}else {vErrors.push(err232);}errors++;}}else {const err233 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}}if(data103.completionClaim !== undefined){let data109 = data103.completionClaim;if(data109 && typeof data109 == "object" && !Array.isArray(data109)){if(data109.contractRevision === undefined){const err234 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "contractRevision"},message:"must have required property '"+"contractRevision"+"'"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}if(data109.objectiveSatisfied === undefined){const err235 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "objectiveSatisfied"},message:"must have required property '"+"objectiveSatisfied"+"'"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}if(data109.criteria === undefined){const err236 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "criteria"},message:"must have required property '"+"criteria"+"'"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}if(data109.remainingWork === undefined){const err237 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "remainingWork"},message:"must have required property '"+"remainingWork"+"'"};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}if(data109.contractRevision !== undefined){let data110 = data109.contractRevision;if(typeof data110 === "string"){if(func1(data110) < 1){const err238 = {instancePath:instancePath+"/payload/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err238];}else {vErrors.push(err238);}errors++;}}else {const err239 = {instancePath:instancePath+"/payload/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err239];}else {vErrors.push(err239);}errors++;}}if(data109.objectiveSatisfied !== undefined){if(typeof data109.objectiveSatisfied !== "boolean"){const err240 = {instancePath:instancePath+"/payload/completionClaim/objectiveSatisfied",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/objectiveSatisfied/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err240];}else {vErrors.push(err240);}errors++;}}if(data109.criteria !== undefined){let data112 = data109.criteria;if(Array.isArray(data112)){const len2 = data112.length;for(let i2=0; i2 160){const err329 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err329];}else {vErrors.push(err329);}errors++;}if(func1(data155) < 1){const err330 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err330];}else {vErrors.push(err330);}errors++;}}else {const err331 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err331];}else {vErrors.push(err331);}errors++;}}if(data.sourceSeq !== undefined){let data156 = data.sourceSeq;if(!(((typeof data156 == "number") && (!(data156 % 1) && !isNaN(data156))) && (isFinite(data156)))){const err332 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err332];}else {vErrors.push(err332);}errors++;}if((typeof data156 == "number") && (isFinite(data156))){if(data156 > 9007199254740991 || isNaN(data156)){const err333 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9007199254740991},message:"must be <= 9007199254740991"};if(vErrors === null){vErrors = [err333];}else {vErrors.push(err333);}errors++;}if(data156 < 1 || isNaN(data156)){const err334 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err334];}else {vErrors.push(err334);}errors++;}}}if(data.sourceInstanceId !== undefined){let data157 = data.sourceInstanceId;if(typeof data157 === "string"){if(func1(data157) > 160){const err335 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err335];}else {vErrors.push(err335);}errors++;}if(func1(data157) < 1){const err336 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err336];}else {vErrors.push(err336);}errors++;}}else {const err337 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err337];}else {vErrors.push(err337);}errors++;}}if(data.sourceKind !== undefined){let data158 = data.sourceKind;if(!((data158 === "runner") || (data158 === "control_plane"))){const err338 = {instancePath:instancePath+"/sourceKind",schemaPath:"#/properties/sourceKind/enum",keyword:"enum",params:{allowedValues: schema50.properties.sourceKind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err338];}else {vErrors.push(err338);}errors++;}}if(data.runId !== undefined){let data159 = data.runId;if(typeof data159 === "string"){if(func1(data159) > 160){const err339 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err339];}else {vErrors.push(err339);}errors++;}if(func1(data159) < 1){const err340 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err340];}else {vErrors.push(err340);}errors++;}}else {const err341 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err341];}else {vErrors.push(err341);}errors++;}}if(data.normalizedSessionId !== undefined){let data160 = data.normalizedSessionId;if(typeof data160 === "string"){if(func1(data160) > 160){const err342 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err342];}else {vErrors.push(err342);}errors++;}if(func1(data160) < 1){const err343 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err343];}else {vErrors.push(err343);}errors++;}}else {const err344 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err344];}else {vErrors.push(err344);}errors++;}}if(data.turnId !== undefined){let data161 = data.turnId;if(typeof data161 === "string"){if(func1(data161) > 240){const err345 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err345];}else {vErrors.push(err345);}errors++;}if(func1(data161) < 1){const err346 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err346];}else {vErrors.push(err346);}errors++;}}else {const err347 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err347];}else {vErrors.push(err347);}errors++;}}if(data.itemId !== undefined){let data162 = data.itemId;if(typeof data162 === "string"){if(func1(data162) > 240){const err348 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err348];}else {vErrors.push(err348);}errors++;}if(func1(data162) < 1){const err349 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err349];}else {vErrors.push(err349);}errors++;}}else {const err350 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err350];}else {vErrors.push(err350);}errors++;}}if(data.eventType !== undefined){let data163 = data.eventType;if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((data163 === "runner.connected") || (data163 === "runner.reconnected")) || (data163 === "runner.reconciled")) || (data163 === "runner.disconnected")) || (data163 === "runner.draining")) || (data163 === "runner.backpressure")) || (data163 === "runner.suspending")) || (data163 === "runner.suspended")) || (data163 === "runner.stopped")) || (data163 === "runner.diagnostic")) || (data163 === "runtime.phase.changed")) || (data163 === "sandbox.metric")) || (data163 === "workspace.ready")) || (data163 === "workspace.change.updated")) || (data163 === "workspace.diff.recorded")) || (data163 === "workspace.file.referenced")) || (data163 === "harness.starting")) || (data163 === "harness.ready")) || (data163 === "harness.exited")) || (data163 === "harness.diagnostic")) || (data163 === "plan.updated")) || (data163 === "tool.execution.started")) || (data163 === "tool.execution.progressed")) || (data163 === "tool.execution.completed")) || (data163 === "research.started")) || (data163 === "research.progressed")) || (data163 === "research.completed")) || (data163 === "delegation.started")) || (data163 === "delegation.updated")) || (data163 === "delegation.completed")) || (data163 === "model.route.changed")) || (data163 === "model.verification.updated")) || (data163 === "context.compacted")) || (data163 === "artifact.viewed")) || (data163 === "artifact.generated")) || (data163 === "review.mode.changed")) || (data163 === "hook.started")) || (data163 === "hook.completed")) || (data163 === "memory.citation.referenced")) || (data163 === "safety.review.started")) || (data163 === "safety.review.completed")) || (data163 === "terminal.input.sent")) || (data163 === "wait.started")) || (data163 === "wait.completed")) || (data163 === "provider.notice.recorded")) || (data163 === "session.starting")) || (data163 === "session.started")) || (data163 === "session.resuming")) || (data163 === "session.resumed")) || (data163 === "session.reconciled")) || (data163 === "session.updated")) || (data163 === "session.closed")) || (data163 === "session.failed")) || (data163 === "turn.submitted")) || (data163 === "turn.accepted")) || (data163 === "turn.started")) || (data163 === "turn.completed")) || (data163 === "turn.failed")) || (data163 === "turn.interrupted")) || (data163 === "turn.cancelled")) || (data163 === "item.started")) || (data163 === "item.delta")) || (data163 === "item.completed")) || (data163 === "item.failed")) || (data163 === "usage.reported")) || (data163 === "semantic_tool.input")) || (data163 === "semantic_tool.result")) || (data163 === "semantic_tool.reconciled")) || (data163 === "mcp_app.discovered")) || (data163 === "mcp_app.resource.resolved")) || (data163 === "mcp_app.initializing")) || (data163 === "mcp_app.ready")) || (data163 === "mcp_app.tool_input")) || (data163 === "mcp_app.tool_result")) || (data163 === "mcp_app.action.requested")) || (data163 === "mcp_app.action.resolved")) || (data163 === "mcp_app.host_context.changed")) || (data163 === "mcp_app.failed")) || (data163 === "mcp_app.teardown")) || (data163 === "runtime_request.created")) || (data163 === "runtime_request.resolved")) || (data163 === "runtime_request.expired")) || (data163 === "runtime_request.cancelled")) || (data163 === "interaction.request.proposed")) || (data163 === "interaction.request.materialized")) || (data163 === "interaction.request.rejected")) || (data163 === "interaction.response.progressed")) || (data163 === "interaction.response.resolved")) || (data163 === "interaction.response.delivered")) || (data163 === "run.attached")) || (data163 === "run.detached")) || (data163 === "run.result.proposed")) || (data163 === "run.result.accepted")) || (data163 === "run.result.rejected")) || (data163 === "attention.request.proposed")) || (data163 === "attention.request.routed")) || (data163 === "attention.request.resolved")) || (data163 === "attention.request.expired")) || (data163 === "attention.request.superseded")) || (data163 === "work.assessment.recorded")) || (data163 === "issue.status.decision.recorded")) || (data163 === "issue.status.decision.applied")) || (data163 === "issue.status.decision.rejected")) || (data163 === "issue.status.decision.superseded")) || (data163 === "run.terminal"))){const err351 = {instancePath:instancePath+"/eventType",schemaPath:"#/properties/eventType/enum",keyword:"enum",params:{allowedValues: schema50.properties.eventType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err351];}else {vErrors.push(err351);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err352 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err352];}else {vErrors.push(err352);}errors++;}}if(data.priority !== undefined){let data165 = data.priority;if(!(((data165 === 0) || (data165 === 1)) || (data165 === 2))){const err353 = {instancePath:instancePath+"/priority",schemaPath:"#/properties/priority/enum",keyword:"enum",params:{allowedValues: schema50.properties.priority.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err353];}else {vErrors.push(err353);}errors++;}}if(data.emittedAt !== undefined){let data166 = data.emittedAt;if(typeof data166 === "string"){if(!(formats0.test(data166))){const err354 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err354];}else {vErrors.push(err354);}errors++;}}else {const err355 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err355];}else {vErrors.push(err355);}errors++;}}if(data.observedAt !== undefined){let data167 = data.observedAt;if(typeof data167 === "string"){if(!(formats0.test(data167))){const err356 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err356];}else {vErrors.push(err356);}errors++;}}else {const err357 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err357];}else {vErrors.push(err357);}errors++;}}if(data.payload !== undefined){let data168 = data.payload;if(data168 && typeof data168 == "object" && !Array.isArray(data168)){if(data168.channel !== undefined){let data169 = data168.channel;if(!(((((data169 === "progress") || (data169 === "final")) || (data169 === "summary")) || (data169 === "detail")) || (data169 === "unknown"))){const err358 = {instancePath:instancePath+"/payload/channel",schemaPath:"#/properties/payload/properties/channel/enum",keyword:"enum",params:{allowedValues: schema50.properties.payload.properties.channel.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err358];}else {vErrors.push(err358);}errors++;}}if(data168.providerPhase !== undefined){if(typeof data168.providerPhase !== "string"){const err359 = {instancePath:instancePath+"/payload/providerPhase",schemaPath:"#/properties/payload/properties/providerPhase/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err359];}else {vErrors.push(err359);}errors++;}}if(data168.providerMethod !== undefined){if(typeof data168.providerMethod !== "string"){const err360 = {instancePath:instancePath+"/payload/providerMethod",schemaPath:"#/properties/payload/properties/providerMethod/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err360];}else {vErrors.push(err360);}errors++;}}}else {const err361 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err361];}else {vErrors.push(err361);}errors++;}}if(data.debug !== undefined){let data172 = data.debug;if(data172 && typeof data172 == "object" && !Array.isArray(data172)){}else {const err362 = {instancePath:instancePath+"/debug",schemaPath:"#/properties/debug/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err362];}else {vErrors.push(err362);}errors++;}}}else {const err363 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err363];}else {vErrors.push(err363);}errors++;}validate28.errors = vErrors;return errors === 0;}validate28.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema203 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v2/event.schema.json","title":"PRP v2 native event","type":"object","required":["schema","sourceEventId","sourceSeq","sourceInstanceId","sourceKind","runId","eventType","schemaVersion","priority","emittedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.event.v2"},"sourceEventId":{"type":"string","minLength":1,"maxLength":160},"sourceSeq":{"type":"integer","minimum":1,"maximum":9007199254740991},"sourceInstanceId":{"type":"string","minLength":1,"maxLength":160},"sourceKind":{"enum":["runner","control_plane"]},"runId":{"type":"string","minLength":1,"maxLength":160},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":160},"turnId":{"type":"string","minLength":1,"maxLength":160},"itemId":{"type":"string","minLength":1,"maxLength":160},"eventType":{"enum":["runner.connected","runner.reconnected","runner.reconciled","runner.disconnected","runner.draining","runner.suspending","runner.suspended","runner.stopped","runner.diagnostic","runtime.phase.changed","sandbox.metric","workspace.ready","workspace.change.updated","workspace.diff.recorded","workspace.file.referenced","harness.starting","harness.ready","harness.exited","harness.diagnostic","plan.updated","tool.execution.started","tool.execution.progressed","tool.execution.completed","research.started","research.progressed","research.completed","delegation.started","delegation.updated","delegation.completed","model.route.changed","model.verification.updated","context.compacted","artifact.viewed","artifact.generated","review.mode.changed","hook.started","hook.completed","memory.citation.referenced","safety.review.started","safety.review.completed","terminal.input.sent","wait.started","wait.completed","provider.notice.recorded","session.starting","session.started","session.resuming","session.resumed","session.reconciled","session.updated","session.closed","session.failed","session.capabilities.updated","session.goal.snapshot","session.goal.updated","session.goal.cleared","turn.submitted","turn.accepted","turn.started","turn.completed","turn.failed","turn.interrupted","turn.cancelled","item.started","item.delta","item.completed","item.failed","usage.reported","semantic_tool.input","semantic_tool.result","mcp_app.discovered","mcp_app.resource.resolved","mcp_app.initializing","mcp_app.ready","mcp_app.tool_input","mcp_app.tool_result","mcp_app.action.requested","mcp_app.action.resolved","mcp_app.host_context.changed","mcp_app.failed","mcp_app.teardown","runtime_request.created","runtime_request.resolved","runtime_request.expired","runtime_request.cancelled","interaction.request.proposed","interaction.request.materialized","interaction.request.rejected","interaction.response.progressed","interaction.response.resolved","interaction.response.delivered","run.attached","run.detached","run.result.proposed","run.result.accepted","run.result.rejected","attention.request.proposed","attention.request.routed","attention.request.resolved","attention.request.expired","attention.request.superseded","work.assessment.recorded","issue.status.decision.recorded","issue.status.decision.applied","issue.status.decision.rejected","issue.status.decision.superseded","run.terminal"]},"schemaVersion":{"const":2},"priority":{"enum":[0,1,2]},"emittedAt":{"type":"string","format":"date-time"},"observedAt":{"type":"string","format":"date-time"},"payload":{"type":"object","additionalProperties":true},"debug":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"eventType":{"enum":["session.goal.snapshot","session.goal.updated"]}}},"then":{"properties":{"payload":{"type":"object","required":["goal"],"properties":{"goal":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot"},{"type":"null"}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"const":"session.capabilities.updated"}}},"then":{"properties":{"payload":{"type":"object","required":["sessionGoals"],"properties":{"sessionGoals":{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"},"turnControls":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls"}},"additionalProperties":true}}}}],"additionalProperties":true};const schema204 = {"type":"object","required":["objective","status","tokenBudget","tokensUsed","elapsedSeconds","iterations","lastReason","createdAt","updatedAt","completedAt","workingNow"],"properties":{"objective":{"type":"string","minLength":1,"maxLength":4000},"status":{"enum":["active","paused","blocked","limited","usage_limited","budget_limited","complete"]},"tokenBudget":{"type":["integer","null"],"minimum":1},"tokensUsed":{"type":["integer","null"],"minimum":0},"elapsedSeconds":{"type":["number","null"],"minimum":0},"iterations":{"type":["integer","null"],"minimum":0},"lastReason":{"type":["string","null"],"maxLength":4000},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"completedAt":{"type":["string","null"],"format":"date-time"},"workingNow":{"type":"boolean"}},"additionalProperties":true};const schema207 = {"type":"object","required":["steering","queuedFollowUp"],"properties":{"steering":{"type":"boolean"},"queuedFollowUp":{"type":"boolean"}},"additionalProperties":false};function validate111(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v2/event.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate111.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data0 = data.eventType;if(!((data0 === "session.goal.snapshot") || (data0 === "session.goal.updated"))){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data1 = data.payload;if(data1 && typeof data1 == "object" && !Array.isArray(data1)){if(data1.goal === undefined){const err1 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/required",keyword:"required",params:{missingProperty: "goal"},message:"must have required property '"+"goal"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data1.goal !== undefined){let data2 = data1.goal;const _errs10 = errors;let valid5 = false;let passing0 = null;const _errs11 = errors;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.objective === undefined){const err2 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "objective"},message:"must have required property '"+"objective"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data2.status === undefined){const err3 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data2.tokenBudget === undefined){const err4 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "tokenBudget"},message:"must have required property '"+"tokenBudget"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data2.tokensUsed === undefined){const err5 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "tokensUsed"},message:"must have required property '"+"tokensUsed"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data2.elapsedSeconds === undefined){const err6 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "elapsedSeconds"},message:"must have required property '"+"elapsedSeconds"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data2.iterations === undefined){const err7 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "iterations"},message:"must have required property '"+"iterations"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data2.lastReason === undefined){const err8 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "lastReason"},message:"must have required property '"+"lastReason"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data2.createdAt === undefined){const err9 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "createdAt"},message:"must have required property '"+"createdAt"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data2.updatedAt === undefined){const err10 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "updatedAt"},message:"must have required property '"+"updatedAt"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data2.completedAt === undefined){const err11 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "completedAt"},message:"must have required property '"+"completedAt"+"'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data2.workingNow === undefined){const err12 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "workingNow"},message:"must have required property '"+"workingNow"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.objective !== undefined){let data3 = data2.objective;if(typeof data3 === "string"){if(func1(data3) > 4000){const err13 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data3) < 1){const err14 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data2.status !== undefined){let data4 = data2.status;if(!(((((((data4 === "active") || (data4 === "paused")) || (data4 === "blocked")) || (data4 === "limited")) || (data4 === "usage_limited")) || (data4 === "budget_limited")) || (data4 === "complete"))){const err16 = {instancePath:instancePath+"/payload/goal/status",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/status/enum",keyword:"enum",params:{allowedValues: schema204.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data2.tokenBudget !== undefined){let data5 = data2.tokenBudget;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err17 = {instancePath:instancePath+"/payload/goal/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokenBudget/type",keyword:"type",params:{type: schema204.properties.tokenBudget.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 1 || isNaN(data5)){const err18 = {instancePath:instancePath+"/payload/goal/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokenBudget/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}if(data2.tokensUsed !== undefined){let data6 = data2.tokensUsed;if((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (data6 !== null)){const err19 = {instancePath:instancePath+"/payload/goal/tokensUsed",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokensUsed/type",keyword:"type",params:{type: schema204.properties.tokensUsed.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err20 = {instancePath:instancePath+"/payload/goal/tokensUsed",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokensUsed/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data2.elapsedSeconds !== undefined){let data7 = data2.elapsedSeconds;if((!((typeof data7 == "number") && (isFinite(data7)))) && (data7 !== null)){const err21 = {instancePath:instancePath+"/payload/goal/elapsedSeconds",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/elapsedSeconds/type",keyword:"type",params:{type: schema204.properties.elapsedSeconds.type},message:"must be number,null"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 < 0 || isNaN(data7)){const err22 = {instancePath:instancePath+"/payload/goal/elapsedSeconds",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/elapsedSeconds/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}if(data2.iterations !== undefined){let data8 = data2.iterations;if((!(((typeof data8 == "number") && (!(data8 % 1) && !isNaN(data8))) && (isFinite(data8)))) && (data8 !== null)){const err23 = {instancePath:instancePath+"/payload/goal/iterations",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/iterations/type",keyword:"type",params:{type: schema204.properties.iterations.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if((typeof data8 == "number") && (isFinite(data8))){if(data8 < 0 || isNaN(data8)){const err24 = {instancePath:instancePath+"/payload/goal/iterations",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/iterations/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}if(data2.lastReason !== undefined){let data9 = data2.lastReason;if((typeof data9 !== "string") && (data9 !== null)){const err25 = {instancePath:instancePath+"/payload/goal/lastReason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/lastReason/type",keyword:"type",params:{type: schema204.properties.lastReason.type},message:"must be string,null"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(typeof data9 === "string"){if(func1(data9) > 4000){const err26 = {instancePath:instancePath+"/payload/goal/lastReason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/lastReason/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}if(data2.createdAt !== undefined){let data10 = data2.createdAt;if((typeof data10 !== "string") && (data10 !== null)){const err27 = {instancePath:instancePath+"/payload/goal/createdAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/createdAt/type",keyword:"type",params:{type: schema204.properties.createdAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(typeof data10 === "string"){if(!(formats0.test(data10))){const err28 = {instancePath:instancePath+"/payload/goal/createdAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/createdAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}if(data2.updatedAt !== undefined){let data11 = data2.updatedAt;if((typeof data11 !== "string") && (data11 !== null)){const err29 = {instancePath:instancePath+"/payload/goal/updatedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/updatedAt/type",keyword:"type",params:{type: schema204.properties.updatedAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(typeof data11 === "string"){if(!(formats0.test(data11))){const err30 = {instancePath:instancePath+"/payload/goal/updatedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/updatedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}}if(data2.completedAt !== undefined){let data12 = data2.completedAt;if((typeof data12 !== "string") && (data12 !== null)){const err31 = {instancePath:instancePath+"/payload/goal/completedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/completedAt/type",keyword:"type",params:{type: schema204.properties.completedAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(typeof data12 === "string"){if(!(formats0.test(data12))){const err32 = {instancePath:instancePath+"/payload/goal/completedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/completedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}if(data2.workingNow !== undefined){if(typeof data2.workingNow !== "boolean"){const err33 = {instancePath:instancePath+"/payload/goal/workingNow",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/workingNow/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}else {const err34 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}var _valid1 = _errs11 === errors;if(_valid1){valid5 = true;passing0 = 0;}const _errs36 = errors;if(data2 !== null){const err35 = {instancePath:instancePath+"/payload/goal",schemaPath:"#/allOf/0/then/properties/payload/properties/goal/oneOf/1/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}var _valid1 = _errs36 === errors;if(_valid1 && valid5){valid5 = false;passing0 = [passing0, 1];}else {if(_valid1){valid5 = true;passing0 = 1;}}if(!valid5){const err36 = {instancePath:instancePath+"/payload/goal",schemaPath:"#/allOf/0/then/properties/payload/properties/goal/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}else {errors = _errs10;if(vErrors !== null){if(_errs10){vErrors.length = _errs10;}else {vErrors = null;}}}}}else {const err37 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}}var _valid0 = _errs5 === errors;valid1 = _valid0;if(valid1){var props1 = {};props1.payload = true;props1.eventType = true;}}if(!valid1){const err38 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}const _errs39 = errors;let valid8 = true;const _errs40 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("session.capabilities.updated" !== data.eventType){const err39 = {};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}}var _valid2 = _errs40 === errors;errors = _errs39;if(vErrors !== null){if(_errs39){vErrors.length = _errs39;}else {vErrors = null;}}if(_valid2){const _errs42 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data15 = data.payload;if(data15 && typeof data15 == "object" && !Array.isArray(data15)){if(data15.sessionGoals === undefined){const err40 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "sessionGoals"},message:"must have required property '"+"sessionGoals"+"'"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if(data15.sessionGoals !== undefined){let data16 = data15.sessionGoals;if(data16 && typeof data16 == "object" && !Array.isArray(data16)){if(data16.availability === undefined){const err41 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "availability"},message:"must have required property '"+"availability"+"'"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data16.actions === undefined){const err42 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "actions"},message:"must have required property '"+"actions"+"'"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if(data16.autonomousUpdates === undefined){const err43 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "autonomousUpdates"},message:"must have required property '"+"autonomousUpdates"+"'"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}if(data16.persistentAcrossResume === undefined){const err44 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "persistentAcrossResume"},message:"must have required property '"+"persistentAcrossResume"+"'"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}if(data16.maxObjectiveChars === undefined){const err45 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "maxObjectiveChars"},message:"must have required property '"+"maxObjectiveChars"+"'"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(data16.tokenBudgetControl === undefined){const err46 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "tokenBudgetControl"},message:"must have required property '"+"tokenBudgetControl"+"'"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(data16.usageReporting === undefined){const err47 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "usageReporting"},message:"must have required property '"+"usageReporting"+"'"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}if(data16.availability !== undefined){let data17 = data16.availability;if(!(((data17 === "available") || (data17 === "unsupported")) || (data17 === "policy_disabled"))){const err48 = {instancePath:instancePath+"/payload/sessionGoals/availability",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/availability/enum",keyword:"enum",params:{allowedValues: schema44.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}if(data16.actions !== undefined){let data18 = data16.actions;if(Array.isArray(data18)){const len0 = data18.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data18[i1], data18[j0])){const err50 = {instancePath:instancePath+"/payload/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;break outer0;}}}}}else {const err51 = {instancePath:instancePath+"/payload/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data16.autonomousUpdates !== undefined){if(typeof data16.autonomousUpdates !== "boolean"){const err52 = {instancePath:instancePath+"/payload/sessionGoals/autonomousUpdates",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/autonomousUpdates/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}if(data16.persistentAcrossResume !== undefined){if(typeof data16.persistentAcrossResume !== "boolean"){const err53 = {instancePath:instancePath+"/payload/sessionGoals/persistentAcrossResume",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/persistentAcrossResume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}if(data16.maxObjectiveChars !== undefined){let data22 = data16.maxObjectiveChars;if(!(((typeof data22 == "number") && (!(data22 % 1) && !isNaN(data22))) && (isFinite(data22)))){const err54 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}if((typeof data22 == "number") && (isFinite(data22))){if(data22 > 4000 || isNaN(data22)){const err55 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/maximum",keyword:"maximum",params:{comparison: "<=", limit: 4000},message:"must be <= 4000"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}if(data22 < 1 || isNaN(data22)){const err56 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}}if(data16.tokenBudgetControl !== undefined){if(typeof data16.tokenBudgetControl !== "boolean"){const err57 = {instancePath:instancePath+"/payload/sessionGoals/tokenBudgetControl",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/tokenBudgetControl/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}if(data16.usageReporting !== undefined){if(typeof data16.usageReporting !== "boolean"){const err58 = {instancePath:instancePath+"/payload/sessionGoals/usageReporting",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/usageReporting/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}if(data16.reasonCode !== undefined){let data25 = data16.reasonCode;if(typeof data25 === "string"){if(func1(data25) > 160){const err59 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}if(func1(data25) < 1){const err60 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}else {const err61 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}if(data16.reason !== undefined){let data26 = data16.reason;if(typeof data26 === "string"){if(func1(data26) > 1000){const err62 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(func1(data26) < 1){const err63 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}else {const err64 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}}else {const err65 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}if(data15.turnControls !== undefined){let data27 = data15.turnControls;if(data27 && typeof data27 == "object" && !Array.isArray(data27)){if(data27.steering === undefined){const err66 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/required",keyword:"required",params:{missingProperty: "steering"},message:"must have required property '"+"steering"+"'"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}if(data27.queuedFollowUp === undefined){const err67 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/required",keyword:"required",params:{missingProperty: "queuedFollowUp"},message:"must have required property '"+"queuedFollowUp"+"'"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}for(const key0 in data27){if(!((key0 === "steering") || (key0 === "queuedFollowUp"))){const err68 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}if(data27.steering !== undefined){if(typeof data27.steering !== "boolean"){const err69 = {instancePath:instancePath+"/payload/turnControls/steering",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/properties/steering/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}if(data27.queuedFollowUp !== undefined){if(typeof data27.queuedFollowUp !== "boolean"){const err70 = {instancePath:instancePath+"/payload/turnControls/queuedFollowUp",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/properties/queuedFollowUp/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}}}else {const err71 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}}else {const err72 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}}}var _valid2 = _errs42 === errors;valid8 = _valid2;if(valid8){var props2 = {};props2.payload = true;props2.eventType = true;}}if(!valid8){const err73 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}if(props1 !== true && props2 !== undefined){if(props2 === true){props1 = true;}else {props1 = props1 || {};Object.assign(props1, props2);}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err74 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}if(data.sourceEventId === undefined){const err75 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceEventId"},message:"must have required property '"+"sourceEventId"+"'"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}if(data.sourceSeq === undefined){const err76 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceSeq"},message:"must have required property '"+"sourceSeq"+"'"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}if(data.sourceInstanceId === undefined){const err77 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceInstanceId"},message:"must have required property '"+"sourceInstanceId"+"'"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(data.sourceKind === undefined){const err78 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceKind"},message:"must have required property '"+"sourceKind"+"'"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}if(data.runId === undefined){const err79 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}if(data.eventType === undefined){const err80 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "eventType"},message:"must have required property '"+"eventType"+"'"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}if(data.schemaVersion === undefined){const err81 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}if(data.priority === undefined){const err82 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "priority"},message:"must have required property '"+"priority"+"'"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(data.emittedAt === undefined){const err83 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "emittedAt"},message:"must have required property '"+"emittedAt"+"'"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}if(data.payload === undefined){const err84 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "payload"},message:"must have required property '"+"payload"+"'"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}if(data.schema !== undefined){if("paperclip.prp.event.v2" !== data.schema){const err85 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.event.v2"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}}if(data.sourceEventId !== undefined){let data31 = data.sourceEventId;if(typeof data31 === "string"){if(func1(data31) > 160){const err86 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}if(func1(data31) < 1){const err87 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}else {const err88 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}}if(data.sourceSeq !== undefined){let data32 = data.sourceSeq;if(!(((typeof data32 == "number") && (!(data32 % 1) && !isNaN(data32))) && (isFinite(data32)))){const err89 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}if((typeof data32 == "number") && (isFinite(data32))){if(data32 > 9007199254740991 || isNaN(data32)){const err90 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9007199254740991},message:"must be <= 9007199254740991"};if(vErrors === null){vErrors = [err90];}else {vErrors.push(err90);}errors++;}if(data32 < 1 || isNaN(data32)){const err91 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}}if(data.sourceInstanceId !== undefined){let data33 = data.sourceInstanceId;if(typeof data33 === "string"){if(func1(data33) > 160){const err92 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}if(func1(data33) < 1){const err93 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}}else {const err94 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}}if(data.sourceKind !== undefined){let data34 = data.sourceKind;if(!((data34 === "runner") || (data34 === "control_plane"))){const err95 = {instancePath:instancePath+"/sourceKind",schemaPath:"#/properties/sourceKind/enum",keyword:"enum",params:{allowedValues: schema203.properties.sourceKind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}}if(data.runId !== undefined){let data35 = data.runId;if(typeof data35 === "string"){if(func1(data35) > 160){const err96 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}if(func1(data35) < 1){const err97 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}}else {const err98 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}if(data.normalizedSessionId !== undefined){let data36 = data.normalizedSessionId;if(typeof data36 === "string"){if(func1(data36) > 160){const err99 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}if(func1(data36) < 1){const err100 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}}else {const err101 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}if(data.turnId !== undefined){let data37 = data.turnId;if(typeof data37 === "string"){if(func1(data37) > 160){const err102 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}if(func1(data37) < 1){const err103 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}}else {const err104 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}}if(data.itemId !== undefined){let data38 = data.itemId;if(typeof data38 === "string"){if(func1(data38) > 160){const err105 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}if(func1(data38) < 1){const err106 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}}else {const err107 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}}if(data.eventType !== undefined){let data39 = data.eventType;if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((data39 === "runner.connected") || (data39 === "runner.reconnected")) || (data39 === "runner.reconciled")) || (data39 === "runner.disconnected")) || (data39 === "runner.draining")) || (data39 === "runner.suspending")) || (data39 === "runner.suspended")) || (data39 === "runner.stopped")) || (data39 === "runner.diagnostic")) || (data39 === "runtime.phase.changed")) || (data39 === "sandbox.metric")) || (data39 === "workspace.ready")) || (data39 === "workspace.change.updated")) || (data39 === "workspace.diff.recorded")) || (data39 === "workspace.file.referenced")) || (data39 === "harness.starting")) || (data39 === "harness.ready")) || (data39 === "harness.exited")) || (data39 === "harness.diagnostic")) || (data39 === "plan.updated")) || (data39 === "tool.execution.started")) || (data39 === "tool.execution.progressed")) || (data39 === "tool.execution.completed")) || (data39 === "research.started")) || (data39 === "research.progressed")) || (data39 === "research.completed")) || (data39 === "delegation.started")) || (data39 === "delegation.updated")) || (data39 === "delegation.completed")) || (data39 === "model.route.changed")) || (data39 === "model.verification.updated")) || (data39 === "context.compacted")) || (data39 === "artifact.viewed")) || (data39 === "artifact.generated")) || (data39 === "review.mode.changed")) || (data39 === "hook.started")) || (data39 === "hook.completed")) || (data39 === "memory.citation.referenced")) || (data39 === "safety.review.started")) || (data39 === "safety.review.completed")) || (data39 === "terminal.input.sent")) || (data39 === "wait.started")) || (data39 === "wait.completed")) || (data39 === "provider.notice.recorded")) || (data39 === "session.starting")) || (data39 === "session.started")) || (data39 === "session.resuming")) || (data39 === "session.resumed")) || (data39 === "session.reconciled")) || (data39 === "session.updated")) || (data39 === "session.closed")) || (data39 === "session.failed")) || (data39 === "session.capabilities.updated")) || (data39 === "session.goal.snapshot")) || (data39 === "session.goal.updated")) || (data39 === "session.goal.cleared")) || (data39 === "turn.submitted")) || (data39 === "turn.accepted")) || (data39 === "turn.started")) || (data39 === "turn.completed")) || (data39 === "turn.failed")) || (data39 === "turn.interrupted")) || (data39 === "turn.cancelled")) || (data39 === "item.started")) || (data39 === "item.delta")) || (data39 === "item.completed")) || (data39 === "item.failed")) || (data39 === "usage.reported")) || (data39 === "semantic_tool.input")) || (data39 === "semantic_tool.result")) || (data39 === "mcp_app.discovered")) || (data39 === "mcp_app.resource.resolved")) || (data39 === "mcp_app.initializing")) || (data39 === "mcp_app.ready")) || (data39 === "mcp_app.tool_input")) || (data39 === "mcp_app.tool_result")) || (data39 === "mcp_app.action.requested")) || (data39 === "mcp_app.action.resolved")) || (data39 === "mcp_app.host_context.changed")) || (data39 === "mcp_app.failed")) || (data39 === "mcp_app.teardown")) || (data39 === "runtime_request.created")) || (data39 === "runtime_request.resolved")) || (data39 === "runtime_request.expired")) || (data39 === "runtime_request.cancelled")) || (data39 === "interaction.request.proposed")) || (data39 === "interaction.request.materialized")) || (data39 === "interaction.request.rejected")) || (data39 === "interaction.response.progressed")) || (data39 === "interaction.response.resolved")) || (data39 === "interaction.response.delivered")) || (data39 === "run.attached")) || (data39 === "run.detached")) || (data39 === "run.result.proposed")) || (data39 === "run.result.accepted")) || (data39 === "run.result.rejected")) || (data39 === "attention.request.proposed")) || (data39 === "attention.request.routed")) || (data39 === "attention.request.resolved")) || (data39 === "attention.request.expired")) || (data39 === "attention.request.superseded")) || (data39 === "work.assessment.recorded")) || (data39 === "issue.status.decision.recorded")) || (data39 === "issue.status.decision.applied")) || (data39 === "issue.status.decision.rejected")) || (data39 === "issue.status.decision.superseded")) || (data39 === "run.terminal"))){const err108 = {instancePath:instancePath+"/eventType",schemaPath:"#/properties/eventType/enum",keyword:"enum",params:{allowedValues: schema203.properties.eventType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}if(data.schemaVersion !== undefined){if(2 !== data.schemaVersion){const err109 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 2},message:"must be equal to constant"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}}if(data.priority !== undefined){let data41 = data.priority;if(!(((data41 === 0) || (data41 === 1)) || (data41 === 2))){const err110 = {instancePath:instancePath+"/priority",schemaPath:"#/properties/priority/enum",keyword:"enum",params:{allowedValues: schema203.properties.priority.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}if(data.emittedAt !== undefined){let data42 = data.emittedAt;if(typeof data42 === "string"){if(!(formats0.test(data42))){const err111 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}else {const err112 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}}if(data.observedAt !== undefined){let data43 = data.observedAt;if(typeof data43 === "string"){if(!(formats0.test(data43))){const err113 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}else {const err114 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}if(data.payload !== undefined){let data44 = data.payload;if(data44 && typeof data44 == "object" && !Array.isArray(data44)){}else {const err115 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}}if(data.debug !== undefined){let data45 = data.debug;if(data45 && typeof data45 == "object" && !Array.isArray(data45)){}else {const err116 = {instancePath:instancePath+"/debug",schemaPath:"#/properties/debug/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}}else {const err117 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}validate111.errors = vErrors;return errors === 0;}validate111.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema209 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v3/event.schema.json","title":"PRP v3 native event","type":"object","required":["schema","sourceEventId","sourceSeq","sourceInstanceId","sourceKind","runId","eventType","schemaVersion","priority","emittedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.event.v3"},"sourceEventId":{"type":"string","minLength":1,"maxLength":160},"sourceSeq":{"type":"integer","minimum":1,"maximum":9007199254740991},"sourceInstanceId":{"type":"string","minLength":1,"maxLength":160},"sourceKind":{"enum":["runner","control_plane"]},"runId":{"type":"string","minLength":1,"maxLength":160},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":160},"turnId":{"type":"string","minLength":1,"maxLength":160},"itemId":{"type":"string","minLength":1,"maxLength":160},"eventType":{"enum":["runner.connected","runner.reconnected","runner.reconciled","runner.disconnected","runner.draining","runner.suspending","runner.suspended","runner.stopped","runner.diagnostic","runtime.phase.changed","sandbox.metric","workspace.ready","workspace.change.updated","workspace.diff.recorded","workspace.file.referenced","harness.starting","harness.ready","harness.exited","harness.diagnostic","plan.updated","tool.execution.started","tool.execution.progressed","tool.execution.completed","research.started","research.progressed","research.completed","delegation.started","delegation.updated","delegation.completed","model.route.changed","model.verification.updated","context.compacted","artifact.viewed","artifact.generated","review.mode.changed","hook.started","hook.completed","memory.citation.referenced","safety.review.started","safety.review.completed","terminal.input.sent","wait.started","wait.completed","provider.notice.recorded","session.starting","session.started","session.resuming","session.resumed","session.reconciled","session.updated","session.closed","session.failed","session.capabilities.updated","session.goal.snapshot","session.goal.updated","session.goal.cleared","turn.submitted","turn.accepted","turn.started","turn.completed","turn.failed","turn.interrupted","turn.cancelled","item.started","item.delta","item.completed","item.failed","usage.reported","semantic_tool.input","semantic_tool.result","mcp_app.discovered","mcp_app.resource.resolved","mcp_app.initializing","mcp_app.ready","mcp_app.tool_input","mcp_app.tool_result","mcp_app.action.requested","mcp_app.action.resolved","mcp_app.host_context.changed","mcp_app.failed","mcp_app.teardown","runtime_request.created","runtime_request.resolved","runtime_request.expired","runtime_request.cancelled","interaction.request.proposed","interaction.request.materialized","interaction.request.rejected","interaction.response.progressed","interaction.response.resolved","interaction.response.delivered","run.attached","run.detached","run.result.proposed","run.result.accepted","run.result.rejected","attention.request.proposed","attention.request.routed","attention.request.resolved","attention.request.expired","attention.request.superseded","work.assessment.recorded","issue.status.decision.recorded","issue.status.decision.applied","issue.status.decision.rejected","issue.status.decision.superseded","run.terminal","external_provider.dispatch_requested","external_provider.operation_settled"]},"schemaVersion":{"const":3},"priority":{"enum":[0,1,2]},"emittedAt":{"type":"string","format":"date-time"},"observedAt":{"type":"string","format":"date-time"},"payload":{"type":"object","additionalProperties":true},"debug":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"eventType":{"enum":["session.goal.snapshot","session.goal.updated"]}},"type":"object"},"then":{"properties":{"payload":{"type":"object","required":["goal"],"properties":{"goal":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot"},{"type":"null"}]}},"additionalProperties":true}},"type":"object"}},{"if":{"properties":{"eventType":{"const":"session.capabilities.updated"}},"type":"object"},"then":{"properties":{"payload":{"type":"object","required":["sessionGoals"],"properties":{"sessionGoals":{"$ref":"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability"},"turnControls":{"$ref":"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls"}},"additionalProperties":true}},"type":"object"}},{"if":{"properties":{"eventType":{"const":"external_provider.dispatch_requested"}},"type":"object"},"then":{"properties":{"payload":{"oneOf":[{"type":"object","properties":{"binding":{"type":"object","properties":{"companyId":{"type":"string","minLength":1,"maxLength":240},"agentId":{"type":"string","minLength":1,"maxLength":240},"bindingId":{"type":"string","minLength":1,"maxLength":240},"runId":{"type":"string","minLength":1,"maxLength":240},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":240},"turnId":{"type":"string","minLength":1,"maxLength":240},"bindingGeneration":{"type":"integer","minimum":1},"assignmentRevision":{"type":"integer","minimum":1}},"additionalProperties":false,"required":["companyId","agentId","bindingId","runId","normalizedSessionId","turnId","bindingGeneration","assignmentRevision"]},"text":{"type":"string","minLength":1,"maxLength":1048576},"instructions":{"type":"string","maxLength":1048576},"acceptByUnixMs":{"type":"integer","minimum":1},"expiresAtUnixMs":{"type":"integer","minimum":1},"completionContract":{"type":"object","properties":{"revision":{"type":"string","minLength":1},"criterionIds":{"type":"array","minItems":1,"maxItems":256,"uniqueItems":true,"items":{"type":"string","minLength":1}}},"required":["revision","criterionIds"],"additionalProperties":false},"tools":{"type":"array","items":{"type":"object"},"maxItems":512}},"additionalProperties":false,"required":["binding","text","instructions","acceptByUnixMs","expiresAtUnixMs","completionContract","tools"]},{"type":"object","properties":{"binding":{"type":"object","properties":{"companyId":{"type":"string","minLength":1,"maxLength":240},"agentId":{"type":"string","minLength":1,"maxLength":240},"bindingId":{"type":"string","minLength":1,"maxLength":240},"runId":{"type":"string","minLength":1,"maxLength":240},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":240},"turnId":{"type":"string","minLength":1,"maxLength":240},"bindingGeneration":{"type":"integer","minimum":1},"assignmentRevision":{"type":"integer","minimum":1}},"additionalProperties":false,"required":["companyId","agentId","bindingId","runId","normalizedSessionId","turnId","bindingGeneration","assignmentRevision"]},"kind":{"const":"authority_revoked"},"reason":{"type":"string"},"externalStopConfirmed":{"const":false}},"required":["binding","kind","externalStopConfirmed"],"additionalProperties":false}]}},"type":"object"}},{"if":{"properties":{"eventType":{"const":"external_provider.operation_settled"}},"type":"object"},"then":{"properties":{"payload":{"type":"object","properties":{"binding":{"type":"object","properties":{"companyId":{"type":"string","minLength":1,"maxLength":240},"agentId":{"type":"string","minLength":1,"maxLength":240},"bindingId":{"type":"string","minLength":1,"maxLength":240},"runId":{"type":"string","minLength":1,"maxLength":240},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":240},"turnId":{"type":"string","minLength":1,"maxLength":240},"bindingGeneration":{"type":"integer","minimum":1},"assignmentRevision":{"type":"integer","minimum":1}},"additionalProperties":false,"required":["companyId","agentId","bindingId","runId","normalizedSessionId","turnId","bindingGeneration","assignmentRevision"]},"requestId":{"type":"string","minLength":1,"maxLength":240},"outcome":{"type":"object"}},"required":["binding","requestId","outcome"],"additionalProperties":false}},"type":"object"}}],"additionalProperties":true};function validate114(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v3/event.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate114.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(errors === _errs3){if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){let data0 = data.eventType;if(!((data0 === "session.goal.snapshot") || (data0 === "session.goal.updated"))){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}else {const err1 = {};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs6 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data1 = data.payload;if(data1 && typeof data1 == "object" && !Array.isArray(data1)){if(data1.goal === undefined){const err2 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/required",keyword:"required",params:{missingProperty: "goal"},message:"must have required property '"+"goal"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data1.goal !== undefined){let data2 = data1.goal;const _errs12 = errors;let valid5 = false;let passing0 = null;const _errs13 = errors;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.objective === undefined){const err3 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "objective"},message:"must have required property '"+"objective"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data2.status === undefined){const err4 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data2.tokenBudget === undefined){const err5 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "tokenBudget"},message:"must have required property '"+"tokenBudget"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data2.tokensUsed === undefined){const err6 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "tokensUsed"},message:"must have required property '"+"tokensUsed"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data2.elapsedSeconds === undefined){const err7 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "elapsedSeconds"},message:"must have required property '"+"elapsedSeconds"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data2.iterations === undefined){const err8 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "iterations"},message:"must have required property '"+"iterations"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data2.lastReason === undefined){const err9 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "lastReason"},message:"must have required property '"+"lastReason"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data2.createdAt === undefined){const err10 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "createdAt"},message:"must have required property '"+"createdAt"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data2.updatedAt === undefined){const err11 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "updatedAt"},message:"must have required property '"+"updatedAt"+"'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data2.completedAt === undefined){const err12 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "completedAt"},message:"must have required property '"+"completedAt"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.workingNow === undefined){const err13 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/required",keyword:"required",params:{missingProperty: "workingNow"},message:"must have required property '"+"workingNow"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data2.objective !== undefined){let data3 = data2.objective;if(typeof data3 === "string"){if(func1(data3) > 4000){const err14 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(func1(data3) < 1){const err15 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/payload/goal/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/objective/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data2.status !== undefined){let data4 = data2.status;if(!(((((((data4 === "active") || (data4 === "paused")) || (data4 === "blocked")) || (data4 === "limited")) || (data4 === "usage_limited")) || (data4 === "budget_limited")) || (data4 === "complete"))){const err17 = {instancePath:instancePath+"/payload/goal/status",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/status/enum",keyword:"enum",params:{allowedValues: schema204.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data2.tokenBudget !== undefined){let data5 = data2.tokenBudget;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err18 = {instancePath:instancePath+"/payload/goal/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokenBudget/type",keyword:"type",params:{type: schema204.properties.tokenBudget.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 1 || isNaN(data5)){const err19 = {instancePath:instancePath+"/payload/goal/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokenBudget/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}}if(data2.tokensUsed !== undefined){let data6 = data2.tokensUsed;if((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (data6 !== null)){const err20 = {instancePath:instancePath+"/payload/goal/tokensUsed",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokensUsed/type",keyword:"type",params:{type: schema204.properties.tokensUsed.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err21 = {instancePath:instancePath+"/payload/goal/tokensUsed",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/tokensUsed/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}if(data2.elapsedSeconds !== undefined){let data7 = data2.elapsedSeconds;if((!((typeof data7 == "number") && (isFinite(data7)))) && (data7 !== null)){const err22 = {instancePath:instancePath+"/payload/goal/elapsedSeconds",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/elapsedSeconds/type",keyword:"type",params:{type: schema204.properties.elapsedSeconds.type},message:"must be number,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 < 0 || isNaN(data7)){const err23 = {instancePath:instancePath+"/payload/goal/elapsedSeconds",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/elapsedSeconds/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}if(data2.iterations !== undefined){let data8 = data2.iterations;if((!(((typeof data8 == "number") && (!(data8 % 1) && !isNaN(data8))) && (isFinite(data8)))) && (data8 !== null)){const err24 = {instancePath:instancePath+"/payload/goal/iterations",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/iterations/type",keyword:"type",params:{type: schema204.properties.iterations.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if((typeof data8 == "number") && (isFinite(data8))){if(data8 < 0 || isNaN(data8)){const err25 = {instancePath:instancePath+"/payload/goal/iterations",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/iterations/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}if(data2.lastReason !== undefined){let data9 = data2.lastReason;if((typeof data9 !== "string") && (data9 !== null)){const err26 = {instancePath:instancePath+"/payload/goal/lastReason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/lastReason/type",keyword:"type",params:{type: schema204.properties.lastReason.type},message:"must be string,null"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(typeof data9 === "string"){if(func1(data9) > 4000){const err27 = {instancePath:instancePath+"/payload/goal/lastReason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/lastReason/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}}if(data2.createdAt !== undefined){let data10 = data2.createdAt;if((typeof data10 !== "string") && (data10 !== null)){const err28 = {instancePath:instancePath+"/payload/goal/createdAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/createdAt/type",keyword:"type",params:{type: schema204.properties.createdAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(typeof data10 === "string"){if(!(formats0.test(data10))){const err29 = {instancePath:instancePath+"/payload/goal/createdAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/createdAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}}if(data2.updatedAt !== undefined){let data11 = data2.updatedAt;if((typeof data11 !== "string") && (data11 !== null)){const err30 = {instancePath:instancePath+"/payload/goal/updatedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/updatedAt/type",keyword:"type",params:{type: schema204.properties.updatedAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(typeof data11 === "string"){if(!(formats0.test(data11))){const err31 = {instancePath:instancePath+"/payload/goal/updatedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/updatedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}}if(data2.completedAt !== undefined){let data12 = data2.completedAt;if((typeof data12 !== "string") && (data12 !== null)){const err32 = {instancePath:instancePath+"/payload/goal/completedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/completedAt/type",keyword:"type",params:{type: schema204.properties.completedAt.type},message:"must be string,null"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(typeof data12 === "string"){if(!(formats0.test(data12))){const err33 = {instancePath:instancePath+"/payload/goal/completedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/completedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}}if(data2.workingNow !== undefined){if(typeof data2.workingNow !== "boolean"){const err34 = {instancePath:instancePath+"/payload/goal/workingNow",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/properties/workingNow/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}else {const err35 = {instancePath:instancePath+"/payload/goal",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/snapshot/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}var _valid1 = _errs13 === errors;if(_valid1){valid5 = true;passing0 = 0;}const _errs38 = errors;if(data2 !== null){const err36 = {instancePath:instancePath+"/payload/goal",schemaPath:"#/allOf/0/then/properties/payload/properties/goal/oneOf/1/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}var _valid1 = _errs38 === errors;if(_valid1 && valid5){valid5 = false;passing0 = [passing0, 1];}else {if(_valid1){valid5 = true;passing0 = 1;}}if(!valid5){const err37 = {instancePath:instancePath+"/payload/goal",schemaPath:"#/allOf/0/then/properties/payload/properties/goal/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}else {errors = _errs12;if(vErrors !== null){if(_errs12){vErrors.length = _errs12;}else {vErrors = null;}}}}}else {const err38 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}}else {const err39 = {instancePath,schemaPath:"#/allOf/0/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}var _valid0 = _errs6 === errors;valid1 = _valid0;if(valid1){var props1 = {};props1.payload = true;props1.eventType = true;}}if(!valid1){const err40 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}const _errs41 = errors;let valid8 = true;const _errs42 = errors;if(errors === _errs42){if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("session.capabilities.updated" !== data.eventType){const err41 = {};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}}else {const err42 = {};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}var _valid2 = _errs42 === errors;errors = _errs41;if(vErrors !== null){if(_errs41){vErrors.length = _errs41;}else {vErrors = null;}}if(_valid2){const _errs45 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data15 = data.payload;if(data15 && typeof data15 == "object" && !Array.isArray(data15)){if(data15.sessionGoals === undefined){const err43 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "sessionGoals"},message:"must have required property '"+"sessionGoals"+"'"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}if(data15.sessionGoals !== undefined){let data16 = data15.sessionGoals;if(data16 && typeof data16 == "object" && !Array.isArray(data16)){if(data16.availability === undefined){const err44 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "availability"},message:"must have required property '"+"availability"+"'"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}if(data16.actions === undefined){const err45 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "actions"},message:"must have required property '"+"actions"+"'"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(data16.autonomousUpdates === undefined){const err46 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "autonomousUpdates"},message:"must have required property '"+"autonomousUpdates"+"'"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(data16.persistentAcrossResume === undefined){const err47 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "persistentAcrossResume"},message:"must have required property '"+"persistentAcrossResume"+"'"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}if(data16.maxObjectiveChars === undefined){const err48 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "maxObjectiveChars"},message:"must have required property '"+"maxObjectiveChars"+"'"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}if(data16.tokenBudgetControl === undefined){const err49 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "tokenBudgetControl"},message:"must have required property '"+"tokenBudgetControl"+"'"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}if(data16.usageReporting === undefined){const err50 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/required",keyword:"required",params:{missingProperty: "usageReporting"},message:"must have required property '"+"usageReporting"+"'"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}if(data16.availability !== undefined){let data17 = data16.availability;if(!(((data17 === "available") || (data17 === "unsupported")) || (data17 === "policy_disabled"))){const err51 = {instancePath:instancePath+"/payload/sessionGoals/availability",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/availability/enum",keyword:"enum",params:{allowedValues: schema44.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data16.actions !== undefined){let data18 = data16.actions;if(Array.isArray(data18)){const len0 = data18.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data18[i1], data18[j0])){const err53 = {instancePath:instancePath+"/payload/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;break outer0;}}}}}else {const err54 = {instancePath:instancePath+"/payload/sessionGoals/actions",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/actions/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}if(data16.autonomousUpdates !== undefined){if(typeof data16.autonomousUpdates !== "boolean"){const err55 = {instancePath:instancePath+"/payload/sessionGoals/autonomousUpdates",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/autonomousUpdates/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data16.persistentAcrossResume !== undefined){if(typeof data16.persistentAcrossResume !== "boolean"){const err56 = {instancePath:instancePath+"/payload/sessionGoals/persistentAcrossResume",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/persistentAcrossResume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}if(data16.maxObjectiveChars !== undefined){let data22 = data16.maxObjectiveChars;if(!(((typeof data22 == "number") && (!(data22 % 1) && !isNaN(data22))) && (isFinite(data22)))){const err57 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}if((typeof data22 == "number") && (isFinite(data22))){if(data22 > 4000 || isNaN(data22)){const err58 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/maximum",keyword:"maximum",params:{comparison: "<=", limit: 4000},message:"must be <= 4000"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}if(data22 < 1 || isNaN(data22)){const err59 = {instancePath:instancePath+"/payload/sessionGoals/maxObjectiveChars",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/maxObjectiveChars/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}}if(data16.tokenBudgetControl !== undefined){if(typeof data16.tokenBudgetControl !== "boolean"){const err60 = {instancePath:instancePath+"/payload/sessionGoals/tokenBudgetControl",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/tokenBudgetControl/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}if(data16.usageReporting !== undefined){if(typeof data16.usageReporting !== "boolean"){const err61 = {instancePath:instancePath+"/payload/sessionGoals/usageReporting",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/usageReporting/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}if(data16.reasonCode !== undefined){let data25 = data16.reasonCode;if(typeof data25 === "string"){if(func1(data25) > 160){const err62 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(func1(data25) < 1){const err63 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}else {const err64 = {instancePath:instancePath+"/payload/sessionGoals/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}if(data16.reason !== undefined){let data26 = data16.reason;if(typeof data26 === "string"){if(func1(data26) > 1000){const err65 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}if(func1(data26) < 1){const err66 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}}else {const err67 = {instancePath:instancePath+"/payload/sessionGoals/reason",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}}}else {const err68 = {instancePath:instancePath+"/payload/sessionGoals",schemaPath:"https://paperclip.dev/schemas/prp/v2/session-goal.schema.json#/$defs/capability/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}if(data15.turnControls !== undefined){let data27 = data15.turnControls;if(data27 && typeof data27 == "object" && !Array.isArray(data27)){if(data27.steering === undefined){const err69 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/required",keyword:"required",params:{missingProperty: "steering"},message:"must have required property '"+"steering"+"'"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}if(data27.queuedFollowUp === undefined){const err70 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/required",keyword:"required",params:{missingProperty: "queuedFollowUp"},message:"must have required property '"+"queuedFollowUp"+"'"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}for(const key0 in data27){if(!((key0 === "steering") || (key0 === "queuedFollowUp"))){const err71 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}if(data27.steering !== undefined){if(typeof data27.steering !== "boolean"){const err72 = {instancePath:instancePath+"/payload/turnControls/steering",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/properties/steering/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}}if(data27.queuedFollowUp !== undefined){if(typeof data27.queuedFollowUp !== "boolean"){const err73 = {instancePath:instancePath+"/payload/turnControls/queuedFollowUp",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/properties/queuedFollowUp/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}}}else {const err74 = {instancePath:instancePath+"/payload/turnControls",schemaPath:"https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json#/$defs/turnControls/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}}}else {const err75 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}}}else {const err76 = {instancePath,schemaPath:"#/allOf/1/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}var _valid2 = _errs45 === errors;valid8 = _valid2;if(valid8){var props2 = {};props2.payload = true;props2.eventType = true;}}if(!valid8){const err77 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(props1 !== true && props2 !== undefined){if(props2 === true){props1 = true;}else {props1 = props1 || {};Object.assign(props1, props2);}}const _errs81 = errors;let valid19 = true;const _errs82 = errors;if(errors === _errs82){if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("external_provider.dispatch_requested" !== data.eventType){const err78 = {};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}}}else {const err79 = {};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}}var _valid3 = _errs82 === errors;errors = _errs81;if(vErrors !== null){if(_errs81){vErrors.length = _errs81;}else {vErrors = null;}}if(_valid3){const _errs85 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data31 = data.payload;const _errs88 = errors;let valid22 = false;let passing1 = null;const _errs89 = errors;if(data31 && typeof data31 == "object" && !Array.isArray(data31)){if(data31.binding === undefined){const err80 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "binding"},message:"must have required property '"+"binding"+"'"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}if(data31.text === undefined){const err81 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "text"},message:"must have required property '"+"text"+"'"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}if(data31.instructions === undefined){const err82 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "instructions"},message:"must have required property '"+"instructions"+"'"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(data31.acceptByUnixMs === undefined){const err83 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "acceptByUnixMs"},message:"must have required property '"+"acceptByUnixMs"+"'"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}if(data31.expiresAtUnixMs === undefined){const err84 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "expiresAtUnixMs"},message:"must have required property '"+"expiresAtUnixMs"+"'"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}if(data31.completionContract === undefined){const err85 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "completionContract"},message:"must have required property '"+"completionContract"+"'"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}if(data31.tools === undefined){const err86 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/required",keyword:"required",params:{missingProperty: "tools"},message:"must have required property '"+"tools"+"'"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}for(const key1 in data31){if(!(((((((key1 === "binding") || (key1 === "text")) || (key1 === "instructions")) || (key1 === "acceptByUnixMs")) || (key1 === "expiresAtUnixMs")) || (key1 === "completionContract")) || (key1 === "tools"))){const err87 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}if(data31.binding !== undefined){let data32 = data31.binding;if(data32 && typeof data32 == "object" && !Array.isArray(data32)){if(data32.companyId === undefined){const err88 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "companyId"},message:"must have required property '"+"companyId"+"'"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}if(data32.agentId === undefined){const err89 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "agentId"},message:"must have required property '"+"agentId"+"'"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}if(data32.bindingId === undefined){const err90 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err90];}else {vErrors.push(err90);}errors++;}if(data32.runId === undefined){const err91 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}if(data32.normalizedSessionId === undefined){const err92 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}if(data32.turnId === undefined){const err93 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}if(data32.bindingGeneration === undefined){const err94 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "bindingGeneration"},message:"must have required property '"+"bindingGeneration"+"'"};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}if(data32.assignmentRevision === undefined){const err95 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/required",keyword:"required",params:{missingProperty: "assignmentRevision"},message:"must have required property '"+"assignmentRevision"+"'"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}for(const key2 in data32){if(!((((((((key2 === "companyId") || (key2 === "agentId")) || (key2 === "bindingId")) || (key2 === "runId")) || (key2 === "normalizedSessionId")) || (key2 === "turnId")) || (key2 === "bindingGeneration")) || (key2 === "assignmentRevision"))){const err96 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}}if(data32.companyId !== undefined){let data33 = data32.companyId;if(typeof data33 === "string"){if(func1(data33) > 240){const err97 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/companyId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}if(func1(data33) < 1){const err98 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/companyId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}else {const err99 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/companyId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}}if(data32.agentId !== undefined){let data34 = data32.agentId;if(typeof data34 === "string"){if(func1(data34) > 240){const err100 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/agentId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}if(func1(data34) < 1){const err101 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/agentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}else {const err102 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/agentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}if(data32.bindingId !== undefined){let data35 = data32.bindingId;if(typeof data35 === "string"){if(func1(data35) > 240){const err103 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}if(func1(data35) < 1){const err104 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}}else {const err105 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}}if(data32.runId !== undefined){let data36 = data32.runId;if(typeof data36 === "string"){if(func1(data36) > 240){const err106 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/runId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}if(func1(data36) < 1){const err107 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}}else {const err108 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}if(data32.normalizedSessionId !== undefined){let data37 = data32.normalizedSessionId;if(typeof data37 === "string"){if(func1(data37) > 240){const err109 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}if(func1(data37) < 1){const err110 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}else {const err111 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}if(data32.turnId !== undefined){let data38 = data32.turnId;if(typeof data38 === "string"){if(func1(data38) > 240){const err112 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}if(func1(data38) < 1){const err113 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}else {const err114 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}if(data32.bindingGeneration !== undefined){let data39 = data32.bindingGeneration;if(!(((typeof data39 == "number") && (!(data39 % 1) && !isNaN(data39))) && (isFinite(data39)))){const err115 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingGeneration/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}if((typeof data39 == "number") && (isFinite(data39))){if(data39 < 1 || isNaN(data39)){const err116 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/bindingGeneration/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}}if(data32.assignmentRevision !== undefined){let data40 = data32.assignmentRevision;if(!(((typeof data40 == "number") && (!(data40 % 1) && !isNaN(data40))) && (isFinite(data40)))){const err117 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/assignmentRevision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}if((typeof data40 == "number") && (isFinite(data40))){if(data40 < 1 || isNaN(data40)){const err118 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/properties/assignmentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}}}}else {const err119 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/binding/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}}if(data31.text !== undefined){let data41 = data31.text;if(typeof data41 === "string"){if(func1(data41) > 1048576){const err120 = {instancePath:instancePath+"/payload/text",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/text/maxLength",keyword:"maxLength",params:{limit: 1048576},message:"must NOT have more than 1048576 characters"};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}if(func1(data41) < 1){const err121 = {instancePath:instancePath+"/payload/text",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/text/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}else {const err122 = {instancePath:instancePath+"/payload/text",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/text/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}}if(data31.instructions !== undefined){let data42 = data31.instructions;if(typeof data42 === "string"){if(func1(data42) > 1048576){const err123 = {instancePath:instancePath+"/payload/instructions",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/instructions/maxLength",keyword:"maxLength",params:{limit: 1048576},message:"must NOT have more than 1048576 characters"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}else {const err124 = {instancePath:instancePath+"/payload/instructions",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/instructions/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}}if(data31.acceptByUnixMs !== undefined){let data43 = data31.acceptByUnixMs;if(!(((typeof data43 == "number") && (!(data43 % 1) && !isNaN(data43))) && (isFinite(data43)))){const err125 = {instancePath:instancePath+"/payload/acceptByUnixMs",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/acceptByUnixMs/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}if((typeof data43 == "number") && (isFinite(data43))){if(data43 < 1 || isNaN(data43)){const err126 = {instancePath:instancePath+"/payload/acceptByUnixMs",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/acceptByUnixMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}}}if(data31.expiresAtUnixMs !== undefined){let data44 = data31.expiresAtUnixMs;if(!(((typeof data44 == "number") && (!(data44 % 1) && !isNaN(data44))) && (isFinite(data44)))){const err127 = {instancePath:instancePath+"/payload/expiresAtUnixMs",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/expiresAtUnixMs/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;}if((typeof data44 == "number") && (isFinite(data44))){if(data44 < 1 || isNaN(data44)){const err128 = {instancePath:instancePath+"/payload/expiresAtUnixMs",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/expiresAtUnixMs/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}}}if(data31.completionContract !== undefined){let data45 = data31.completionContract;if(data45 && typeof data45 == "object" && !Array.isArray(data45)){if(data45.revision === undefined){const err129 = {instancePath:instancePath+"/payload/completionContract",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}if(data45.criterionIds === undefined){const err130 = {instancePath:instancePath+"/payload/completionContract",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/required",keyword:"required",params:{missingProperty: "criterionIds"},message:"must have required property '"+"criterionIds"+"'"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}for(const key3 in data45){if(!((key3 === "revision") || (key3 === "criterionIds"))){const err131 = {instancePath:instancePath+"/payload/completionContract",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}}if(data45.revision !== undefined){let data46 = data45.revision;if(typeof data46 === "string"){if(func1(data46) < 1){const err132 = {instancePath:instancePath+"/payload/completionContract/revision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/revision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;}}else {const err133 = {instancePath:instancePath+"/payload/completionContract/revision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/revision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err133];}else {vErrors.push(err133);}errors++;}}if(data45.criterionIds !== undefined){let data47 = data45.criterionIds;if(Array.isArray(data47)){if(data47.length > 256){const err134 = {instancePath:instancePath+"/payload/completionContract/criterionIds",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/criterionIds/maxItems",keyword:"maxItems",params:{limit: 256},message:"must NOT have more than 256 items"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}if(data47.length < 1){const err135 = {instancePath:instancePath+"/payload/completionContract/criterionIds",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/criterionIds/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}const len1 = data47.length;for(let i2=0; i2 1){const indices0 = {};for(;i3--;){let item0 = data47[i3];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j1 = indices0[item0];const err138 = {instancePath:instancePath+"/payload/completionContract/criterionIds",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/criterionIds/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;break;}indices0[item0] = i3;}}}else {const err139 = {instancePath:instancePath+"/payload/completionContract/criterionIds",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/properties/criterionIds/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}}}else {const err140 = {instancePath:instancePath+"/payload/completionContract",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/completionContract/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}}if(data31.tools !== undefined){let data49 = data31.tools;if(Array.isArray(data49)){if(data49.length > 512){const err141 = {instancePath:instancePath+"/payload/tools",schemaPath:"#/allOf/2/then/properties/payload/oneOf/0/properties/tools/maxItems",keyword:"maxItems",params:{limit: 512},message:"must NOT have more than 512 items"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}const len2 = data49.length;for(let i4=0; i4 240){const err158 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/companyId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err158];}else {vErrors.push(err158);}errors++;}if(func1(data52) < 1){const err159 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/companyId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err159];}else {vErrors.push(err159);}errors++;}}else {const err160 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/companyId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err160];}else {vErrors.push(err160);}errors++;}}if(data51.agentId !== undefined){let data53 = data51.agentId;if(typeof data53 === "string"){if(func1(data53) > 240){const err161 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/agentId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err161];}else {vErrors.push(err161);}errors++;}if(func1(data53) < 1){const err162 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/agentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err162];}else {vErrors.push(err162);}errors++;}}else {const err163 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/agentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err163];}else {vErrors.push(err163);}errors++;}}if(data51.bindingId !== undefined){let data54 = data51.bindingId;if(typeof data54 === "string"){if(func1(data54) > 240){const err164 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err164];}else {vErrors.push(err164);}errors++;}if(func1(data54) < 1){const err165 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err165];}else {vErrors.push(err165);}errors++;}}else {const err166 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err166];}else {vErrors.push(err166);}errors++;}}if(data51.runId !== undefined){let data55 = data51.runId;if(typeof data55 === "string"){if(func1(data55) > 240){const err167 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/runId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err167];}else {vErrors.push(err167);}errors++;}if(func1(data55) < 1){const err168 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}}else {const err169 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}}if(data51.normalizedSessionId !== undefined){let data56 = data51.normalizedSessionId;if(typeof data56 === "string"){if(func1(data56) > 240){const err170 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}if(func1(data56) < 1){const err171 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}}else {const err172 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}}if(data51.turnId !== undefined){let data57 = data51.turnId;if(typeof data57 === "string"){if(func1(data57) > 240){const err173 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}if(func1(data57) < 1){const err174 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}}else {const err175 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}}if(data51.bindingGeneration !== undefined){let data58 = data51.bindingGeneration;if(!(((typeof data58 == "number") && (!(data58 % 1) && !isNaN(data58))) && (isFinite(data58)))){const err176 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingGeneration/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if((typeof data58 == "number") && (isFinite(data58))){if(data58 < 1 || isNaN(data58)){const err177 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/bindingGeneration/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}}}if(data51.assignmentRevision !== undefined){let data59 = data51.assignmentRevision;if(!(((typeof data59 == "number") && (!(data59 % 1) && !isNaN(data59))) && (isFinite(data59)))){const err178 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/assignmentRevision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err178];}else {vErrors.push(err178);}errors++;}if((typeof data59 == "number") && (isFinite(data59))){if(data59 < 1 || isNaN(data59)){const err179 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/properties/assignmentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err179];}else {vErrors.push(err179);}errors++;}}}}else {const err180 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/binding/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err180];}else {vErrors.push(err180);}errors++;}}if(data31.kind !== undefined){if("authority_revoked" !== data31.kind){const err181 = {instancePath:instancePath+"/payload/kind",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/kind/const",keyword:"const",params:{allowedValue: "authority_revoked"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err181];}else {vErrors.push(err181);}errors++;}}if(data31.reason !== undefined){if(typeof data31.reason !== "string"){const err182 = {instancePath:instancePath+"/payload/reason",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err182];}else {vErrors.push(err182);}errors++;}}if(data31.externalStopConfirmed !== undefined){if(false !== data31.externalStopConfirmed){const err183 = {instancePath:instancePath+"/payload/externalStopConfirmed",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/properties/externalStopConfirmed/const",keyword:"const",params:{allowedValue: false},message:"must be equal to constant"};if(vErrors === null){vErrors = [err183];}else {vErrors.push(err183);}errors++;}}}else {const err184 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err184];}else {vErrors.push(err184);}errors++;}var _valid4 = _errs132 === errors;if(_valid4 && valid22){valid22 = false;passing1 = [passing1, 1];}else {if(_valid4){valid22 = true;passing1 = 1;if(props3 !== true){props3 = true;}}}if(!valid22){const err185 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/2/then/properties/payload/oneOf",keyword:"oneOf",params:{passingSchemas: passing1},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err185];}else {vErrors.push(err185);}errors++;}else {errors = _errs88;if(vErrors !== null){if(_errs88){vErrors.length = _errs88;}else {vErrors = null;}}}}}else {const err186 = {instancePath,schemaPath:"#/allOf/2/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err186];}else {vErrors.push(err186);}errors++;}var _valid3 = _errs85 === errors;valid19 = _valid3;if(valid19){var props4 = {};props4.payload = true;props4.eventType = true;}}if(!valid19){const err187 = {instancePath,schemaPath:"#/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err187];}else {vErrors.push(err187);}errors++;}if(props1 !== true && props4 !== undefined){if(props4 === true){props1 = true;}else {props1 = props1 || {};Object.assign(props1, props4);}}const _errs159 = errors;let valid33 = true;const _errs160 = errors;if(errors === _errs160){if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("external_provider.operation_settled" !== data.eventType){const err188 = {};if(vErrors === null){vErrors = [err188];}else {vErrors.push(err188);}errors++;}}}else {const err189 = {};if(vErrors === null){vErrors = [err189];}else {vErrors.push(err189);}errors++;}}var _valid5 = _errs160 === errors;errors = _errs159;if(vErrors !== null){if(_errs159){vErrors.length = _errs159;}else {vErrors = null;}}if(_valid5){const _errs163 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data64 = data.payload;if(data64 && typeof data64 == "object" && !Array.isArray(data64)){if(data64.binding === undefined){const err190 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/required",keyword:"required",params:{missingProperty: "binding"},message:"must have required property '"+"binding"+"'"};if(vErrors === null){vErrors = [err190];}else {vErrors.push(err190);}errors++;}if(data64.requestId === undefined){const err191 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/required",keyword:"required",params:{missingProperty: "requestId"},message:"must have required property '"+"requestId"+"'"};if(vErrors === null){vErrors = [err191];}else {vErrors.push(err191);}errors++;}if(data64.outcome === undefined){const err192 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/required",keyword:"required",params:{missingProperty: "outcome"},message:"must have required property '"+"outcome"+"'"};if(vErrors === null){vErrors = [err192];}else {vErrors.push(err192);}errors++;}for(const key6 in data64){if(!(((key6 === "binding") || (key6 === "requestId")) || (key6 === "outcome"))){const err193 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key6},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err193];}else {vErrors.push(err193);}errors++;}}if(data64.binding !== undefined){let data65 = data64.binding;if(data65 && typeof data65 == "object" && !Array.isArray(data65)){if(data65.companyId === undefined){const err194 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "companyId"},message:"must have required property '"+"companyId"+"'"};if(vErrors === null){vErrors = [err194];}else {vErrors.push(err194);}errors++;}if(data65.agentId === undefined){const err195 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "agentId"},message:"must have required property '"+"agentId"+"'"};if(vErrors === null){vErrors = [err195];}else {vErrors.push(err195);}errors++;}if(data65.bindingId === undefined){const err196 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err196];}else {vErrors.push(err196);}errors++;}if(data65.runId === undefined){const err197 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err197];}else {vErrors.push(err197);}errors++;}if(data65.normalizedSessionId === undefined){const err198 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err198];}else {vErrors.push(err198);}errors++;}if(data65.turnId === undefined){const err199 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err199];}else {vErrors.push(err199);}errors++;}if(data65.bindingGeneration === undefined){const err200 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "bindingGeneration"},message:"must have required property '"+"bindingGeneration"+"'"};if(vErrors === null){vErrors = [err200];}else {vErrors.push(err200);}errors++;}if(data65.assignmentRevision === undefined){const err201 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/required",keyword:"required",params:{missingProperty: "assignmentRevision"},message:"must have required property '"+"assignmentRevision"+"'"};if(vErrors === null){vErrors = [err201];}else {vErrors.push(err201);}errors++;}for(const key7 in data65){if(!((((((((key7 === "companyId") || (key7 === "agentId")) || (key7 === "bindingId")) || (key7 === "runId")) || (key7 === "normalizedSessionId")) || (key7 === "turnId")) || (key7 === "bindingGeneration")) || (key7 === "assignmentRevision"))){const err202 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key7},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err202];}else {vErrors.push(err202);}errors++;}}if(data65.companyId !== undefined){let data66 = data65.companyId;if(typeof data66 === "string"){if(func1(data66) > 240){const err203 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/companyId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err203];}else {vErrors.push(err203);}errors++;}if(func1(data66) < 1){const err204 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/companyId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err204];}else {vErrors.push(err204);}errors++;}}else {const err205 = {instancePath:instancePath+"/payload/binding/companyId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/companyId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err205];}else {vErrors.push(err205);}errors++;}}if(data65.agentId !== undefined){let data67 = data65.agentId;if(typeof data67 === "string"){if(func1(data67) > 240){const err206 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/agentId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err206];}else {vErrors.push(err206);}errors++;}if(func1(data67) < 1){const err207 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/agentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err207];}else {vErrors.push(err207);}errors++;}}else {const err208 = {instancePath:instancePath+"/payload/binding/agentId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/agentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err208];}else {vErrors.push(err208);}errors++;}}if(data65.bindingId !== undefined){let data68 = data65.bindingId;if(typeof data68 === "string"){if(func1(data68) > 240){const err209 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err209];}else {vErrors.push(err209);}errors++;}if(func1(data68) < 1){const err210 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err210];}else {vErrors.push(err210);}errors++;}}else {const err211 = {instancePath:instancePath+"/payload/binding/bindingId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err211];}else {vErrors.push(err211);}errors++;}}if(data65.runId !== undefined){let data69 = data65.runId;if(typeof data69 === "string"){if(func1(data69) > 240){const err212 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/runId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err212];}else {vErrors.push(err212);}errors++;}if(func1(data69) < 1){const err213 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err213];}else {vErrors.push(err213);}errors++;}}else {const err214 = {instancePath:instancePath+"/payload/binding/runId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err214];}else {vErrors.push(err214);}errors++;}}if(data65.normalizedSessionId !== undefined){let data70 = data65.normalizedSessionId;if(typeof data70 === "string"){if(func1(data70) > 240){const err215 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err215];}else {vErrors.push(err215);}errors++;}if(func1(data70) < 1){const err216 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err216];}else {vErrors.push(err216);}errors++;}}else {const err217 = {instancePath:instancePath+"/payload/binding/normalizedSessionId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err217];}else {vErrors.push(err217);}errors++;}}if(data65.turnId !== undefined){let data71 = data65.turnId;if(typeof data71 === "string"){if(func1(data71) > 240){const err218 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err218];}else {vErrors.push(err218);}errors++;}if(func1(data71) < 1){const err219 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err219];}else {vErrors.push(err219);}errors++;}}else {const err220 = {instancePath:instancePath+"/payload/binding/turnId",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err220];}else {vErrors.push(err220);}errors++;}}if(data65.bindingGeneration !== undefined){let data72 = data65.bindingGeneration;if(!(((typeof data72 == "number") && (!(data72 % 1) && !isNaN(data72))) && (isFinite(data72)))){const err221 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingGeneration/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err221];}else {vErrors.push(err221);}errors++;}if((typeof data72 == "number") && (isFinite(data72))){if(data72 < 1 || isNaN(data72)){const err222 = {instancePath:instancePath+"/payload/binding/bindingGeneration",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/bindingGeneration/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err222];}else {vErrors.push(err222);}errors++;}}}if(data65.assignmentRevision !== undefined){let data73 = data65.assignmentRevision;if(!(((typeof data73 == "number") && (!(data73 % 1) && !isNaN(data73))) && (isFinite(data73)))){const err223 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/assignmentRevision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err223];}else {vErrors.push(err223);}errors++;}if((typeof data73 == "number") && (isFinite(data73))){if(data73 < 1 || isNaN(data73)){const err224 = {instancePath:instancePath+"/payload/binding/assignmentRevision",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/properties/assignmentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err224];}else {vErrors.push(err224);}errors++;}}}}else {const err225 = {instancePath:instancePath+"/payload/binding",schemaPath:"#/allOf/3/then/properties/payload/properties/binding/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err225];}else {vErrors.push(err225);}errors++;}}if(data64.requestId !== undefined){let data74 = data64.requestId;if(typeof data74 === "string"){if(func1(data74) > 240){const err226 = {instancePath:instancePath+"/payload/requestId",schemaPath:"#/allOf/3/then/properties/payload/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err226];}else {vErrors.push(err226);}errors++;}if(func1(data74) < 1){const err227 = {instancePath:instancePath+"/payload/requestId",schemaPath:"#/allOf/3/then/properties/payload/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err227];}else {vErrors.push(err227);}errors++;}}else {const err228 = {instancePath:instancePath+"/payload/requestId",schemaPath:"#/allOf/3/then/properties/payload/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err228];}else {vErrors.push(err228);}errors++;}}if(data64.outcome !== undefined){let data75 = data64.outcome;if(!(data75 && typeof data75 == "object" && !Array.isArray(data75))){const err229 = {instancePath:instancePath+"/payload/outcome",schemaPath:"#/allOf/3/then/properties/payload/properties/outcome/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err229];}else {vErrors.push(err229);}errors++;}}}else {const err230 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/3/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err230];}else {vErrors.push(err230);}errors++;}}}else {const err231 = {instancePath,schemaPath:"#/allOf/3/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err231];}else {vErrors.push(err231);}errors++;}var _valid5 = _errs163 === errors;valid33 = _valid5;if(valid33){var props5 = {};props5.payload = true;props5.eventType = true;}}if(!valid33){const err232 = {instancePath,schemaPath:"#/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err232];}else {vErrors.push(err232);}errors++;}if(props1 !== true && props5 !== undefined){if(props5 === true){props1 = true;}else {props1 = props1 || {};Object.assign(props1, props5);}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err233 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}if(data.sourceEventId === undefined){const err234 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceEventId"},message:"must have required property '"+"sourceEventId"+"'"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}if(data.sourceSeq === undefined){const err235 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceSeq"},message:"must have required property '"+"sourceSeq"+"'"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}if(data.sourceInstanceId === undefined){const err236 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceInstanceId"},message:"must have required property '"+"sourceInstanceId"+"'"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}if(data.sourceKind === undefined){const err237 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sourceKind"},message:"must have required property '"+"sourceKind"+"'"};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}if(data.runId === undefined){const err238 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err238];}else {vErrors.push(err238);}errors++;}if(data.eventType === undefined){const err239 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "eventType"},message:"must have required property '"+"eventType"+"'"};if(vErrors === null){vErrors = [err239];}else {vErrors.push(err239);}errors++;}if(data.schemaVersion === undefined){const err240 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err240];}else {vErrors.push(err240);}errors++;}if(data.priority === undefined){const err241 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "priority"},message:"must have required property '"+"priority"+"'"};if(vErrors === null){vErrors = [err241];}else {vErrors.push(err241);}errors++;}if(data.emittedAt === undefined){const err242 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "emittedAt"},message:"must have required property '"+"emittedAt"+"'"};if(vErrors === null){vErrors = [err242];}else {vErrors.push(err242);}errors++;}if(data.payload === undefined){const err243 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "payload"},message:"must have required property '"+"payload"+"'"};if(vErrors === null){vErrors = [err243];}else {vErrors.push(err243);}errors++;}if(data.schema !== undefined){if("paperclip.prp.event.v3" !== data.schema){const err244 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.event.v3"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err244];}else {vErrors.push(err244);}errors++;}}if(data.sourceEventId !== undefined){let data77 = data.sourceEventId;if(typeof data77 === "string"){if(func1(data77) > 160){const err245 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err245];}else {vErrors.push(err245);}errors++;}if(func1(data77) < 1){const err246 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err246];}else {vErrors.push(err246);}errors++;}}else {const err247 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err247];}else {vErrors.push(err247);}errors++;}}if(data.sourceSeq !== undefined){let data78 = data.sourceSeq;if(!(((typeof data78 == "number") && (!(data78 % 1) && !isNaN(data78))) && (isFinite(data78)))){const err248 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err248];}else {vErrors.push(err248);}errors++;}if((typeof data78 == "number") && (isFinite(data78))){if(data78 > 9007199254740991 || isNaN(data78)){const err249 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9007199254740991},message:"must be <= 9007199254740991"};if(vErrors === null){vErrors = [err249];}else {vErrors.push(err249);}errors++;}if(data78 < 1 || isNaN(data78)){const err250 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err250];}else {vErrors.push(err250);}errors++;}}}if(data.sourceInstanceId !== undefined){let data79 = data.sourceInstanceId;if(typeof data79 === "string"){if(func1(data79) > 160){const err251 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err251];}else {vErrors.push(err251);}errors++;}if(func1(data79) < 1){const err252 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err252];}else {vErrors.push(err252);}errors++;}}else {const err253 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err253];}else {vErrors.push(err253);}errors++;}}if(data.sourceKind !== undefined){let data80 = data.sourceKind;if(!((data80 === "runner") || (data80 === "control_plane"))){const err254 = {instancePath:instancePath+"/sourceKind",schemaPath:"#/properties/sourceKind/enum",keyword:"enum",params:{allowedValues: schema209.properties.sourceKind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err254];}else {vErrors.push(err254);}errors++;}}if(data.runId !== undefined){let data81 = data.runId;if(typeof data81 === "string"){if(func1(data81) > 160){const err255 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err255];}else {vErrors.push(err255);}errors++;}if(func1(data81) < 1){const err256 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err256];}else {vErrors.push(err256);}errors++;}}else {const err257 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err257];}else {vErrors.push(err257);}errors++;}}if(data.normalizedSessionId !== undefined){let data82 = data.normalizedSessionId;if(typeof data82 === "string"){if(func1(data82) > 160){const err258 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err258];}else {vErrors.push(err258);}errors++;}if(func1(data82) < 1){const err259 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err259];}else {vErrors.push(err259);}errors++;}}else {const err260 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err260];}else {vErrors.push(err260);}errors++;}}if(data.turnId !== undefined){let data83 = data.turnId;if(typeof data83 === "string"){if(func1(data83) > 160){const err261 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err261];}else {vErrors.push(err261);}errors++;}if(func1(data83) < 1){const err262 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err262];}else {vErrors.push(err262);}errors++;}}else {const err263 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err263];}else {vErrors.push(err263);}errors++;}}if(data.itemId !== undefined){let data84 = data.itemId;if(typeof data84 === "string"){if(func1(data84) > 160){const err264 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err264];}else {vErrors.push(err264);}errors++;}if(func1(data84) < 1){const err265 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err265];}else {vErrors.push(err265);}errors++;}}else {const err266 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err266];}else {vErrors.push(err266);}errors++;}}if(data.eventType !== undefined){let data85 = data.eventType;if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((data85 === "runner.connected") || (data85 === "runner.reconnected")) || (data85 === "runner.reconciled")) || (data85 === "runner.disconnected")) || (data85 === "runner.draining")) || (data85 === "runner.suspending")) || (data85 === "runner.suspended")) || (data85 === "runner.stopped")) || (data85 === "runner.diagnostic")) || (data85 === "runtime.phase.changed")) || (data85 === "sandbox.metric")) || (data85 === "workspace.ready")) || (data85 === "workspace.change.updated")) || (data85 === "workspace.diff.recorded")) || (data85 === "workspace.file.referenced")) || (data85 === "harness.starting")) || (data85 === "harness.ready")) || (data85 === "harness.exited")) || (data85 === "harness.diagnostic")) || (data85 === "plan.updated")) || (data85 === "tool.execution.started")) || (data85 === "tool.execution.progressed")) || (data85 === "tool.execution.completed")) || (data85 === "research.started")) || (data85 === "research.progressed")) || (data85 === "research.completed")) || (data85 === "delegation.started")) || (data85 === "delegation.updated")) || (data85 === "delegation.completed")) || (data85 === "model.route.changed")) || (data85 === "model.verification.updated")) || (data85 === "context.compacted")) || (data85 === "artifact.viewed")) || (data85 === "artifact.generated")) || (data85 === "review.mode.changed")) || (data85 === "hook.started")) || (data85 === "hook.completed")) || (data85 === "memory.citation.referenced")) || (data85 === "safety.review.started")) || (data85 === "safety.review.completed")) || (data85 === "terminal.input.sent")) || (data85 === "wait.started")) || (data85 === "wait.completed")) || (data85 === "provider.notice.recorded")) || (data85 === "session.starting")) || (data85 === "session.started")) || (data85 === "session.resuming")) || (data85 === "session.resumed")) || (data85 === "session.reconciled")) || (data85 === "session.updated")) || (data85 === "session.closed")) || (data85 === "session.failed")) || (data85 === "session.capabilities.updated")) || (data85 === "session.goal.snapshot")) || (data85 === "session.goal.updated")) || (data85 === "session.goal.cleared")) || (data85 === "turn.submitted")) || (data85 === "turn.accepted")) || (data85 === "turn.started")) || (data85 === "turn.completed")) || (data85 === "turn.failed")) || (data85 === "turn.interrupted")) || (data85 === "turn.cancelled")) || (data85 === "item.started")) || (data85 === "item.delta")) || (data85 === "item.completed")) || (data85 === "item.failed")) || (data85 === "usage.reported")) || (data85 === "semantic_tool.input")) || (data85 === "semantic_tool.result")) || (data85 === "mcp_app.discovered")) || (data85 === "mcp_app.resource.resolved")) || (data85 === "mcp_app.initializing")) || (data85 === "mcp_app.ready")) || (data85 === "mcp_app.tool_input")) || (data85 === "mcp_app.tool_result")) || (data85 === "mcp_app.action.requested")) || (data85 === "mcp_app.action.resolved")) || (data85 === "mcp_app.host_context.changed")) || (data85 === "mcp_app.failed")) || (data85 === "mcp_app.teardown")) || (data85 === "runtime_request.created")) || (data85 === "runtime_request.resolved")) || (data85 === "runtime_request.expired")) || (data85 === "runtime_request.cancelled")) || (data85 === "interaction.request.proposed")) || (data85 === "interaction.request.materialized")) || (data85 === "interaction.request.rejected")) || (data85 === "interaction.response.progressed")) || (data85 === "interaction.response.resolved")) || (data85 === "interaction.response.delivered")) || (data85 === "run.attached")) || (data85 === "run.detached")) || (data85 === "run.result.proposed")) || (data85 === "run.result.accepted")) || (data85 === "run.result.rejected")) || (data85 === "attention.request.proposed")) || (data85 === "attention.request.routed")) || (data85 === "attention.request.resolved")) || (data85 === "attention.request.expired")) || (data85 === "attention.request.superseded")) || (data85 === "work.assessment.recorded")) || (data85 === "issue.status.decision.recorded")) || (data85 === "issue.status.decision.applied")) || (data85 === "issue.status.decision.rejected")) || (data85 === "issue.status.decision.superseded")) || (data85 === "run.terminal")) || (data85 === "external_provider.dispatch_requested")) || (data85 === "external_provider.operation_settled"))){const err267 = {instancePath:instancePath+"/eventType",schemaPath:"#/properties/eventType/enum",keyword:"enum",params:{allowedValues: schema209.properties.eventType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err267];}else {vErrors.push(err267);}errors++;}}if(data.schemaVersion !== undefined){if(3 !== data.schemaVersion){const err268 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 3},message:"must be equal to constant"};if(vErrors === null){vErrors = [err268];}else {vErrors.push(err268);}errors++;}}if(data.priority !== undefined){let data87 = data.priority;if(!(((data87 === 0) || (data87 === 1)) || (data87 === 2))){const err269 = {instancePath:instancePath+"/priority",schemaPath:"#/properties/priority/enum",keyword:"enum",params:{allowedValues: schema209.properties.priority.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err269];}else {vErrors.push(err269);}errors++;}}if(data.emittedAt !== undefined){let data88 = data.emittedAt;if(typeof data88 === "string"){if(!(formats0.test(data88))){const err270 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err270];}else {vErrors.push(err270);}errors++;}}else {const err271 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err271];}else {vErrors.push(err271);}errors++;}}if(data.observedAt !== undefined){let data89 = data.observedAt;if(typeof data89 === "string"){if(!(formats0.test(data89))){const err272 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err272];}else {vErrors.push(err272);}errors++;}}else {const err273 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err273];}else {vErrors.push(err273);}errors++;}}if(data.payload !== undefined){let data90 = data.payload;if(data90 && typeof data90 == "object" && !Array.isArray(data90)){}else {const err274 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err274];}else {vErrors.push(err274);}errors++;}}if(data.debug !== undefined){let data91 = data.debug;if(data91 && typeof data91 == "object" && !Array.isArray(data91)){}else {const err275 = {instancePath:instancePath+"/debug",schemaPath:"#/properties/debug/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err275];}else {vErrors.push(err275);}errors++;}}}else {const err276 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err276];}else {vErrors.push(err276);}errors++;}validate114.errors = vErrors;return errors === 0;}validate114.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema213 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/request.schema.json","title":"PRP request","oneOf":[{"type":"object","required":["schema","requestKind","requestId","type","status","prompt","choices","origin","turnId"],"properties":{"schema":{"const":"paperclip.runtime_request.v2"},"requestKind":{"const":"permission_approval"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"const":"permission"},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","minItems":1,"maxItems":4,"items":{"type":"object","required":["key","label"],"properties":{"key":{"enum":["accept","accept_for_session","decline","cancel"]},"label":{"type":"string","minLength":1,"maxLength":500}},"additionalProperties":false},"allOf":[{"contains":{"type":"object","required":["key"],"properties":{"key":{"const":"accept"}}},"minContains":0,"maxContains":1},{"contains":{"type":"object","required":["key"],"properties":{"key":{"const":"accept_for_session"}}},"minContains":0,"maxContains":1},{"contains":{"type":"object","required":["key"],"properties":{"key":{"const":"decline"}}},"minContains":0,"maxContains":1},{"contains":{"type":"object","required":["key"],"properties":{"key":{"const":"cancel"}}},"minContains":0,"maxContains":1}]},"details":{"type":"object","additionalProperties":true},"origin":{"type":"object","required":["adapter"],"properties":{"adapter":{"type":"string","minLength":1,"maxLength":160},"provider":{"type":"string","minLength":1,"maxLength":160},"method":{"type":"string","minLength":1,"maxLength":500}},"additionalProperties":false},"turnId":{"type":"string","minLength":1,"maxLength":240},"itemId":{"type":["string","null"],"minLength":1,"maxLength":240}},"additionalProperties":false},{"type":"object","required":["schema","requestKind","requestId","type","status","prompt","input"],"properties":{"schema":{"const":"paperclip.runtime_request.v2"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"const":"input"},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"input":{"$ref":"https://paperclip.dev/schemas/prp/v1/question-set.schema.json"},"origin":{"type":"object","required":["adapter"],"properties":{"adapter":{"type":"string","minLength":1,"maxLength":160},"provider":{"type":"string","minLength":1,"maxLength":160},"method":{"type":"string","minLength":1,"maxLength":500}},"additionalProperties":false},"turnId":{"type":"string","minLength":1,"maxLength":240},"itemId":{"type":"string","minLength":1,"maxLength":240}},"additionalProperties":false},{"type":"object","required":["schema","requestKind","requestId","type","status","prompt"],"properties":{"schema":{"const":"paperclip.runtime_request.v1"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"enum":["permission","input"]},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","items":{"type":"object","required":["key","label"],"properties":{"key":{"type":"string","minLength":1},"label":{"type":"string","minLength":1}},"additionalProperties":true}}},"additionalProperties":true},{"type":"object","required":["schema","requestKind","requestId","kind","blocking","payload"],"properties":{"schema":{"const":"paperclip.interaction_request.v1"},"requestKind":{"const":"issue_thread"},"requestId":{"type":"string","minLength":1,"maxLength":160},"kind":{"enum":["request_confirmation","request_checkbox_confirmation","request_item_verdicts","ask_user_questions","suggest_tasks"]},"blocking":{"type":"boolean"},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true}]};const schema214 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/question-set.schema.json","title":"Paperclip question set","type":"object","required":["schema","questions"],"properties":{"schema":{"const":"paperclip.question_set.v1"},"title":{"type":"string","maxLength":1000},"description":{"type":"string","maxLength":100000},"submitLabel":{"type":"string","maxLength":200},"questions":{"type":"array","minItems":1,"maxItems":64,"items":{"$ref":"#/$defs/question"}}},"$defs":{"option":{"type":"object","required":["id","label"],"properties":{"id":{"type":"string","minLength":1,"maxLength":160},"label":{"type":"string","minLength":1,"maxLength":1000},"description":{"type":"string","maxLength":4000},"recommended":{"type":"boolean"}},"additionalProperties":false},"customAnswer":{"type":"object","required":["enabled"],"properties":{"enabled":{"const":true},"label":{"type":"string","maxLength":1000},"placeholder":{"type":"string","maxLength":1000}},"additionalProperties":false},"textValidation":{"type":"object","properties":{"minLength":{"type":"integer","minimum":0,"maximum":100000},"maxLength":{"type":"integer","minimum":0,"maximum":100000},"pattern":{"type":"string","maxLength":1000},"inputType":{"enum":["text","number","integer"]},"minimum":{"type":"number"},"maximum":{"type":"number"}},"additionalProperties":false},"question":{"type":"object","required":["id","prompt","required","answerMode"],"properties":{"id":{"type":"string","minLength":1,"maxLength":160},"header":{"type":"string","maxLength":1000},"prompt":{"type":"string","minLength":1,"maxLength":4000},"helpText":{"type":"string","maxLength":4000},"required":{"type":"boolean"},"answerMode":{"enum":["single_select","multi_select","text"]},"initialText":{"type":"string","maxLength":100000,"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds."},"options":{"type":"array","maxItems":128,"items":{"$ref":"#/$defs/option"}},"customAnswer":{"$ref":"#/$defs/customAnswer"},"textValidation":{"$ref":"#/$defs/textValidation"}},"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"properties":{"options":{"type":"array","maxItems":0}},"not":{"required":["customAnswer"]}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"required":["options"],"not":{"required":["initialText"]},"properties":{"options":{"type":"array","minItems":1}}}}],"additionalProperties":false}},"additionalProperties":false};const schema215 = {"type":"object","required":["id","prompt","required","answerMode"],"properties":{"id":{"type":"string","minLength":1,"maxLength":160},"header":{"type":"string","maxLength":1000},"prompt":{"type":"string","minLength":1,"maxLength":4000},"helpText":{"type":"string","maxLength":4000},"required":{"type":"boolean"},"answerMode":{"enum":["single_select","multi_select","text"]},"initialText":{"type":"string","maxLength":100000,"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds."},"options":{"type":"array","maxItems":128,"items":{"$ref":"#/$defs/option"}},"customAnswer":{"$ref":"#/$defs/customAnswer"},"textValidation":{"$ref":"#/$defs/textValidation"}},"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"properties":{"options":{"type":"array","maxItems":0}},"not":{"required":["customAnswer"]}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"required":["options"],"not":{"required":["initialText"]},"properties":{"options":{"type":"array","minItems":1}}}}],"additionalProperties":false};const schema216 = {"type":"object","required":["id","label"],"properties":{"id":{"type":"string","minLength":1,"maxLength":160},"label":{"type":"string","minLength":1,"maxLength":1000},"description":{"type":"string","maxLength":4000},"recommended":{"type":"boolean"}},"additionalProperties":false};const schema217 = {"type":"object","required":["enabled"],"properties":{"enabled":{"const":true},"label":{"type":"string","maxLength":1000},"placeholder":{"type":"string","maxLength":1000}},"additionalProperties":false};const schema218 = {"type":"object","properties":{"minLength":{"type":"integer","minimum":0,"maximum":100000},"maxLength":{"type":"integer","minimum":0,"maximum":100000},"pattern":{"type":"string","maxLength":1000},"inputType":{"enum":["text","number","integer"]},"minimum":{"type":"number"},"maximum":{"type":"number"}},"additionalProperties":false};function validate118(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate118.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((data.answerMode === undefined) && (missing0 = "answerMode")){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}else {if(data.answerMode !== undefined){if("text" !== data.answerMode){const err1 = {};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;const _errs6 = errors;const _errs7 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing1;if((data.customAnswer === undefined) && (missing1 = "customAnswer")){const err2 = {};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}var valid3 = _errs7 === errors;if(valid3){const err3 = {instancePath,schemaPath:"#/allOf/0/then/not",keyword:"not",params:{},message:"must NOT be valid"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}else {errors = _errs6;if(vErrors !== null){if(_errs6){vErrors.length = _errs6;}else {vErrors = null;}}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.options !== undefined){let data1 = data.options;if(Array.isArray(data1)){if(data1.length > 0){const err4 = {instancePath:instancePath+"/options",schemaPath:"#/allOf/0/then/properties/options/maxItems",keyword:"maxItems",params:{limit: 0},message:"must NOT have more than 0 items"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {const err5 = {instancePath:instancePath+"/options",schemaPath:"#/allOf/0/then/properties/options/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}}var _valid0 = _errs5 === errors;valid1 = _valid0;if(valid1){var props0 = {};props0.options = true;props0.answerMode = true;}}if(!valid1){const err6 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}const _errs11 = errors;let valid5 = true;const _errs12 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing2;if((data.answerMode === undefined) && (missing2 = "answerMode")){const err7 = {};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}else {if(data.answerMode !== undefined){let data2 = data.answerMode;if(!((data2 === "single_select") || (data2 === "multi_select"))){const err8 = {};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}}var _valid1 = _errs12 === errors;errors = _errs11;if(vErrors !== null){if(_errs11){vErrors.length = _errs11;}else {vErrors = null;}}if(_valid1){const _errs14 = errors;const _errs15 = errors;const _errs16 = errors;if(data && typeof data == "object" && !Array.isArray(data)){let missing3;if((data.initialText === undefined) && (missing3 = "initialText")){const err9 = {};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}var valid7 = _errs16 === errors;if(valid7){const err10 = {instancePath,schemaPath:"#/allOf/1/then/not",keyword:"not",params:{},message:"must NOT be valid"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}else {errors = _errs15;if(vErrors !== null){if(_errs15){vErrors.length = _errs15;}else {vErrors = null;}}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.options === undefined){const err11 = {instancePath,schemaPath:"#/allOf/1/then/required",keyword:"required",params:{missingProperty: "options"},message:"must have required property '"+"options"+"'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data.options !== undefined){let data3 = data.options;if(Array.isArray(data3)){if(data3.length < 1){const err12 = {instancePath:instancePath+"/options",schemaPath:"#/allOf/1/then/properties/options/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {const err13 = {instancePath:instancePath+"/options",schemaPath:"#/allOf/1/then/properties/options/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}var _valid1 = _errs14 === errors;valid5 = _valid1;if(valid5){var props1 = {};props1.options = true;props1.answerMode = true;}}if(!valid5){const err14 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(props0 !== true && props1 !== undefined){if(props1 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props1);}}if(data && typeof data == "object" && !Array.isArray(data)){if(data.id === undefined){const err15 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data.prompt === undefined){const err16 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "prompt"},message:"must have required property '"+"prompt"+"'"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(data.required === undefined){const err17 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "required"},message:"must have required property '"+"required"+"'"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(data.answerMode === undefined){const err18 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "answerMode"},message:"must have required property '"+"answerMode"+"'"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}for(const key0 in data){if(!(func66.call(schema215.properties, key0))){const err19 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.id !== undefined){let data4 = data.id;if(typeof data4 === "string"){if(func1(data4) > 160){const err20 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(func1(data4) < 1){const err21 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}else {const err22 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.header !== undefined){let data5 = data.header;if(typeof data5 === "string"){if(func1(data5) > 1000){const err23 = {instancePath:instancePath+"/header",schemaPath:"#/properties/header/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/header",schemaPath:"#/properties/header/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.prompt !== undefined){let data6 = data.prompt;if(typeof data6 === "string"){if(func1(data6) > 4000){const err25 = {instancePath:instancePath+"/prompt",schemaPath:"#/properties/prompt/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(func1(data6) < 1){const err26 = {instancePath:instancePath+"/prompt",schemaPath:"#/properties/prompt/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}else {const err27 = {instancePath:instancePath+"/prompt",schemaPath:"#/properties/prompt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}if(data.helpText !== undefined){let data7 = data.helpText;if(typeof data7 === "string"){if(func1(data7) > 4000){const err28 = {instancePath:instancePath+"/helpText",schemaPath:"#/properties/helpText/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}else {const err29 = {instancePath:instancePath+"/helpText",schemaPath:"#/properties/helpText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data.required !== undefined){if(typeof data.required !== "boolean"){const err30 = {instancePath:instancePath+"/required",schemaPath:"#/properties/required/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}if(data.answerMode !== undefined){let data9 = data.answerMode;if(!(((data9 === "single_select") || (data9 === "multi_select")) || (data9 === "text"))){const err31 = {instancePath:instancePath+"/answerMode",schemaPath:"#/properties/answerMode/enum",keyword:"enum",params:{allowedValues: schema215.properties.answerMode.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data.initialText !== undefined){let data10 = data.initialText;if(typeof data10 === "string"){if(func1(data10) > 100000){const err32 = {instancePath:instancePath+"/initialText",schemaPath:"#/properties/initialText/maxLength",keyword:"maxLength",params:{limit: 100000},message:"must NOT have more than 100000 characters"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}else {const err33 = {instancePath:instancePath+"/initialText",schemaPath:"#/properties/initialText/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}if(data.options !== undefined){let data11 = data.options;if(Array.isArray(data11)){if(data11.length > 128){const err34 = {instancePath:instancePath+"/options",schemaPath:"#/properties/options/maxItems",keyword:"maxItems",params:{limit: 128},message:"must NOT have more than 128 items"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}const len0 = data11.length;for(let i0=0; i0 160){const err38 = {instancePath:instancePath+"/options/" + i0+"/id",schemaPath:"#/$defs/option/properties/id/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(func1(data13) < 1){const err39 = {instancePath:instancePath+"/options/" + i0+"/id",schemaPath:"#/$defs/option/properties/id/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}else {const err40 = {instancePath:instancePath+"/options/" + i0+"/id",schemaPath:"#/$defs/option/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}if(data12.label !== undefined){let data14 = data12.label;if(typeof data14 === "string"){if(func1(data14) > 1000){const err41 = {instancePath:instancePath+"/options/" + i0+"/label",schemaPath:"#/$defs/option/properties/label/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(func1(data14) < 1){const err42 = {instancePath:instancePath+"/options/" + i0+"/label",schemaPath:"#/$defs/option/properties/label/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}else {const err43 = {instancePath:instancePath+"/options/" + i0+"/label",schemaPath:"#/$defs/option/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}if(data12.description !== undefined){let data15 = data12.description;if(typeof data15 === "string"){if(func1(data15) > 4000){const err44 = {instancePath:instancePath+"/options/" + i0+"/description",schemaPath:"#/$defs/option/properties/description/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}else {const err45 = {instancePath:instancePath+"/options/" + i0+"/description",schemaPath:"#/$defs/option/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}if(data12.recommended !== undefined){if(typeof data12.recommended !== "boolean"){const err46 = {instancePath:instancePath+"/options/" + i0+"/recommended",schemaPath:"#/$defs/option/properties/recommended/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}}else {const err47 = {instancePath:instancePath+"/options/" + i0,schemaPath:"#/$defs/option/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}}else {const err48 = {instancePath:instancePath+"/options",schemaPath:"#/properties/options/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}if(data.customAnswer !== undefined){let data17 = data.customAnswer;if(data17 && typeof data17 == "object" && !Array.isArray(data17)){if(data17.enabled === undefined){const err49 = {instancePath:instancePath+"/customAnswer",schemaPath:"#/$defs/customAnswer/required",keyword:"required",params:{missingProperty: "enabled"},message:"must have required property '"+"enabled"+"'"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}for(const key2 in data17){if(!(((key2 === "enabled") || (key2 === "label")) || (key2 === "placeholder"))){const err50 = {instancePath:instancePath+"/customAnswer",schemaPath:"#/$defs/customAnswer/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}if(data17.enabled !== undefined){if(true !== data17.enabled){const err51 = {instancePath:instancePath+"/customAnswer/enabled",schemaPath:"#/$defs/customAnswer/properties/enabled/const",keyword:"const",params:{allowedValue: true},message:"must be equal to constant"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}if(data17.label !== undefined){let data19 = data17.label;if(typeof data19 === "string"){if(func1(data19) > 1000){const err52 = {instancePath:instancePath+"/customAnswer/label",schemaPath:"#/$defs/customAnswer/properties/label/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}else {const err53 = {instancePath:instancePath+"/customAnswer/label",schemaPath:"#/$defs/customAnswer/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}if(data17.placeholder !== undefined){let data20 = data17.placeholder;if(typeof data20 === "string"){if(func1(data20) > 1000){const err54 = {instancePath:instancePath+"/customAnswer/placeholder",schemaPath:"#/$defs/customAnswer/properties/placeholder/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}else {const err55 = {instancePath:instancePath+"/customAnswer/placeholder",schemaPath:"#/$defs/customAnswer/properties/placeholder/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}}else {const err56 = {instancePath:instancePath+"/customAnswer",schemaPath:"#/$defs/customAnswer/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}if(data.textValidation !== undefined){let data21 = data.textValidation;if(data21 && typeof data21 == "object" && !Array.isArray(data21)){for(const key3 in data21){if(!((((((key3 === "minLength") || (key3 === "maxLength")) || (key3 === "pattern")) || (key3 === "inputType")) || (key3 === "minimum")) || (key3 === "maximum"))){const err57 = {instancePath:instancePath+"/textValidation",schemaPath:"#/$defs/textValidation/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}if(data21.minLength !== undefined){let data22 = data21.minLength;if(!(((typeof data22 == "number") && (!(data22 % 1) && !isNaN(data22))) && (isFinite(data22)))){const err58 = {instancePath:instancePath+"/textValidation/minLength",schemaPath:"#/$defs/textValidation/properties/minLength/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}if((typeof data22 == "number") && (isFinite(data22))){if(data22 > 100000 || isNaN(data22)){const err59 = {instancePath:instancePath+"/textValidation/minLength",schemaPath:"#/$defs/textValidation/properties/minLength/maximum",keyword:"maximum",params:{comparison: "<=", limit: 100000},message:"must be <= 100000"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}if(data22 < 0 || isNaN(data22)){const err60 = {instancePath:instancePath+"/textValidation/minLength",schemaPath:"#/$defs/textValidation/properties/minLength/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}}if(data21.maxLength !== undefined){let data23 = data21.maxLength;if(!(((typeof data23 == "number") && (!(data23 % 1) && !isNaN(data23))) && (isFinite(data23)))){const err61 = {instancePath:instancePath+"/textValidation/maxLength",schemaPath:"#/$defs/textValidation/properties/maxLength/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}if((typeof data23 == "number") && (isFinite(data23))){if(data23 > 100000 || isNaN(data23)){const err62 = {instancePath:instancePath+"/textValidation/maxLength",schemaPath:"#/$defs/textValidation/properties/maxLength/maximum",keyword:"maximum",params:{comparison: "<=", limit: 100000},message:"must be <= 100000"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(data23 < 0 || isNaN(data23)){const err63 = {instancePath:instancePath+"/textValidation/maxLength",schemaPath:"#/$defs/textValidation/properties/maxLength/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}}if(data21.pattern !== undefined){let data24 = data21.pattern;if(typeof data24 === "string"){if(func1(data24) > 1000){const err64 = {instancePath:instancePath+"/textValidation/pattern",schemaPath:"#/$defs/textValidation/properties/pattern/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}else {const err65 = {instancePath:instancePath+"/textValidation/pattern",schemaPath:"#/$defs/textValidation/properties/pattern/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}if(data21.inputType !== undefined){let data25 = data21.inputType;if(!(((data25 === "text") || (data25 === "number")) || (data25 === "integer"))){const err66 = {instancePath:instancePath+"/textValidation/inputType",schemaPath:"#/$defs/textValidation/properties/inputType/enum",keyword:"enum",params:{allowedValues: schema218.properties.inputType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}}if(data21.minimum !== undefined){let data26 = data21.minimum;if(!((typeof data26 == "number") && (isFinite(data26)))){const err67 = {instancePath:instancePath+"/textValidation/minimum",schemaPath:"#/$defs/textValidation/properties/minimum/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}}if(data21.maximum !== undefined){let data27 = data21.maximum;if(!((typeof data27 == "number") && (isFinite(data27)))){const err68 = {instancePath:instancePath+"/textValidation/maximum",schemaPath:"#/$defs/textValidation/properties/maximum/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}}else {const err69 = {instancePath:instancePath+"/textValidation",schemaPath:"#/$defs/textValidation/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}}else {const err70 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}validate118.errors = vErrors;return errors === 0;}validate118.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate117(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/question-set.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate117.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.questions === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "questions"},message:"must have required property '"+"questions"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "title")) || (key0 === "description")) || (key0 === "submitLabel")) || (key0 === "questions"))){const err2 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.schema !== undefined){if("paperclip.question_set.v1" !== data.schema){const err3 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.question_set.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.title !== undefined){let data1 = data.title;if(typeof data1 === "string"){if(func1(data1) > 1000){const err4 = {instancePath:instancePath+"/title",schemaPath:"#/properties/title/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}else {const err5 = {instancePath:instancePath+"/title",schemaPath:"#/properties/title/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.description !== undefined){let data2 = data.description;if(typeof data2 === "string"){if(func1(data2) > 100000){const err6 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 100000},message:"must NOT have more than 100000 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {const err7 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.submitLabel !== undefined){let data3 = data.submitLabel;if(typeof data3 === "string"){if(func1(data3) > 200){const err8 = {instancePath:instancePath+"/submitLabel",schemaPath:"#/properties/submitLabel/maxLength",keyword:"maxLength",params:{limit: 200},message:"must NOT have more than 200 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/submitLabel",schemaPath:"#/properties/submitLabel/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.questions !== undefined){let data4 = data.questions;if(Array.isArray(data4)){if(data4.length > 64){const err10 = {instancePath:instancePath+"/questions",schemaPath:"#/properties/questions/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data4.length < 1){const err11 = {instancePath:instancePath+"/questions",schemaPath:"#/properties/questions/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}const len0 = data4.length;for(let i0=0; i0 160){const err12 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/0/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(func1(data2) < 1){const err13 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/0/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}else {const err14 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/0/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.type !== undefined){if("permission" !== data.type){const err15 = {instancePath:instancePath+"/type",schemaPath:"#/oneOf/0/properties/type/const",keyword:"const",params:{allowedValue: "permission"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.status !== undefined){let data4 = data.status;if(!((((data4 === "pending") || (data4 === "resolved")) || (data4 === "expired")) || (data4 === "cancelled"))){const err16 = {instancePath:instancePath+"/status",schemaPath:"#/oneOf/0/properties/status/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[0].properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.prompt !== undefined){let data5 = data.prompt;if(typeof data5 === "string"){if(func1(data5) > 4000){const err17 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/0/properties/prompt/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data5) < 1){const err18 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/0/properties/prompt/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/0/properties/prompt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.choices !== undefined){let data6 = data.choices;if(Array.isArray(data6)){const _errs15 = errors;const len0 = data6.length;let valid3 = true;if(data6.length > 0){let count0 = 0;for(let i0=0; i0 1){valid3 = false;break;}if(count0 >= 0){valid3 = true;}}}}if(!valid3){const err23 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/allOf/0/contains",keyword:"contains",params:{minContains: 0, maxContains: 1},message:"must contain at least 0 and no more than 1 valid item(s)"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}else {errors = _errs15;if(vErrors !== null){if(_errs15){vErrors.length = _errs15;}else {vErrors = null;}}}}if(Array.isArray(data6)){const _errs20 = errors;const len1 = data6.length;let valid5 = true;if(data6.length > 0){let count1 = 0;for(let i1=0; i1 1){valid5 = false;break;}if(count1 >= 0){valid5 = true;}}}}if(!valid5){const err27 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/allOf/1/contains",keyword:"contains",params:{minContains: 0, maxContains: 1},message:"must contain at least 0 and no more than 1 valid item(s)"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}else {errors = _errs20;if(vErrors !== null){if(_errs20){vErrors.length = _errs20;}else {vErrors = null;}}}}if(Array.isArray(data6)){const _errs25 = errors;const len2 = data6.length;let valid7 = true;if(data6.length > 0){let count2 = 0;for(let i2=0; i2 1){valid7 = false;break;}if(count2 >= 0){valid7 = true;}}}}if(!valid7){const err31 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/allOf/2/contains",keyword:"contains",params:{minContains: 0, maxContains: 1},message:"must contain at least 0 and no more than 1 valid item(s)"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}else {errors = _errs25;if(vErrors !== null){if(_errs25){vErrors.length = _errs25;}else {vErrors = null;}}}}if(Array.isArray(data6)){const _errs30 = errors;const len3 = data6.length;let valid9 = true;if(data6.length > 0){let count3 = 0;for(let i3=0; i3 1){valid9 = false;break;}if(count3 >= 0){valid9 = true;}}}}if(!valid9){const err35 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/allOf/3/contains",keyword:"contains",params:{minContains: 0, maxContains: 1},message:"must contain at least 0 and no more than 1 valid item(s)"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}else {errors = _errs30;if(vErrors !== null){if(_errs30){vErrors.length = _errs30;}else {vErrors = null;}}}}if(Array.isArray(data6)){if(data6.length > 4){const err36 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/maxItems",keyword:"maxItems",params:{limit: 4},message:"must NOT have more than 4 items"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}if(data6.length < 1){const err37 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}const len4 = data6.length;for(let i4=0; i4 500){const err42 = {instancePath:instancePath+"/choices/" + i4+"/label",schemaPath:"#/oneOf/0/properties/choices/items/properties/label/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if(func1(data17) < 1){const err43 = {instancePath:instancePath+"/choices/" + i4+"/label",schemaPath:"#/oneOf/0/properties/choices/items/properties/label/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}else {const err44 = {instancePath:instancePath+"/choices/" + i4+"/label",schemaPath:"#/oneOf/0/properties/choices/items/properties/label/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}}else {const err45 = {instancePath:instancePath+"/choices/" + i4,schemaPath:"#/oneOf/0/properties/choices/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}}else {const err46 = {instancePath:instancePath+"/choices",schemaPath:"#/oneOf/0/properties/choices/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}}if(data.details !== undefined){let data18 = data.details;if(data18 && typeof data18 == "object" && !Array.isArray(data18)){}else {const err47 = {instancePath:instancePath+"/details",schemaPath:"#/oneOf/0/properties/details/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data.origin !== undefined){let data19 = data.origin;if(data19 && typeof data19 == "object" && !Array.isArray(data19)){if(data19.adapter === undefined){const err48 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/0/properties/origin/required",keyword:"required",params:{missingProperty: "adapter"},message:"must have required property '"+"adapter"+"'"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}for(const key2 in data19){if(!(((key2 === "adapter") || (key2 === "provider")) || (key2 === "method"))){const err49 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/0/properties/origin/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}if(data19.adapter !== undefined){let data20 = data19.adapter;if(typeof data20 === "string"){if(func1(data20) > 160){const err50 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/0/properties/origin/properties/adapter/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}if(func1(data20) < 1){const err51 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/0/properties/origin/properties/adapter/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}}else {const err52 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/0/properties/origin/properties/adapter/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}}if(data19.provider !== undefined){let data21 = data19.provider;if(typeof data21 === "string"){if(func1(data21) > 160){const err53 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/0/properties/origin/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}if(func1(data21) < 1){const err54 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/0/properties/origin/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}else {const err55 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/0/properties/origin/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data19.method !== undefined){let data22 = data19.method;if(typeof data22 === "string"){if(func1(data22) > 500){const err56 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/0/properties/origin/properties/method/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}if(func1(data22) < 1){const err57 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/0/properties/origin/properties/method/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}else {const err58 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/0/properties/origin/properties/method/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}}else {const err59 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/0/properties/origin/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data.turnId !== undefined){let data23 = data.turnId;if(typeof data23 === "string"){if(func1(data23) > 240){const err60 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/0/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}if(func1(data23) < 1){const err61 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/0/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}else {const err62 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/0/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}}if(data.itemId !== undefined){let data24 = data.itemId;if((typeof data24 !== "string") && (data24 !== null)){const err63 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/0/properties/itemId/type",keyword:"type",params:{type: schema213.oneOf[0].properties.itemId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}if(typeof data24 === "string"){if(func1(data24) > 240){const err64 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/0/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}if(func1(data24) < 1){const err65 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/0/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}}}else {const err66 = {instancePath,schemaPath:"#/oneOf/0/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}var _valid0 = _errs1 === errors;if(_valid0){valid0 = true;passing0 = 0;var props0 = true;}const _errs56 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err67 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}if(data.requestKind === undefined){const err68 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "requestKind"},message:"must have required property '"+"requestKind"+"'"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}if(data.requestId === undefined){const err69 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "requestId"},message:"must have required property '"+"requestId"+"'"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}if(data.type === undefined){const err70 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "type"},message:"must have required property '"+"type"+"'"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if(data.status === undefined){const err71 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}if(data.prompt === undefined){const err72 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "prompt"},message:"must have required property '"+"prompt"+"'"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(data.input === undefined){const err73 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: "input"},message:"must have required property '"+"input"+"'"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}for(const key3 in data){if(!(func66.call(schema213.oneOf[1].properties, key3))){const err74 = {instancePath,schemaPath:"#/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}}if(data.schema !== undefined){if("paperclip.runtime_request.v2" !== data.schema){const err75 = {instancePath:instancePath+"/schema",schemaPath:"#/oneOf/1/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.runtime_request.v2"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}}if(data.requestKind !== undefined){if("runtime" !== data.requestKind){const err76 = {instancePath:instancePath+"/requestKind",schemaPath:"#/oneOf/1/properties/requestKind/const",keyword:"const",params:{allowedValue: "runtime"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}}if(data.requestId !== undefined){let data27 = data.requestId;if(typeof data27 === "string"){if(func1(data27) > 160){const err77 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/1/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(func1(data27) < 1){const err78 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/1/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}}else {const err79 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/1/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}}if(data.type !== undefined){if("input" !== data.type){const err80 = {instancePath:instancePath+"/type",schemaPath:"#/oneOf/1/properties/type/const",keyword:"const",params:{allowedValue: "input"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}}if(data.status !== undefined){let data29 = data.status;if(!((((data29 === "pending") || (data29 === "resolved")) || (data29 === "expired")) || (data29 === "cancelled"))){const err81 = {instancePath:instancePath+"/status",schemaPath:"#/oneOf/1/properties/status/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[1].properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}}if(data.prompt !== undefined){let data30 = data.prompt;if(typeof data30 === "string"){if(func1(data30) > 4000){const err82 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/1/properties/prompt/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(func1(data30) < 1){const err83 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/1/properties/prompt/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}}else {const err84 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/1/properties/prompt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}}if(data.input !== undefined){if(!(validate117(data.input, {instancePath:instancePath+"/input",parentData:data,parentDataProperty:"input",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate117.errors : vErrors.concat(validate117.errors);errors = vErrors.length;}}if(data.origin !== undefined){let data32 = data.origin;if(data32 && typeof data32 == "object" && !Array.isArray(data32)){if(data32.adapter === undefined){const err85 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/1/properties/origin/required",keyword:"required",params:{missingProperty: "adapter"},message:"must have required property '"+"adapter"+"'"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}for(const key4 in data32){if(!(((key4 === "adapter") || (key4 === "provider")) || (key4 === "method"))){const err86 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/1/properties/origin/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}}if(data32.adapter !== undefined){let data33 = data32.adapter;if(typeof data33 === "string"){if(func1(data33) > 160){const err87 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/1/properties/origin/properties/adapter/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}if(func1(data33) < 1){const err88 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/1/properties/origin/properties/adapter/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}}else {const err89 = {instancePath:instancePath+"/origin/adapter",schemaPath:"#/oneOf/1/properties/origin/properties/adapter/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err89];}else {vErrors.push(err89);}errors++;}}if(data32.provider !== undefined){let data34 = data32.provider;if(typeof data34 === "string"){if(func1(data34) > 160){const err90 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/1/properties/origin/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err90];}else {vErrors.push(err90);}errors++;}if(func1(data34) < 1){const err91 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/1/properties/origin/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}else {const err92 = {instancePath:instancePath+"/origin/provider",schemaPath:"#/oneOf/1/properties/origin/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}}if(data32.method !== undefined){let data35 = data32.method;if(typeof data35 === "string"){if(func1(data35) > 500){const err93 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/1/properties/origin/properties/method/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}if(func1(data35) < 1){const err94 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/1/properties/origin/properties/method/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err94];}else {vErrors.push(err94);}errors++;}}else {const err95 = {instancePath:instancePath+"/origin/method",schemaPath:"#/oneOf/1/properties/origin/properties/method/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err95];}else {vErrors.push(err95);}errors++;}}}else {const err96 = {instancePath:instancePath+"/origin",schemaPath:"#/oneOf/1/properties/origin/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err96];}else {vErrors.push(err96);}errors++;}}if(data.turnId !== undefined){let data36 = data.turnId;if(typeof data36 === "string"){if(func1(data36) > 240){const err97 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/1/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err97];}else {vErrors.push(err97);}errors++;}if(func1(data36) < 1){const err98 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/1/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err98];}else {vErrors.push(err98);}errors++;}}else {const err99 = {instancePath:instancePath+"/turnId",schemaPath:"#/oneOf/1/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err99];}else {vErrors.push(err99);}errors++;}}if(data.itemId !== undefined){let data37 = data.itemId;if(typeof data37 === "string"){if(func1(data37) > 240){const err100 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/1/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err100];}else {vErrors.push(err100);}errors++;}if(func1(data37) < 1){const err101 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/1/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}}else {const err102 = {instancePath:instancePath+"/itemId",schemaPath:"#/oneOf/1/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}}else {const err103 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}var _valid0 = _errs56 === errors;if(_valid0 && valid0){valid0 = false;passing0 = [passing0, 1];}else {if(_valid0){valid0 = true;passing0 = 1;if(props0 !== true){props0 = true;}}const _errs81 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err104 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}if(data.requestKind === undefined){const err105 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "requestKind"},message:"must have required property '"+"requestKind"+"'"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}if(data.requestId === undefined){const err106 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "requestId"},message:"must have required property '"+"requestId"+"'"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}if(data.type === undefined){const err107 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "type"},message:"must have required property '"+"type"+"'"};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}if(data.status === undefined){const err108 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}if(data.prompt === undefined){const err109 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: "prompt"},message:"must have required property '"+"prompt"+"'"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}if(data.schema !== undefined){if("paperclip.runtime_request.v1" !== data.schema){const err110 = {instancePath:instancePath+"/schema",schemaPath:"#/oneOf/2/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.runtime_request.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}if(data.requestKind !== undefined){if("runtime" !== data.requestKind){const err111 = {instancePath:instancePath+"/requestKind",schemaPath:"#/oneOf/2/properties/requestKind/const",keyword:"const",params:{allowedValue: "runtime"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}if(data.requestId !== undefined){let data40 = data.requestId;if(typeof data40 === "string"){if(func1(data40) > 160){const err112 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/2/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}if(func1(data40) < 1){const err113 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/2/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}else {const err114 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/2/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}if(data.type !== undefined){let data41 = data.type;if(!((data41 === "permission") || (data41 === "input"))){const err115 = {instancePath:instancePath+"/type",schemaPath:"#/oneOf/2/properties/type/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[2].properties.type.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}}if(data.status !== undefined){let data42 = data.status;if(!((((data42 === "pending") || (data42 === "resolved")) || (data42 === "expired")) || (data42 === "cancelled"))){const err116 = {instancePath:instancePath+"/status",schemaPath:"#/oneOf/2/properties/status/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[2].properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}if(data.prompt !== undefined){let data43 = data.prompt;if(typeof data43 === "string"){if(func1(data43) > 4000){const err117 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/2/properties/prompt/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}if(func1(data43) < 1){const err118 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/2/properties/prompt/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}}else {const err119 = {instancePath:instancePath+"/prompt",schemaPath:"#/oneOf/2/properties/prompt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}}if(data.choices !== undefined){let data44 = data.choices;if(Array.isArray(data44)){const len5 = data44.length;for(let i5=0; i5 160){const err137 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/3/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err137];}else {vErrors.push(err137);}errors++;}if(func1(data50) < 1){const err138 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/3/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}}else {const err139 = {instancePath:instancePath+"/requestId",schemaPath:"#/oneOf/3/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}}if(data.kind !== undefined){let data51 = data.kind;if(!(((((data51 === "request_confirmation") || (data51 === "request_checkbox_confirmation")) || (data51 === "request_item_verdicts")) || (data51 === "ask_user_questions")) || (data51 === "suggest_tasks"))){const err140 = {instancePath:instancePath+"/kind",schemaPath:"#/oneOf/3/properties/kind/enum",keyword:"enum",params:{allowedValues: schema213.oneOf[3].properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}}if(data.blocking !== undefined){if(typeof data.blocking !== "boolean"){const err141 = {instancePath:instancePath+"/blocking",schemaPath:"#/oneOf/3/properties/blocking/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}}if(data.payload !== undefined){let data53 = data.payload;if(data53 && typeof data53 == "object" && !Array.isArray(data53)){}else {const err142 = {instancePath:instancePath+"/payload",schemaPath:"#/oneOf/3/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err142];}else {vErrors.push(err142);}errors++;}}}else {const err143 = {instancePath,schemaPath:"#/oneOf/3/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}var _valid0 = _errs101 === errors;if(_valid0 && valid0){valid0 = false;passing0 = [passing0, 3];}else {if(_valid0){valid0 = true;passing0 = 3;if(props0 !== true){props0 = true;}}}}}if(!valid0){const err144 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;}else {errors = _errs0;if(vErrors !== null){if(_errs0){vErrors.length = _errs0;}else {vErrors = null;}}}validate116.errors = vErrors;evaluated0.props = props0;return errors === 0;}validate116.evaluated = {"dynamicProps":true,"dynamicItems":false};const pattern12 = new RegExp("^[a-z][a-z0-9_.-]*$", "u");const pattern15 = new RegExp("^sha256:[0-9a-f]{64}$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/fixture.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.fixtureVersion === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "fixtureVersion"},message:"must have required property '"+"fixtureVersion"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.protocolVersion === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "protocolVersion"},message:"must have required property '"+"protocolVersion"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.name === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "name"},message:"must have required property '"+"name"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.description === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "description"},message:"must have required property '"+"description"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.identity === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "identity"},message:"must have required property '"+"identity"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.capabilities === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "capabilities"},message:"must have required property '"+"capabilities"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.commands === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "commands"},message:"must have required property '"+"commands"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.events === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "events"},message:"must have required property '"+"events"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.result === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "result"},message:"must have required property '"+"result"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.schema !== undefined){if("paperclip.prp.fixture.v1" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.fixture.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.fixtureVersion !== undefined){if(1 !== data.fixtureVersion){const err11 = {instancePath:instancePath+"/fixtureVersion",schemaPath:"#/properties/fixtureVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.protocolVersion !== undefined){let data2 = data.protocolVersion;if(!(((data2 === 1) || (data2 === 2)) || (data2 === 3))){const err12 = {instancePath:instancePath+"/protocolVersion",schemaPath:"#/properties/protocolVersion/enum",keyword:"enum",params:{allowedValues: schema31.properties.protocolVersion.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.name !== undefined){let data3 = data.name;if(typeof data3 === "string"){if(func1(data3) > 120){const err13 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/maxLength",keyword:"maxLength",params:{limit: 120},message:"must NOT have more than 120 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data3) < 1){const err14 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.description !== undefined){let data4 = data.description;if(typeof data4 === "string"){if(func1(data4) > 1000){const err16 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data4) < 1){const err17 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}else {const err18 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.identity !== undefined){if(!(validate21(data.identity, {instancePath:instancePath+"/identity",parentData:data,parentDataProperty:"identity",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate21.errors : vErrors.concat(validate21.errors);errors = vErrors.length;}}if(data.capabilities !== undefined){let data6 = data.capabilities;const _errs11 = errors;let valid1 = false;let passing0 = null;const _errs12 = errors;if(data6 && typeof data6 == "object" && !Array.isArray(data6)){if(data6.schema === undefined){const err19 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data6.sessionReusePolicy === undefined){const err20 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "sessionReusePolicy"},message:"must have required property '"+"sessionReusePolicy"+"'"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data6.driver === undefined){const err21 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "driver"},message:"must have required property '"+"driver"+"'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data6.steer === undefined){const err22 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "steer"},message:"must have required property '"+"steer"+"'"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(data6.interrupt === undefined){const err23 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "interrupt"},message:"must have required property '"+"interrupt"+"'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data6.resume === undefined){const err24 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "resume"},message:"must have required property '"+"resume"+"'"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(data6.runtimeRequests === undefined){const err25 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "runtimeRequests"},message:"must have required property '"+"runtimeRequests"+"'"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(data6.structuredResult === undefined){const err26 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "structuredResult"},message:"must have required property '"+"structuredResult"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data6.typedEvents === undefined){const err27 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "typedEvents"},message:"must have required property '"+"typedEvents"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data6.schema !== undefined){if("paperclip.prp.capabilities.v1" !== data6.schema){const err28 = {instancePath:instancePath+"/capabilities/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.capabilities.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data6.sessionReusePolicy !== undefined){let data8 = data6.sessionReusePolicy;if(!(((data8 === "new_per_run") || (data8 === "reuse_per_issue")) || (data8 === "reuse_per_workspace"))){const err29 = {instancePath:instancePath+"/capabilities/sessionReusePolicy",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/sessionReusePolicy/enum",keyword:"enum",params:{allowedValues: schema41.properties.sessionReusePolicy.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data6.driver !== undefined){let data9 = data6.driver;if(data9 && typeof data9 == "object" && !Array.isArray(data9)){if(data9.kind === undefined){const err30 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data9.version === undefined){const err31 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(data9.kind !== undefined){let data10 = data9.kind;if(typeof data10 === "string"){if(func1(data10) > 80){const err32 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(func1(data10) < 1){const err33 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}else {const err34 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data9.version !== undefined){let data11 = data9.version;if(typeof data11 === "string"){if(func1(data11) > 80){const err35 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(func1(data11) < 1){const err36 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}else {const err37 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}}else {const err38 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data6.steer !== undefined){if(typeof data6.steer !== "boolean"){const err39 = {instancePath:instancePath+"/capabilities/steer",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/steer/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}if(data6.interrupt !== undefined){if(typeof data6.interrupt !== "boolean"){const err40 = {instancePath:instancePath+"/capabilities/interrupt",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/interrupt/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}if(data6.resume !== undefined){if(typeof data6.resume !== "boolean"){const err41 = {instancePath:instancePath+"/capabilities/resume",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/resume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}if(data6.runtimeRequests !== undefined){if(typeof data6.runtimeRequests !== "boolean"){const err42 = {instancePath:instancePath+"/capabilities/runtimeRequests",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/runtimeRequests/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}if(data6.structuredResult !== undefined){if(typeof data6.structuredResult !== "boolean"){const err43 = {instancePath:instancePath+"/capabilities/structuredResult",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/structuredResult/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}if(data6.typedEvents !== undefined){if(typeof data6.typedEvents !== "boolean"){const err44 = {instancePath:instancePath+"/capabilities/typedEvents",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEvents/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data6.typedEventFamilies !== undefined){let data18 = data6.typedEventFamilies;if(Array.isArray(data18)){if(data18.length > 64){const err45 = {instancePath:instancePath+"/capabilities/typedEventFamilies",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}const len0 = data18.length;for(let i0=0; i0 160){const err51 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/family",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/family/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}if(func1(data20) < 1){const err52 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/family",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/family/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}if(!pattern12.test(data20)){const err53 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/family",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/family/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.-]*$"+"\""};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}else {const err54 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/family",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/family/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}if(data19.version !== undefined){if(1 !== data19.version){const err55 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}}if(data19.availability !== undefined){let data22 = data19.availability;if(!(((data22 === "available") || (data22 === "unsupported")) || (data22 === "policy_disabled"))){const err56 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/availability",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/availability/enum",keyword:"enum",params:{allowedValues: schema41.properties.typedEventFamilies.items.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}if(data19.detailLevel !== undefined){let data23 = data19.detailLevel;if(!((data23 === "summary") || (data23 === "structured"))){const err57 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0+"/detailLevel",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/properties/detailLevel/enum",keyword:"enum",params:{allowedValues: schema41.properties.typedEventFamilies.items.properties.detailLevel.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}}else {const err58 = {instancePath:instancePath+"/capabilities/typedEventFamilies/" + i0,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}}else {const err59 = {instancePath:instancePath+"/capabilities/typedEventFamilies",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEventFamilies/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data6.semanticTools !== undefined){let data24 = data6.semanticTools;if(data24 && typeof data24 == "object" && !Array.isArray(data24)){if(data24.schema === undefined){const err60 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}if(data24.schemaVersion === undefined){const err61 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}if(data24.operations === undefined){const err62 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "operations"},message:"must have required property '"+"operations"+"'"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(data24.schema !== undefined){if("paperclip.prp.semantic_tools.v1" !== data24.schema){const err63 = {instancePath:instancePath+"/capabilities/semanticTools/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.semantic_tools.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}if(data24.schemaVersion !== undefined){if(1 !== data24.schemaVersion){const err64 = {instancePath:instancePath+"/capabilities/semanticTools/schemaVersion",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}if(data24.operations !== undefined){let data27 = data24.operations;if(Array.isArray(data27)){const len1 = data27.length;for(let i1=0; i1 160){const err69 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}if(func1(data29) < 1){const err70 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if(!pattern4.test(data29)){const err71 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}else {const err72 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}}if(data28.version !== undefined){if(1 !== data28.version){const err73 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}}if(data28.availability !== undefined){let data31 = data28.availability;if(!(((data31 === "available") || (data31 === "denied")) || (data31 === "unsupported"))){const err74 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/availability",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/availability/enum",keyword:"enum",params:{allowedValues: schema41.properties.semanticTools.properties.operations.items.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}}if(data28.redactionDisposition !== undefined){if("digest_only" !== data28.redactionDisposition){const err75 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/redactionDisposition",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/redactionDisposition/const",keyword:"const",params:{allowedValue: "digest_only"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}}if(data28.requiredClaims !== undefined){let data33 = data28.requiredClaims;if(Array.isArray(data33)){const len2 = data33.length;for(let i2=0; i2 160){const err76 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims/" + i2,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}if(func1(data34) < 1){const err77 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims/" + i2,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}}else {const err78 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims/" + i2,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}}let i3 = data33.length;let j0;if(i3 > 1){const indices0 = {};for(;i3--;){let item0 = data33[i3];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j0 = indices0[item0];const err79 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;break;}indices0[item0] = i3;}}}else {const err80 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/requiredClaims",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err80];}else {vErrors.push(err80);}errors++;}}if(data28.reasonCode !== undefined){let data35 = data28.reasonCode;if(typeof data35 === "string"){if(func1(data35) > 160){const err81 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err81];}else {vErrors.push(err81);}errors++;}if(func1(data35) < 1){const err82 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;}if(!pattern14.test(data35)){const err83 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}}else {const err84 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;}}}else {const err85 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i1,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}}}else {const err86 = {instancePath:instancePath+"/capabilities/semanticTools/operations",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;}}}else {const err87 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}if(data6.unsupported !== undefined){let data36 = data6.unsupported;if(Array.isArray(data36)){const len3 = data36.length;for(let i4=0; i4 1){const indices1 = {};for(;i5--;){let item1 = data36[i5];if(typeof item1 !== "string"){continue;}if(typeof indices1[item1] == "number"){j1 = indices1[item1];const err90 = {instancePath:instancePath+"/capabilities/unsupported",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/unsupported/uniqueItems",keyword:"uniqueItems",params:{i: i5, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i5+" are identical)"};if(vErrors === null){vErrors = [err90];}else {vErrors.push(err90);}errors++;break;}indices1[item1] = i5;}}}else {const err91 = {instancePath:instancePath+"/capabilities/unsupported",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/unsupported/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err91];}else {vErrors.push(err91);}errors++;}}}else {const err92 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err92];}else {vErrors.push(err92);}errors++;}var _valid0 = _errs12 === errors;if(_valid0){valid1 = true;passing0 = 0;var props0 = true;}const _errs72 = errors;if(!(validate23(data6, {instancePath:instancePath+"/capabilities",parentData:data,parentDataProperty:"capabilities",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate23.errors : vErrors.concat(validate23.errors);errors = vErrors.length;}var _valid0 = _errs72 === errors;if(_valid0 && valid1){valid1 = false;passing0 = [passing0, 1];}else {if(_valid0){valid1 = true;passing0 = 1;if(props0 !== true){props0 = true;}}const _errs73 = errors;if(!(validate26(data6, {instancePath:instancePath+"/capabilities",parentData:data,parentDataProperty:"capabilities",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate26.errors : vErrors.concat(validate26.errors);errors = vErrors.length;}var _valid0 = _errs73 === errors;if(_valid0 && valid1){valid1 = false;passing0 = [passing0, 2];}else {if(_valid0){valid1 = true;passing0 = 2;if(props0 !== true){props0 = true;}}}}if(!valid1){const err93 = {instancePath:instancePath+"/capabilities",schemaPath:"#/properties/capabilities/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err93];}else {vErrors.push(err93);}errors++;}else {errors = _errs11;if(vErrors !== null){if(_errs11){vErrors.length = _errs11;}else {vErrors = null;}}}}if(data.commands !== undefined){let data38 = data.commands;if(Array.isArray(data38)){const len4 = data38.length;for(let i6=0; i6 160){const err101 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/commandId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err101];}else {vErrors.push(err101);}errors++;}if(func1(data41) < 1){const err102 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/commandId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err102];}else {vErrors.push(err102);}errors++;}}else {const err103 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/commandId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err103];}else {vErrors.push(err103);}errors++;}}if(data39.controllerSeq !== undefined){let data42 = data39.controllerSeq;if(!(((typeof data42 == "number") && (!(data42 % 1) && !isNaN(data42))) && (isFinite(data42)))){const err104 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/controllerSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err104];}else {vErrors.push(err104);}errors++;}if((typeof data42 == "number") && (isFinite(data42))){if(data42 < 1 || isNaN(data42)){const err105 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/controllerSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err105];}else {vErrors.push(err105);}errors++;}}}if(data39.type !== undefined){let data43 = data39.type;if(!((((((((((((((((((data43 === "run.prepare") || (data43 === "run.attach")) || (data43 === "session.open")) || (data43 === "turn.start")) || (data43 === "turn.steer")) || (data43 === "turn.interrupt")) || (data43 === "turn.stop")) || (data43 === "request.resolve")) || (data43 === "interaction.receipt")) || (data43 === "semantic_tool.result")) || (data43 === "session.snapshot")) || (data43 === "session.close")) || (data43 === "session.budget.increase")) || (data43 === "session.destroy")) || (data43 === "run.cancel")) || (data43 === "runner.drain")) || (data43 === "runner.suspend")) || (data43 === "runner.shutdown"))){const err106 = {instancePath:instancePath+"/commands/" + i6+"/type",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/type/enum",keyword:"enum",params:{allowedValues: schema47.properties.type.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err106];}else {vErrors.push(err106);}errors++;}}if(data39.issuedAt !== undefined){let data44 = data39.issuedAt;if(typeof data44 === "string"){if(!(formats0.test(data44))){const err107 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/issuedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err107];}else {vErrors.push(err107);}errors++;}}else {const err108 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/issuedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}if(data39.deadlineAt !== undefined){let data45 = data39.deadlineAt;if(typeof data45 === "string"){if(!(formats0.test(data45))){const err109 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/deadlineAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}}else {const err110 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v1/command.schema.json/properties/deadlineAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}if(data39.precondition !== undefined){let data46 = data39.precondition;if(data46 && typeof data46 == "object" && !Array.isArray(data46)){if(data46.runnerState !== undefined){let data47 = data46.runnerState;if(Array.isArray(data47)){const len5 = data47.length;for(let i7=0; i7 160){const err122 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}if(func1(data57) < 1){const err123 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}else {const err124 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}}if(data56.objective !== undefined){let data58 = data56.objective;if(typeof data58 === "string"){if(func1(data58) > 4000){const err125 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/objective/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}if(func1(data58) < 1){const err126 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/objective/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}}else {const err127 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/objective/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;}}if(data56.status !== undefined){let data59 = data56.status;if(!((data59 === "active") || (data59 === "paused"))){const err128 = {instancePath:instancePath+"/commands/" + i6+"/payload/status",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/status/enum",keyword:"enum",params:{allowedValues: schema48.allOf[0].then.properties.payload.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}}if(data56.tokenBudget !== undefined){let data60 = data56.tokenBudget;if((!(((typeof data60 == "number") && (!(data60 % 1) && !isNaN(data60))) && (isFinite(data60)))) && (data60 !== null)){const err129 = {instancePath:instancePath+"/commands/" + i6+"/payload/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/tokenBudget/type",keyword:"type",params:{type: schema48.allOf[0].then.properties.payload.properties.tokenBudget.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}if((typeof data60 == "number") && (isFinite(data60))){if(data60 < 1 || isNaN(data60)){const err130 = {instancePath:instancePath+"/commands/" + i6+"/payload/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/properties/tokenBudget/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}}}}else {const err131 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}}}var _valid2 = _errs119 === errors;valid32 = _valid2;if(valid32){var props2 = {};props2.payload = true;props2.type = true;}}if(!valid32){const err132 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;}if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.schema === undefined){const err133 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err133];}else {vErrors.push(err133);}errors++;}if(data39.commandId === undefined){const err134 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "commandId"},message:"must have required property '"+"commandId"+"'"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}if(data39.controllerSeq === undefined){const err135 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "controllerSeq"},message:"must have required property '"+"controllerSeq"+"'"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}if(data39.type === undefined){const err136 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "type"},message:"must have required property '"+"type"+"'"};if(vErrors === null){vErrors = [err136];}else {vErrors.push(err136);}errors++;}if(data39.issuedAt === undefined){const err137 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "issuedAt"},message:"must have required property '"+"issuedAt"+"'"};if(vErrors === null){vErrors = [err137];}else {vErrors.push(err137);}errors++;}if(data39.payload === undefined){const err138 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/required",keyword:"required",params:{missingProperty: "payload"},message:"must have required property '"+"payload"+"'"};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}if(data39.schema !== undefined){if("paperclip.prp.command.v2" !== data39.schema){const err139 = {instancePath:instancePath+"/commands/" + i6+"/schema",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.command.v2"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}}if(data39.commandId !== undefined){let data62 = data39.commandId;if(typeof data62 === "string"){if(func1(data62) > 160){const err140 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/commandId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}if(func1(data62) < 1){const err141 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/commandId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}}else {const err142 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/commandId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err142];}else {vErrors.push(err142);}errors++;}}if(data39.controllerSeq !== undefined){let data63 = data39.controllerSeq;if(!(((typeof data63 == "number") && (!(data63 % 1) && !isNaN(data63))) && (isFinite(data63)))){const err143 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/controllerSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}if((typeof data63 == "number") && (isFinite(data63))){if(data63 < 1 || isNaN(data63)){const err144 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/controllerSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;}}}if(data39.type !== undefined){let data64 = data39.type;if(!(((((((((((((((((((((data64 === "run.prepare") || (data64 === "run.attach")) || (data64 === "session.open")) || (data64 === "turn.start")) || (data64 === "turn.steer")) || (data64 === "turn.interrupt")) || (data64 === "turn.stop")) || (data64 === "request.resolve")) || (data64 === "interaction.receipt")) || (data64 === "semantic_tool.result")) || (data64 === "session.snapshot")) || (data64 === "session.close")) || (data64 === "session.budget.increase")) || (data64 === "session.destroy")) || (data64 === "run.cancel")) || (data64 === "runner.drain")) || (data64 === "runner.suspend")) || (data64 === "runner.shutdown")) || (data64 === "session.goal.get")) || (data64 === "session.goal.set")) || (data64 === "session.goal.clear"))){const err145 = {instancePath:instancePath+"/commands/" + i6+"/type",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/type/enum",keyword:"enum",params:{allowedValues: schema48.properties.type.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err145];}else {vErrors.push(err145);}errors++;}}if(data39.issuedAt !== undefined){let data65 = data39.issuedAt;if(typeof data65 === "string"){if(!(formats0.test(data65))){const err146 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/issuedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err146];}else {vErrors.push(err146);}errors++;}}else {const err147 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/issuedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err147];}else {vErrors.push(err147);}errors++;}}if(data39.deadlineAt !== undefined){let data66 = data39.deadlineAt;if(typeof data66 === "string"){if(!(formats0.test(data66))){const err148 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/deadlineAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err148];}else {vErrors.push(err148);}errors++;}}else {const err149 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v2/command.schema.json/properties/deadlineAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err149];}else {vErrors.push(err149);}errors++;}}if(data39.precondition !== undefined){let data67 = data39.precondition;if(data67 && typeof data67 == "object" && !Array.isArray(data67)){if(data67.runnerState !== undefined){let data68 = data67.runnerState;if(Array.isArray(data68)){const len8 = data68.length;for(let i10=0; i10 160){const err162 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err162];}else {vErrors.push(err162);}errors++;}if(func1(data78) < 1){const err163 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err163];}else {vErrors.push(err163);}errors++;}}else {const err164 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err164];}else {vErrors.push(err164);}errors++;}}if(data77.objective !== undefined){let data79 = data77.objective;if(typeof data79 === "string"){if(func1(data79) > 4000){const err165 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/objective/maxLength",keyword:"maxLength",params:{limit: 4000},message:"must NOT have more than 4000 characters"};if(vErrors === null){vErrors = [err165];}else {vErrors.push(err165);}errors++;}if(func1(data79) < 1){const err166 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/objective/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err166];}else {vErrors.push(err166);}errors++;}}else {const err167 = {instancePath:instancePath+"/commands/" + i6+"/payload/objective",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/objective/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err167];}else {vErrors.push(err167);}errors++;}}if(data77.status !== undefined){let data80 = data77.status;if(!((data80 === "active") || (data80 === "paused"))){const err168 = {instancePath:instancePath+"/commands/" + i6+"/payload/status",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/status/enum",keyword:"enum",params:{allowedValues: schema49.allOf[0].then.properties.payload.properties.status.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}}if(data77.tokenBudget !== undefined){let data81 = data77.tokenBudget;if((!(((typeof data81 == "number") && (!(data81 % 1) && !isNaN(data81))) && (isFinite(data81)))) && (data81 !== null)){const err169 = {instancePath:instancePath+"/commands/" + i6+"/payload/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/tokenBudget/type",keyword:"type",params:{type: schema49.allOf[0].then.properties.payload.properties.tokenBudget.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}if((typeof data81 == "number") && (isFinite(data81))){if(data81 < 1 || isNaN(data81)){const err170 = {instancePath:instancePath+"/commands/" + i6+"/payload/tokenBudget",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/properties/tokenBudget/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}}}}else {const err171 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}}}else {const err172 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}var _valid3 = _errs169 === errors;valid46 = _valid3;if(valid46){var props3 = {};props3.payload = true;props3.type = true;}}if(!valid46){const err173 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}const _errs182 = errors;let valid50 = true;const _errs183 = errors;if(errors === _errs183){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){if("external_provider.operation" !== data39.type){const err174 = {};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}}}else {const err175 = {};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}}var _valid4 = _errs183 === errors;errors = _errs182;if(vErrors !== null){if(_errs182){vErrors.length = _errs182;}else {vErrors = null;}}if(_valid4){const _errs186 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data83 = data39.payload;if(data83 && typeof data83 == "object" && !Array.isArray(data83)){if(data83.requestId === undefined){const err176 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "requestId"},message:"must have required property '"+"requestId"+"'"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if(data83.bindingId === undefined){const err177 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}if(data83.runId === undefined){const err178 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err178];}else {vErrors.push(err178);}errors++;}if(data83.normalizedSessionId === undefined){const err179 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err179];}else {vErrors.push(err179);}errors++;}if(data83.turnId === undefined){const err180 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err180];}else {vErrors.push(err180);}errors++;}if(data83.bindingGeneration === undefined){const err181 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "bindingGeneration"},message:"must have required property '"+"bindingGeneration"+"'"};if(vErrors === null){vErrors = [err181];}else {vErrors.push(err181);}errors++;}if(data83.assignmentRevision === undefined){const err182 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "assignmentRevision"},message:"must have required property '"+"assignmentRevision"+"'"};if(vErrors === null){vErrors = [err182];}else {vErrors.push(err182);}errors++;}if(data83.digest === undefined){const err183 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "digest"},message:"must have required property '"+"digest"+"'"};if(vErrors === null){vErrors = [err183];}else {vErrors.push(err183);}errors++;}if(data83.action === undefined){const err184 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "action"},message:"must have required property '"+"action"+"'"};if(vErrors === null){vErrors = [err184];}else {vErrors.push(err184);}errors++;}if(data83.input === undefined){const err185 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/required",keyword:"required",params:{missingProperty: "input"},message:"must have required property '"+"input"+"'"};if(vErrors === null){vErrors = [err185];}else {vErrors.push(err185);}errors++;}for(const key1 in data83){if(!(func66.call(schema49.allOf[1].then.properties.payload.properties, key1))){const err186 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err186];}else {vErrors.push(err186);}errors++;}}if(data83.requestId !== undefined){let data84 = data83.requestId;if(typeof data84 === "string"){if(func1(data84) > 240){const err187 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/requestId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err187];}else {vErrors.push(err187);}errors++;}if(func1(data84) < 1){const err188 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/requestId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err188];}else {vErrors.push(err188);}errors++;}}else {const err189 = {instancePath:instancePath+"/commands/" + i6+"/payload/requestId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/requestId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err189];}else {vErrors.push(err189);}errors++;}}if(data83.bindingId !== undefined){let data85 = data83.bindingId;if(typeof data85 === "string"){if(func1(data85) > 240){const err190 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err190];}else {vErrors.push(err190);}errors++;}if(func1(data85) < 1){const err191 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err191];}else {vErrors.push(err191);}errors++;}}else {const err192 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err192];}else {vErrors.push(err192);}errors++;}}if(data83.runId !== undefined){let data86 = data83.runId;if(typeof data86 === "string"){if(func1(data86) > 240){const err193 = {instancePath:instancePath+"/commands/" + i6+"/payload/runId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/runId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err193];}else {vErrors.push(err193);}errors++;}if(func1(data86) < 1){const err194 = {instancePath:instancePath+"/commands/" + i6+"/payload/runId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err194];}else {vErrors.push(err194);}errors++;}}else {const err195 = {instancePath:instancePath+"/commands/" + i6+"/payload/runId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err195];}else {vErrors.push(err195);}errors++;}}if(data83.normalizedSessionId !== undefined){let data87 = data83.normalizedSessionId;if(typeof data87 === "string"){if(func1(data87) > 240){const err196 = {instancePath:instancePath+"/commands/" + i6+"/payload/normalizedSessionId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err196];}else {vErrors.push(err196);}errors++;}if(func1(data87) < 1){const err197 = {instancePath:instancePath+"/commands/" + i6+"/payload/normalizedSessionId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err197];}else {vErrors.push(err197);}errors++;}}else {const err198 = {instancePath:instancePath+"/commands/" + i6+"/payload/normalizedSessionId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err198];}else {vErrors.push(err198);}errors++;}}if(data83.turnId !== undefined){let data88 = data83.turnId;if(typeof data88 === "string"){if(func1(data88) > 240){const err199 = {instancePath:instancePath+"/commands/" + i6+"/payload/turnId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err199];}else {vErrors.push(err199);}errors++;}if(func1(data88) < 1){const err200 = {instancePath:instancePath+"/commands/" + i6+"/payload/turnId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err200];}else {vErrors.push(err200);}errors++;}}else {const err201 = {instancePath:instancePath+"/commands/" + i6+"/payload/turnId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err201];}else {vErrors.push(err201);}errors++;}}if(data83.bindingGeneration !== undefined){let data89 = data83.bindingGeneration;if(!(((typeof data89 == "number") && (!(data89 % 1) && !isNaN(data89))) && (isFinite(data89)))){const err202 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingGeneration",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingGeneration/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err202];}else {vErrors.push(err202);}errors++;}if((typeof data89 == "number") && (isFinite(data89))){if(data89 < 1 || isNaN(data89)){const err203 = {instancePath:instancePath+"/commands/" + i6+"/payload/bindingGeneration",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/bindingGeneration/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err203];}else {vErrors.push(err203);}errors++;}}}if(data83.assignmentRevision !== undefined){let data90 = data83.assignmentRevision;if(!(((typeof data90 == "number") && (!(data90 % 1) && !isNaN(data90))) && (isFinite(data90)))){const err204 = {instancePath:instancePath+"/commands/" + i6+"/payload/assignmentRevision",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/assignmentRevision/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err204];}else {vErrors.push(err204);}errors++;}if((typeof data90 == "number") && (isFinite(data90))){if(data90 < 1 || isNaN(data90)){const err205 = {instancePath:instancePath+"/commands/" + i6+"/payload/assignmentRevision",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/assignmentRevision/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err205];}else {vErrors.push(err205);}errors++;}}}if(data83.digest !== undefined){let data91 = data83.digest;if(typeof data91 === "string"){if(!pattern15.test(data91)){const err206 = {instancePath:instancePath+"/commands/" + i6+"/payload/digest",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/digest/pattern",keyword:"pattern",params:{pattern: "^sha256:[0-9a-f]{64}$"},message:"must match pattern \""+"^sha256:[0-9a-f]{64}$"+"\""};if(vErrors === null){vErrors = [err206];}else {vErrors.push(err206);}errors++;}}else {const err207 = {instancePath:instancePath+"/commands/" + i6+"/payload/digest",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/digest/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err207];}else {vErrors.push(err207);}errors++;}}if(data83.action !== undefined){let data92 = data83.action;if(!((((data92 === "accept") || (data92 === "tool")) || (data92 === "progress")) || (data92 === "finish"))){const err208 = {instancePath:instancePath+"/commands/" + i6+"/payload/action",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/action/enum",keyword:"enum",params:{allowedValues: schema49.allOf[1].then.properties.payload.properties.action.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err208];}else {vErrors.push(err208);}errors++;}}if(data83.input !== undefined){let data93 = data83.input;if(!(data93 && typeof data93 == "object" && !Array.isArray(data93))){const err209 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err209];}else {vErrors.push(err209);}errors++;}}}else {const err210 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err210];}else {vErrors.push(err210);}errors++;}}}else {const err211 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err211];}else {vErrors.push(err211);}errors++;}var _valid4 = _errs186 === errors;valid50 = _valid4;if(valid50){var props4 = {};props4.payload = true;props4.type = true;}}if(!valid50){const err212 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err212];}else {vErrors.push(err212);}errors++;}if(props3 !== true && props4 !== undefined){if(props4 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props4);}}const _errs211 = errors;let valid54 = true;const _errs212 = errors;if(errors === _errs212){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){const _errs214 = errors;if("external_provider.operation" !== data39.type){const err213 = {};if(vErrors === null){vErrors = [err213];}else {vErrors.push(err213);}errors++;}var valid55 = _errs214 === errors;}else {var valid55 = true;}if(valid55){if(data39.payload !== undefined){let data95 = data39.payload;const _errs215 = errors;if(errors === _errs215){if(data95 && typeof data95 == "object" && !Array.isArray(data95)){if(data95.action !== undefined){if("accept" !== data95.action){const err214 = {};if(vErrors === null){vErrors = [err214];}else {vErrors.push(err214);}errors++;}}}else {const err215 = {};if(vErrors === null){vErrors = [err215];}else {vErrors.push(err215);}errors++;}}var valid55 = _errs215 === errors;}else {var valid55 = true;}}}else {const err216 = {};if(vErrors === null){vErrors = [err216];}else {vErrors.push(err216);}errors++;}}var _valid5 = _errs212 === errors;errors = _errs211;if(vErrors !== null){if(_errs211){vErrors.length = _errs211;}else {vErrors = null;}}if(_valid5){const _errs218 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data97 = data39.payload;if(data97 && typeof data97 == "object" && !Array.isArray(data97)){if(data97.input !== undefined){let data98 = data97.input;if(data98 && typeof data98 == "object" && !Array.isArray(data98)){for(const key2 in data98){const err217 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/then/properties/payload/properties/input/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err217];}else {vErrors.push(err217);}errors++;}}else {const err218 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err218];}else {vErrors.push(err218);}errors++;}}}else {const err219 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err219];}else {vErrors.push(err219);}errors++;}}}else {const err220 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err220];}else {vErrors.push(err220);}errors++;}var _valid5 = _errs218 === errors;valid54 = _valid5;if(valid54){var props5 = {};props5.payload = true;props5.type = true;}}if(!valid54){const err221 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err221];}else {vErrors.push(err221);}errors++;}if(props3 !== true && props5 !== undefined){if(props5 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props5);}}const _errs226 = errors;let valid59 = true;const _errs227 = errors;if(errors === _errs227){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){const _errs229 = errors;if("external_provider.operation" !== data39.type){const err222 = {};if(vErrors === null){vErrors = [err222];}else {vErrors.push(err222);}errors++;}var valid60 = _errs229 === errors;}else {var valid60 = true;}if(valid60){if(data39.payload !== undefined){let data100 = data39.payload;const _errs230 = errors;if(errors === _errs230){if(data100 && typeof data100 == "object" && !Array.isArray(data100)){if(data100.action !== undefined){if("tool" !== data100.action){const err223 = {};if(vErrors === null){vErrors = [err223];}else {vErrors.push(err223);}errors++;}}}else {const err224 = {};if(vErrors === null){vErrors = [err224];}else {vErrors.push(err224);}errors++;}}var valid60 = _errs230 === errors;}else {var valid60 = true;}}}else {const err225 = {};if(vErrors === null){vErrors = [err225];}else {vErrors.push(err225);}errors++;}}var _valid6 = _errs227 === errors;errors = _errs226;if(vErrors !== null){if(_errs226){vErrors.length = _errs226;}else {vErrors = null;}}if(_valid6){const _errs233 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data102 = data39.payload;if(data102 && typeof data102 == "object" && !Array.isArray(data102)){if(data102.input !== undefined){let data103 = data102.input;if(data103 && typeof data103 == "object" && !Array.isArray(data103)){if(data103.name === undefined){const err226 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/required",keyword:"required",params:{missingProperty: "name"},message:"must have required property '"+"name"+"'"};if(vErrors === null){vErrors = [err226];}else {vErrors.push(err226);}errors++;}if(data103.arguments === undefined){const err227 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/required",keyword:"required",params:{missingProperty: "arguments"},message:"must have required property '"+"arguments"+"'"};if(vErrors === null){vErrors = [err227];}else {vErrors.push(err227);}errors++;}for(const key3 in data103){if(!((key3 === "name") || (key3 === "arguments"))){const err228 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err228];}else {vErrors.push(err228);}errors++;}}if(data103.name !== undefined){let data104 = data103.name;if(typeof data104 === "string"){if(func1(data104) > 160){const err229 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/name",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/properties/name/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err229];}else {vErrors.push(err229);}errors++;}if(func1(data104) < 1){const err230 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/name",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err230];}else {vErrors.push(err230);}errors++;}}else {const err231 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/name",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err231];}else {vErrors.push(err231);}errors++;}}if(data103.arguments !== undefined){let data105 = data103.arguments;if(!(data105 && typeof data105 == "object" && !Array.isArray(data105))){const err232 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/arguments",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/properties/arguments/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err232];}else {vErrors.push(err232);}errors++;}}}else {const err233 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}}}else {const err234 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}}}else {const err235 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}var _valid6 = _errs233 === errors;valid59 = _valid6;if(valid59){var props6 = {};props6.payload = true;props6.type = true;}}if(!valid59){const err236 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}if(props3 !== true && props6 !== undefined){if(props6 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props6);}}const _errs245 = errors;let valid65 = true;const _errs246 = errors;if(errors === _errs246){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){const _errs248 = errors;if("external_provider.operation" !== data39.type){const err237 = {};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}var valid66 = _errs248 === errors;}else {var valid66 = true;}if(valid66){if(data39.payload !== undefined){let data107 = data39.payload;const _errs249 = errors;if(errors === _errs249){if(data107 && typeof data107 == "object" && !Array.isArray(data107)){if(data107.action !== undefined){if("progress" !== data107.action){const err238 = {};if(vErrors === null){vErrors = [err238];}else {vErrors.push(err238);}errors++;}}}else {const err239 = {};if(vErrors === null){vErrors = [err239];}else {vErrors.push(err239);}errors++;}}var valid66 = _errs249 === errors;}else {var valid66 = true;}}}else {const err240 = {};if(vErrors === null){vErrors = [err240];}else {vErrors.push(err240);}errors++;}}var _valid7 = _errs246 === errors;errors = _errs245;if(vErrors !== null){if(_errs245){vErrors.length = _errs245;}else {vErrors = null;}}if(_valid7){const _errs252 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data109 = data39.payload;if(data109 && typeof data109 == "object" && !Array.isArray(data109)){if(data109.input !== undefined){let data110 = data109.input;if(data110 && typeof data110 == "object" && !Array.isArray(data110)){if(data110.text === undefined){const err241 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/required",keyword:"required",params:{missingProperty: "text"},message:"must have required property '"+"text"+"'"};if(vErrors === null){vErrors = [err241];}else {vErrors.push(err241);}errors++;}for(const key4 in data110){if(!(key4 === "text")){const err242 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err242];}else {vErrors.push(err242);}errors++;}}if(data110.text !== undefined){let data111 = data110.text;if(typeof data111 === "string"){if(func1(data111) > 12000){const err243 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/text",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/properties/text/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err243];}else {vErrors.push(err243);}errors++;}if(func1(data111) < 1){const err244 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/text",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/properties/text/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err244];}else {vErrors.push(err244);}errors++;}}else {const err245 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/text",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/properties/text/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err245];}else {vErrors.push(err245);}errors++;}}}else {const err246 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err246];}else {vErrors.push(err246);}errors++;}}}else {const err247 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err247];}else {vErrors.push(err247);}errors++;}}}else {const err248 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err248];}else {vErrors.push(err248);}errors++;}var _valid7 = _errs252 === errors;valid65 = _valid7;if(valid65){var props7 = {};props7.payload = true;props7.type = true;}}if(!valid65){const err249 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/4/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err249];}else {vErrors.push(err249);}errors++;}if(props3 !== true && props7 !== undefined){if(props7 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props7);}}const _errs262 = errors;let valid71 = true;const _errs263 = errors;if(errors === _errs263){if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.type !== undefined){const _errs265 = errors;if("external_provider.operation" !== data39.type){const err250 = {};if(vErrors === null){vErrors = [err250];}else {vErrors.push(err250);}errors++;}var valid72 = _errs265 === errors;}else {var valid72 = true;}if(valid72){if(data39.payload !== undefined){let data113 = data39.payload;const _errs266 = errors;if(errors === _errs266){if(data113 && typeof data113 == "object" && !Array.isArray(data113)){if(data113.action !== undefined){if("finish" !== data113.action){const err251 = {};if(vErrors === null){vErrors = [err251];}else {vErrors.push(err251);}errors++;}}}else {const err252 = {};if(vErrors === null){vErrors = [err252];}else {vErrors.push(err252);}errors++;}}var valid72 = _errs266 === errors;}else {var valid72 = true;}}}else {const err253 = {};if(vErrors === null){vErrors = [err253];}else {vErrors.push(err253);}errors++;}}var _valid8 = _errs263 === errors;errors = _errs262;if(vErrors !== null){if(_errs262){vErrors.length = _errs262;}else {vErrors = null;}}if(_valid8){const _errs269 = errors;if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.payload !== undefined){let data115 = data39.payload;if(data115 && typeof data115 == "object" && !Array.isArray(data115)){if(data115.input !== undefined){let data116 = data115.input;if(data116 && typeof data116 == "object" && !Array.isArray(data116)){if(data116.result === undefined){const err254 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/properties/input/required",keyword:"required",params:{missingProperty: "result"},message:"must have required property '"+"result"+"'"};if(vErrors === null){vErrors = [err254];}else {vErrors.push(err254);}errors++;}for(const key5 in data116){if(!(key5 === "result")){const err255 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/properties/input/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key5},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err255];}else {vErrors.push(err255);}errors++;}}if(data116.result !== undefined){let data117 = data116.result;if(!(data117 && typeof data117 == "object" && !Array.isArray(data117))){const err256 = {instancePath:instancePath+"/commands/" + i6+"/payload/input/result",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/properties/input/properties/result/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err256];}else {vErrors.push(err256);}errors++;}}}else {const err257 = {instancePath:instancePath+"/commands/" + i6+"/payload/input",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/properties/input/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err257];}else {vErrors.push(err257);}errors++;}}}else {const err258 = {instancePath:instancePath+"/commands/" + i6+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err258];}else {vErrors.push(err258);}errors++;}}}else {const err259 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/then/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err259];}else {vErrors.push(err259);}errors++;}var _valid8 = _errs269 === errors;valid71 = _valid8;if(valid71){var props8 = {};props8.payload = true;props8.type = true;}}if(!valid71){const err260 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/allOf/5/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err260];}else {vErrors.push(err260);}errors++;}if(props3 !== true && props8 !== undefined){if(props8 === true){props3 = true;}else {props3 = props3 || {};Object.assign(props3, props8);}}if(data39 && typeof data39 == "object" && !Array.isArray(data39)){if(data39.schema === undefined){const err261 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err261];}else {vErrors.push(err261);}errors++;}if(data39.commandId === undefined){const err262 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "commandId"},message:"must have required property '"+"commandId"+"'"};if(vErrors === null){vErrors = [err262];}else {vErrors.push(err262);}errors++;}if(data39.controllerSeq === undefined){const err263 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "controllerSeq"},message:"must have required property '"+"controllerSeq"+"'"};if(vErrors === null){vErrors = [err263];}else {vErrors.push(err263);}errors++;}if(data39.type === undefined){const err264 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "type"},message:"must have required property '"+"type"+"'"};if(vErrors === null){vErrors = [err264];}else {vErrors.push(err264);}errors++;}if(data39.issuedAt === undefined){const err265 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "issuedAt"},message:"must have required property '"+"issuedAt"+"'"};if(vErrors === null){vErrors = [err265];}else {vErrors.push(err265);}errors++;}if(data39.payload === undefined){const err266 = {instancePath:instancePath+"/commands/" + i6,schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/required",keyword:"required",params:{missingProperty: "payload"},message:"must have required property '"+"payload"+"'"};if(vErrors === null){vErrors = [err266];}else {vErrors.push(err266);}errors++;}if(data39.schema !== undefined){if("paperclip.prp.command.v3" !== data39.schema){const err267 = {instancePath:instancePath+"/commands/" + i6+"/schema",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.command.v3"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err267];}else {vErrors.push(err267);}errors++;}}if(data39.commandId !== undefined){let data119 = data39.commandId;if(typeof data119 === "string"){if(func1(data119) > 160){const err268 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/commandId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err268];}else {vErrors.push(err268);}errors++;}if(func1(data119) < 1){const err269 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/commandId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err269];}else {vErrors.push(err269);}errors++;}}else {const err270 = {instancePath:instancePath+"/commands/" + i6+"/commandId",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/commandId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err270];}else {vErrors.push(err270);}errors++;}}if(data39.controllerSeq !== undefined){let data120 = data39.controllerSeq;if(!(((typeof data120 == "number") && (!(data120 % 1) && !isNaN(data120))) && (isFinite(data120)))){const err271 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/controllerSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err271];}else {vErrors.push(err271);}errors++;}if((typeof data120 == "number") && (isFinite(data120))){if(data120 < 1 || isNaN(data120)){const err272 = {instancePath:instancePath+"/commands/" + i6+"/controllerSeq",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/controllerSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err272];}else {vErrors.push(err272);}errors++;}}}if(data39.type !== undefined){let data121 = data39.type;if(!((((((((((((((((((((((data121 === "run.prepare") || (data121 === "run.attach")) || (data121 === "session.open")) || (data121 === "turn.start")) || (data121 === "turn.steer")) || (data121 === "turn.interrupt")) || (data121 === "turn.stop")) || (data121 === "request.resolve")) || (data121 === "interaction.receipt")) || (data121 === "semantic_tool.result")) || (data121 === "session.snapshot")) || (data121 === "session.close")) || (data121 === "session.budget.increase")) || (data121 === "session.destroy")) || (data121 === "run.cancel")) || (data121 === "runner.drain")) || (data121 === "runner.suspend")) || (data121 === "runner.shutdown")) || (data121 === "session.goal.get")) || (data121 === "session.goal.set")) || (data121 === "session.goal.clear")) || (data121 === "external_provider.operation"))){const err273 = {instancePath:instancePath+"/commands/" + i6+"/type",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/type/enum",keyword:"enum",params:{allowedValues: schema49.properties.type.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err273];}else {vErrors.push(err273);}errors++;}}if(data39.issuedAt !== undefined){let data122 = data39.issuedAt;if(typeof data122 === "string"){if(!(formats0.test(data122))){const err274 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/issuedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err274];}else {vErrors.push(err274);}errors++;}}else {const err275 = {instancePath:instancePath+"/commands/" + i6+"/issuedAt",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/issuedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err275];}else {vErrors.push(err275);}errors++;}}if(data39.deadlineAt !== undefined){let data123 = data39.deadlineAt;if(typeof data123 === "string"){if(!(formats0.test(data123))){const err276 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/deadlineAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err276];}else {vErrors.push(err276);}errors++;}}else {const err277 = {instancePath:instancePath+"/commands/" + i6+"/deadlineAt",schemaPath:"https://paperclip.dev/schemas/prp/v3/command.schema.json/properties/deadlineAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err277];}else {vErrors.push(err277);}errors++;}}if(data39.precondition !== undefined){let data124 = data39.precondition;if(data124 && typeof data124 == "object" && !Array.isArray(data124)){if(data124.runnerState !== undefined){let data125 = data124.runnerState;if(Array.isArray(data125)){const len11 = data125.length;for(let i13=0; i13 12000){const err310 = {instancePath:instancePath+"/result/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err310];}else {vErrors.push(err310);}errors++;}if(func1(data142) < 1){const err311 = {instancePath:instancePath+"/result/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err311];}else {vErrors.push(err311);}errors++;}}else {const err312 = {instancePath:instancePath+"/result/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err312];}else {vErrors.push(err312);}errors++;}}if(data137.completionClaim !== undefined){let data143 = data137.completionClaim;if(data143 && typeof data143 == "object" && !Array.isArray(data143)){if(data143.contractRevision === undefined){const err313 = {instancePath:instancePath+"/result/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "contractRevision"},message:"must have required property '"+"contractRevision"+"'"};if(vErrors === null){vErrors = [err313];}else {vErrors.push(err313);}errors++;}if(data143.objectiveSatisfied === undefined){const err314 = {instancePath:instancePath+"/result/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "objectiveSatisfied"},message:"must have required property '"+"objectiveSatisfied"+"'"};if(vErrors === null){vErrors = [err314];}else {vErrors.push(err314);}errors++;}if(data143.criteria === undefined){const err315 = {instancePath:instancePath+"/result/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "criteria"},message:"must have required property '"+"criteria"+"'"};if(vErrors === null){vErrors = [err315];}else {vErrors.push(err315);}errors++;}if(data143.remainingWork === undefined){const err316 = {instancePath:instancePath+"/result/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "remainingWork"},message:"must have required property '"+"remainingWork"+"'"};if(vErrors === null){vErrors = [err316];}else {vErrors.push(err316);}errors++;}if(data143.contractRevision !== undefined){let data144 = data143.contractRevision;if(typeof data144 === "string"){if(func1(data144) < 1){const err317 = {instancePath:instancePath+"/result/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err317];}else {vErrors.push(err317);}errors++;}}else {const err318 = {instancePath:instancePath+"/result/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err318];}else {vErrors.push(err318);}errors++;}}if(data143.objectiveSatisfied !== undefined){if(typeof data143.objectiveSatisfied !== "boolean"){const err319 = {instancePath:instancePath+"/result/completionClaim/objectiveSatisfied",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/objectiveSatisfied/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err319];}else {vErrors.push(err319);}errors++;}}if(data143.criteria !== undefined){let data146 = data143.criteria;if(Array.isArray(data146)){const len16 = data146.length;for(let i18=0; i18 12000){const err16 = {instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data4) < 1){const err17 = {instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}else {const err18 = {instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.completionClaim !== undefined){let data5 = data.completionClaim;if(data5 && typeof data5 == "object" && !Array.isArray(data5)){if(data5.contractRevision === undefined){const err19 = {instancePath:instancePath+"/completionClaim",schemaPath:"#/properties/completionClaim/required",keyword:"required",params:{missingProperty: "contractRevision"},message:"must have required property '"+"contractRevision"+"'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data5.objectiveSatisfied === undefined){const err20 = {instancePath:instancePath+"/completionClaim",schemaPath:"#/properties/completionClaim/required",keyword:"required",params:{missingProperty: "objectiveSatisfied"},message:"must have required property '"+"objectiveSatisfied"+"'"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data5.criteria === undefined){const err21 = {instancePath:instancePath+"/completionClaim",schemaPath:"#/properties/completionClaim/required",keyword:"required",params:{missingProperty: "criteria"},message:"must have required property '"+"criteria"+"'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data5.remainingWork === undefined){const err22 = {instancePath:instancePath+"/completionClaim",schemaPath:"#/properties/completionClaim/required",keyword:"required",params:{missingProperty: "remainingWork"},message:"must have required property '"+"remainingWork"+"'"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(data5.contractRevision !== undefined){let data6 = data5.contractRevision;if(typeof data6 === "string"){if(func1(data6) < 1){const err23 = {instancePath:instancePath+"/completionClaim/contractRevision",schemaPath:"#/properties/completionClaim/properties/contractRevision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/completionClaim/contractRevision",schemaPath:"#/properties/completionClaim/properties/contractRevision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data5.objectiveSatisfied !== undefined){if(typeof data5.objectiveSatisfied !== "boolean"){const err25 = {instancePath:instancePath+"/completionClaim/objectiveSatisfied",schemaPath:"#/properties/completionClaim/properties/objectiveSatisfied/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data5.criteria !== undefined){let data8 = data5.criteria;if(Array.isArray(data8)){const len0 = data8.length;for(let i0=0; i0 ({ role: "assistant", timestamp: messageTimestamp, content: [], stopReason, usage }); +// Protocol fixture emits Pi 1's serialized delta form. Actual SDK serialization +// and the owned extension are exercised separately by the local-only package test. +const streamBlocks = new Map(); +const emit = value => process.stdout.write(JSON.stringify(value) + "\n"); +const output = value => { + if (value.type === "message_start" && value.message?.role === "assistant") streamBlocks.clear(); + if (value.type === "message_update") { + const original = value.assistantMessageEvent; + const kind = original.type.split("_")[0]; + const index = original.contentIndex ?? (kind === "thinking" ? 1 : 0); + if (original.type === "toolcall_start") { + const call = original.toolCall ?? original.partial?.content?.[index]; + streamBlocks.set(index, { kind, call }); + emit({ type: "message_update", usage: {}, assistantMessageEvent: { type: "toolcall_start", contentIndex: index, id: call.id, toolName: call.name } }); + return; + } + if (!streamBlocks.has(index)) { + streamBlocks.set(index, { kind, text: "" }); + emit({ type: "message_update", usage: {}, assistantMessageEvent: { type: `${kind}_start`, contentIndex: index } }); + } + if (typeof original.delta === "string") streamBlocks.get(index).text += original.delta; + const { partial: _partial, ...delta } = original; + emit({ type: "message_update", usage: {}, assistantMessageEvent: { ...delta, contentIndex: index } }); return; + } + if (value.type === "message_end" && value.message?.role === "assistant") { + const content = []; + for (const [index, block] of streamBlocks) { + content[index] = block.call ?? { type: block.kind, [block.kind === "text" ? "text" : "thinking"]: block.text }; + emit({ type: "message_update", usage: {}, assistantMessageEvent: { type: `${block.kind}_end`, contentIndex: index, ...(block.call ? { toolCall: block.call } : { content: block.text }) } }); + } + value = { ...value, message: { ...value.message, content } }; + streamBlocks.clear(); + } + emit(value); +}; +const endMessage = (stopReason = "stop", usage = { input: 11, output: 3, cacheRead: 2, cacheWrite: 0, cost: { total: 0.01 } }) => { + output({ type: "message_end", message: nativeMessage(stopReason, usage) }); assistantActive = false; +}; +let active = false; +let modelIteration = 0; +const invocationNamespace = JSON.parse(process.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION).invocationNamespace; +const toolIdentity = (id) => `pi-${createHash("sha256").update(JSON.stringify([invocationNamespace, modelIteration, id])).digest("hex")}`; +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(); } + output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: answer } }); + endMessage(); + output({ type: "turn_end" }); + output({ type: "agent_end" }); + // An agent_end alone must not settle the ACP prompt. + setTimeout(() => { active = false; output({ type: "agent_settled" }); }, 15); +}; +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 }); 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" }] }, + { role: "assistant", content: [{ type: "text", text: "Historical reply" }] }, + ] : [] }); return; } + if (request.type === "get_commands") { response({ commands: process.env.PI_FIXTURE_EXTENSION_FAIL ? [] : [{ name: "paperclip-runtime-ready-v1", description: "Paperclip runtime gate v1", source: "extension" }] }); return; } + const inputDisposition = process.env.PI_FIXTURE_INPUT_DISPOSITION === "missing" ? {} : { disposition: process.env.PI_FIXTURE_INPUT_DISPOSITION === "malformed" ? { queued: true } : process.env.PI_FIXTURE_INPUT_DISPOSITION ?? "queued" }; + 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(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; } + begin(); + if (request.message === "duplicate-message-start") { output({ type: "message_start", message: nativeMessage() }); return; } + if (request.message === "mismatched-message-end") { output({ type: "message_end", message: { ...nativeMessage(), timestamp: messageTimestamp + 1 } }); return; } + if (request.message === "missing-message-end") { output({ type: "agent_settled" }); return; } + if (["narration-final", "tool-only", "empty-final"].includes(request.message)) { + output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Calling finish." } }); + endMessage("toolUse", {}); + const args = {}; output({ type: "tool_execution_start", toolCallId: "finish", toolName: "paperclip_finish", args }); + output({ type: "tool_execution_end", toolCallId: "finish", toolName: "paperclip_finish", result: { content: [{ type: "text", text: "accepted" }] } }); + if (request.message === "tool-only") { output({ type: "turn_end" }); active = false; output({ type: "agent_settled" }); } + else finish(request.message === "empty-final" ? "" : "EXACT_MARKER"); + return; + } + if (request.message === "reused-tool-ids") { + for (const [index, toolName] of ["mcp__paperclip__paperclip_finish", "mcp__paperclip__paperclip_finish", "mcp__paperclip__get_task_context"].entries()) { + if (index) { output({ type: "turn_end" }); begin(); } + const args = { revision: index + 1 }; + output({ type: "message_update", assistantMessageEvent: { type: "toolcall_start", contentIndex: 0, partial: { content: [{ type: "toolCall", id: "call_0", name: toolName, arguments: args }] } } }); + endMessage("toolUse", {}); + output({ type: "tool_execution_start", toolCallId: "call_0", toolName, args }); + output({ type: "tool_execution_update", toolCallId: "call_0", toolName, partialResult: { content: [{ type: "text", text: "working" }] } }); + output({ type: "tool_execution_end", toolCallId: "call_0", toolName, result: { content: [{ type: "text", text: index ? "accepted" : "correct criteria" }] }, isError: index === 0 }); + } + finish("reused IDs handled"); return; + } + if (request.message.startsWith("bash-")) { + const failure = request.message === "bash-failure"; + const oversized = request.message === "bash-oversized"; + const args = { command: failure ? "printf failure >&2; exit 7" : "printf done", timeout: 3 }; + const result = { content: [{ type: "text", text: oversized ? "x".repeat(65536) : failure ? "failure\n" : "done\n" }], details: { exitCode: failure ? 7 : 0, truncated: false } }; + output({ type: "tool_execution_start", toolCallId: "bash-fixture", toolName: "bash", args }); + output({ type: "tool_execution_update", toolCallId: "bash-fixture", toolName: "bash", partialResult: { content: [{ type: "text", text: "partial" }] } }); + output({ type: "tool_execution_end", toolCallId: "bash-fixture", toolName: "bash", result, isError: failure }); + finish("bash completed"); return; + } + if (request.message === "die") { process.exit(4); } + if (request.message === "long") return; + if (["retry-success", "retry-failure", "retry-unknown"].includes(request.message)) { + output({ type: "auto_retry_start", attempt: 2, maxAttempts: 2, delayMs: 10, errorMessage: "Fixture provider unavailable" }); + output({ type: "auto_retry_end", attempt: 2, ...(request.message === "retry-unknown" ? {} : { success: request.message === "retry-success" }) }); + if (request.message === "retry-failure") { + endMessage("error", { input: 1, output: 0 }); + active = false; output({ type: "turn_end" }); output({ type: "agent_settled" }); + } else finish("retry outcome received"); + return; + } + if (request.message === "auto-compact" || request.message === "retry-compact") { output({ type: "compaction_start", reason: "threshold" }); if (request.message === "retry-compact") output({ type: "summarization_retry_scheduled" }); output({ type: "compaction_end", reason: "threshold", result: compaction(), aborted: false, willRetry: true }); finish("compacted"); return; } + if (request.message === "question") { output({ type: "extension_ui_request", id: "question-id", method: "input", title: "Project name", placeholder: "Name" }); return; } + if (request.message === "oversized-question") { output({ type: "extension_ui_request", id: "oversized", method: "select", title: "Native question", options: ["x".repeat(1001)] }); return; } + if (request.message.startsWith("native-question-")) { + const method = request.message.slice("native-question-".length); + output({ type: "extension_ui_request", id: `native-${method}`, method, title: "Native question", options: ["Red", "Blue"], message: "Continue?", placeholder: "Name", prefill: "Old\ntext" }); return; + } + if (request.message === "permission") { output({ type: "extension_ui_request", id: "permission-id", method: "select", title: "paperclip.pi.permission.v1:" + JSON.stringify({toolCallId:toolIdentity("tool-1"),nativeToolCallId:"tool-1",modelIteration,toolName:"bash",input:{command:"pwd"}}), options: ["Allow once", "Allow for this session", "Deny"] }); return; } + if (request.message === "failure") { + endMessage("error", { input: 1, output: 0 }); + active = false; output({ type: "turn_end" }); output({ type: "agent_settled" }); return; + } + output({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "fixture reasoning" } }); + finish("hello🌒\u2028world"); return; + } + response(); +}).on("close", () => process.exit(0)); diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/model-admission-proof.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/model-admission-proof.darwin-arm64.json new file mode 100644 index 0000000000..d5c36f1561 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/model-admission-proof.darwin-arm64.json @@ -0,0 +1,41 @@ +{ + "schema": "paperclip.pi-model-admission-proof.v1", + "recordedAt": "2026-09-28T20:42:44.289Z", + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "closureDigest": "sha256:b30047dce9c4c25e1fbc88a1aa828690a6a11ebdc3f6fcc083875986eb2d9585", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "node": "24.19.0" + }, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "initialReportedCurrentModelId": "openrouter/moonshotai/kimi-k2.6", + "selectedReportedCurrentModelId": "openrouter/deepseek/deepseek-v4-flash-0731", + "provenance": "Actual ACP session/new models.currentModelId and session/set_config_option configOptions.currentValue. The pinned wrapper builds both from Pi native RPC get_state after model selection.", + "credentials": "explicit authorized OpenRouter binding; value omitted", + "rpcMethods": [ + "initialize", + "session/new", + "session/set_config_option" + ], + "promptRequests": 0, + "paidProviderCalls": 0, + "billingDeltaUsd": 0, + "qualificationStatus": "pending", + "limits": [ + "This proof verifies authenticated model selection without prompting.", + "The separate Product hello pass uses the same pinned runtime and mandatory model-verification admission gate; this metadata-only probe is not its transcript.", + "Native tool, permission, question, steering delivery and remote behavior remain separate qualifications." + ], + "executionTrees": { + "packages/paperclip-runner/src": "a8123cb4396c77ce0127d5ccf49d3584b63a9a58", + "packages/paperclip-runner/scripts": "9ede97769bb3f2bbfa8e02be220b5ce06e438a99", + "packages/paperclip-runner/runner": "2cc84e150aecbd5faf2c8bcecc4b6352c24dbca8", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "1e2f846b8b2dda202b79ee83baeb0860a3b9ab64", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.darwin-arm64.json new file mode 100644 index 0000000000..ff8e9c0726 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.darwin-arm64.json @@ -0,0 +1,123 @@ +{ + "schema": "paperclip.pi-native-controls-proof.v1", + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "actualSelectedModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "modelPromptRequests": 1, + "visibleText": "STEERED_CURRENTQUEUED_NEXT", + "staleSteerRejected": true, + "permissionRequests": 1, + "settlement": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 1941, + "outputTokens": 134, + "cachedReadTokens": 3584, + "cachedWriteTokens": 0, + "totalTokens": 5659, + "_meta": { + "paperclipPi": { + "provenance": "assistant_message_receipts", + "costUsd": 0.00040961200000000006, + "costSource": "pi_pricing_estimate" + } + } + }, + "metadata": null + }, + "oracle": { + "markerExistedBefore": false, + "markerExistsAfter": false, + "method": "independent filesystem stat before cleanup" + }, + "limits": [ + "Direct ACP active steering, queued native follow-up and owned extension denial; durable PRP restart replay is not exercised.", + "Pipe write acknowledgement is followed by the matching failed tool update and settled prompt." + ], + "attemptId": "pi-native-controls-01", + "runtimeCodeRevision": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "profileVersion": 3, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "direct-native-acp" + }, + "qualificationStatus": "pending", + "controls": [ + { + "method": "pi/steer", + "kind": "steer", + "accepted": true, + "activePromptPending": true, + "ackBeforePermissionResponse": true, + "activeSessionIdentityMatched": true + }, + { + "method": "pi/follow_up", + "kind": "follow_up", + "accepted": true, + "activePromptPending": true, + "ackBeforePermissionResponse": true, + "activeSessionIdentityMatched": true + } + ], + "decision": { + "count": 1, + "toolTitle": "Pi write", + "originalOfferedOptionIds": [ + "allow_once", + "allow_always", + "reject_once" + ], + "selectedOriginalOptionId": "reject_once", + "persistedAndFsyncedBeforeResponse": true, + "pipeWriteAcknowledged": true, + "matchingFailedToolUpdate": true, + "rawRequestIdsRetainedPrivately": true + }, + "result": { + "status": "passed", + "durationSeconds": 21.342, + "exitCode": 0, + "stopReason": null + }, + "cost": { + "catalogEstimateUsd": 0.00040961200000000006, + "costSource": "pi_pricing_estimate", + "providerReportedUsd": null, + "observedKeyDeltaUsd": 0.000140985, + "attributedProviderSpendUsd": 0.000140985, + "billingReconciliation": "Later exclusive-key observation increased after unchanged initial reads; early and later observations retained separately.", + "cumulativeObservedExclusiveKeyDeltaUsd": 0.025720049, + "earlyObservedKeyDeltaUsd": 0 + }, + "supervision": { + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "source": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "packDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeDeadlineSeconds": 120, + "outerDeadlineSeconds": 180, + "deadlineClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0, + "providerPromptMaximum": 1 + }, + "cleanup": { + "childExited": true, + "commandLeaseClosed": true, + "temporaryWorkspaceRemoved": true, + "residualAttemptProcesses": 0 + }, + "publicationBoundary": { + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false, + "credentialsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.v4.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.v4.darwin-arm64.json new file mode 100644 index 0000000000..156128effe --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.v4.darwin-arm64.json @@ -0,0 +1,122 @@ +{ + "schema": "paperclip.pi-native-controls-proof.v1", + "sourceRevision": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "providerPackDigest": "sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855", + "profileDigest": "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", + "actualSelectedModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "modelPromptRequests": 1, + "visibleText": "STEERED_CURRENTQUEUED_NEXT", + "staleSteerRejected": true, + "permissionRequests": 1, + "settlement": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 2030, + "outputTokens": 1293, + "cachedReadTokens": 5632, + "cachedWriteTokens": 0, + "totalTokens": 8955, + "_meta": { + "paperclipPi": { + "provenance": "assistant_message_receipts", + "costUsd": 0.000803936, + "costSource": "pi_pricing_estimate" + } + } + }, + "metadata": null + }, + "oracle": { + "markerExistedBefore": false, + "markerExistsAfter": false, + "method": "independent filesystem stat before cleanup" + }, + "limits": [ + "Direct ACP active steering, queued native follow-up and owned extension denial; durable PRP restart replay is not exercised.", + "Pipe write acknowledgement is followed by the matching failed tool update and settled prompt." + ], + "attemptId": "pi-native-controls-02", + "profileVersion": 4, + "qualificationStatus": "pending", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "direct-native-acp" + }, + "controls": [ + { + "method": "pi/steer", + "kind": "steer", + "accepted": true, + "activePromptPending": true, + "ackBeforePermissionResponse": true, + "activeSessionIdentityMatched": true + }, + { + "method": "pi/follow_up", + "kind": "follow_up", + "accepted": true, + "activePromptPending": true, + "ackBeforePermissionResponse": true, + "activeSessionIdentityMatched": true + } + ], + "decision": { + "count": 1, + "normalizedInvocationId": "pi-1635ad4557c73da41a62a757dc1598a7acfc89ed44351ffe5a36d615d7e6493a", + "toolTitle": "Pi write", + "originalOfferedOptionIds": [ + "allow_once", + "allow_always", + "reject_once" + ], + "selectedOriginalOptionId": "reject_once", + "persistedAndFsyncedBeforeResponse": true, + "pipeWriteAcknowledged": true, + "matchingFailedToolUpdate": true, + "rawRequestAndSessionIdsRetainedPrivately": true + }, + "result": { + "durationSeconds": 20.255, + "exitCode": 0, + "stopReason": null, + "status": "passed" + }, + "cost": { + "catalogEstimateUsd": 0.000803936, + "costSource": "pi_pricing_estimate", + "providerReportedUsd": null, + "observedKeyDeltaUsd": 0.000546502, + "earlyObservedKeyDeltaUsd": 0, + "cumulativeObservedExclusiveKeyDeltaUsd": 0.028706633, + "billingReconciliation": "Later exclusive-key observation resolves initial unchanged reads; no intervening provider activity." + }, + "supervision": { + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "source": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "packDigest": "sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855", + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeDeadlineSeconds": 120, + "outerDeadlineSeconds": 300, + "deadlineClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0, + "providerPromptMaximum": 1 + }, + "cleanup": { + "probeFinallyCompleted": true, + "childCloseAndVerifiedCommandLeaseCloseAwaited": true, + "temporaryWorkspaceRemovalAwaited": true, + "matchingLiveProbeOrPiProcesses": 0, + "databaseStarted": false + }, + "publication": { + "hiddenReasoningIncluded": false, + "credentialsIncluded": false, + "providerSessionIdsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.v5.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.v5.darwin-arm64.json new file mode 100644 index 0000000000..c302868de6 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/native-controls-proof.v5.darwin-arm64.json @@ -0,0 +1,122 @@ +{ + "schema": "paperclip.pi-native-controls-proof.v1", + "sourceRevision": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "providerPackDigest": "sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500", + "profileDigest": "sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "actualSelectedModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "modelPromptRequests": 1, + "visibleText": "STEERED_CURRENTQUEUED_NEXT", + "staleSteerRejected": true, + "permissionRequests": 1, + "settlement": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 3727, + "outputTokens": 140, + "cachedReadTokens": 1792, + "cachedWriteTokens": 0, + "totalTokens": 5659, + "_meta": { + "paperclipPi": { + "provenance": "assistant_message_receipts", + "costUsd": 0.0006111560000000002, + "costSource": "pi_pricing_estimate" + } + } + }, + "metadata": null + }, + "oracle": { + "markerExistedBefore": false, + "markerExistsAfter": false, + "method": "independent filesystem stat before cleanup" + }, + "limits": [ + "Direct ACP active steering, queued native follow-up and owned extension denial; durable PRP restart replay is not exercised.", + "Pipe write acknowledgement is followed by the matching failed tool update and settled prompt." + ], + "attemptId": "pi-native-controls-03", + "profileVersion": 5, + "qualificationStatus": "pending", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "direct-native-acp" + }, + "controls": [ + { + "method": "pi/steer", + "kind": "steer", + "accepted": true, + "activePromptPending": true, + "ackBeforePermissionResponse": true, + "activeSessionIdentityMatched": true + }, + { + "method": "pi/follow_up", + "kind": "follow_up", + "accepted": true, + "activePromptPending": true, + "ackBeforePermissionResponse": true, + "activeSessionIdentityMatched": true + } + ], + "decision": { + "count": 1, + "normalizedInvocationId": "pi-849b369266c920633abffa988509e4ba61fcdf464f62c73900fd5ddd6d477402", + "toolTitle": "Pi write", + "originalOfferedOptionIds": [ + "allow_once", + "allow_always", + "reject_once" + ], + "selectedOriginalOptionId": "reject_once", + "persistedAndFsyncedBeforeResponse": true, + "pipeWriteAcknowledged": true, + "matchingFailedToolUpdate": true, + "rawRequestAndSessionIdsRetainedPrivately": true + }, + "result": { + "durationSeconds": 19.199, + "exitCode": 0, + "stopReason": null, + "status": "passed" + }, + "cost": { + "catalogEstimateUsd": 0.0006111560000000002, + "costSource": "pi_pricing_estimate", + "providerReportedUsd": null, + "observedKeyDeltaUsd": 0.000151739, + "earlyObservedKeyDeltaUsd": 0, + "cumulativeObservedExclusiveKeyDeltaUsd": 0.028858372, + "billingReconciliation": "Later exclusive-key observation resolves initial unchanged reads; no intervening provider activity." + }, + "supervision": { + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "source": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "packDigest": "sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500", + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeDeadlineSeconds": 120, + "outerDeadlineSeconds": 300, + "deadlineClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0, + "providerPromptMaximum": 1 + }, + "cleanup": { + "probeFinallyCompleted": true, + "childCloseAndVerifiedCommandLeaseCloseAwaited": true, + "temporaryWorkspaceRemovalAwaited": true, + "matchingLiveProbeOrPiProcesses": 0, + "databaseStarted": false + }, + "publication": { + "hiddenReasoningIncluded": false, + "credentialsIncluded": false, + "providerSessionIdsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/native-denial-proof.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/native-denial-proof.darwin-arm64.json new file mode 100644 index 0000000000..d350246264 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/native-denial-proof.darwin-arm64.json @@ -0,0 +1,121 @@ +{ + "schema": "paperclip.pi-native-denial-proof.v1", + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "actualSelectedModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "modelPromptRequests": 1, + "permissionRequests": 1, + "decisions": [ + { + "requestId": 0, + "toolTitle": "Pi write", + "options": [ + { + "optionId": "allow_once", + "name": "Allow once", + "kind": "allow_once" + }, + { + "optionId": "allow_always", + "name": "Allow for this session", + "kind": "allow_always" + }, + { + "optionId": "reject_once", + "name": "Deny", + "kind": "reject_once" + } + ], + "decisionOptionId": "reject_once", + "persistedBeforeResponse": true, + "pipeWriteAcknowledged": true, + "toolCallIdSha256": "caa61c37ddbe3dddc3a077f55a4450da3c70e4f98e8913e8a229bb3fc507307b", + "durableOriginalDecisionRecordSha256": "68ab7a1eb4cf5ea29417223f66448b3cba53d2dc4c37c5de3617c52804379436" + } + ], + "providerFailedToolUpdates": [ + { + "status": "failed", + "toolCallIdSha256": "caa61c37ddbe3dddc3a077f55a4450da3c70e4f98e8913e8a229bb3fc507307b" + } + ], + "settlement": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 1735, + "outputTokens": 125, + "cachedReadTokens": 1792, + "cachedWriteTokens": 0, + "totalTokens": 3652, + "_meta": { + "paperclipPi": { + "provenance": "assistant_message_receipts", + "costUsd": 0.00032807600000000006, + "costSource": "pi_pricing_estimate" + } + } + }, + "metadata": null + }, + "oracle": { + "markerExistedBefore": false, + "markerExistsAfter": false, + "method": "independent filesystem stat before cleanup" + }, + "limits": [ + "Direct ACP permission delivery and owned extension denial; durable PRP restart replay is not exercised.", + "Pipe write acknowledgement is followed by the matching failed tool update and settled prompt." + ], + "executionTrees": { + "packages/paperclip-runner/src": "a8123cb4396c77ce0127d5ccf49d3584b63a9a58", + "packages/paperclip-runner/scripts": "9ede97769bb3f2bbfa8e02be220b5ce06e438a99", + "packages/paperclip-runner/runner": "2cc84e150aecbd5faf2c8bcecc4b6352c24dbca8", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "1e2f846b8b2dda202b79ee83baeb0860a3b9ab64", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "local immutable ACP wrapper and owned extension" + }, + "identityHandling": "Original JSON-RPC id, toolCallId and offered optionId were persisted privately before responding. Public toolCallId hashes preserve request/update equality without exposing invocation identifiers.", + "cost": { + "catalogEstimateUsd": 0.000328076, + "estimateSource": "pi_pricing_estimate", + "observedKeyDeltaBeforeNextCaseUsd": 0, + "providerBillVerified": false, + "status": "accounted_in_aggregate", + "explanation": "Three immediate post-probe billing observations were unchanged, followed by a delayed increase before the file case. All spend is accounted in the combined exclusive-key delta; no free-inference or per-generation attribution claim.", + "aggregateBillingGroup": "pi-denial-file-01", + "aggregateDenialAndFileDeltaUsd": 0.010995043 + }, + "supervision": { + "reservationUsd": 2, + "combinedDenialAndFileReservationUsd": 4, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 180, + "deadlineClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0 + }, + "cleanup": { + "wrapperClosed": true, + "immutableSnapshotClosed": true, + "workspaceRemoved": true, + "residualAttemptProcesses": 0 + }, + "qualificationStatus": "pending", + "publicationBoundary": { + "credentialsIncluded": false, + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-native-proof.linux-x64.v3.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-native-proof.linux-x64.v3.json new file mode 100644 index 0000000000..41029c4b0b --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-native-proof.linux-x64.v3.json @@ -0,0 +1,42 @@ +{ + "provider": "pi", + "target": { + "platform": "linux", + "architecture": "x64" + }, + "sourceSha": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "packDigest": "sha256:5d4cf665692088c34b90f46fc37d943e63dbf81cdfb0bed890b626d145aeebf0", + "candidate": { + "version": "0.84.2", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "closureDigest": "sha256:002812b62463ffae84b8e8b58567a6f4ba5025dd830528c5ed3b4c50c2a0ee29", + "qualification": "pending", + "path": "provider-assets/pi/linux-x64", + "sha256": "sha256:1122af73027d3824b3cc964e4d85f80dd6c28216300be7f12842786732d15b72" + }, + "commandDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "runnerdSha256": "7935dd1ab411c0e4a577fbad0d7dc432b5bc396cc349f2fd597fa97c28a039a7", + "protocolVersion": 1, + "nativeInfo": { + "name": "pi-acp", + "title": "pi ACP adapter", + "version": "0.0.33" + }, + "exitCode": 0, + "eofSettled": true, + "prompts": 0, + "network": "none", + "credentials": false, + "schema": "paperclip.pi-linux-native-admission-proof.v1", + "imageDigest": "sha256:c5fa7976bba92a186a2f70dc8b8ddc58ab86606b80a819c89bf550d2d057c832", + "imageName": "ghcr.io/paperclipai/paperclip-daytona-runner:e2e-content-14a042557b61ea6e83046aa67c2d10028ed6a21101444ee6adc9d1c69b3d27db", + "profileVersion": 3, + "sourceBoundary": "Pi-only committed source06cf, before combined provider stack. No authenticated session or Daytona allocation.", + "qualificationStatus": "pending", + "publicationBoundary": { + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false, + "credentialsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.1e0d11c48.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.1e0d11c48.darwin-arm64.json new file mode 100644 index 0000000000..73921f1b6e --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.1e0d11c48.darwin-arm64.json @@ -0,0 +1,79 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T17:16:19.824Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "1e0d11c482f8ef50b6afc1430cb736579114b266", + "distDigest": "sha256:70dcefa5e8a12d43c6b7fe2bda54b8dbd8e5706b17cb97a94f36a70a7974398c", + "bridgeDigest": "sha256:6f2f1e35c497cc480fa061ed8579c709f7f4732cd5cb8f7e27fef7fb992f3559", + "productionLockDigest": "sha256:67f4ff4c747a7bcad77cbdd7ed264d387a30e950def41a44dbd8664caa6a172b", + "note": "Integrated pack probe used the deployed generic installation registry and its immutable snapshot. Initialize steering and missing-credential assertions passed. Evidence serialization initially used an incorrect manifest member; this record was repaired from the actual artifacts entries after the provider was closed. No prompt was sent." + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 2, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:b18deb44976b92e5e6b4f874f6e28dbbf23b34c917879ee3f7cdc03472919476", + "closureDigest": "sha256:2fde022b3714c497f352e628789c4dd47b6446351dc69779293b3a0eafcbc17f", + "assetTreeDigest": "sha256:48ac0fe511df6550cf7b3de06e78265dda1687bcb0f11f4bb72358a7e75d4173", + "distributionManifestDigest": "sha256:43a6ced3b8926dfefd8c46c67cfb113fdca531caadd79978053edacfdc8b70f2", + "wrapperDigest": "sha256:80c0e302e75b39d71404c69afb2d5182c468ccdee68bfe8b09f1f40e20a47fb4", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:87f5348be5da3fb505baea1208de7b62e88ec714adcf0ac0a16a51952f57e2da" + }, + "providerPack": { + "manifestDigest": "sha256:f859e30e51326ff875d616e2bb3fd4c0246ce1f58a5988968b91bae176a68281", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:6a1f7cef751ac59c54bb9dbd3fa7bc03eee27a7ba2b551aa75cd520314f05391" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Unchanged pinned Pi closure from the previous complete capability receipt; integrated probe reasserted steering and missing-credential rejection." + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ] +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.2e65b64d5.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.2e65b64d5.darwin-arm64.json new file mode 100644 index 0000000000..37467da7e4 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.2e65b64d5.darwin-arm64.json @@ -0,0 +1,149 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T18:27:26.822Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "2e65b64d5a2148583109b8157d6e72f54f5ad29b", + "distDigest": "sha256:d46bb029974dd0707aed83050ecb527d5f2f1689393ded35c1d785d0c415c1b1", + "bridgeDigest": "sha256:3028ba0c1bc4f93cd899c2ebb5a7fc9b904de9706251d8fd681616a9cfc728f6", + "productionLockDigest": "sha256:650e23d20e967bcfbfced888e131199b9a06e66a1ba4f64cfb68383b59def4a8", + "note": "Built after the Node engine policy repair and Pi v2 fixture corrections, including the prior HTTPS, failed-turn receipt and credential-provenance fixes. This probe executed the deployed generic installation registry and immutable snapshot. Every capability field below was read and asserted from this actual initialize response. No prompt was sent." + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 2, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "closureDigest": "sha256:b30047dce9c4c25e1fbc88a1aa828690a6a11ebdc3f6fcc083875986eb2d9585", + "assetTreeDigest": "sha256:5c046265335d4a6924efcd207f837d5aa0e0fc3baa0bf6ea7059afe8d6ab3939", + "distributionManifestDigest": "sha256:406345b3b75d8aee399da886fbdf8566aaaa44ff29fe3462da21f0c29901aecd", + "wrapperDigest": "sha256:2eb201c3a931177490a8e18f4a8392f00cbcf21847e9cc171de528da5e253935", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:0a2ca138d6d87a85498bd982ee64543ea8b14f2ecceded96cfc27b0836cc4254" + }, + "providerPack": { + "manifestDigest": "sha256:7300b9c449c02b61d2f93ef765f371c277e39a58d04739c6c24dcf7939500701", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:a909a86d0c0697697ef9dc8922785648e84e25dd687653b7c7428c863db5856b" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Read and asserted in full from this pack launch.", + "rpcMethods": [ + "initialize", + "session/new" + ] + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ], + "verification": { + "sourceRevision": "2e65b64d5a2148583109b8157d6e72f54f5ad29b", + "resolvedSourceLockDigest": "sha256:650e23d20e967bcfbfced888e131199b9a06e66a1ba4f64cfb68383b59def4a8", + "lockResolution": "clean tracked input, full workspace resolution, matched reviewed Docker pin before frozen filtered install", + "nodeVersionPolicy": "passed", + "typescriptBuild": "passed", + "rustBinaryBuild": "passed", + "providerPackBuild": "passed", + "fullRunnerVitest": { + "filesPassed": 160, + "filesSkipped": 1, + "passed": 2202, + "failed": 0, + "skipped": 11, + "durationSeconds": 223.84, + "attachTransitionRunnerDigest": "sha256:66122cae0160bfa2655d64afb7c7b5c01aa5fa7bba8ad608c7ee768c14f51fec", + "note": "Used the matching foundation transition runner plus locally built Rust fake binaries. Optional installed-Pi test runs separately below." + }, + "nativeProviderRustTests": { + "passed": 13, + "failed": 0, + "ignored": 0 + }, + "vitest": { + "files": 4, + "passed": 31, + "failed": 0, + "maxWorkers": 1, + "hookTimeoutMs": 10000, + "actualDistributionInstallation": "enabled; current provider-pack assets" + }, + "nodeTests": { + "files": 3, + "passed": 17, + "failed": 0, + "skipped": 0, + "actualPackages": "fresh provider-pack assets" + }, + "verificationHistory": [ + { + "suite": "full runner Vitest before building this worktree Rust fixtures", + "passed": 2102, + "failed": 99, + "skipped": 12, + "failurePhase": "test setup", + "failure": "Three test files lacked required local Rust fake binaries. Built the binaries, then the complete suite passed as recorded above." + } + ], + "priorCi": { + "head": "ee75eab87a741adec31b82e658542d50232a3b7d", + "runId": 36462967091, + "correctedFindings": [ + "isolated distribution manifest missing required engines.node", + "TypeScript Pi candidate fixture retained pre-upgrade identity", + "Rust Pi controls fixture retained pre-upgrade identity" + ], + "infrastructure": "Four server shards reported runner shutdown and operation cancellation, with no test failure summaries; no shared server source change was made. Fresh-head CI must recheck them." + }, + "notRun": [ + "full repository typecheck, build, and tests for this provider branch", + "full Rust workspace test suite for this provider branch", + "credentialed model evaluation", + "Linux x64 or macOS x64 execution", + "Daytona deployment" + ] + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.d039e1b7b.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.d039e1b7b.darwin-arm64.json new file mode 100644 index 0000000000..d0602bcc46 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.d039e1b7b.darwin-arm64.json @@ -0,0 +1,138 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T18:05:21.044Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "d039e1b7b072862b4c326ba7194dc42617fa5984", + "distDigest": "sha256:4a06098b322bb2c92db66c99d51e9eac7009f442d53077982a88ede937fea044", + "bridgeDigest": "sha256:d2fb33ed3ca0f42bbc6c33486f4ff34a8e29aa2cebf94c64d1747b1ce4089eb1", + "productionLockDigest": "sha256:650e23d20e967bcfbfced888e131199b9a06e66a1ba4f64cfb68383b59def4a8", + "note": "Built after the assigned HTTPS gateway repair and the shared failed-turn receipt and credential-provenance fixes. This probe executed the deployed generic installation registry and immutable snapshot. Every capability field below was read and asserted from this actual initialize response. No prompt was sent." + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 2, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:c577a771778febe24b920388da77f6143971b8eec29e1ba9a433dac5b76de3cd", + "closureDigest": "sha256:a26e98fe3f4d3d93c13beffdb4e8b778d438dd1b3bca8a9b1610154342382681", + "assetTreeDigest": "sha256:44b058bf5a10b82d355351e8034d82e0a82d8de3236c80ccd586922953a4de4a", + "distributionManifestDigest": "sha256:bd46c419b3a9c56332eda72dfc5fbd7621d8f0a271be8fb6202b005a39ee4c7a", + "wrapperDigest": "sha256:2eb201c3a931177490a8e18f4a8392f00cbcf21847e9cc171de528da5e253935", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:0a2ca138d6d87a85498bd982ee64543ea8b14f2ecceded96cfc27b0836cc4254" + }, + "providerPack": { + "manifestDigest": "sha256:5a47fc67b886c1e05d0f630ed89c1b9f5324610b649d1db020a3e036e36fc85d", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:9416ff4c0d87ae343b6af4354b7b871ece77d86799f0daaf866915647ae44eb5" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Read and asserted in full from this pack launch.", + "rpcMethods": [ + "initialize", + "session/new" + ] + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ], + "verification": { + "sourceRevision": "d039e1b7b072862b4c326ba7194dc42617fa5984", + "resolvedSourceLockDigest": "sha256:650e23d20e967bcfbfced888e131199b9a06e66a1ba4f64cfb68383b59def4a8", + "lockResolution": "clean tracked input, full workspace resolution, matched reviewed Docker pin before frozen filtered install", + "typescriptBuild": "passed", + "providerPackBuild": "passed", + "vitest": { + "files": 4, + "passed": 31, + "failed": 0, + "maxWorkers": 1, + "hookTimeoutMs": 10000, + "actualDistributionInstallation": "enabled; current provider-pack assets", + "durationSeconds": 12.22 + }, + "verificationHistory": [ + { + "suite": "four Pi Vitest files", + "passed": 30, + "failed": 1, + "failurePhase": "afterEach cleanup", + "failure": "Hook timed out in 10000ms while deleting the large immutable snapshot during concurrent pack builds; launch assertions completed", + "durationSeconds": 41.63 + }, + { + "suite": "four Pi Vitest files", + "passed": 31, + "failed": 0, + "hookTimeoutMs": 60000, + "durationSeconds": 42.07 + }, + { + "suite": "four Pi Vitest files", + "passed": 31, + "failed": 0, + "hookTimeoutMs": 10000, + "maxWorkers": 1, + "durationSeconds": 12.22, + "note": "Repeated after pack/probe IO settled, with unchanged source and default cleanup timeout." + } + ], + "nodeTests": { + "files": 3, + "passed": 17, + "failed": 0, + "skipped": 0, + "actualPackages": "fresh provider-pack assets" + }, + "notRun": [ + "full repository typecheck, build, and tests for this provider branch", + "credentialed model evaluation", + "Linux x64 or macOS x64 execution", + "Daytona deployment" + ] + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.58511d79d.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.58511d79d.json new file mode 100644 index 0000000000..8819e06f70 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.58511d79d.json @@ -0,0 +1,138 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T18:38:17.647Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "58511d79d9c4a1525d98e219d027654902b704e1", + "distDigest": "sha256:e465121951d5824d22b64d4d057a280493b5c01a9134ddec3bac1b1741422541", + "bridgeDigest": "sha256:4ef5ffcf16a3e7f8e975843d99dc568aed94ca1113e24bfd4174237d92d8ecca", + "productionLockDigest": "sha256:650e23d20e967bcfbfced888e131199b9a06e66a1ba4f64cfb68383b59def4a8", + "note": "Built after the final-spawn credential-marker preservation and direct candidate-admission fixes, including the Node engine policy, Pi v2 fixture, HTTPS and failed-turn receipt repairs. This probe executed the deployed generic installation registry and immutable snapshot. Every capability field below was read and asserted from this actual initialize response. No prompt was sent." + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 2, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "closureDigest": "sha256:b30047dce9c4c25e1fbc88a1aa828690a6a11ebdc3f6fcc083875986eb2d9585", + "assetTreeDigest": "sha256:5c046265335d4a6924efcd207f837d5aa0e0fc3baa0bf6ea7059afe8d6ab3939", + "distributionManifestDigest": "sha256:406345b3b75d8aee399da886fbdf8566aaaa44ff29fe3462da21f0c29901aecd", + "wrapperDigest": "sha256:2eb201c3a931177490a8e18f4a8392f00cbcf21847e9cc171de528da5e253935", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:0a2ca138d6d87a85498bd982ee64543ea8b14f2ecceded96cfc27b0836cc4254" + }, + "providerPack": { + "manifestDigest": "sha256:a9728fe4298a5eee00555382af01e85550820a294957caf2eba0c79bf33c4388", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:a909a86d0c0697697ef9dc8922785648e84e25dd687653b7c7428c863db5856b" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Read and asserted in full from this pack launch.", + "rpcMethods": [ + "initialize", + "session/new" + ] + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ], + "verification": { + "sourceRevision": "58511d79d9c4a1525d98e219d027654902b704e1", + "resolvedSourceLockDigest": "sha256:650e23d20e967bcfbfced888e131199b9a06e66a1ba4f64cfb68383b59def4a8", + "lockResolution": "clean tracked input, full workspace resolution, matched reviewed Docker pin before frozen filtered install", + "typescriptBuild": "passed", + "providerPackBuild": "passed", + "launchBoundaryTests": { + "files": 3, + "passed": 128, + "failed": 0, + "coverage": "native backend admission, scoped credential environment, durable final processLauncher specification for all candidate providers" + }, + "vitest": { + "files": 4, + "passed": 31, + "failed": 0, + "maxWorkers": 1, + "hookTimeoutMs": 10000, + "actualDistributionInstallation": "enabled; current provider-pack assets" + }, + "nodeTests": { + "files": 3, + "passed": 17, + "failed": 0, + "skipped": 0, + "actualPackages": "fresh provider-pack assets" + }, + "precedingFullSuite": { + "sourceRevision": "2e65b64d5a2148583109b8157d6e72f54f5ad29b", + "fullRunnerVitest": { + "filesPassed": 160, + "filesSkipped": 1, + "passed": 2202, + "failed": 0, + "skipped": 11, + "durationSeconds": 223.84, + "attachTransitionRunnerDigest": "sha256:66122cae0160bfa2655d64afb7c7b5c01aa5fa7bba8ad608c7ee768c14f51fec", + "note": "Used the matching foundation transition runner plus locally built Rust fake binaries. Optional installed-Pi test runs separately below." + }, + "nativeProviderRustTests": { + "passed": 13, + "failed": 0, + "ignored": 0 + }, + "note": "These broader checks ran before the final shared launch-boundary patch; current-source focused checks are recorded separately above." + }, + "notRun": [ + "full runner TypeScript suite after the final shared launch-boundary patch", + "full repository typecheck, build, and tests for this provider branch", + "full Rust workspace test suite for this provider branch", + "credentialed model evaluation", + "Linux x64 or macOS x64 execution", + "Daytona deployment" + ] + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.9f2d0420e.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.9f2d0420e.json new file mode 100644 index 0000000000..a8b6094b03 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.9f2d0420e.json @@ -0,0 +1,83 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T20:02:30.796Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "9f2d0420ed9a398e3bacd1d7e996614f3296ac56", + "distDigest": "sha256:3fb488c2a156f8ee1feab94f98985662da393bfeb516a19150638d6b45ec55cd", + "bridgeDigest": "sha256:f6c56eac02b7962f100add6c5ce2daeeae4ab2c70aaa39b046b8bb1fc584e175", + "productionLockDigest": "sha256:e0c928a494f90ddad3c00791e83f09315ee8c82df2a0418a809dcf93649a8ab3", + "note": "Built from merged foundation c3b7e9ecd plus the Pi provider branch, including native candidate identity and visible completion-contract fixes. This probe executes the deployed generic registry and immutable snapshot without credentials or prompts. It predates shared copy-performance fix 2a30219f1; the public paid ledger separately retains the startup failure from this source." + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 2, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "closureDigest": "sha256:b30047dce9c4c25e1fbc88a1aa828690a6a11ebdc3f6fcc083875986eb2d9585", + "assetTreeDigest": "sha256:5c046265335d4a6924efcd207f837d5aa0e0fc3baa0bf6ea7059afe8d6ab3939", + "distributionManifestDigest": "sha256:406345b3b75d8aee399da886fbdf8566aaaa44ff29fe3462da21f0c29901aecd", + "wrapperDigest": "sha256:2eb201c3a931177490a8e18f4a8392f00cbcf21847e9cc171de528da5e253935", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:0a2ca138d6d87a85498bd982ee64543ea8b14f2ecceded96cfc27b0836cc4254" + }, + "providerPack": { + "manifestDigest": "sha256:2a6f457205743487cdd35ed3634532028e6e0200001f70e087d0e084fc85d452", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:aa71619ea0f5e08d78c13bbca1812038d4ab6fee8550177be210b5afeb26d01a" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Read and asserted in full from this pack launch.", + "rpcMethods": [ + "initialize", + "session/new" + ] + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ] +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.a6167ce3a.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.a6167ce3a.json new file mode 100644 index 0000000000..b292668a16 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.a6167ce3a.json @@ -0,0 +1,83 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T19:35:49.729Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "a6167ce3a7804c31f4f9990a9ba6761c1d91f27c", + "distDigest": "sha256:2d0c65ff3d859717cc27fd03739a5e639171d4c46804a1884b3e1301c865eaa8", + "bridgeDigest": "sha256:a81fb87bf2204b0fddac15f952724f691645d3ddbafb93b75b7776f61cbaa5a2", + "productionLockDigest": "sha256:e0c928a494f90ddad3c00791e83f09315ee8c82df2a0418a809dcf93649a8ab3", + "note": "Built from the recorded source after candidate initialization, CLI canonical-path, usage provenance and canonical reasoning projection fixes. The generic registry launched the immutable snapshot, asserted every capability from its initialize response and verified missing-credential refusal. No prompt was sent by this offline probe." + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 2, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "closureDigest": "sha256:b30047dce9c4c25e1fbc88a1aa828690a6a11ebdc3f6fcc083875986eb2d9585", + "assetTreeDigest": "sha256:5c046265335d4a6924efcd207f837d5aa0e0fc3baa0bf6ea7059afe8d6ab3939", + "distributionManifestDigest": "sha256:406345b3b75d8aee399da886fbdf8566aaaa44ff29fe3462da21f0c29901aecd", + "wrapperDigest": "sha256:2eb201c3a931177490a8e18f4a8392f00cbcf21847e9cc171de528da5e253935", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:0a2ca138d6d87a85498bd982ee64543ea8b14f2ecceded96cfc27b0836cc4254" + }, + "providerPack": { + "manifestDigest": "sha256:0ed45bf84d172aa7087baec3d662275206e8121b0ba142aaa9039dcfbf4402a2", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:cfdb2b53f8f4500205560cfc33ca12c2b6c4ffaf54e666250c184f655d095d13" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Read and asserted in full from this pack launch.", + "rpcMethods": [ + "initialize", + "session/new" + ] + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ] +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.dd78df1e.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.dd78df1e.json new file mode 100644 index 0000000000..35e2447d4c --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.dd78df1e.json @@ -0,0 +1,92 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T20:17:59.387Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "distDigest": "sha256:2df3f6f348001bd1dcfc1c0a151533ebbc00295bc37518f83c68a73d3637e3f6", + "bridgeDigest": "sha256:a852df2c032b5f353db29012c397aa6070891116a699a459a94f044e3890f057", + "productionLockDigest": "sha256:e0c928a494f90ddad3c00791e83f09315ee8c82df2a0418a809dcf93649a8ab3", + "note": "Built from Pi source dd78df1e after foundation f063fbf2b, including bounded immutable snapshot copy, explicit candidate admission, exact completion instructions and paused-cost coverage. This probe executed the deployed generic installation registry and immutable snapshot. Every capability field was asserted from its actual initialize response. No prompt was sent.", + "executionTrees": { + "packages/paperclip-runner/src": "a8123cb4396c77ce0127d5ccf49d3584b63a9a58", + "packages/paperclip-runner/scripts": "9ede97769bb3f2bbfa8e02be220b5ce06e438a99", + "packages/paperclip-runner/runner": "2cc84e150aecbd5faf2c8bcecc4b6352c24dbca8", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "1e2f846b8b2dda202b79ee83baeb0860a3b9ab64", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + } + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 2, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "closureDigest": "sha256:b30047dce9c4c25e1fbc88a1aa828690a6a11ebdc3f6fcc083875986eb2d9585", + "assetTreeDigest": "sha256:5c046265335d4a6924efcd207f837d5aa0e0fc3baa0bf6ea7059afe8d6ab3939", + "distributionManifestDigest": "sha256:406345b3b75d8aee399da886fbdf8566aaaa44ff29fe3462da21f0c29901aecd", + "wrapperDigest": "sha256:2eb201c3a931177490a8e18f4a8392f00cbcf21847e9cc171de528da5e253935", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:0a2ca138d6d87a85498bd982ee64543ea8b14f2ecceded96cfc27b0836cc4254" + }, + "providerPack": { + "manifestDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:f51286992633bc54b6b169c255d3620146dfa975b24f22aae69b73994a234aa9" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Read and asserted in full from this pack launch.", + "rpcMethods": [ + "initialize", + "session/new" + ] + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ] +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.json new file mode 100644 index 0000000000..9ed5737d32 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.darwin-arm64.json @@ -0,0 +1,134 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T21:14:07.293Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "distDigest": "sha256:8e338aeae5f127fce97e55cc17e315a995ae0c9dc04592c3632335caa3b18ee7", + "bridgeDigest": "sha256:7d0e7ea882992dd3b8b713ff370de95178d70cc96c7e8a67a254b54cef6c8dd3", + "productionLockDigest": "sha256:2c46a68811ba1d504a41b6f4d3f642bc93f4a1c615097754c9c6486881dba8e6", + "note": "Built with the Pi version 3 authenticated MCP error and standard Bash output fixes, shared version 3 profile admission, and bounded immutable snapshot copying. This probe executed the deployed generic installation registry and immutable snapshot. Every capability field below was read and asserted from this actual initialize response. No prompt was sent.", + "executionTrees": { + "packages/paperclip-runner/src": "7df6b47574e6838e63a7d13c403c615a749702db", + "packages/paperclip-runner/scripts": "f845a2dd5b1ac2f1a4ef8d95db8fba2daea9999f", + "packages/paperclip-runner/runner": "9c595f7eae18197bb5cfd61c1af463df0a8741cd", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "784152935b091eed6ea1795d39c23cfbbd154ab7", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + }, + "runtimeCodeRevision": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "runtimeTreeEquality": "7710736ca adds only the capability report and Pi evidence JSON after06cf356a8; all listed execution trees match." + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 3, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "closureDigest": "sha256:a523fa338cef7db88a42b81e2dc8a5cc41b78775aed1be0a9439f6622ea8a710", + "assetTreeDigest": "sha256:af5213260863e4618a7137d8b647a19ae2e1916a40993ada6ee6ade659ae991b", + "distributionManifestDigest": "sha256:a31cd4912a2dccbf8d2b66794bfeb7f27aeb68ee3d2456359a9d326afacd2378", + "wrapperDigest": "sha256:df6b6270d95154aef058e3dacb766d0f0ca0d26fbfaccc4c804cdb088fd89e1e", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:55d898be2c0a5ba7ff6abdcd33b83dd4af8e2a9c0561fd0f0460ee78925f4b87" + }, + "providerPack": { + "manifestDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:37e3d1234c5f81b4257fdf3a1074c97fff42625e84b10a6418721fec4d3670e0" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Read and asserted in full from this pack launch.", + "rpcMethods": [ + "initialize", + "session/new" + ], + "fullRunnerSessionOpen": { + "status": "typed_rejection_without_prompt", + "classification": "session_ensure_failed", + "durationIncludingCloseMs": 12111, + "configuredSessionOpenDeadlineMs": 30000, + "authenticatedSuccess": false + } + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ], + "runnerd": { + "sourceRevision": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "rustTree": "9c595f7eae18197bb5cfd61c1af463df0a8741cd", + "sha256": "2c8efdc99f988b1c8ab0e270677a3321af0fd6d5789dbf1b671faa921031b42f", + "profileVersion": 3, + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472" + }, + "verification": { + "typescriptBuild": "passed", + "releaseRunnerBuild": "passed", + "focusedVitest": { + "files": 7, + "passed": 95, + "skipped": 0, + "actualDistributionInstallationIncluded": true + }, + "installedPackageChecksPassed": 25, + "nativeProviderRustTestsPassed": 16, + "cleanLock": { + "sha256": "2c46a68811ba1d504a41b6f4d3f642bc93f4a1c615097754c9c6486881dba8e6", + "independentResolutions": 2, + "arguments": [ + "--resolution-only", + "--ignore-scripts", + "--no-frozen-lockfile" + ], + "trackedLockChanged": false + }, + "failedBeforeRepair": { + "mcpValidationErrorCases": 1, + "bashStructuredOutputCases": 3 + } + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.f58cfa1cb.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.f58cfa1cb.darwin-arm64.json new file mode 100644 index 0000000000..b270885838 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.f58cfa1cb.darwin-arm64.json @@ -0,0 +1,107 @@ +{ + "schema": "paperclip.pi-provider-pack-offline-proof.v1", + "recordedAt": "2026-09-28T17:33:47.419Z", + "classification": "offline_candidate_admission", + "source": { + "runnerRevision": "f58cfa1cbf4503b93a0be4480f51b492d8203b7c", + "distDigest": "sha256:c7f250e0fbe0b6a6357bacb2d1487675d74a32f96b42922d7739ef86ddb9b6e0", + "bridgeDigest": "sha256:431b2400f13a20249b2d4383188e496f071f7d009a1835873561711f567f55b3", + "productionLockDigest": "sha256:b89a0f0a647b77f61b231bcdd95e1fc26eaf9e9b8010d53a16428fae49b96f26", + "note": "Built after the final foundation rebase and Pi retry-outcome repair. This probe executed the deployed generic installation registry and immutable snapshot. Every capability field below was read and asserted from this actual initialize response. No prompt was sent." + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "nodeVersion": "24.19.0", + "execution": "local macOS ARM64; immutable guarded provider snapshot", + "packageManager": "pnpm@9.15.4" + }, + "candidate": { + "agent": "pi", + "qualification": "pending", + "agentProfileVersion": 2, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "reportedModelId": null, + "modelVerification": "not_performed_authentication_missing", + "wrapperVersion": "0.0.33", + "runtimeVersion": "0.84.2", + "profileDigest": "sha256:5ed73f923b24bf203cf39d6c5dedd5dfd21e94efcb2c2580b4adf53f1b91fd2f", + "closureDigest": "sha256:c79fa33fd134e860fd777cd467873438b17b80b0b462a89e95cc63fbaa46bada", + "assetTreeDigest": "sha256:ceb81538aff1d04bff44505aa1e08ace869039c8055ed82667fab64e21f05ab1", + "distributionManifestDigest": "sha256:cc56a2bd75d56de01982a731e7d43b0b5e96fd3f7e06cdbbd1bc858bf2ffcd05", + "wrapperDigest": "sha256:2eb201c3a931177490a8e18f4a8392f00cbcf21847e9cc171de528da5e253935", + "helperDigest": "sha256:4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", + "extensionDigest": "sha256:87f5348be5da3fb505baea1208de7b62e88ec714adcf0ac0a16a51952f57e2da" + }, + "providerPack": { + "manifestDigest": "sha256:3de76fb7d3902d29285e3da51e36d2ba4654c29bab0c867a6209fadea77c2181", + "portableNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "piNodeDigest": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1", + "acpxSidecarDigest": "sha256:ee85322a4947f21fe079cc22ed1c7282a38fb84016ae28b56cde4f58b9001d5e" + }, + "observed": { + "closedSnapshotLaunch": true, + "acpInitialize": "passed", + "authMethods": [], + "capabilities": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + }, + "sessionNew": "rejected_authentication_missing", + "expectedError": "Bind the configured OpenRouter API credential to this Paperclip runtime.", + "missingCredentialRejected": true, + "capabilityProvenance": "Read and asserted in full from this pack launch.", + "rpcMethods": [ + "initialize", + "session/new" + ] + }, + "usage": { + "providerCalls": 0, + "promptRequests": 0, + "paidProviderCalls": 0, + "credentials": "none", + "spentUsd": 0 + }, + "limitations": [ + "No provider authentication, model prompt, or reported currentModelId was verified.", + "Native tool execution, permissions, questions, and semantic MCP calls were not exercised by this pack-launch probe.", + "Linux x64, macOS x64, and Daytona execution were not exercised.", + "Production qualification remains pending." + ], + "verification": { + "sourceRevision": "f58cfa1cbf4503b93a0be4480f51b492d8203b7c", + "typescriptBuild": "passed", + "providerPackBuild": "passed", + "vitest": { + "files": 4, + "passed": 30, + "failed": 0, + "actualDistributionInstallation": "enabled; same pinned runtime closure" + }, + "nodeTests": { + "files": 3, + "passed": 17, + "failed": 0, + "skipped": 0, + "actualPackages": "fresh provider-pack assets" + }, + "notRun": [ + "full repository typecheck, build, and tests for this provider branch", + "credentialed model evaluation", + "Linux x64 or macOS x64 execution", + "Daytona deployment" + ] + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.v4.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.v4.darwin-arm64.json new file mode 100644 index 0000000000..81a8302c9c --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.v4.darwin-arm64.json @@ -0,0 +1,170 @@ +{ + "schema": "paperclip.pi-v4-offline-qualification/v1", + "sourceRevision": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "profileVersion": 4, + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "qualification": "pending", + "paidCalls": 0, + "credentials": false, + "sourceTrees": { + "packages/paperclip-runner/src": "51b705e493c584b5d0e952afd06011d2af023319", + "packages/paperclip-runner/scripts": "1f2c76baccdd0dfec0a592b711524533af33a323", + "packages/paperclip-runner/runner": "ebbff2a80ed8bce24df98de19cb9480c098e778e", + "patches": "5d9ae2a6546e13f780269e4fdbf03efa99fbafe4", + "server/src": "1a4c4b7000becae6021d74e7dc0f2f36180c767c", + "tests/runner-e2e": "6d15ec5668fda2eed3bd308e11e0fda0cf48ca19" + }, + "verification": { + "recursiveTypecheck": "passed", + "fullRepositoryBuild": "passed", + "targetedTypeScriptTestsPassed": 49, + "optionalInstallationSuitePassed": 5, + "packageAndMaterializerTestsPassed": 24, + "nativeBackendRustTestsPassed": 13, + "independentCodeReview": "no remaining blocking finding", + "cleanDependencyResolutions": 2, + "resolvedLockSha256": "9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "trackedLockUnchanged": true + }, + "pack": { + "sourceRevision": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "packDigest": "sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855", + "manifestFileSha256": "e622e3f9aff2aa5e79888d38945de27ea71d112cb6eeefff6b2dd1f846ffc8a3", + "target": { + "platform": "darwin", + "architecture": "arm64" + }, + "productionLock": "sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "distDigest": "sha256:2819c0a8d81732e1314749b6c82c5a50bc7a5a5996452935c9dc72e38717ad20", + "verifiedProviders": { + "cursor": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "closureDigest": "sha256:77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:3b6de19114d4df62f648604025a0d71937dfa7c2b15b397cce5da2c5da268994", + "verifiedFileCount": 446 + }, + "copilot": { + "version": "1.0.88", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "closureDigest": "sha256:fb3b367a45cd76122fe931521fa2a18adf234ba944fc302db9e10e005e57037e", + "qualification": "pending", + "path": "provider-assets/copilot/darwin-arm64", + "sha256": "sha256:49ad871973843e2bfc3ee1d1886f6a1537e887748cc6f3d5148b3ec7f3c7298f", + "verifiedFileCount": 1 + }, + "pi": { + "version": "0.84.2", + "profileDigest": "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", + "closureDigest": "sha256:b0e02ed16d27fd1d90a5e91d6309f1c47d6cf55f8937a8f680baeee19646d320", + "qualification": "pending", + "path": "provider-assets/pi/darwin-arm64", + "sha256": "sha256:5a617823d6bfbaf5e91e1009c9eabcd6c2eeadd622b096d3fd09d366269095e4", + "verifiedFileCount": 13827 + } + }, + "providerInferenceCalls": 0, + "providerProcessesStarted": 0 + }, + "nativeLaunch": { + "schema": "paperclip.combined-provider-offline-smoke/v1", + "provider": "pi", + "target": { + "platform": "darwin", + "architecture": "arm64" + }, + "sourceRevision": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "providerPackDigest": "sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855", + "candidate": { + "version": "0.84.2", + "profileDigest": "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", + "closureDigest": "sha256:b0e02ed16d27fd1d90a5e91d6309f1c47d6cf55f8937a8f680baeee19646d320", + "qualification": "pending", + "path": "provider-assets/pi/darwin-arm64", + "sha256": "sha256:5a617823d6bfbaf5e91e1009c9eabcd6c2eeadd622b096d3fd09d366269095e4" + }, + "commandDigest": "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", + "registryLaunch": "verifyAcpxProfileInstallation/openCommand/spawn", + "initializeRequestId": 0, + "protocolVersion": 1, + "nativeInfo": { + "name": "pi-acp", + "title": "pi ACP adapter", + "version": "0.0.33" + }, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": false + }, + "_meta": { + "paperclipPi": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + } + }, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": false + }, + "sessionCapabilities": { + "list": {} + } + }, + "exitCode": 0, + "eofSettled": true, + "prompts": 0, + "inferenceCalls": 0, + "network": "macOS sandbox-exec deny network*", + "credentials": false, + "explicitModelOnlyForProfileAdmission": "openrouter/deepseek/deepseek-v4-flash-0731", + "modelSelected": false, + "providerGroupCleanup": true, + "privateStateRemoved": true + }, + "runnerBuild": { + "source": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "rustTree": "ebbff2a80ed8bce24df98de19cb9480c098e778e", + "binary": "/tmp/paperclip-rich-acp-paid-20260928/runnerd-pi-f556110d5-373848d2d7287b2c", + "sha256": "373848d2d7287b2c47b2cee422cb7bd25073a594a620a9ad574f2d3d51cd1670", + "profileVersion": 4, + "profileDigest": "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "mode": "0555", + "build": "pnpm build (release runnerd)" + }, + "runnerStartup": { + "schema": "paperclip.pi-session-open-diagnostic.v1", + "credentials": "none", + "promptCalls": 0, + "providerCalls": 0, + "durationMs": 11083, + "result": { + "status": "rejected_without_prompt", + "error": "Error: PRP command session.open failed: {\"commandId\":\"command_prp_00000002\",\"commandType\":\"session.open\",\"controllerSeq\":2,\"result\":{\"code\":\"command_execution_failed\",\"message\":\"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=session_ensure_failed)\"},\"status\":\"failed\"}" + }, + "interpretation": "Timely typed missing-credential rejection, not authenticated session success." + }, + "limits": [ + "macOS x64 runtime execution remains pending", + "Linux v4 image and Daytona qualification pending", + "No authenticated v4 prompt has been submitted", + "Native ID provenance is retained only in private ACP wire, not current canonical/UI events", + "Historical v3 paid proofs remain historical" + ] +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.v5.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.v5.darwin-arm64.json new file mode 100644 index 0000000000..09aeba4426 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/offline-provider-pack-proof.v5.darwin-arm64.json @@ -0,0 +1,283 @@ +{ + "schema": "paperclip.pi-v5-offline-qualification/v1", + "sourceRevision": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "profileVersion": 5, + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "qualification": "pending", + "paidCalls": 0, + "credentials": false, + "sourceTrees": { + "packages/paperclip-runner/src": "08da7f645ec75be82e525da2d0cae46b329b734f", + "packages/paperclip-runner/scripts": "5413d7ec0e8306efa29c43c063c642e7984c13ce", + "packages/paperclip-runner/runner": "b9b519d1766df8af629a2eacb91d51ee6a1f92c2", + "patches": "5d9ae2a6546e13f780269e4fdbf03efa99fbafe4", + "server/src": "1a4c4b7000becae6021d74e7dc0f2f36180c767c", + "tests/runner-e2e": "6d15ec5668fda2eed3bd308e11e0fda0cf48ca19" + }, + "verification": { + "recursiveTypecheck": "passed", + "fullRepositoryBuild": "passed", + "typescriptIncludingActualInstallation": 43, + "installedWrapperAndNativeSdk": 19, + "materializer": 8, + "nativeBackendRustTestsPassed": 13, + "independentCodeReview": "no blocking findings", + "cleanDependencyResolutions": 2, + "resolvedLockSha256": "9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "trackedLockUnchanged": true + }, + "pack": { + "sourceRevision": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "packDigest": "sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500", + "manifestFileSha256": "6fe0fae6057a1d4503e0b0901bce330b403b64f7610a27695ff289f0f2181c58", + "target": { + "platform": "darwin", + "architecture": "arm64" + }, + "productionLock": "sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "distDigest": "sha256:2fc9b100eb4b3a8dde6d51cdcf3741815dde783579fe35c7d04e59833440ffff", + "verifiedProviders": { + "cursor": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "closureDigest": "sha256:77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:3b6de19114d4df62f648604025a0d71937dfa7c2b15b397cce5da2c5da268994", + "verifiedFileCount": 446 + }, + "copilot": { + "version": "1.0.88", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "closureDigest": "sha256:fb3b367a45cd76122fe931521fa2a18adf234ba944fc302db9e10e005e57037e", + "qualification": "pending", + "path": "provider-assets/copilot/darwin-arm64", + "sha256": "sha256:49ad871973843e2bfc3ee1d1886f6a1537e887748cc6f3d5148b3ec7f3c7298f", + "verifiedFileCount": 1 + }, + "pi": { + "version": "0.84.2", + "profileDigest": "sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "closureDigest": "sha256:0010175e4bc200f145386e59f7a824cf7532ad273e9db6824d22c4f82cdcd6ff", + "qualification": "pending", + "path": "provider-assets/pi/darwin-arm64", + "sha256": "sha256:a679a009c1fe370fed020acad1c642848a278278306885edd9c47de897cfe566", + "verifiedFileCount": 13827 + } + }, + "providerInferenceCalls": 0, + "providerProcessesStarted": 0 + }, + "outerNode": { + "version": "24.21.0", + "bundledUndici": "7.29.1", + "sha256": "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b", + "relocatedPackInterpreterExecuted": true + }, + "nativeLaunch": { + "schema": "paperclip.combined-provider-offline-smoke/v1", + "provider": "pi", + "target": { + "platform": "darwin", + "architecture": "arm64" + }, + "sourceRevision": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "providerPackDigest": "sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500", + "candidate": { + "version": "0.84.2", + "profileDigest": "sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "closureDigest": "sha256:0010175e4bc200f145386e59f7a824cf7532ad273e9db6824d22c4f82cdcd6ff", + "qualification": "pending", + "path": "provider-assets/pi/darwin-arm64", + "sha256": "sha256:a679a009c1fe370fed020acad1c642848a278278306885edd9c47de897cfe566" + }, + "commandDigest": "sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "registryLaunch": "verifyAcpxProfileInstallation/openCommand/spawn", + "initializeRequestId": 0, + "protocolVersion": 1, + "nativeInfo": { + "name": "pi-acp", + "title": "pi ACP adapter", + "version": "0.0.33" + }, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": false + }, + "_meta": { + "paperclipPi": { + "version": 1, + "steering": true, + "queuedFollowUp": true, + "questions": [ + "select", + "confirm", + "input", + "editor" + ], + "nativePermissions": true, + "promptUsage": true, + "nativePlan": false, + "pendingRequestRecovery": "live-process-only" + } + }, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": false + }, + "sessionCapabilities": { + "list": {} + } + }, + "exitCode": 0, + "eofSettled": true, + "prompts": 0, + "inferenceCalls": 0, + "network": "macOS sandbox-exec deny network*", + "credentials": false, + "explicitModelOnlyForProfileAdmission": "openrouter/deepseek/deepseek-v4-flash-0731", + "modelSelected": false, + "providerGroupCleanup": true, + "privateStateRemoved": true + }, + "runnerBinary": { + "source": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "rustTree": "b9b519d1766df8af629a2eacb91d51ee6a1f92c2", + "sha256": "fe03a5f5e7b7d51aafb398aa435e4a200ca6e0206c46b84ba0baa15aa0be5f31", + "profileVersion": 5, + "profileDigest": "sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "mode": "0555", + "build": "pnpm build (release runnerd)" + }, + "fullRunnerStartup": { + "credentials": false, + "promptCalls": 0, + "durationMs": 8558, + "result": "typed_rejection", + "code": "command_execution_failed", + "classification": "session_ensure_failed", + "unchangedDeadlineMs": 30000, + "interpretation": "Credential-free admission rejection; not authenticated session success." + }, + "securityProof": "security-closure-proof.v5.json", + "limits": [ + "macOS ARM64 credential-free launch only; no authenticated v5 qualification in this artifact.", + "x64 Node archives and closure identities are pinned; target execution is not demonstrated by this artifact.", + "Historical v1-v4 evidence remains bound to its own source and bytes." + ], + "independentCombinedRegistryStartup": { + "schema": "paperclip.combined-provider-probe-index/v2", + "sourceRevision": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "providerPackDigest": "sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500", + "platform": { + "platform": "darwin", + "architecture": "arm64" + }, + "outerInterpreter": { + "version": "24.21.0", + "bundledUndici": "7.29.1", + "sha256": "sha256:e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b" + }, + "proofs": { + "cursor": { + "sha256": "39d5e570970f476dd861f26d616383a5fb8751e90644e8f902f316d530df4b8b", + "registryLaunch": "verifyAcpxProfileInstallation/openCommand/spawn", + "candidate": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "closureDigest": "sha256:77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:3b6de19114d4df62f648604025a0d71937dfa7c2b15b397cce5da2c5da268994" + }, + "initialized": true, + "protocolVersion": 1, + "nativeInfo": null, + "eofExit": { + "exitCode": null, + "cleanExit": false + }, + "explicitCleanupRequired": true, + "leaseCleanupPassed": true, + "privateStateRemoved": true, + "supervisorExitCode": 0, + "networkPolicy": "deny network", + "fixtureRequests": [], + "durationSeconds": 7.233 + }, + "copilot": { + "sha256": "44cdc7243d93f7bf0a78cb955d15c145f7b0cbf482664f4a03ebd887d2d7ea5f", + "registryLaunch": "verifyAcpxProfileInstallation/openCommand/spawn", + "candidate": { + "version": "1.0.88", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "closureDigest": "sha256:fb3b367a45cd76122fe931521fa2a18adf234ba944fc302db9e10e005e57037e", + "qualification": "pending", + "path": "provider-assets/copilot/darwin-arm64", + "sha256": "sha256:49ad871973843e2bfc3ee1d1886f6a1537e887748cc6f3d5148b3ec7f3c7298f" + }, + "initialized": true, + "protocolVersion": 1, + "nativeInfo": { + "name": "Copilot", + "title": "Copilot", + "version": "1.0.88" + }, + "eofExit": { + "exitCode": 0, + "cleanExit": true + }, + "explicitCleanupRequired": false, + "leaseCleanupPassed": true, + "privateStateRemoved": true, + "supervisorExitCode": 0, + "networkPolicy": "deny network except loopback", + "fixtureRequests": [], + "durationSeconds": 2.872 + }, + "pi": { + "sha256": "1c4395d673fc4ba789cf90b570ae4f8e9431fe5676eae3fcad9c6347aa971d84", + "registryLaunch": "verifyAcpxProfileInstallation/openCommand/spawn", + "candidate": { + "version": "0.84.2", + "profileDigest": "sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "closureDigest": "sha256:0010175e4bc200f145386e59f7a824cf7532ad273e9db6824d22c4f82cdcd6ff", + "qualification": "pending", + "path": "provider-assets/pi/darwin-arm64", + "sha256": "sha256:a679a009c1fe370fed020acad1c642848a278278306885edd9c47de897cfe566" + }, + "initialized": true, + "protocolVersion": 1, + "nativeInfo": { + "name": "pi-acp", + "title": "pi ACP adapter", + "version": "0.0.33" + }, + "eofExit": { + "exitCode": 0, + "cleanExit": true + }, + "explicitCleanupRequired": false, + "leaseCleanupPassed": true, + "privateStateRemoved": true, + "supervisorExitCode": 0, + "networkPolicy": "deny network", + "fixtureRequests": [], + "durationSeconds": 11.046 + } + }, + "credentialsProvided": false, + "promptsSent": 0, + "providerInferenceCalls": 0, + "remainingProbeSupervisors": 0, + "qualification": "pending; credential-free initialization only; no model/paid/Daytona qualification", + "unchangedSiblingProviderIdentities": [ + "cursor", + "copilot" + ] + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/paid-qualification-progress.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/paid-qualification-progress.darwin-arm64.json new file mode 100644 index 0000000000..2cfffbbef7 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/paid-qualification-progress.darwin-arm64.json @@ -0,0 +1,842 @@ +{ + "schema": "paperclip.pi-paid-qualification-progress.v1", + "recordedAt": "2026-09-28T22:41:36.062345+00:00", + "qualificationStatus": "pending", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "local" + }, + "declaredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "observedModel": { + "attemptId": "pi-context-paid-04", + "id": "openrouter/deepseek/deepseek-v4-flash-0731", + "provider": "openrouter", + "source": "actual runtime snapshot providerModel" + }, + "budget": { + "authorizedMaximumUsd": 25, + "reservedPerBatchUsd": 2, + "measuredSpendUsd": 0.028858372, + "measurement": "exclusive OpenRouter key billing delta; model catalog estimates are not provider billing", + "billingGroups": [ + { + "id": "pi-denial-file-01", + "attemptIds": [ + "pi-native-denial-01", + "pi-product-file-01" + ], + "measuredDeltaUsd": 0.010995043, + "attribution": "Exclusive key aggregate; delayed charge crossed attempt baselines, so per-generation attribution is unavailable.", + "delayedDeltaBetweenAttemptWindowsUsd": 0.000105107, + "billingObservedThrough": "2026-09-28T20:55:09Z" + } + ], + "pendingBillingAttemptIds": [] + }, + "attempts": [ + { + "attemptId": "pi-context-paid-01", + "family": "runner-protocol", + "caseId": "get-task-context", + "startedAt": "2026-09-28T19:06:12.910826+00:00", + "finishedAt": "2026-09-28T19:06:20.148262+00:00", + "durationSeconds": 7.237, + "sourceRevision": "58511d79d9c4a1525d98e219d027654902b704e1", + "providerPackDigest": "sha256:a9728fe4298a5eee00555382af01e85550820a294957caf2eba0c79bf33c4388", + "runnerdDigest": "sha256:2138cc9dd7898d284f5761719112079666dd013e035d705aba73e965faf2fd86", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 0, + "retainedMachineDisposition": "infrastructure_failure", + "diagnosis": "CLI main guard did not recognize the /tmp symlink path; no artifact or provider prompt. Fixed by shared commit ac17430b0.", + "submittedProviderTurns": 0, + "providerRequestCount": 0, + "billingDeltaUsd": 0, + "billingCoverage": "exclusive OpenRouter key usage delta, two spaced post-attempt observations", + "terminalUsage": null, + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 120 + } + }, + { + "attemptId": "pi-context-paid-02", + "family": "runner-protocol", + "caseId": "get-task-context", + "startedAt": "2026-09-28T19:06:45.550028+00:00", + "finishedAt": "2026-09-28T19:07:03.655460+00:00", + "durationSeconds": 18.103, + "sourceRevision": "58511d79d9c4a1525d98e219d027654902b704e1", + "providerPackDigest": "sha256:a9728fe4298a5eee00555382af01e85550820a294957caf2eba0c79bf33c4388", + "runnerdDigest": "sha256:2138cc9dd7898d284f5761719112079666dd013e035d705aba73e965faf2fd86", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 2, + "retainedMachineDisposition": "infrastructure_failure", + "diagnosis": "Sidecar initialization accepted only Claude/Codex and rejected Pi before prompting. Fixed by shared commit ac17430b0.", + "submittedProviderTurns": 0, + "providerRequestCount": 0, + "billingDeltaUsd": 0, + "billingCoverage": "exclusive OpenRouter key usage delta, two spaced post-attempt observations", + "terminalUsage": null, + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 120 + } + }, + { + "attemptId": "pi-context-paid-03", + "family": "runner-protocol", + "caseId": "get-task-context", + "startedAt": "2026-09-28T19:19:10.856746+00:00", + "finishedAt": "2026-09-28T19:19:18.160151+00:00", + "durationSeconds": 7.279, + "sourceRevision": "788105248a3ba594b30b5bcec3fa266d8a51d8d4", + "providerPackDigest": "sha256:4de47c31a8131424495366741bd491ff5fa10723ab9a3918c07a3e42982dbe3c", + "runnerdDigest": "sha256:2138cc9dd7898d284f5761719112079666dd013e035d705aba73e965faf2fd86", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 1, + "retainedMachineDisposition": "infrastructure_failure", + "diagnosis": "Supervisor invoked Python3.9; canonical eval uses a newer TemporaryDirectory API. Switched supervisor to its Python3.14 executable before retry.", + "submittedProviderTurns": 0, + "providerRequestCount": 0, + "billingDeltaUsd": 0, + "billingCoverage": "exclusive OpenRouter key usage delta, two spaced post-attempt observations", + "terminalUsage": null, + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 420 + } + }, + { + "attemptId": "pi-context-paid-04", + "family": "runner-protocol", + "caseId": "get-task-context", + "startedAt": "2026-09-28T19:19:52.195195+00:00", + "finishedAt": "2026-09-28T19:22:45.531217+00:00", + "durationSeconds": 173.331, + "sourceRevision": "788105248a3ba594b30b5bcec3fa266d8a51d8d4", + "providerPackDigest": "sha256:4de47c31a8131424495366741bd491ff5fa10723ab9a3918c07a3e42982dbe3c", + "runnerdDigest": "sha256:2138cc9dd7898d284f5761719112079666dd013e035d705aba73e965faf2fd86", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 1, + "retainedMachineDisposition": "infrastructure_failure", + "diagnosis": "Canonical scorer retained timeout after120000ms without terminal turn or usage. Four semantic operations succeeded. The canonical reasoning delta was incorrectly projected as assistant text; shared commit a978d1bc5 repairs that distinct projection defect. Provider completion remains unproven.", + "submittedProviderTurns": 1, + "providerRequestCount": null, + "billingDeltaUsd": 0.005748807, + "billingCoverage": "exclusive OpenRouter key usage delta, two spaced post-attempt observations", + "terminalUsage": null, + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 420 + } + }, + { + "attemptId": "pi-product-hello-01", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "startedAt": "2026-09-28T19:33:28.605392+00:00", + "finishedAt": "2026-09-28T19:33:46.814523+00:00", + "durationSeconds": 18.208, + "sourceRevision": "68fde1eb5779838518e322ced202a3e454548d34", + "providerPackDigest": "sha256:6e257bfd54dcb6e9d6263cfd408b3087ccd2f6eb91941ec38bd9ddd4b26b5c0b", + "runnerdDigest": "sha256:79d49c3cef01f328742651f62a449661695d0955741a1c37bd89abf54cbc8c2b", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 1, + "retainedMachineDisposition": "transient_infrastructure", + "diagnosis": "Embedded PostgreSQL lifecycle symlink hydration was missing; initdb failed before browser/provider startup. Package hydration and a fresh disposable initdb check passed before deliberate repeat.", + "submittedProviderTurns": 0, + "providerRequestCount": 0, + "billingDeltaUsd": 0, + "billingCoverage": "exclusive OpenRouter key usage delta, two spaced post-attempt observations", + "terminalUsage": null, + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 300 + }, + "retainedStatus": "failed", + "cleanup": "not_started", + "fixtureReportedSource": { + "sha": null, + "ref": null, + "workflowRunUrl": null + } + }, + { + "attemptId": "pi-product-hello-02", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "startedAt": "2026-09-28T19:35:51.721674+00:00", + "finishedAt": "2026-09-28T19:36:32.051800+00:00", + "durationSeconds": 40.33, + "sourceRevision": "a6167ce3a7804c31f4f9990a9ba6761c1d91f27c", + "providerPackDigest": "sha256:0ed45bf84d172aa7087baec3d662275206e8121b0ba142aaa9039dcfbf4402a2", + "runnerdDigest": "sha256:79d49c3cef01f328742651f62a449661695d0955741a1c37bd89abf54cbc8c2b", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 1, + "retainedMachineDisposition": "permanent_infrastructure", + "diagnosis": "The real server rejected candidate agent creation422 before any provider prompt. Host-only qualification was wired at launch but not the earlier provider-profile resolver. Shared correction 26dde3cfe is included in the merged foundation.", + "submittedProviderTurns": 0, + "providerRequestCount": 0, + "billingDeltaUsd": 0, + "billingCoverage": "exclusive OpenRouter key usage delta, two spaced post-attempt observations", + "terminalUsage": null, + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 300 + }, + "retainedStatus": "failed", + "cleanup": "passed", + "fixtureReportedSource": { + "sha": null, + "ref": null, + "workflowRunUrl": null + } + }, + { + "attemptId": "pi-product-hello-03", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "startedAt": "2026-09-28T19:44:53.789569+00:00", + "finishedAt": "2026-09-28T19:45:32.427487+00:00", + "durationSeconds": 38.638, + "sourceRevision": "d6b0b97786a61e607291c656229bbb54a81dc061", + "providerPackDigest": "sha256:4ab928ff5fbc68b0b0e21733c4cdaebb3f9fb7197b1985d1094a21fd3d51b2fe", + "runnerdDigest": "sha256:79d49c3cef01f328742651f62a449661695d0955741a1c37bd89abf54cbc8c2b", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 1, + "retainedMachineDisposition": "candidate_failure", + "retainedStatus": "failed", + "cleanup": "passed", + "fixtureReportedSource": { + "sha": "d6b0b97786a61e607291c656229bbb54a81dc061", + "ref": "refs/heads/codex/runner-pi-acp", + "workflowRunUrl": null + }, + "diagnosis": "Agent creation succeeded after shared admission repair; the actual verified runnerd backend factory then hit a dormant Pi-only transportDriverIdentity rejection before provider launch. The shared identity correction is included in foundation c3b7e9ecd.", + "submittedProviderTurns": 0, + "providerRequestCount": 0, + "billingDeltaUsd": 0, + "billingCoverage": "exclusive OpenRouter key usage delta, two spaced post-attempt observations", + "terminalUsage": null, + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 300, + "configuredActiveTimeoutSeconds": 120 + } + }, + { + "attemptId": "pi-product-hello-04", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "startedAt": "2026-09-28T20:03:06.338199+00:00", + "finishedAt": "2026-09-28T20:04:32.924765+00:00", + "durationSeconds": 86.582, + "sourceRevision": "9f2d0420ed9a398e3bacd1d7e996614f3296ac56", + "providerPackDigest": "sha256:2a6f457205743487cdd35ed3634532028e6e0200001f70e087d0e084fc85d452", + "runnerdDigest": "sha256:3fdff11d17faf096fce29b57a8e762e6dd241ef5009771913eb4798d10a0deca", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 1, + "retainedMachineDisposition": "candidate_failure", + "retainedStatus": "failed", + "cleanup": "passed", + "fixtureReportedSource": { + "sha": "9f2d0420ed9a398e3bacd1d7e996614f3296ac56", + "ref": "refs/heads/codex/runner-pi-acp", + "workflowRunUrl": null + }, + "diagnosis": "The earlier candidate and transport identity gates are corrected. PRP run.prepare succeeds, but session.open times out before prompt submission at its unchanged 30-second deadline. A credential-free deployed-pack reproduction and separate stage timing isolate immutable snapshot copying as exceeding that deadline. Shared fix 2a30219f1 reduces measured copy latency; end-to-end admission after that fix is not yet proven.", + "submittedProviderTurns": 0, + "providerRequestCount": 0, + "billingDeltaUsd": 0, + "billingCoverage": "exclusive OpenRouter key usage delta, two spaced post-attempt observations", + "terminalUsage": null, + "retainedCostCoverage": "unavailable", + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 300, + "configuredActiveTimeoutSeconds": 120 + } + }, + { + "attemptId": "pi-product-hello-05", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "startedAt": "2026-09-28T20:19:17.958518+00:00", + "finishedAt": "2026-09-28T20:35:14.566352+00:00", + "durationSeconds": 72.168, + "durationClock": "monotonic; host sleep does not advance this clock on this machine", + "canonicalWallDurationMs": 921632, + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "runnerdDigest": "sha256:f9aad049d57a2073ce4ec96b6577266afdba5b99db75bb8fc21825a5b7281e39", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 1, + "retainedMachineDisposition": "secret_leak", + "retainedInnerDisposition": "candidate_failure", + "retainedStatus": "failed", + "cleanup": "failed", + "diagnosis": "Authenticated session and provider turn admission succeeded. The host then entered Maintenance Sleep for888s. After wake, the canonical issue/run wait timed out, browser/API cleanup failed, and the scanner rejected an uninspectable incomplete ZIP. The scanner reported no credential-value match; its fail-closed secret_leak classification is retained. No terminal or semantic-tool event was observed before capture ended.", + "submittedProviderTurns": 1, + "providerRequestCount": null, + "billingDeltaUsd": 0.000351509, + "billingCoverage": "exclusive OpenRouter key usage delta; two spaced post-attempt observations plus later unchanged reconciliation", + "terminalUsage": null, + "retainedCostCoverage": "unavailable", + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "outerDeadlineSeconds": 300, + "configuredActiveTimeoutSeconds": 120, + "limitation": "Host sleep suspended the monotonic supervisor and billing polling; no assertion prevented idle sleep for this attempt." + }, + "evidence": { + "authenticatedSessionStarted": true, + "sessionStartedAfterRunMs": 11631, + "turnSubmitted": true, + "turnAccepted": true, + "uniqueRetainedEvents": 32, + "semanticToolEvents": 0, + "terminalEvents": 0, + "reportedExactModelVerified": false, + "partialModelMetadata": "unknown/openrouter", + "pmsetSleepSeconds": 888, + "billingWallObservationGapSeconds": 887.495, + "zipScanReason": "zip could not be inspected: unzip exited with code9", + "credentialValueMatchDetected": false, + "exactAttemptProcessesRemaining": 0 + } + }, + { + "attemptId": "pi-product-hello-06", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "startedAt": "2026-09-28T20:38:49.368264+00:00", + "finishedAt": "2026-09-28T20:39:38.907308+00:00", + "durationSeconds": 49.539, + "wallDurationSeconds": 49.539, + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "runnerdDigest": "sha256:f9aad049d57a2073ce4ec96b6577266afdba5b99db75bb8fc21825a5b7281e39", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "exitCode": 0, + "retainedMachineDisposition": null, + "retainedStatus": "passed", + "cleanup": "passed", + "diagnosis": "All six canonical hello matchers pass. Authenticated Pi executes paperclip_finish over semantic MCP; the provider turn completes, task becomes done, and native run succeeds. Sleep assertion and dual-clock supervision avoid the prior environmental interruption. USD remains unpriced; independent measured billing delta is separate.", + "submittedProviderTurns": 1, + "providerRequestCount": null, + "billingDeltaUsd": 0.000654767, + "billingCoverage": "exclusive OpenRouter key delta, two spaced post-attempt observations", + "terminalUsage": { + "inputTokens": 26867, + "outputTokens": 283, + "cachedInputTokens": 0, + "usageSource": "per_run", + "costStatus": "unpriced", + "billingType": "unknown" + }, + "retainedCostCoverage": "unpriced", + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "outerClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0 + }, + "evidence": "product-hello-proof.darwin-arm64.json" + }, + { + "attemptId": "pi-native-denial-01", + "family": "native-acp", + "caseId": "restrictive-native-write-denial", + "startedAt": "2026-09-28T20:47:09.907468+00:00", + "finishedAt": "2026-09-28T20:47:30.345865+00:00", + "durationSeconds": 20.438, + "exitCode": 0, + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "submittedProviderTurns": 1, + "providerRequestCount": null, + "billingDeltaUsd": null, + "billingCoverage": "accounted in aggregate pi-denial-file-01; no per-generation attribution", + "billingGroupId": "pi-denial-file-01", + "automaticRetries": 0, + "retainedStatus": "passed", + "retainedMachineDisposition": null, + "cleanup": "passed", + "terminalUsage": { + "inputTokens": 1735, + "outputTokens": 125, + "cachedReadTokens": 1792, + "cachedWriteTokens": 0, + "totalTokens": 3652, + "_meta": { + "paperclipPi": { + "provenance": "assistant_message_receipts", + "costUsd": 0.00032807600000000006, + "costSource": "pi_pricing_estimate" + } + } + }, + "evidence": "native-denial-proof.darwin-arm64.json", + "diagnosis": "Original offered deny choice persisted before response; matching native write failed, prompt settled, and independent marker stat proved no write occurred." + }, + { + "attemptId": "pi-product-file-01", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.file-edit-validate", + "startedAt": "2026-09-28T20:49:00.303832+00:00", + "finishedAt": "2026-09-28T20:51:41.406899+00:00", + "durationSeconds": 161.103, + "exitCode": 1, + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "submittedProviderTurns": 1, + "providerRequestCount": null, + "billingDeltaUsd": null, + "billingCoverage": "accounted in aggregate pi-denial-file-01; no per-generation attribution", + "billingGroupId": "pi-denial-file-01", + "automaticRetries": 0, + "retainedStatus": "failed", + "retainedMachineDisposition": "candidate_failure", + "cleanup": "passed", + "terminalUsage": null, + "evidence": "product-file-failure.darwin-arm64.json", + "diagnosis": "Five semantic finish requests failed with generic bridge error; active deadline expired without file oracle or terminal receipt. Version 3 retains bounded validation details and native Bash output." + }, + { + "attemptId": "pi-product-file-02", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.file-edit-validate", + "startedAt": "2026-09-28T21:17:12.291736+00:00", + "finishedAt": "2026-09-28T21:18:19.632350+00:00", + "durationSeconds": 67.34, + "exitCode": 0, + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "submittedProviderTurns": null, + "providerRequestCount": null, + "billingDeltaUsd": 0.003324737, + "billingCoverage": "exclusive OpenRouter key delta, two spaced post-attempt observations", + "retainedStatus": "passed", + "retainedMachineDisposition": null, + "cleanup": "passed", + "terminalUsage": { + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "biller": "openrouter", + "provider": "openrouter", + "costStatus": "unpriced", + "billingType": "unknown", + "inputTokens": 14461, + "outputTokens": 3604, + "cachedInputTokens": 116736, + "usageSource": "per_run" + }, + "evidence": "product-file-proof.darwin-arm64.json", + "diagnosis": "File oracle passed; retained MCP validation enabled deliverable registration and successful finish.", + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "sleepAssertion": "caffeinate -i", + "deadlineClocks": [ + "wall", + "monotonic" + ], + "automaticRetries": 0 + } + }, + { + "attemptId": "pi-product-question-01", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.question-resume-complete", + "startedAt": "2026-09-28T21:20:00.032734+00:00", + "finishedAt": "2026-09-28T21:22:55.721708+00:00", + "durationSeconds": 175.689, + "exitCode": 0, + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "submittedProviderTurns": null, + "providerRequestCount": null, + "billingDeltaUsd": 0.001413697, + "billingCoverage": "exclusive OpenRouter key delta, two spaced post-attempt observations", + "retainedStatus": "passed", + "retainedMachineDisposition": null, + "cleanup": "passed", + "terminalUsage": { + "runCount": 2, + "byRun": [ + null, + { + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "biller": "openrouter", + "provider": "openrouter", + "costStatus": "unpriced", + "billingType": "unknown", + "inputTokens": 17894, + "outputTokens": 1891, + "cachedInputTokens": 19200, + "usageSource": "per_run", + "sessionReused": true + } + ], + "coverage": "partial: waiting run receipt unavailable; resumed run receipt retained" + }, + "evidence": "product-question-proof.darwin-arm64.json", + "diagnosis": "Typed semantic question answered with original question and option identity; warm continuation finished. Waiting-run usage remains missing.", + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "sleepAssertion": "caffeinate -i", + "deadlineClocks": [ + "wall", + "monotonic" + ], + "automaticRetries": 0 + } + }, + { + "attemptId": "pi-product-plan-01", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.plan-approve-complete", + "startedAt": "2026-09-28T21:23:40.821883+00:00", + "finishedAt": "2026-09-28T21:25:59.888902+00:00", + "durationSeconds": 139.067, + "exitCode": 1, + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "submittedProviderTurns": null, + "providerRequestCount": null, + "billingDeltaUsd": 0.003090504, + "billingCoverage": "exclusive OpenRouter key delta, two spaced post-attempt observations", + "retainedStatus": "failed", + "retainedMachineDisposition": "candidate_failure", + "cleanup": "passed", + "terminalUsage": null, + "evidence": "product-plan-failure.darwin-arm64.json", + "diagnosis": "Plan and exact revision confirmation persisted, but Rust MCP identity loss placed its UI card in fallback; unchanged meaningful oracle failed.", + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "sleepAssertion": "caffeinate -i", + "deadlineClocks": [ + "wall", + "monotonic" + ], + "automaticRetries": 0 + } + }, + { + "attemptId": "pi-native-controls-01", + "family": "native-acp", + "caseId": "active-steer-queued-follow-up-and-denied-write", + "startedAt": "2026-09-28T21:32:45.580333+00:00", + "finishedAt": "2026-09-28T21:33:06.922601+00:00", + "durationSeconds": 21.342, + "exitCode": 0, + "automaticRetries": 0, + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "submittedProviderTurns": 1, + "providerRequestCount": null, + "billingDeltaUsd": 0.000140985, + "billingCoverage": "exclusive OpenRouter key delta; delayed charge observed after unchanged initial readings", + "retainedStatus": "passed", + "retainedMachineDisposition": null, + "cleanup": "passed", + "terminalUsage": { + "inputTokens": 1941, + "outputTokens": 134, + "cachedReadTokens": 3584, + "cachedWriteTokens": 0, + "totalTokens": 5659, + "_meta": { + "paperclipPi": { + "provenance": "assistant_message_receipts", + "costUsd": 0.00040961200000000006, + "costSource": "pi_pricing_estimate" + } + } + }, + "evidence": "native-controls-proof.darwin-arm64.json", + "diagnosis": "Active steer and native queued follow-up were acknowledged and consumed in order; original native write denied, no marker, terminal settlement, stale steer rejected.", + "supervision": { + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "source": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "packDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeDeadlineSeconds": 120, + "outerDeadlineSeconds": 180, + "deadlineClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0, + "providerPromptMaximum": 1 + }, + "earlyObservedKeyDeltaUsd": 0 + }, + { + "attemptId": "pi-product-plan-02", + "family": "product-e2e", + "caseId": "extended-harnesses.runner-acpx-pi.local.plan-approve-complete", + "startedAt": "2026-09-28T21:39:55.842556+00:00", + "finishedAt": "2026-09-28T21:44:44.425729+00:00", + "durationSeconds": 288.581, + "sourceRevision": "e35b11db21b8da8527fa0b6c6f5fe186bdced111", + "packSourceRevision": "7ab463697037c9456a4ad2b83ea0e9c28b353f8a", + "providerPackDigest": "sha256:0800f10114e3d8e2e981ea27f0ab47f1da9349dbcc22bf55a76ebbc4af00601d", + "runnerdDigest": "sha256:955d0714472a0a8727aaac55c357425b4157cba54f37483ded58217e8859a26d", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "exitCode": 1, + "retainedStatus": "failed", + "retainedMachineDisposition": "candidate_failure", + "cleanup": "passed", + "submittedProviderTurns": 2, + "providerRequestCount": null, + "billingDeltaUsd": 0.002440082, + "billingCoverage": "exclusive OpenRouter key delta; two spaced post-attempt observations", + "terminalUsage": null, + "automaticRetries": 0, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "deadlineClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0 + }, + "diagnosis": "All three distinct resumed semantic executions reuse native call_0. Owned extension forwards it as MCP JSON-RPC id, causing shared exact-call dedupe to reject later corrected/different requests.", + "evidence": "product-plan-resume-failure.darwin-arm64.json" + }, + { + "attemptId": "pi-product-plan-03", + "family": "Product E2E", + "caseId": "extended-harnesses.runner-acpx-pi.local.plan-approve-complete", + "sourceRevision": "92bcd6f07ae9a3905333dbaa2542b1401945f5fa", + "runtimeSourceRevision": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "profileVersion": 4, + "profileDigest": "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", + "providerPackDigest": "sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "status": "failed", + "failureClass": "transient_infrastructure", + "failurePhase": "embedded PostgreSQL fixture bootstrap", + "providerPromptCalls": 0, + "billingDeltaUsd": 0, + "terminalUsage": null, + "automaticRetries": 0, + "evidence": "product-plan-infrastructure-failure.v4.darwin-arm64.json" + }, + { + "attemptId": "pi-native-controls-02", + "family": "native-acp", + "caseId": "active-steer-queued-follow-up-and-denied-write", + "profileVersion": 4, + "source": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "packDigest": "sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855", + "profileDigest": "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "status": "passed", + "modelPromptRequests": 1, + "durationSeconds": 20.255, + "billingDeltaUsd": 0.000546502, + "earlyObservedKeyDeltaUsd": 0, + "billingCoverage": "exclusive key delta settled on later observation", + "terminalUsage": { + "inputTokens": 2030, + "outputTokens": 1293, + "cachedReadTokens": 5632, + "cachedWriteTokens": 0, + "totalTokens": 8955, + "_meta": { + "paperclipPi": { + "provenance": "assistant_message_receipts", + "costUsd": 0.000803936, + "costSource": "pi_pricing_estimate" + } + } + }, + "automaticRetries": 0, + "evidence": "native-controls-proof.v4.darwin-arm64.json" + }, + { + "attemptId": "pi-native-controls-03", + "family": "native-acp", + "caseId": "active-steer-queued-follow-up-and-denied-write", + "profileVersion": 5, + "source": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "packDigest": "sha256:cf7d0998bbc2bed7b893c726c2b6455ba8a683d9cff7d92afedbff2d5900d500", + "profileDigest": "sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "status": "passed", + "modelPromptRequests": 1, + "durationSeconds": 19.199, + "billingDeltaUsd": 0.000151739, + "earlyObservedKeyDeltaUsd": 0, + "billingCoverage": "exclusive key delta settled on later observation", + "terminalUsage": { + "inputTokens": 3727, + "outputTokens": 140, + "cachedReadTokens": 1792, + "cachedWriteTokens": 0, + "totalTokens": 5659, + "_meta": { + "paperclipPi": { + "provenance": "assistant_message_receipts", + "costUsd": 0.0006111560000000002, + "costSource": "pi_pricing_estimate" + } + } + }, + "automaticRetries": 0, + "evidence": "native-controls-proof.v5.darwin-arm64.json" + } + ], + "observedSemanticOperations": { + "attemptId": "pi-context-paid-04", + "succeeded": [ + "get_task_context", + "get_task_history", + "list_documents", + "read_document" + ], + "terminalTurnObserved": false, + "terminalUsageAvailable": false, + "realPaperclipRequests": 0 + }, + "verification": { + "sharedReasoningProjection": { + "commit": "a978d1bc5", + "regressionsFailedBefore": 3, + "focusedTestsPassedAfter": 20, + "additionalCodexEventTestsPassed": 8, + "sourceTypecheck": "passed" + }, + "productPrerequisites": { + "source": "68fde1eb5779838518e322ced202a3e454548d34", + "typecheck": "passed after building missing workspace plugin-sdk artifact", + "unitTestsPassed": 585 + }, + "offlineProof": "offline-provider-pack-proof.darwin-arm64.json", + "liveCompletionContract": { + "schemaAndHandlerCommit": "dc58afb28", + "modelInstructionsCommit": "aa4b88b16", + "beforeFixFailures": 3, + "fullLiveSessionTestsPassed": 45, + "instructionFollowupFocusedTestsPassed": 3, + "sourceTypecheck": "passed", + "boundary": "Advisory native report does not mutate mock state or settle provider turn; stale revision, wrong turn, conflicting report and unauthorized create_task rejected" + }, + "currentPiSource": { + "source": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "typescriptBuild": "passed", + "releaseRunnerBuild": "passed", + "vitestPassed": 107, + "vitestSkipped": 1, + "installedPackageChecksPassed": 22, + "deployedPackCredentialFreeProbe": "passed", + "fullCredentialFreeRunnerStartup": "terminal rejection within30s deadline; not authenticated success", + "cleanDependencyResolution": "two independent runs from tracked manifest/config/patch inputs with pnpm9.15.4 --resolution-only --ignore-scripts --no-frozen-lockfile", + "cleanDependencyLockSha256": "2c46a68811ba1d504a41b6f4d3f642bc93f4a1c615097754c9c6486881dba8e6", + "scope": "historical version 2 verification, before version 3 bridge/output changes" + }, + "startupDiagnosis": "startup-diagnostic.darwin-arm64.json", + "authenticatedModelNoPrompt": { + "evidence": "model-admission-proof.darwin-arm64.json", + "actualSelectedCurrentModelId": "openrouter/deepseek/deepseek-v4-flash-0731", + "promptRequests": 0, + "billingDeltaUsd": 0 + }, + "version3": { + "source": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "packSource": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "typescriptBuild": "passed", + "releaseRunnerBuild": "passed", + "vitestPassed": 95, + "installedPackageChecksPassed": 25, + "nativeRustTestsPassed": 16, + "offlineProof": "offline-provider-pack-proof.darwin-arm64.json", + "linuxInitializeProof": "offline-native-proof.linux-x64.v3.json" + }, + "version4": { + "source": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "offlineProof": "offline-provider-pack-proof.v4.darwin-arm64.json", + "recursiveTypecheck": "passed", + "fullBuild": "passed", + "focusedTypeScriptTestsPassed": 49, + "optionalInstallationSuitePassed": 5, + "installedPackageAndMaterializerTestsPassed": 24, + "nativeRustTestsPassed": 13, + "authenticatedQualification": "Product blocked before provider startup by local database fixture infrastructure; separate native controls probe passed", + "nativeControlsProof": "native-controls-proof.v4.darwin-arm64.json" + }, + "version5": { + "source": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "offlineProof": "offline-provider-pack-proof.v5.darwin-arm64.json", + "securityClosureProof": "security-closure-proof.v5.json", + "nativeControlsProof": "native-controls-proof.v5.darwin-arm64.json", + "recursiveTypecheck": "passed", + "fullBuild": "passed", + "focusedTypeScriptTestsPassed": 43, + "installedWrapperAndNativeSdkTestsPassed": 19, + "materializerTestsPassed": 8, + "nativeRustTestsPassed": 13, + "qualification": "pending; one direct ACP controls probe passed, complete Product/Daytona matrix remains pending" + } + }, + "publicationBoundary": { + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false, + "credentialsIncluded": false + }, + "remaining": [ + "Successful canonical Runner protocol completion", + "Current version5 local Product hello, file, question, semantic plan and pending-input restart journeys", + "Durable native pending-request reconnect/recovery beyond direct ACP controls", + "Complete token and price/billing coverage, separate from catalog estimates", + "Linux x64 Daytona authenticated qualification" + ] +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/product-file-failure.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/product-file-failure.darwin-arm64.json new file mode 100644 index 0000000000..962f55425c --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/product-file-failure.darwin-arm64.json @@ -0,0 +1,90 @@ +{ + "schema": "paperclip.pi-product-file-failure.v1", + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "repositoryRevision": "06e3bae33be97d014f4d9add8d3a46a995568050", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "local" + }, + "attemptId": "pi-product-file-01", + "caseId": "extended-harnesses.runner-acpx-pi.local.file-edit-validate", + "result": { + "status": "failed", + "failureClass": "candidate_failure", + "errorCode": "native_session_interrupted", + "activeDeadlineMs": 120000, + "canonicalDurationMs": 138060, + "supervisorDurationSeconds": 161.103, + "terminalUsage": null, + "matcherResults": [], + "cleanup": "passed" + }, + "observations": { + "nativeToolCalls": 20, + "failedFinishCalls": 5, + "frozenCompletionContract": { + "revision": "1", + "criterionId": "objective" + }, + "finishErrorText": "Paperclip tool request was rejected", + "fileOracleCaptured": false, + "finalIssueState": "in_progress" + }, + "evidenceLimitations": [ + "The original five finish argument objects did not cross the privacy projection and are not retained.", + "The canonical fixture removed its workspace and provider session during cleanup; file bytes cannot be independently reconstructed.", + "No terminal token receipt exists. Canonical billing has unavailable status; numeric default fields are not proof of zero inference.", + "Private reasoning content is excluded." + ], + "defects": { + "semanticValidationDetails": "Owned MCP bridge discarded authenticated isError content; fixed in version 3.", + "nativeBashOutput": "Upstream private terminal metadata was not preserved in standard ACP fields; fixed in version 3.", + "canonicalExitCode": "Shared projection still reports unknown; no inference from prose or status." + }, + "cost": { + "aggregateBillingGroup": "pi-denial-file-01", + "aggregateDenialAndFileDeltaUsd": 0.010995043, + "attributableFileGenerationCostUsd": null, + "catalogEstimateUsd": null + }, + "supervision": { + "reservationUsd": 2, + "combinedReservationUsd": 4, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "deadlineClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0 + }, + "cleanup": { + "canonicalCleanupPassed": true, + "residualAttemptProcesses": 0, + "fixtureWorkspaceRemoved": true, + "providerSnapshotRemoved": true, + "providerScratchRemoved": true + }, + "executionTrees": { + "packages/paperclip-runner/src": "a8123cb4396c77ce0127d5ccf49d3584b63a9a58", + "packages/paperclip-runner/scripts": "9ede97769bb3f2bbfa8e02be220b5ce06e438a99", + "packages/paperclip-runner/runner": "2cc84e150aecbd5faf2c8bcecc4b6352c24dbca8", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "1e2f846b8b2dda202b79ee83baeb0860a3b9ab64", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + }, + "qualificationStatus": "pending", + "publicationBoundary": { + "credentialsIncluded": false, + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/product-file-proof.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/product-file-proof.darwin-arm64.json new file mode 100644 index 0000000000..2ca25f6644 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/product-file-proof.darwin-arm64.json @@ -0,0 +1,182 @@ +{ + "schema": "paperclip.pi-product-file-proof.v1", + "attemptId": "pi-product-file-02", + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "repositoryRevision": "9cd42f6df9356cb673cd12accd94dc3e171dc13d", + "runtimeCodeRevision": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "profileVersion": 3, + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "runnerdDigest": "sha256:2c8efdc99f988b1c8ab0e270677a3321af0fd6d5789dbf1b671faa921031b42f", + "executionTrees": { + "packages/paperclip-runner/src": "7df6b47574e6838e63a7d13c403c615a749702db", + "packages/paperclip-runner/scripts": "f845a2dd5b1ac2f1a4ef8d95db8fba2daea9999f", + "packages/paperclip-runner/runner": "9c595f7eae18197bb5cfd61c1af463df0a8741cd", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "784152935b091eed6ea1795d39c23cfbbd154ab7", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + }, + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "local" + }, + "result": { + "status": "passed", + "durationMs": 49427, + "supervisorDurationSeconds": 67.34, + "matcherResults": [ + { + "matcher": { + "kind": "message_exact", + "expected": "EXTENDED-FILE-ede290404669-1" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "message_occurrences", + "expected": "EXTENDED-FILE-ede290404669-1", + "count": 1 + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "issue_status", + "expected": "done" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "run_status", + "expected": "succeeded" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "runtime_mode", + "expected": "native" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "environment", + "expected": "local" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "file_exact", + "path": "extended-ede290404669-1.txt", + "expected": "verified-ede290404669-1\n" + }, + "passed": true, + "detail": "matched" + } + ], + "cleanup": "passed" + }, + "fileOracle": { + "path": "extended-ede290404669-1.txt", + "expected": "verified-ede290404669-1\n", + "expectedBytesSha256": "0987a044fc5ad449b2b96210c1b7dcb1205431c353cb87c34366618c7438ece7", + "byteLength": 24, + "method": "Canonical matcher read actual workspace file before cleanup and compared exact UTF-8 contents.", + "rawArtifactCopyRetained": false + }, + "semanticCorrection": { + "failedFinishCalls": 1, + "retainedValidationError": "Paperclip tool request was rejected: Completion cites a workspace-only file that the user cannot download. Before finishing, use register_deliverable for requested file outputs and cite deliverable: from the receipt, with /api/attachments//content as the download link. For repository changes, cite an accessible PR or registered work product instead. No human completion approval was created.", + "registeredDeliverableCalls": 1, + "registrationDisposition": "applied", + "filePreparationState": "prepared", + "downloadPathShape": "/api/attachments//content?download=1", + "externalChatDeliveryConfirmed": false, + "successfulFinishCalls": 1 + }, + "bashProjection": { + "terminalToolCalls": 5, + "allResultsRetained": true, + "canonicalExitCode": "unknown", + "statuses": [ + "completed", + "completed", + "completed", + "failed", + "completed" + ] + }, + "terminal": { + "schema": "paperclip.prp.terminal.v1", + "runTerminalState": "succeeded", + "turnTerminalState": "completed", + "reportedWorkDisposition": "done" + }, + "usage": { + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "biller": "openrouter", + "provider": "openrouter", + "costStatus": "unpriced", + "billingType": "unknown", + "inputTokens": 14461, + "outputTokens": 3604, + "cachedInputTokens": 116736, + "usageSource": "per_run" + }, + "cost": { + "catalogEstimateUsd": null, + "providerReportedUsd": null, + "costStatus": "unpriced", + "billingComplete": false, + "exclusiveKeyDeltaUsd": 0.003324737, + "measurement": "Exclusive key baseline and two spaced post-attempt observations, separate from model catalog estimates." + }, + "screenshot": { + "sha256": "741695cf889df6755c2b02c70ec71b445723a3289953bfe9dd3333caa91c7cf0", + "visuallyInspected": true, + "observed": [ + "Done status", + "one final completion marker", + "24 B file download card and file attachment", + "no error banner" + ], + "imageRetainedPrivately": true + }, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "sleepAssertion": "caffeinate -i", + "deadlineClocks": [ + "wall", + "monotonic" + ], + "automaticRetries": 0 + }, + "cleanup": { + "canonicalCleanupPassed": true, + "fixtureRootRemoved": true, + "residualAttemptProcesses": 0 + }, + "qualificationStatus": "pending", + "publicationBoundary": { + "credentialsIncluded": false, + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/product-hello-profile-rejection.v6.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/product-hello-profile-rejection.v6.darwin-arm64.json new file mode 100644 index 0000000000..3c598cd1cd --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/product-hello-profile-rejection.v6.darwin-arm64.json @@ -0,0 +1,71 @@ +{ + "schema": "paperclip.pi-product-evidence.v1", + "recordedAt": "2026-09-29T15:02:32.094914+00:00", + "profileVersion": 6, + "qualification": "pending", + "attemptId": "pi-v6-local-hello-20260929-01", + "caseId": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "sourceRevision": "6f163033a4a8c6a7590d227f7054bf2356dfe477", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "platform": "darwin-arm64", + "providerPackDigest": "sha256:844c245d85c529bbe40ca87393a1da3716d57dfc030b1d56240ab7cb7512aea9", + "providerPackManifestSha256": "849a4cd00afb05acd7f22366bc4b8b1f719c50993f0d53e31b837b75fd8963df", + "profileDigest": "sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f", + "closureDigest": "sha256:b2ce6bb2d9b93c4265b2c363cbf1433e06bb3349327f59b6f8eb3fdbd1b03d6a", + "actualRuntime": { + "sidecarSha256": "7c10054f7382826b6c330e75d5e27c08a3f2e3d8352debc04865f0687bfe67c7", + "nodeSha256": "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b", + "daemonSha256": "f04278d1464872bbed087fca101dbbd56251b4b6bdcc2958e6c9995ef45ff387", + "sourceBinding": "entire frozen Runner dist and Pi assets copied into actual controller package root and independently hashed before launch" + }, + "build": { + "privateFreshPnpmStore": true, + "packageImportMethod": "copy", + "pnpmVersion": "9.15.4", + "trackedLockSha256": "e0c928a494f90ddad3c00791e83f09315ee8c82df2a0418a809dcf93649a8ab3", + "stagingResolvedLockSha256": "5e79e3fce2173814528e6e55a0404249f8b677f2d1c56530d9947b6d0efe6827", + "installedWrapperAndNativeSdkTestsPassed": 25, + "credentialFreeProductSupportTestsPassed": 642, + "productTypecheck": "passed" + }, + "result": { + "status": "failed", + "classification": "pre-provider profile validation failure", + "message": "input.provider.profile does not match the qualified ACPX v1 profile", + "cause": "Shared native-execution runtime parser only admitted agentProfileVersion 1 through 5 while the declared qualified profile was version 6.", + "providerInferenceStarted": false, + "runCount": 2, + "runSources": [ + "assignment", + "automation" + ], + "terminalRunStatuses": [ + "failed", + "failed" + ], + "terminalUsageReceipts": 0, + "durationSeconds": 46.424, + "harnessAutomaticRetries": 0 + }, + "billing": { + "reservationUsd": 2, + "watchdogDeltaUsd": 0.5, + "observedExclusiveKeyDeltaUsd": 0, + "beforeObservedAt": "2026-09-29T15:01:45.669895+00:00", + "afterObservedAt": "2026-09-29T15:02:32.029924+00:00", + "finality": "early key observations; delayed charges must be reconciled independently", + "missingRunUsageMeans": "unknown, not zero" + }, + "cleanup": { + "product": "passed", + "supervisorOwnedProcessesObserved": 40, + "remainingOwnedProcesses": 0 + }, + "requiredFollowup": [ + "Add shared runtime parser regression for exact v6 profile", + "Rebuild frozen source, sidecar and provider pack", + "Obtain new central reservation before another attempt", + "Complete five basic Product journeys, seven Runner cases and native Product cases", + "Qualify authenticated Linux x64 Daytona and macOS x64 separately" + ] +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/product-hello-proof.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/product-hello-proof.darwin-arm64.json new file mode 100644 index 0000000000..eb1a4e094e --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/product-hello-proof.darwin-arm64.json @@ -0,0 +1,126 @@ +{ + "schema": "paperclip.pi-product-qualification-proof.v1", + "attemptId": "pi-product-hello-06", + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "runnerdDigest": "sha256:f9aad049d57a2073ce4ec96b6577266afdba5b99db75bb8fc21825a5b7281e39", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "executionTrees": { + "packages/paperclip-runner/src": "a8123cb4396c77ce0127d5ccf49d3584b63a9a58", + "packages/paperclip-runner/scripts": "9ede97769bb3f2bbfa8e02be220b5ce06e438a99", + "packages/paperclip-runner/runner": "2cc84e150aecbd5faf2c8bcecc4b6352c24dbca8", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "1e2f846b8b2dda202b79ee83baeb0860a3b9ab64", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + }, + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "local" + }, + "caseId": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "status": "passed", + "failureClass": null, + "canonicalDurationMs": 32668, + "supervisedDurationSeconds": 49.539, + "supervisedWallDurationSeconds": 49.539, + "agentRunDurationMs": 25621, + "configuredModel": "openrouter/deepseek/deepseek-v4-flash-0731", + "biller": "openrouter", + "runtimeMode": "native", + "matchers": [ + { + "kind": "message_exact", + "passed": true, + "detail": "matched" + }, + { + "kind": "message_occurrences", + "passed": true, + "detail": "matched" + }, + { + "kind": "issue_status", + "passed": true, + "detail": "matched" + }, + { + "kind": "run_status", + "passed": true, + "detail": "matched" + }, + { + "kind": "runtime_mode", + "passed": true, + "detail": "matched" + }, + { + "kind": "environment", + "passed": true, + "detail": "matched" + } + ], + "semanticTools": [ + { + "name": "mcp__paperclip__paperclip_finish", + "result": "completed" + } + ], + "terminal": { + "schema": "paperclip.prp.terminal.v1", + "runTerminalState": "succeeded", + "turnTerminalState": "completed", + "reportedWorkDisposition": "done" + }, + "usage": { + "inputTokens": 26867, + "outputTokens": 283, + "cachedInputTokens": 0, + "usageSource": "per_run", + "costStatus": "unpriced", + "billingType": "unknown" + }, + "cost": { + "providerReportedUsd": null, + "catalogEstimateUsd": 0.00384062, + "catalogEstimateProvenance": "Pi assistant message receipts priced by its model catalog; estimate notice explicitly marks billing unverified", + "measuredExclusiveKeyDeltaUsd": 0.000654767, + "canonicalBillingComplete": false, + "billingCoverage": "two spaced post-attempt OpenRouter key usage observations" + }, + "cleanup": "passed", + "evidenceScan": { + "leaks": [], + "missing": [] + }, + "sourceModelVerification": { + "mandatoryAdmissionGate": "requireVerifiedAcpxModel compares actual ACP currentModelId before accepting a prompt", + "retainedProductModelMetadata": "unknown/openrouter at session.started; final usage carries configured exact model", + "separateAuthenticatedNoPromptProof": "model-admission-proof.darwin-arm64.json" + }, + "qualificationStatus": "pending", + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "outerClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0 + }, + "publicationBoundary": { + "credentialsIncluded": false, + "privateReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false + }, + "limitations": [ + "This is one local hello-complete journey, not full provider qualification.", + "Dollar usage remains unpriced in Product; independent key-delta billing is retained separately.", + "File, question, plan, restart, active steering and Daytona are not proven by this case." + ] +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-failure.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-failure.darwin-arm64.json new file mode 100644 index 0000000000..bb1f7caa45 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-failure.darwin-arm64.json @@ -0,0 +1,123 @@ +{ + "schema": "paperclip.pi-product-plan-failure.v1", + "attemptId": "pi-product-plan-01", + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "runtimeCodeRevision": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "repositoryRevision": "9cd42f6df9356cb673cd12accd94dc3e171dc13d", + "profileVersion": 3, + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "runnerdDigest": "sha256:2c8efdc99f988b1c8ab0e270677a3321af0fd6d5789dbf1b671faa921031b42f", + "executionTrees": { + "packages/paperclip-runner/src": "7df6b47574e6838e63a7d13c403c615a749702db", + "packages/paperclip-runner/scripts": "f845a2dd5b1ac2f1a4ef8d95db8fba2daea9999f", + "packages/paperclip-runner/runner": "9c595f7eae18197bb5cfd61c1af463df0a8741cd", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "784152935b091eed6ea1795d39c23cfbbd154ab7", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + }, + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "local" + }, + "result": { + "status": "failed", + "failureClass": "candidate_failure", + "durationMs": 119553, + "supervisorDurationSeconds": 139.067, + "failedBoundary": "Native saved Plan did not appear at its canonical write_document boundary within30s.", + "matcherResults": [], + "cleanup": "passed" + }, + "semanticState": { + "runStatus": "succeeded", + "nativePhase": "committed", + "issueStatus": "in_review", + "plan": { + "key": "plan", + "revision": 1, + "body": "# OpenRouter breadth plan bed573ffa86f-1\n\n1. Define the OpenRouter breadth scope and success criteria, and record the ready marker PC_P_READY_bed573ffa86f-1 in this canonical Plan.\n2. Submit this Plan for approval via a confirmation request bound to this exact Plan revision; do not implement anything before that revision is accepted.", + "revisionIdSha256": "a0b4b8055d87bf25d8e44aedab42b4439416dfd5e13af8d6e0e34d600412dccf" + }, + "interaction": { + "kind": "request_confirmation", + "status": "pending", + "targetType": "issue_document", + "targetKey": "plan", + "targetRevisionMatchesWriteReceipt": true, + "decisionSubmitted": false + } + }, + "canonicalToolEvent": { + "name": "mcp__paperclip__write_document", + "status": "completed", + "transport": "builtin", + "namespace": null, + "outputDigest": "sha256:1bf0af100a60fbf97b9e97e4e946be3ae2ff1dc0ff56e9f69e0a0d96a30d9425" + }, + "retainedDom": { + "matchingSnapshots": 4, + "planTestId": "task-chat-plan-preview-fallback", + "ariaLabel": "Open Plan revision 1", + "ancestorTurnTestId": "task-chat-turn", + "ancestorSettled": "true", + "expectedPlanTestId": "task-chat-plan-preview", + "oracleRemainsUnchanged": true + }, + "diagnosis": { + "cause": "Rust ACPX projection hardcodes builtin transport and null namespace while preserving mcp__paperclip__write_document as the name; UI exact write_document matching therefore creates a fallback Plan.", + "productionSeam": "packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs:normalize_acpx_tool_call", + "presentationSeam": "ui/src/components/task-chat/transcript-adapter.ts:embedPlanDocumentAtWriteBoundary", + "selectorDefect": false, + "followupFix": { + "commit": "5aa02662b34fc70f5fe9ffd5ac8f7ae81b5fe3bd", + "scope": "shared Rust MCP identity normalization plus negative/positive settled UI boundary regression", + "verification": "43 ACP boundary tests, 12 provider units and 158 TaskChatThread tests reported by owning agent", + "liveRetry": "pending; no new prompt" + } + }, + "usage": null, + "cost": { + "providerReportedUsd": null, + "terminalUsageAvailable": false, + "exclusiveKeyDeltaUsd": 0.003090504, + "measurement": "Exclusive key baseline and two spaced post-attempt observations; not inferred from the missing usage receipt." + }, + "screenshot": { + "sha256": "9d1e8b5892f620f0ff94895eefba22c2d3cc57ff5ec5a79d20c419ebbef38e13", + "visuallyInspected": true, + "observed": [ + "Plan card visible", + "Complete two-step Plan visible in side pane", + "Pending Confirm and Request changes controls" + ], + "imageRetainedPrivately": true + }, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "sleepAssertion": "caffeinate -i", + "deadlineClocks": [ + "wall", + "monotonic" + ], + "automaticRetries": 0 + }, + "cleanup": { + "canonicalCleanupPassed": true, + "fixtureRootRemoved": true, + "residualAttemptProcesses": 0 + }, + "qualificationStatus": "pending", + "publicationBoundary": { + "credentialsIncluded": false, + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-infrastructure-failure.v4.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-infrastructure-failure.v4.darwin-arm64.json new file mode 100644 index 0000000000..c03ec81101 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-infrastructure-failure.v4.darwin-arm64.json @@ -0,0 +1,110 @@ +{ + "schema": "paperclip.pi-product-infrastructure-failure/v1", + "attemptId": "pi-product-plan-03", + "sourceRevision": "92bcd6f07ae9a3905333dbaa2542b1401945f5fa", + "runtimeSourceRevision": "f556110d588a9de9fefe676a9a62bf09e98afb85", + "profileVersion": 4, + "profileDigest": "sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991", + "providerPackDigest": "sha256:4df7e9fa164929c7ae7d8e8711f0bf7d587d9fa6a246d0a8340f318f57168855", + "runnerdDigest": "sha256:373848d2d7287b2c47b2cee422cb7bd25073a594a620a9ad574f2d3d51cd1670", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "case": "extended-harnesses.runner-acpx-pi.local.plan-approve-complete", + "result": { + "schema": "paperclip.runner-e2e.result/v2", + "executionId": "extended-harnesses.runner-acpx-pi.local.plan-approve-complete", + "suiteId": "extended-harnesses", + "suiteDefinitionHash": "e75cb2222d2f52ec3a2a425211d195d36b95882de2cb9ed0ad93231b7c42768a", + "source": { + "sha": "92bcd6f07ae9a3905333dbaa2542b1401945f5fa", + "ref": "refs/heads/codex/runner-pi-acp", + "workflowRunUrl": null + }, + "attempt": 1, + "status": "failed", + "failureClass": "transient_infrastructure", + "error": "Playwright exited 1 before writing a result", + "profileId": "runner-acpx-pi", + "environmentId": "local", + "caseId": "plan-approve-complete", + "provider": "acpx", + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "runtimeMode": "native", + "startedAt": "2026-09-28T22:14:34.240Z", + "finishedAt": "2026-09-28T22:14:40.919Z", + "durationMs": 6679, + "cleanup": "not_started" + }, + "providerPromptCalls": 0, + "providerStartupReached": false, + "terminalUsage": null, + "billingDeltaUsd": 0, + "billingCoverage": "Two spaced post-attempt exclusive-key observations show unchanged usage; the fixture failed before provider startup.", + "supervision": { + "reservationUsd": 2, + "watchdogUsd": 0.5, + "activeDeadlineSeconds": 120, + "outerDeadlineSeconds": 300, + "automaticRetries": 0, + "supervisorDurationSeconds": 13.774, + "outerDeadlineFired": false + }, + "diagnosis": { + "failurePhase": "embedded PostgreSQL initdb bootstrap before Playwright test", + "observed": "Embedded PostgreSQL init script exited1 after selecting max_connections20/shared_buffers400kB; original bootstrap stderr was not retained.", + "hostCapacity": { + "recordedAt": "2026-09-28T22:16:06.846217+00:00", + "sysvSemaphoreSets": 5135, + "sysvSemaphoresInUse": 87263, + "sysvSemaphoreLimit": 87381, + "freeSemaphores": 118, + "usualPostgresSetSize": 17, + "attemptFixtureRemoved": true, + "scope": "read-only host capacity observation; unrelated IPC resources untouched" + }, + "inference": "A later credential-free initdb invocation of the same pinned PostgreSQL binary reproduced semaphore exhaustion with16 free and17 required. The original Product bootstrap stderr remains unavailable.", + "unrelatedProcessesOrIpcModified": false, + "disposableReproduction": { + "nativePackage": "@embedded-postgres/darwin-arm64@18.1.0-beta.16", + "initdbSha256": "5a6ad92c2ce915b1a13aa2f8f2b2199996daf0cbe6b9f1d0044a387303b368ec", + "arguments": [ + "--pgdata=", + "--username=paperclip_fixture", + "--auth=trust", + "--encoding=UTF8", + "--locale=C", + "--no-sync" + ], + "before": { + "sets": 5141, + "used": 87365, + "limit": 87381, + "recordedAt": "2026-09-28T22:17:28.611679+00:00" + }, + "after": { + "sets": 5141, + "used": 87365, + "limit": 87381, + "recordedAt": "2026-09-28T22:17:29.168791+00:00" + }, + "exitCode": 1, + "durationSeconds": 0.557, + "error": "FATAL: could not create semaphores: No space left on device", + "failedSystemCall": "semget(..., 17, 03600)", + "processGroupRemaining": false, + "temporaryDirectoryRemoved": true, + "providerCalls": 0, + "postgresServerStarted": false + } + }, + "independentCleanup": { + "fixtureDirectoryRemoved": true, + "matchingAttemptProcesses": 0, + "canonicalCleanup": "not_started; failed before test setup" + }, + "privateLogSha256": "2600fbad9d5c736bd7e76526acd3eaec9de305f0b9d22f7f7e5bd3333b3f28ba", + "publication": { + "credentials": false, + "hiddenReasoning": false, + "rawProviderTranscript": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-resume-failure.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-resume-failure.darwin-arm64.json new file mode 100644 index 0000000000..5e1c25a1e8 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/product-plan-resume-failure.darwin-arm64.json @@ -0,0 +1,151 @@ +{ + "schema": "paperclip.pi-product-plan-failure.v1", + "attemptId": "pi-product-plan-02", + "sourceRevision": "e35b11db21b8da8527fa0b6c6f5fe186bdced111", + "packSourceRevision": "7ab463697037c9456a4ad2b83ea0e9c28b353f8a", + "profileVersion": 3, + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "providerPackDigest": "sha256:0800f10114e3d8e2e981ea27f0ab47f1da9349dbcc22bf55a76ebbc4af00601d", + "runnerdDigest": "sha256:955d0714472a0a8727aaac55c357425b4157cba54f37483ded58217e8859a26d", + "runnerBuild": { + "sourceRevision": "e35b11db21b8da8527fa0b6c6f5fe186bdced111", + "rustTree": "e2c76e35ae8bb19b968a64a2b2eb75c6df216c82", + "sha256": "955d0714472a0a8727aaac55c357425b4157cba54f37483ded58217e8859a26d", + "mode": "0555", + "build": "cargo build --release --locked -p paperclip-runner-core --bin paperclip-runnerd -j2", + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472" + }, + "executionTrees": { + "packages/paperclip-runner/src": "c1d4c8f25cce16c3600697b9430480d5711c84bd", + "packages/paperclip-runner/scripts": "651962ebb671e1de4fbc1b0886051955de5ed4fd", + "packages/paperclip-runner/runner": "e2c76e35ae8bb19b968a64a2b2eb75c6df216c82", + "patches": "3384cf8e956e766c4ec730ccf76c424ab9d99ec9", + "server/src": "1a4c4b7000becae6021d74e7dc0f2f36180c767c", + "tests/runner-e2e": "6d15ec5668fda2eed3bd308e11e0fda0cf48ca19" + }, + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "local" + }, + "result": { + "status": "failed", + "failureClass": "candidate_failure", + "durationMs": 260246, + "supervisorDurationSeconds": 288.581, + "outerDeadlineFired": false, + "matcherResults": [], + "cleanup": "passed" + }, + "semanticState": { + "issueStatus": "in_progress", + "initialRunStatus": "succeeded", + "initialRunDisposition": "yielded", + "resumedRunStatus": "failed", + "resumedRunErrorCode": "native_session_interrupted", + "resumedRunError": "native session timed out after 120000ms", + "planRevision": 1, + "planBody": "# OpenRouter breadth plan 4842a379236d-1\n\nPlan status marker: PC_P_READY_4842a379236d-1\n\n## Steps\n\n1. Survey the OpenRouter provider surface relevant to breadth coverage, inventory available models/providers and the current integration gaps, and record findings as the basis for the work items.\n2. Define the concrete breadth expansion work items (target providers, models, and acceptance criteria) and sequence them for implementation after this plan is approved.", + "confirmationStatus": "accepted", + "confirmationResult": { + "version": 1, + "outcome": "accepted" + }, + "targetRevisionMatchesPlan": true, + "canonicalNativePlanBoundaryPassed": true + }, + "resumedToolResults": [ + { + "executionId": "call_0", + "name": "paperclip_finish", + "namespace": "paperclip", + "transport": "mcp", + "status": "failed", + "output": "{\"content\":[{\"type\":\"text\",\"text\":\"Paperclip tool request was rejected: completionClaim.criteria must contain exactly these criterion IDs, once each: [\\\"human_response\\\"]. Keep contractRevision \\\"2\\\" and correct the report without repeating completed work.\"}],\"details\":{}}", + "outputTruncated": false + }, + { + "executionId": "call_0", + "name": "paperclip_finish", + "namespace": "paperclip", + "transport": "mcp", + "status": "failed", + "output": "{\"content\":[{\"type\":\"text\",\"text\":\"Paperclip tool request was rejected: Duplicate call identity conflict.\"}],\"details\":{}}", + "outputTruncated": false + }, + { + "executionId": "call_0", + "name": "get_task_context", + "namespace": "paperclip", + "transport": "mcp", + "status": "failed", + "output": "{\"content\":[{\"type\":\"text\",\"text\":\"Paperclip tool request was rejected: Duplicate call identity conflict.\"}],\"details\":{}}", + "outputTruncated": false + } + ], + "diagnosis": { + "projectionRepair": "5aa02662b34fc70f5fe9ffd5ac8f7ae81b5fe3bd", + "projectionConfirmed": "Canonical Plan preview matched before revision-bound acceptance; tool names now normalize to MCP namespace/name.", + "laterBoundary": "All three distinct resumed semantic executions reuse native call_0. Owned extension forwards it as MCP JSON-RPC id, causing shared exact-call dedupe to reject later corrected/different requests.", + "requiredFix": "Occurrence-scoped provider-owned identities, keeping exact invocation replay and shared bridge authority intact.", + "timeoutChanged": false, + "graderChanged": false + }, + "usage": { + "byRun": [ + null, + null + ], + "coverage": "unavailable" + }, + "cost": { + "exclusiveKeyDeltaUsd": 0.002440082, + "providerReportedUsd": null, + "terminalUsageAvailable": false + }, + "screenshots": { + "pending": { + "sha256": "fb25f41cee954bf808614623011cab696abaf728878a400422fdc51b094a94b3", + "visuallyInspected": true, + "observed": [ + "Plan revision1 at canonical tool boundary", + "complete two-step Plan in side pane", + "Confirm and Request changes" + ] + }, + "failure": { + "sha256": "de6f1e84f7b329a99f76e90fcbba7db788d9189bee0f616bc89c423835694fe4", + "visuallyInspected": true, + "observed": [ + "Accepted Plan context", + "visible completion validation correction", + "Run failed banner" + ] + } + }, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "deadlineClocks": [ + "wall", + "monotonic" + ], + "sleepAssertion": "caffeinate -i", + "automaticRetries": 0 + }, + "cleanup": { + "canonicalCleanupPassed": true, + "fixtureRootRemoved": true, + "residualAttemptProcesses": 0 + }, + "qualificationStatus": "pending", + "publicationBoundary": { + "credentialsIncluded": false, + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/product-question-proof.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/product-question-proof.darwin-arm64.json new file mode 100644 index 0000000000..5ab8fc6d69 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/product-question-proof.darwin-arm64.json @@ -0,0 +1,196 @@ +{ + "schema": "paperclip.pi-product-question-proof.v1", + "attemptId": "pi-product-question-01", + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "runtimeCodeRevision": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "repositoryRevision": "9cd42f6df9356cb673cd12accd94dc3e171dc13d", + "profileVersion": 3, + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "runnerdDigest": "sha256:2c8efdc99f988b1c8ab0e270677a3321af0fd6d5789dbf1b671faa921031b42f", + "executionTrees": { + "packages/paperclip-runner/src": "7df6b47574e6838e63a7d13c403c615a749702db", + "packages/paperclip-runner/scripts": "f845a2dd5b1ac2f1a4ef8d95db8fba2daea9999f", + "packages/paperclip-runner/runner": "9c595f7eae18197bb5cfd61c1af463df0a8741cd", + "packages/paperclip-runner/protocol": "1d50458fb572d0c75f9a398f419209c52c23ba6f", + "patches": "784152935b091eed6ea1795d39c23cfbbd154ab7", + "server/src": "b267adc1e0ad36955b68314829cfd4256ab88d37", + "tests/runner-e2e": "5ff4b8a85a14e3e005e3e240c145899c0bc742b5" + }, + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "execution": "local" + }, + "result": { + "status": "passed", + "durationMs": 158388, + "supervisorDurationSeconds": 175.689, + "matcherResults": [ + { + "matcher": { + "kind": "message_exact", + "expected": "PC_Q_C_05746e362d67-1" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "message_occurrences", + "expected": "PC_Q_C_05746e362d67-1", + "count": 1 + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "issue_status", + "expected": "done" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "run_status", + "expected": "succeeded" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "runtime_mode", + "expected": "native" + }, + "passed": true, + "detail": "matched" + }, + { + "matcher": { + "kind": "environment", + "expected": "local" + }, + "passed": true, + "detail": "matched" + } + ], + "cleanup": "passed" + }, + "interaction": { + "kind": "ask_user_questions", + "title": "Verification word", + "initialStatus": "pending", + "resolvedStatus": "answered", + "continuationPolicy": "wake_assignee", + "questions": [ + { + "id": "verification-word", + "prompt": "Choose the verification word.", + "selectionMode": "single", + "required": true, + "options": [ + { + "id": "cobalt", + "label": "Cobalt" + }, + { + "id": "amber", + "label": "Amber" + } + ] + } + ], + "result": { + "version": 1, + "answers": [ + { + "questionId": "verification-word", + "optionIds": [ + "cobalt" + ] + } + ], + "summaryMarkdown": null + }, + "requestIdentityPreserved": true, + "distinctContinuationRun": true, + "serverRestartedBeforeAnswer": false + }, + "usage": { + "runCount": 2, + "byRun": [ + null, + { + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "biller": "openrouter", + "provider": "openrouter", + "costStatus": "unpriced", + "billingType": "unknown", + "inputTokens": 17894, + "outputTokens": 1891, + "cachedInputTokens": 19200, + "usageSource": "per_run", + "sessionReused": true + } + ], + "coverage": "partial: waiting run receipt unavailable; resumed run receipt retained" + }, + "cost": { + "providerReportedUsd": null, + "costStatus": "unpriced", + "billingComplete": false, + "exclusiveKeyDeltaUsd": 0.001413697, + "measurement": "Exclusive key baseline and two spaced post-attempt observations; separate from missing terminal usage." + }, + "screenshot": { + "sha256": "35138e6ef99fe566c2aadce7594f1e582b00b71207214d6d576633e33f1741ee", + "visuallyInspected": true, + "observed": [ + "Done status", + "Questions answered", + "Cobalt answer bubble", + "one completion marker" + ], + "imageRetainedPrivately": true + }, + "supervision": { + "reservationUsd": 2, + "billingWatchdogUsd": 0.5, + "activeTimeoutSeconds": 120, + "outerTimeoutSeconds": 300, + "sleepAssertion": "caffeinate -i", + "deadlineClocks": [ + "wall", + "monotonic" + ], + "automaticRetries": 0 + }, + "cleanup": { + "canonicalCleanupPassed": true, + "fixtureRootRemoved": true, + "residualAttemptProcesses": 0 + }, + "qualificationStatus": "pending", + "publicationBoundary": { + "credentialsIncluded": false, + "hiddenReasoningIncluded": false, + "rawTranscriptsIncluded": false, + "providerSessionIdsIncluded": false + }, + "pendingScreenshot": { + "sha256": "7e94f6db31750b64c5d7a9e60c6ab22364f8af627738db3a7a21cffbb9a85a1b", + "visuallyInspected": true, + "observed": [ + "In Review status", + "Verification word form", + "Cobalt and Amber choices plus freeform Other", + "Submit answers disabled until required selection", + "Cancel action visible" + ], + "imageRetainedPrivately": true + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v10-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v10-identity.json new file mode 100644 index 0000000000..9c251613f0 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v10-identity.json @@ -0,0 +1,30 @@ +{ + "commandDigest": "sha256:5e1a4357fc108fa79a66111413f85f6353b3dd3ab802ddfb80fc66719bc12571", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 10, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "0.84.2", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "0187153354338cf4919d213dd2977e1be1073ad3941c7b9b5c8a09c5b32828e4", + "darwin-x64": "22c593576e78edb27bbe6234aabd31a7d65f6be7503683f568d0730ebe6c75a0", + "linux-x64": "016ce3653bdfc1c7269e3bf7d4bf26e48b0d0b7d409fd153f3f1b882e986654d" + }, + "wrapperSha256": "9fd9a685fb79b4f73a8dac08cbe360a6de87ffcb596cdaff1191e3ac57c24f56", + "helperSha256": "a19087c0af8fb3896fe3290280695f8f936574013bc4acde650f11f023f0f03b", + "extensionSha256": "2b74a4a2e6a42def3d9918a46732e0e1b8c618791d2b22970cfb3e9d285e55ad", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v2", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "nativeAssistantMessages": "sdk-boundaries-v1", + "nativeNotices": "session-extension-v1", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "c619080ec72a577889c382d44ea9024b10efdb9d83e933c03bfdd8da79ede591", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v11-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v11-identity.json new file mode 100644 index 0000000000..93b68d74a2 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v11-identity.json @@ -0,0 +1,32 @@ +{ + "commandDigest": "sha256:5e276f48c8a87b3e6165369faac62d3925282c84b98934575b1b7b97ad50b309", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 11, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "a3e43c23f9603983a59fd256ca1dcf8bbd5cc2728dad095f7bcd3b3e62278597", + "darwin-x64": "8c6e7802d47da9410af56e367f6cf7af572fcbf3e361627cee026e8cffc9bdd3", + "linux-x64": "40e186c53db60d1ab5673ba34f33002e93cea518725a93395d241393b984caef" + }, + "wrapperSha256": "f881b0461d706780953919b791aeb9614921c3be2d7cc41477f2a9afa2cbfb69", + "helperSha256": "a9f9d489ca0f14765ac1c4ab80c6bd0bd3fef46a8f41b8899ed2d9e78c115aba", + "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": "sdk-boundaries-v2-system-structural", + "nativeNotices": "session-extension-v1", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "c619080ec72a577889c382d44ea9024b10efdb9d83e933c03bfdd8da79ede591", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v12-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v12-identity.json new file mode 100644 index 0000000000..784e8816b7 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v12-identity.json @@ -0,0 +1,32 @@ +{ + "commandDigest": "sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 12, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "e17be4d27c589b8f8b5de7d00686c39873fe6f902a3f133bdaf1a9f94dee00a2", + "darwin-x64": "03351f4a250a8db0e79411a9079b43a0ff05f72a2aff41fae17f1fc2de24bd41", + "linux-x64": "29dfc829700c57392f1d56373078dcfc80674cbd50f65674ec64fced81dffb68" + }, + "wrapperSha256": "c41750802680543d72a5a43e21eaf91c31ca8cec833bbf5f27330614936810fe", + "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" + } +} 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/profile-v21-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v21-identity.json new file mode 100644 index 0000000000..11bb6323f5 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v21-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:514cbf86e70c1eaedebdf924bc0f3de4753c7a9e0313a0bb38614b6b9481a9a1", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 21, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410" + }, + "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": "c49186bd73da7f1c9401d8ab10fe581ccccad294efe5da8621dd03adb91dc094", + "runtimeSandboxSourceSha256": "1c1ddb0b24c3417250ce161cda897ba59b1adce7c7609ed553be298464e6c1c4", + "recoveryIdentitySourceSha256": "f333a24b19ef1b5b5d23ea9778e753f7051690e835ef9e4476a962d8a231c375", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-identity.json new file mode 100644 index 0000000000..b30a12edfd --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 22, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410" + }, + "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": "d5b4c1a426afc85f040c9b55358d1d645299586dc8cec72d1be2509187ca3865", + "credentialEnvironmentSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "runtimeSandboxSourceSha256": "1c1ddb0b24c3417250ce161cda897ba59b1adce7c7609ed553be298464e6c1c4", + "recoveryIdentitySourceSha256": "f333a24b19ef1b5b5d23ea9778e753f7051690e835ef9e4476a962d8a231c375", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v6-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v6-identity.json new file mode 100644 index 0000000000..489f751f18 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v6-identity.json @@ -0,0 +1,23 @@ +{ + "commandDigest": "sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 6, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "0.84.2", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "b2ce6bb2d9b93c4265b2c363cbf1433e06bb3349327f59b6f8eb3fdbd1b03d6a", + "darwin-x64": "a9269f6e0675ad1b50b24e76d47b971d4238ff25ddd57074b707db2a9bc83a74", + "linux-x64": "05eb422d9ce9e32b081cb2d2a3433dc113d66a55f993125b74b1666002cf3b67" + }, + "wrapperSha256": "dc4786faff30942e82106c87a8984a75fb979a925ff1d62648500a642b920cac", + "helperSha256": "2df24aee67ca6a5cc813e1f87324f51a72f6a104757daed97a09cbbcb63626d4", + "extensionSha256": "d479d4f510295077b8904645e161a527531233a688760a80cca8db9ae01f3b02", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v1", + "pathPolicy": "sdk-normalization-v1" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json new file mode 100644 index 0000000000..70719d93d9 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json @@ -0,0 +1,24 @@ +{ + "commandDigest": "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 7, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "0.84.2", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "3703361965d4e0b641fcd5eac4079e9b6c0de47b4b245a5b31b7da7b52c94128", + "darwin-x64": "885d9fde187bd1a877838ac309aa68df4b928e0727ca341b3b8f3b1e7467ad12", + "linux-x64": "425afafe8a1419c86333a6eaa93bf56f0e78a4dc919dcef491f13e451550c5e2" + }, + "wrapperSha256": "2b590e8e12133a77ee71cba2a688bf1e2bc73cfb771235a823ed9cdc3cd8c095", + "helperSha256": "153caaa32b93313a81636a28af32ca8dcce295c200853f5897e0387f79a0d92d", + "extensionSha256": "2b74a4a2e6a42def3d9918a46732e0e1b8c618791d2b22970cfb3e9d285e55ad", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v2", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v8-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v8-identity.json new file mode 100644 index 0000000000..434a7c0ad8 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v8-identity.json @@ -0,0 +1,29 @@ +{ + "commandDigest": "sha256:843d30e419914529755c9827d9151a306da1b50b643be7eab1abe797641a37ce", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 8, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "0.84.2", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "0187153354338cf4919d213dd2977e1be1073ad3941c7b9b5c8a09c5b32828e4", + "darwin-x64": "22c593576e78edb27bbe6234aabd31a7d65f6be7503683f568d0730ebe6c75a0", + "linux-x64": "016ce3653bdfc1c7269e3bf7d4bf26e48b0d0b7d409fd153f3f1b882e986654d" + }, + "wrapperSha256": "9fd9a685fb79b4f73a8dac08cbe360a6de87ffcb596cdaff1191e3ac57c24f56", + "helperSha256": "a19087c0af8fb3896fe3290280695f8f936574013bc4acde650f11f023f0f03b", + "extensionSha256": "2b74a4a2e6a42def3d9918a46732e0e1b8c618791d2b22970cfb3e9d285e55ad", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v2", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "nativeAssistantMessages": "sdk-boundaries-v1", + "nativeNotices": "session-extension-v1", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "c619080ec72a577889c382d44ea9024b10efdb9d83e933c03bfdd8da79ede591" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v9-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v9-identity.json new file mode 100644 index 0000000000..bf1e7ce157 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v9-identity.json @@ -0,0 +1,30 @@ +{ + "commandDigest": "sha256:edf058835ee84de3869c4a8e8bdb71a934ffdb9f34daa37ae6faeb92371d1cdf", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 9, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "0.84.2", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "0187153354338cf4919d213dd2977e1be1073ad3941c7b9b5c8a09c5b32828e4", + "darwin-x64": "22c593576e78edb27bbe6234aabd31a7d65f6be7503683f568d0730ebe6c75a0", + "linux-x64": "016ce3653bdfc1c7269e3bf7d4bf26e48b0d0b7d409fd153f3f1b882e986654d" + }, + "wrapperSha256": "9fd9a685fb79b4f73a8dac08cbe360a6de87ffcb596cdaff1191e3ac57c24f56", + "helperSha256": "a19087c0af8fb3896fe3290280695f8f936574013bc4acde650f11f023f0f03b", + "extensionSha256": "2b74a4a2e6a42def3d9918a46732e0e1b8c618791d2b22970cfb3e9d285e55ad", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v2", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "nativeAssistantMessages": "sdk-boundaries-v1", + "nativeNotices": "session-extension-v1", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "c619080ec72a577889c382d44ea9024b10efdb9d83e933c03bfdd8da79ede591", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/prompt-delivery.v6.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/prompt-delivery.v6.darwin-arm64.json new file mode 100644 index 0000000000..a86fc91592 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/prompt-delivery.v6.darwin-arm64.json @@ -0,0 +1,64 @@ +{ + "source": "42948b9d2807aac396b6457b8886f6777248fe0e", + "sdk": "0.84.2", + "wrapper": "0.0.33", + "profileVersion": 6, + "inferenceCalls": 0, + "modelNetworkAllowed": false, + "modelStream": "synthetic capture at actual AgentSession native model request boundary", + "reports": [ + { + "label": "fresh", + "modelRequests": 1, + "executionPromptPresent": true, + "customEntryPresent": true, + "updatedEntryPresent": false, + "genericMetaIgnored": true, + "currentAgentHomeInSystemPrompt": true, + "currentAgentHomeInLatestTask": true, + "previousRootAbsentFromSystemPrompt": null, + "persistedHistoryLoaded": false, + "systemPromptSha256": "a3f26862aed6d2d7801b7cbd3b311235556af2156fd3aa6aaf47401fe6415c3b", + "systemPromptBytes": 5488, + "sessionArgumentPresent": false + }, + { + "label": "loaded", + "modelRequests": 1, + "executionPromptPresent": true, + "customEntryPresent": true, + "updatedEntryPresent": true, + "genericMetaIgnored": true, + "currentAgentHomeInSystemPrompt": true, + "currentAgentHomeInLatestTask": true, + "previousRootAbsentFromSystemPrompt": true, + "persistedHistoryLoaded": true, + "systemPromptSha256": "282704630281dcd7f181a3f12adc030f45c7a135fdc4954ce3a6e2de91723e9f", + "systemPromptBytes": 5515, + "sessionArgumentPresent": true + } + ], + "sha256": { + "probe.mjs": "3c12499e4a078bf96afd4f74b65042da10d13efa998b982571ff6ea8a06001ff", + "packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts": "86faeb55dbc58c268e8210b9ffd2997d6067d116723b776e623eec2fa40f1fde", + "packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts": "ff9aa9773d17351ba5fe01274f64ef0a9845a33e91820d539c3f7674cd5b426b", + "packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts": "d30c99fbb46baa335034f2faf105decef9624a84cda8b0db373a008e435962c7", + "paperclip.js": "d479d4f510295077b8904645e161a527531233a688760a80cca8db9ae01f3b02", + "paperclip-runtime.js": "2df24aee67ca6a5cc813e1f87324f51a72f6a104757daed97a09cbbcb63626d4", + "packages/paperclip-runner/src/backends/runtime-context.ts": "e7c46e81cc9c93f9a4f805b5091ef08c59ea70c62f208cfda70d6edeed363898", + "packages/paperclip-runner/src/contracts/runtime-context.ts": "a17e30a69f3cb38fba00c37e1a06753d723651a004e6050c9e77ae202a61264b" + }, + "limitations": [ + "This directly exercises actual shipped wrapper launch helper and extension plus actual SDK AgentSession native model-request boundary. It does not launch the full ACPX/PRP wrapper process or call a network provider.", + "Uses a synthetic in-memory non-provider credential to pass native SDK auth preflight; no real credentials loaded.", + "Earlier saved user messages retain historical root text. The fresh system prompt and latest task constraints identify the current root and explicitly supersede earlier guidance.", + "Shared composition and host-to-environment binding are separately established by source inspection and existing tests; no end-to-end paid Product claim." + ], + "schema": "paperclip.pi-model-prompt-delivery-proof.v1", + "recordedAt": "2026-09-29", + "runtimeArtifactSource": "edf14a067ee7fba8d472d3cd9143f724fd51f4b5", + "runtimeArtifactProviderPackDigest": "sha256:fcf9802008195797fc5e8f69954ff6d238a3b7518bd21af12e625d7549462145", + "profileDigest": "sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f", + "runtimeArtifactRelation": "The v6 wrapper helper and extension bytes are unchanged by the later host-side startup optimization. Shared composition was imported from the diagnostic EDF build; current source behavior is unchanged.", + "sanitization": "Only versions, source/artifact hashes, request counts, assertion outcomes and limitations are retained. Full prompts, custom entry text, session files and private paths are excluded." +} 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..8c917d959d --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/reserved-credential-names.json @@ -0,0 +1,45 @@ +[ + "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", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN" +] diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/security-closure-proof.v5.json b/packages/paperclip-runner/test-fixtures/pi-acp/security-closure-proof.v5.json new file mode 100644 index 0000000000..c7bf2776f7 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/security-closure-proof.v5.json @@ -0,0 +1,173 @@ +{ + "schema": "paperclip.pi-security-closure-proof.v1", + "sourceRevision": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "advisory": "https://github.com/advisories/GHSA-3wwx-pv8p-q78v", + "profileVersion": 5, + "profileDigest": "sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "npmCiInstalledVersionBeforeOwnedReplacement": "8.9.0", + "installedVersionAfterOwnedReplacement": "8.10.2", + "isolatedLockUnchangedByInstall": true, + "isolatedLockSha256": "0516456f6da2b09f35b15b17b558780f9d98419d2d911a37416e07cf0e306df4", + "upstreamMetadata": { + "package.json": { + "sha256": "820f4adc6d61f2cefbc29ce17e9dfd9aa482248d54be5d0dfa2a868ca000c7b0", + "unchangedFromV4": true + }, + "npm-shrinkwrap.json": { + "sha256": "e09ee75249d2381fbb66d2199c9e699910ce3fa7608d55e93f6fe37c3ded5973", + "unchangedFromV4": true + } + }, + "node": { + "version": "24.21.0", + "bundledUndici": "7.29.1", + "sha256": "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b" + }, + "targets": { + "darwin-arm64": { + "archiveSha256": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", + "executableSha256": "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b", + "executableSize": 122129232 + }, + "darwin-x64": { + "archiveSha256": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", + "executableSha256": "7abcf39bd37ab251015337ff75304d7555f0d8e88c6e0fbf04bce8ce34636f49", + "executableSize": 125270960 + }, + "linux-x64": { + "archiveSha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "executableSha256": "7fde7b8afa198da66257f42ee2001d874c7355631e6d1579a5fb5ef1f246df4c", + "executableSize": 126595440 + } + }, + "closureSha256": { + "darwin-arm64": "0010175e4bc200f145386e59f7a824cf7532ad273e9db6824d22c4f82cdcd6ff", + "darwin-x64": "9dd49bd1341a6e3438db56cc6b3dc772964ebcdd0552bcfbf2e5e621b2970ab6", + "linux-x64": "91169667544764764e2e402868fbb76a9a429575cfdb1b312b8ed8ae669ebc89" + }, + "verifiedFileCount": 13827, + "changedRuntimeFiles": [ + "node/bin/node", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/docs/docs/api/Client.md", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/docs/docs/api/EnvHttpProxyAgent.md", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/docs/docs/api/EventSource.md", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/docs/docs/api/Interceptors.md", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/docs/docs/api/Socks5ProxyAgent.md", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/api/readable.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/cache/memory-cache-store.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/core/connect.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/core/symbols.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/agent.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/balanced-pool.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/client-h1.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/client-h2.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/client.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/dispatcher-base.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/dispatcher.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/dispatcher1-wrapper.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/env-http-proxy-agent.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/proxy-agent.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/retry-agent.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/round-robin-pool.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/dispatcher/socks5-proxy-agent.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/handler/cache-handler.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/handler/decorator-handler.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/handler/deduplication-handler.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/handler/redirect-handler.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/handler/retry-handler.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/interceptor/cache.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/interceptor/decompress.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/interceptor/deduplicate.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/interceptor/dns.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/interceptor/dump.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/mock/mock-agent.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/mock/mock-utils.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/util/cache.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/cookies/parse.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/eventsource/eventsource-stream.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/eventsource/eventsource.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/fetch/index.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/fetch/request.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/fetch/util.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/webidl/index.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/websocket/connection.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/websocket/permessage-deflate.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/websocket/stream/websocketstream.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/lib/web/websocket/websocket.js", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/package.json", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/types/client.d.ts", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/types/interceptors.d.ts", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/types/mock-agent.d.ts", + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici/types/socks5-proxy-agent.d.ts", + "package-lock.json", + "package.json" + ], + "archiveBounds": { + "compressedBytes": 4194304, + "inflatedTarBytes": 4194304, + "regularFiles": 214 + }, + "credentialAccess": false, + "providerPrompts": 0, + "providerInferenceCalls": 0, + "x64ExecutionQualified": false, + "tests": { + "materializer": 8, + "typescriptIncludingActualInstallation": 43, + "installedWrapperAndNativeSdk": 19, + "rustNativeBackend": 13 + }, + "independentReview": { + "source": "aec26ad83f1d082f0d0a5eaffbd615a9e2e26155", + "review": "read-only credential-free", + "packageCount": 143, + "verifiedFiles": 13827, + "closureSha256": "0010175e4bc200f145386e59f7a824cf7532ad273e9db6824d22c4f82cdcd6ff", + "profileSha256": "020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803", + "node": { + "node": "24.21.0", + "undici": "7.29.1" + }, + "installedUndici": "8.10.2", + "unchangedUpstreamMetadata": { + "package.json": "820f4adc6d61f2cefbc29ce17e9dfd9aa482248d54be5d0dfa2a868ca000c7b0", + "npm-shrinkwrap.json": "e09ee75249d2381fbb66d2199c9e699910ce3fa7608d55e93f6fe37c3ded5973" + }, + "unchangedDistributionLockSha256": "0516456f6da2b09f35b15b17b558780f9d98419d2d911a37416e07cf0e306df4", + "scope": "Reviewed source upgrades Pi private interpreter. Parent separately authorized outer pack Node24.21.0 and Docker provider-build Node24.21.0; resulting mac/Linux artifacts are not covered by this installed-distribution proof.", + "x64Execution": "not tested", + "inferenceRequests": 0, + "verification": { + "materializer": { + "passed": 8, + "failed": 0 + }, + "profileAndRecovery": { + "passed": 12, + "failed": 0, + "skipped": 1, + "skip": "Optional actual snapshot launch separately covered by Pi implementer" + }, + "retainedOperatorSetupFailures": 2 + }, + "warmIdentityReview": { + "piOldProfileVersionsRejected": [ + 1, + 2, + 3, + 4 + ], + "privateProfileBinds": [ + "wrapper patch", + "all platform closure pins", + "Node executable via closure" + ], + "outerPackBinds": [ + "Node artifact sha256", + "canonical manifest digest", + "runner launch authority digest", + "persisted launch_profile_digest" + ] + } + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/startup-diagnostic.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/startup-diagnostic.darwin-arm64.json new file mode 100644 index 0000000000..0633e0f144 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/startup-diagnostic.darwin-arm64.json @@ -0,0 +1,97 @@ +{ + "schema": "paperclip.pi-startup-diagnostic.v1", + "recordedAt": "2026-09-28T20:14:44.089567+00:00", + "sourceRevision": "9f2d0420ed9a398e3bacd1d7e996614f3296ac56", + "providerPackDigest": "sha256:2a6f457205743487cdd35ed3634532028e6e0200001f70e087d0e084fc85d452", + "profileDigest": "sha256:0f687e38cb3c607a01fab80f03e19bbbf5cb53a8afb1fc40d71f9ab54551cff1", + "runnerdDigest": "sha256:3fdff11d17faf096fce29b57a8e762e6dd241ef5009771913eb4798d10a0deca", + "environment": { + "platform": "darwin", + "architecture": "arm64", + "node": "24.19.0" + }, + "credentialFreeReproduction": { + "durationMs": 40572, + "result": "runner_local_connect_failed: PRP command session.open timed out", + "runPrepare": "completed", + "sessionOpen": "pending", + "providerIdentityReported": false, + "credentials": "none", + "promptCalls": 0, + "providerCalls": 0 + }, + "before": { + "closedFiles": 13827, + "closedBytes": 234019683, + "stages": [ + { + "phase": "verifyInstallation", + "durationMs": 5168, + "elapsedMs": 5168 + }, + { + "phase": "openImmutableCommand", + "durationMs": 37423, + "elapsedMs": 42591 + }, + { + "phase": "closeImmutableCommand", + "durationMs": 5978, + "elapsedMs": 48568 + } + ], + "sessionOpenDeadlineMs": 30000 + }, + "repair": { + "foundationCommit": "2a30219f194cb094b18b5a19ffc5991b47924088", + "moduleSha256": "ad88337545673913a24ed283bf0b53016737cae471ef4822b92cc2930e31398d", + "copyMs": 5851, + "cleanupMs": 1026, + "copyConcurrency": 8, + "copyBufferBudgetBytes": 33554432, + "oversizedFilePolicy": "one admitted file alone; unchanged384MiB file limit", + "focusedTestsPassed": 10, + "sourceAndSurfacesTypecheck": "passed", + "activeOrStartupTimeoutChanged": false, + "retainedGuarantees": [ + "no-follow and realpath confinement", + "held descriptor identity before/after reads", + "content digest verification", + "root identity checks", + "canonical manifest ordering", + "all in-flight writes drained before failure cleanup" + ] + }, + "limits": [ + "The full post-fix credential-free Runner request settles within the unchanged deadline; this is not authenticated-session success.", + "No paid prompt or provider request was issued by either diagnostic.", + "Private process identifiers, credentials and raw traces are excluded." + ], + "postFixCredentialFreeRunnerProbe": { + "sourceRevision": "dd78df1ef8b279c30c710c9b7a7f9fda22e321d6", + "providerPackDigest": "sha256:f75d3de7814276508f3706edb6e4510ce1dcecbc3e8fa674991fd554e5a75273", + "runnerdDigest": "sha256:f9aad049d57a2073ce4ec96b6577266afdba5b99db75bb8fc21825a5b7281e39", + "requestSettledMs": 10087, + "includingCloseMs": 10198, + "sessionOpenDeadlineMs": 30000, + "result": "terminal session_ensure_failed rejection", + "timedOut": false, + "underlyingErrorVisibility": "The outer PRP response does not preserve the missing-credential message. The independent immutable ACP launch from this exact pack asserted that message at session/new.", + "authenticatedSessionSuccess": false, + "credentials": "none", + "promptCalls": 0, + "providerCalls": 0 + }, + "version3CredentialFreeRunnerProbe": { + "sourceRevision": "7710736ca3924c655c5b0efd172cfd3c0173766a", + "runtimeCodeRevision": "06cf356a8bc94f709fcd15606fe05e17933fe3b2", + "providerPackDigest": "sha256:eb31cadae93805b901d2565912121fca6e79024c0120b1bd7982e05215b5aa5a", + "profileVersion": 3, + "profileDigest": "sha256:72cb225288376f733b9ed3afa5e13565eb4152f0de509bc1181382fa44bee472", + "runnerdDigest": "sha256:2c8efdc99f988b1c8ab0e270677a3321af0fd6d5789dbf1b671faa921031b42f", + "durationIncludingCloseMs": 12111, + "result": "typed session_ensure_failed rejection before prompt; direct ACP probe separately verified missing-bound-credential error", + "authenticatedSessionSuccess": false, + "promptCalls": 0 + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/startup-diagnostic.v6.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/startup-diagnostic.v6.darwin-arm64.json new file mode 100644 index 0000000000..f94e67713b --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/startup-diagnostic.v6.darwin-arm64.json @@ -0,0 +1,137 @@ +{ + "schema": "paperclip.pi-startup-diagnostic.v2", + "recordedAt": "2026-09-29", + "profileVersion": 6, + "profileDigest": "sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f", + "baselineSourceRevision": "edf14a067ee7fba8d472d3cd9143f724fd51f4b5", + "platform": "darwin-arm64", + "nodeVersion": "24.21.0", + "paidFailure": { + "reservationId": "pi-v6-local-hello-20260929-02", + "cell": "extended-harnesses.runner-acpx-pi.local.hello-complete", + "providerPackDigest": "sha256:fcf9802008195797fc5e8f69954ff6d238a3b7518bd21af12e625d7549462145", + "sidecarSha256": "788ec4fe2e4280181fe8fb22784253727ca52e3d81893e15a6e9dab6f853f61c", + "nodeSha256": "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b", + "daemonSha256": "06343113009a039be6b69523c1a4d37009f52d1af1d13cab1a8a33b61dffe2ea", + "result": "runner_local_connect_failed: PRP command session.open timed out", + "runCount": 1, + "durationMs": 121752, + "terminalUsage": "unavailable", + "exclusiveKeyDeltaUsd": 0, + "delayedKeyDeltaUsd": 0, + "delayedObservationAt": "2026-09-29T15:22:15.704922+00:00", + "cleanup": "passed; all 40 owned process identities gone" + }, + "baselineNoKey": { + "durationMs": 35629, + "result": "PRP command session.open timed out", + "promptCalls": 0 + }, + "baselineStages": [ + { + "stage": "verifyPiInstallation", + "durationMs": 70458 + }, + { + "stage": "openCommand-snapshot", + "durationMs": 51936 + }, + { + "stage": "close-snapshot", + "durationMs": 4469 + } + ], + "diagnosticBuild": { + "baseSourceRevision": "edf14a067ee7fba8d472d3cd9143f724fd51f4b5", + "changedFiles": [ + { + "path": "packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts", + "sha256": "fb574b6636dfcebd45fb0be826d3b12cc8411dc90139b85816d1aefad9cceb2d" + }, + { + "path": "packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts", + "sha256": "b5beb5043242c69e50775a900465a3fe0bed5e2cfa2a36b8db9c8cc02be53196" + }, + { + "path": "packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts", + "sha256": "15a3be4df8edfe8b195ca4c49218b06e82198483a8d2bdaea8518ed89c5b4f09" + }, + { + "path": "packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts", + "sha256": "6a08420964ac3f8c91a248b90098cd025a47612ed4060de43db30d1ef80be296" + } + ], + "sidecarSha256": "7c1cb521d8b02afb7cea85d3f55c91b37c4a2cdfcccb2e72f7cde7eb478cf3cb", + "dependencies": "pristine private copy-import dependencies from frozen baseline; no ambient ACPX inputs", + "providerClosureUnchanged": true, + "newProviderPackBuilt": false + }, + "controlledPoolBenchmark": { + "baseSource": "edf14a067ee7fba8d472d3cd9143f724fd51f4b5", + "diagnosticSource": "uncommitted bounded-concurrency verifier changes", + "credentials": "none", + "providerProcesses": 0, + "providerCalls": 0, + "times": [ + { + "stage": "Pi-inventory-eight-hash-streams", + "durationMs": 24429 + }, + { + "stage": "native-snapshot-8-workers", + "durationMs": 30413 + }, + { + "stage": "native-snapshot-8-cleanup", + "durationMs": 1926 + }, + { + "stage": "native-snapshot-32-workers", + "durationMs": 9815 + }, + { + "stage": "native-snapshot-32-cleanup", + "durationMs": 3475 + } + ] + }, + "pooledStages": [ + { + "stage": "verifyPiInstallation", + "durationMs": 1329 + }, + { + "stage": "openCommand-snapshot", + "durationMs": 2569 + }, + { + "stage": "close-snapshot", + "durationMs": 975 + } + ], + "pooledNoKey": { + "requestSettledMs": 9772, + "includingCloseMs": 9899, + "result": "terminal nonretryable session_ensure_failed rejection", + "deadlineMs": 30000, + "promptCalls": 0, + "credentials": "none", + "authenticatedSuccess": false + }, + "regressions": { + "integrityAndInstallation": "19 passed, 1 optional installed-runtime skip", + "actualImmutableAcpInstallation": "5 passed, including exact missing-bound-credential error", + "actualRunnerPrp": "1 passed; terminal rejection 10656 ms, closed 10756 ms, daemon exit 0, no active turn or identity, child environment only LANG/PATH", + "typescriptBuild": "passed" + }, + "limits": [ + "Timings are observations under different filesystem cache/load conditions, not a latency guarantee or a controlled speedup ratio.", + "Neither no-key diagnostic proves authenticated Product success. Current v6 Product 0/5 and Runner 0/7 remain unqualified.", + "All file reads, digests, held-descriptor and link checks remain; no cache or timeout change. Profile declaration binds provider closure, not host verifier implementation, so v6 identity is unchanged while source and sidecar change." + ], + "inventory32Observation": { + "discoveryMs": 9809, + "includingHashMs": 13917, + "unchangedManifestDigest": "sha256:9c4932e603e2c999d542d39e3a8de107dc8a2aef2576d8339f62d5a93a37869c" + } +} 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-message-boundaries-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-message-boundaries-package-contract.test.mjs new file mode 100644 index 0000000000..af58aff37d --- /dev/null +++ b/packages/paperclip-runner/test/acpx-message-boundaries-package-contract.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const root = process.env.PAPERCLIP_TEST_ACPX_ROOT ?? dirname(require.resolve("acpx/package.json")); +const { createAcpRuntime, createAgentRegistry, createFileSessionStore } = await import(pathToFileURL(join(root, "dist/runtime.js"))); +const fixture = String.raw` +const readline = require('node:readline'); +const send = value => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n'); +readline.createInterface({input:process.stdin}).on('line',line=>{ + const request=JSON.parse(line); + if(request.method==='initialize') send({id:request.id,result:{protocolVersion:1,agentCapabilities:{sessionCapabilities:{close:{}}},authMethods:[]}}); + else if(request.method==='session/new') send({id:request.id,result:{sessionId:'fixture-session'}}); + else if(request.method==='session/prompt') { + for(const update of JSON.parse(request.params.prompt[0].text)) send({method:'session/update',params:{sessionId:'fixture-session',update}}); + send({id:request.id,result:{stopReason:'end_turn'}}); + } else if(request.id!==undefined) send({id:request.id,result:{}}); +});`; + +test("actual ACP SDK and runtime retain identified empty assistant boundaries in order", { timeout: 15_000 }, async () => { + const cwd = await mkdtemp(join(tmpdir(), "paperclip-acpx-message-boundary-")); + const children = []; + const runtime = createAcpRuntime({ cwd, sessionStore: createFileSessionStore({ stateDir: join(cwd, "sessions") }), + agentRegistry: createAgentRegistry({ overrides: { pi: ["verified-fixture"] } }), permissionMode: "approve-all", + spawnAgent: () => { const child = spawn(process.execPath, ["-e", fixture], { cwd, stdio: ["pipe", "pipe", "pipe"] }); children.push(child); return child; }, + }); + let handle; + try { + handle = await runtime.ensureSession({ sessionKey: "fixture-boundaries", agent: "pi", mode: "persistent", cwd }); + const chunk = (messageId, text, kind = "delta", thought = false) => ({ sessionUpdate: thought ? "agent_thought_chunk" : "agent_message_chunk", + ...(messageId ? { messageId } : {}), content: { type: "text", text }, + _meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind, unrelated: "DROP_ME" }, + }); + const updates = [chunk("first", "", "start"), chunk("first", "pre-tool narration"), chunk("first", "", "end:toolUse"), + chunk("last", "", "start"), chunk("last", "thought", "delta", true), chunk("last", "EXACT_MARKER"), chunk("last", "", "end:stop"), + chunk(undefined, ""), { sessionUpdate: "agent_message_chunk", messageId: "no-text", content: { type: "image", data: "", mimeType: "image/png" } }]; + for (let index = 0; index < 2; index++) { + const turn = runtime.startTurn({ handle, text: JSON.stringify(updates), mode: "prompt", sessionMode: "persistent", requestId: `request-${index}`, timeoutMs: 5000 }); + const events = []; + for await (const event of turn.events) if (event.type === "text_delta") events.push(event); + assert.equal((await turn.result).status, "completed"); + assert.deepEqual(events.map(event => [event.messageId, event.text, event.meta?.kind, event.stream]), [ + ["first", "", "start", "output"], ["first", "pre-tool narration", "delta", "output"], ["first", "", "end:toolUse", "output"], + ["last", "", "start", "output"], ["last", "thought", "delta", "thought"], ["last", "EXACT_MARKER", "delta", "output"], ["last", "", "end:stop", "output"], + ]); + assert.equal(JSON.stringify(events).includes("DROP_ME"), false); + } + } finally { + try { if (handle) await runtime.close({ handle, discardPersistentState: true }); } + finally { for (const child of children) child.kill("SIGTERM"); await rm(cwd, { recursive: true, force: true }); } + } +}); diff --git a/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs index 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-acp-denial-1.0.88.json b/packages/paperclip-runner/test/fixtures/copilot-acp-denial-1.0.88.json new file mode 100644 index 0000000000..da4123f544 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-acp-denial-1.0.88.json @@ -0,0 +1,862 @@ +{ + "schema": "paperclip.copilot-acp-evidence/v1", + "harnessVersion": "1.0.88", + "package": "@github/copilot-darwin-arm64", + "executableSha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "modelSource": "deterministic loopback fixture, COPILOT_OFFLINE=true; not a live model qualification", + "scenario": "deny-write", + "costUsd": 0, + "filesystemMarkerExistedAtPromptResult": false, + "filesystemMarkerExistedAfterPrompt": false, + "modelCalls": 1, + "modelToolNames": [ + "bash", + "read_bash", + "stop_bash", + "list_bash", + "view", + "create", + "edit", + "skill", + "sql", + "session_store_sql", + "read_agent", + "list_agents", + "write_agent", + "grep", + "glob", + "task" + ], + "permissionResponses": [ + { + "id": 0, + "outcome": { + "outcome": "selected", + "optionId": "reject_once" + } + } + ], + "wire": [ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": true + }, + "sessionCapabilities": { + "close": {}, + "list": {} + } + }, + "agentInfo": { + "name": "Copilot", + "title": "Copilot", + "version": "1.0.88" + }, + "authMethods": [ + { + "id": "copilot-login", + "name": "Log in with Copilot CLI", + "description": "Run `copilot login` in the terminal", + "_meta": { + "terminal-auth": { + "command": "/fixture/verified/copilot", + "args": [ + "login" + ], + "label": "Copilot Login" + } + } + } + ] + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "modes": { + "availableModes": [ + { + "id": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "id": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "id": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "currentModeId": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "add-dir", + "description": "Allow file access to a directory and load its .github skills and agents as trusted configuration", + "input": { + "hint": "directory" + } + }, + { + "name": "allow-all", + "description": "Enable all permissions", + "input": { + "hint": "[on|off|show]" + } + }, + { + "name": "permissions", + "description": "Switch between permission modes", + "input": { + "hint": "[default|allow-all|show]" + } + }, + { + "name": "autopilot", + "description": "Toggle autopilot mode, or set an autopilot objective with an optional AI-credit limit (--max-ai-credits)", + "input": { + "hint": "[on|off|] [--max-ai-credits ]" + } + }, + { + "name": "compact", + "description": "Summarize conversation history to reduce context window usage. Optionally provide focus instructions.", + "input": { + "hint": "focus instructions" + } + }, + { + "name": "blame", + "description": "Trace code history with git blame and linked Copilot sessions", + "input": { + "hint": "text | pull request | commit" + } + }, + { + "name": "chronicle", + "description": "Session history tools and insights", + "input": { + "hint": "standup|search|tips|cost-tips|improve|reindex" + } + }, + { + "name": "computer", + "description": "Show or toggle Computer Use", + "input": { + "hint": "" + } + }, + { + "name": "context", + "description": "Show context window token usage and visualization" + }, + { + "name": "cwd", + "description": "Change working directory or show current directory", + "input": { + "hint": "directory" + } + }, + { + "name": "env", + "description": "Show loaded environment details (instructions, MCP servers, skills, agents, hooks, plugins, LSPs, extensions)" + }, + { + "name": "every", + "description": "Schedule a recurring prompt or skill for this session", + "input": { + "hint": " " + } + }, + { + "name": "after", + "description": "Schedule a one-shot prompt or skill for this session", + "input": { + "hint": " " + } + }, + { + "name": "fleet", + "description": "Enable fleet mode for parallel subagent execution", + "input": { + "hint": "prompt" + } + }, + { + "name": "init", + "description": "Initialize Copilot instructions for this repository", + "input": { + "hint": "" + } + }, + { + "name": "list-dirs", + "description": "Display allowed directories and exact session path grants" + }, + { + "name": "mcp", + "description": "Manage MCP server configuration", + "input": { + "hint": "" + } + }, + { + "name": "memory", + "description": "Show memory status, or enable/disable memory across sessions", + "input": { + "hint": "" + } + }, + { + "name": "model", + "description": "Select the AI model for this session (use 'auto' to let Copilot pick automatically). Use /config model to set the user default, '--repo'/'--local' to set the repo default, or 'plan'/'--plan' to set the plan-mode model.", + "input": { + "hint": "model" + } + }, + { + "name": "plan", + "description": "Create an implementation plan before coding", + "input": { + "hint": "prompt" + } + }, + { + "name": "plugin", + "description": "Manage plugins and plugin marketplaces", + "input": { + "hint": "" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "name" + } + }, + { + "name": "research", + "description": "Run deep research investigation using GitHub search and web sources", + "input": { + "hint": "topic" + } + }, + { + "name": "remote", + "description": "Show remote status or toggle remote control from GitHub web and mobile", + "input": { + "hint": "" + } + }, + { + "name": "reset-allowed-tools", + "description": "Reset session tool and exact-path approvals" + }, + { + "name": "review", + "description": "Run code review agent to analyze changes", + "input": { + "hint": "additional instructions" + } + }, + { + "name": "sandbox", + "description": "Manage sandbox settings, including file path permissions, network permissions, and more", + "input": { + "hint": "" + } + }, + { + "name": "security-review", + "description": "Analyze staged and unstaged changes for security vulnerabilities.", + "input": { + "hint": "additional instructions" + } + }, + { + "name": "session", + "description": "View and manage sessions", + "input": { + "hint": "" + } + }, + { + "name": "share", + "description": "Share the session via GitHub and get a shareable link", + "input": { + "hint": "" + } + }, + { + "name": "skills", + "description": "Manage skills for enhanced capabilities", + "input": { + "hint": "" + } + }, + { + "name": "subconscious", + "description": "Manage Copilot Subconscious memory consolidation", + "input": { + "hint": "" + } + }, + { + "name": "usage", + "description": "Display session usage metrics and statistics" + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "add-dir", + "description": "Allow file access to a directory and load its .github skills and agents as trusted configuration", + "input": { + "hint": "directory" + } + }, + { + "name": "allow-all", + "description": "Enable all permissions", + "input": { + "hint": "[on|off|show]" + } + }, + { + "name": "permissions", + "description": "Switch between permission modes", + "input": { + "hint": "[default|allow-all|show]" + } + }, + { + "name": "autopilot", + "description": "Toggle autopilot mode, or set an autopilot objective with an optional AI-credit limit (--max-ai-credits)", + "input": { + "hint": "[on|off|] [--max-ai-credits ]" + } + }, + { + "name": "compact", + "description": "Summarize conversation history to reduce context window usage. Optionally provide focus instructions.", + "input": { + "hint": "focus instructions" + } + }, + { + "name": "blame", + "description": "Trace code history with git blame and linked Copilot sessions", + "input": { + "hint": "text | pull request | commit" + } + }, + { + "name": "chronicle", + "description": "Session history tools and insights", + "input": { + "hint": "standup|search|tips|cost-tips|improve|reindex" + } + }, + { + "name": "computer", + "description": "Show or toggle Computer Use", + "input": { + "hint": "" + } + }, + { + "name": "context", + "description": "Show context window token usage and visualization" + }, + { + "name": "cwd", + "description": "Change working directory or show current directory", + "input": { + "hint": "directory" + } + }, + { + "name": "env", + "description": "Show loaded environment details (instructions, MCP servers, skills, agents, hooks, plugins, LSPs, extensions)" + }, + { + "name": "every", + "description": "Schedule a recurring prompt or skill for this session", + "input": { + "hint": " " + } + }, + { + "name": "after", + "description": "Schedule a one-shot prompt or skill for this session", + "input": { + "hint": " " + } + }, + { + "name": "fleet", + "description": "Enable fleet mode for parallel subagent execution", + "input": { + "hint": "prompt" + } + }, + { + "name": "init", + "description": "Initialize Copilot instructions for this repository", + "input": { + "hint": "" + } + }, + { + "name": "list-dirs", + "description": "Display allowed directories and exact session path grants" + }, + { + "name": "mcp", + "description": "Manage MCP server configuration", + "input": { + "hint": "" + } + }, + { + "name": "memory", + "description": "Show memory status, or enable/disable memory across sessions", + "input": { + "hint": "" + } + }, + { + "name": "model", + "description": "Select the AI model for this session (use 'auto' to let Copilot pick automatically). Use /config model to set the user default, '--repo'/'--local' to set the repo default, or 'plan'/'--plan' to set the plan-mode model.", + "input": { + "hint": "model" + } + }, + { + "name": "plan", + "description": "Create an implementation plan before coding", + "input": { + "hint": "prompt" + } + }, + { + "name": "plugin", + "description": "Manage plugins and plugin marketplaces", + "input": { + "hint": "" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "name" + } + }, + { + "name": "research", + "description": "Run deep research investigation using GitHub search and web sources", + "input": { + "hint": "topic" + } + }, + { + "name": "remote", + "description": "Show remote status or toggle remote control from GitHub web and mobile", + "input": { + "hint": "" + } + }, + { + "name": "reset-allowed-tools", + "description": "Reset session tool and exact-path approvals" + }, + { + "name": "review", + "description": "Run code review agent to analyze changes", + "input": { + "hint": "additional instructions" + } + }, + { + "name": "sandbox", + "description": "Manage sandbox settings, including file path permissions, network permissions, and more", + "input": { + "hint": "" + } + }, + { + "name": "security-review", + "description": "Analyze staged and unstaged changes for security vulnerabilities.", + "input": { + "hint": "additional instructions" + } + }, + { + "name": "session", + "description": "View and manage sessions", + "input": { + "hint": "" + } + }, + { + "name": "share", + "description": "Share the session via GitHub and get a shareable link", + "input": { + "hint": "" + } + }, + { + "name": "skills", + "description": "Manage skills for enhanced capabilities", + "input": { + "hint": "" + } + }, + { + "name": "subconscious", + "description": "Manage Copilot Subconscious memory consolidation", + "input": { + "hint": "" + } + }, + { + "name": "usage", + "description": "Display session usage metrics and statistics" + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "update": { + "sessionUpdate": "session_info_update", + "title": "Reply with Fixture complete.", + "updatedAt": "2026-09-28T16:16:24.804Z" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "update": { + "sessionUpdate": "usage_update", + "used": 9949, + "size": 64000 + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "type": "assistant.usage", + "timestamp": "2026-09-28T16:16:24.934Z", + "data": { + "model": "gpt-4.1", + "inputTokens": 0, + "outputTokens": 0, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "cost": 0, + "duration": 2, + "timeToFirstTokenMs": 1.585917, + "outputTtftMs": 1.585958, + "interTokenLatencyMs": 0.08529099999999999, + "initiator": "user", + "interactionType": "conversation-agent", + "isByok": true, + "isAuto": false, + "maxPromptTokens": 64000, + "transport": "http", + "apiCallId": "fixture-1", + "rte": false, + "apiEndpoint": "/chat/completions", + "quotaSnapshots": {}, + "availableToolCount": 16, + "toolTokenCount": 5446, + "frontierSource": "estimated", + "cacheDetailsReported": false, + "numToolCalls": 1, + "toolCounts": { + "create": 1 + }, + "finishReason": "tool_calls", + "contentFilterTriggered": false + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "fixture-tool", + "title": "Creating ...T/paperclip-copilot-offline-0nlil9t0/denied.txt", + "kind": "edit", + "status": "pending", + "rawInput": { + "path": "/fixture/workspace/denied.txt", + "file_text": "MUST NOT EXIST" + }, + "locations": [ + { + "path": "/fixture/workspace/denied.txt" + } + ], + "content": [ + { + "type": "diff", + "path": "/fixture/workspace/denied.txt", + "oldText": "", + "newText": "MUST NOT EXIST" + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "toolCall": { + "toolCallId": "fixture-tool", + "title": "Create file", + "kind": "edit", + "status": "pending", + "rawInput": { + "fileName": "/fixture/workspace/denied.txt", + "diff": "\ndiff --git a/fixture/workspace/denied.txt b/fixture/workspace/denied.txt\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/fixture/workspace/denied.txt\n@@ -1,0 +1,1 @@\n+MUST NOT EXIST\n" + }, + "locations": [ + { + "path": "/fixture/workspace/denied.txt" + } + ] + }, + "options": [ + { + "optionId": "allow_once", + "kind": "allow_once", + "name": "Allow once" + }, + { + "optionId": "allow_always", + "kind": "allow_always", + "name": "Always allow" + }, + { + "optionId": "reject_once", + "kind": "reject_once", + "name": "Deny" + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "fixture-tool", + "status": "failed", + "rawOutput": { + "message": "The user rejected this tool call.", + "code": "rejected" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:24.942Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "43e0f054-4c7e-48c6-b798-e13f2c4b291d", + "type": "session.idle", + "timestamp": "2026-09-28T16:16:24.943Z", + "data": { + "mode": "interactive" + } + } + }, + { + "jsonrpc": "2.0", + "id": 3, + "result": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 0, + "outputTokens": 0, + "totalTokens": 0, + "thoughtTokens": 0, + "cachedReadTokens": 0, + "cachedWriteTokens": 0 + } + } + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-acp-settlement-1.0.88.json b/packages/paperclip-runner/test/fixtures/copilot-acp-settlement-1.0.88.json new file mode 100644 index 0000000000..6e6eb7f2bc --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-acp-settlement-1.0.88.json @@ -0,0 +1,1322 @@ +{ + "schema": "paperclip.copilot-acp-evidence/v1", + "harnessVersion": "1.0.88", + "package": "@github/copilot-darwin-arm64", + "executableSha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "modelSource": "deterministic loopback fixture, COPILOT_OFFLINE=true; not a live model qualification", + "scenario": "attached-shell", + "costUsd": 0, + "filesystemMarkerExistedAtPromptResult": true, + "filesystemMarkerExistedAfterPrompt": true, + "modelCalls": 3, + "modelToolNames": [ + "bash", + "read_bash", + "stop_bash", + "list_bash", + "view", + "create", + "edit", + "skill", + "sql", + "session_store_sql", + "read_agent", + "list_agents", + "write_agent", + "grep", + "glob", + "task" + ], + "permissionResponses": [ + { + "id": 0, + "outcome": { + "outcome": "selected", + "optionId": "allow_once" + } + } + ], + "wire": [ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": true + }, + "sessionCapabilities": { + "close": {}, + "list": {} + } + }, + "agentInfo": { + "name": "Copilot", + "title": "Copilot", + "version": "1.0.88" + }, + "authMethods": [ + { + "id": "copilot-login", + "name": "Log in with Copilot CLI", + "description": "Run `copilot login` in the terminal", + "_meta": { + "terminal-auth": { + "command": "/fixture/verified/copilot", + "args": [ + "login" + ], + "label": "Copilot Login" + } + } + } + ] + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "modes": { + "availableModes": [ + { + "id": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "id": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "id": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "currentModeId": "https://agentclientprotocol.com/protocol/session-modes#agent" + }, + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "add-dir", + "description": "Allow file access to a directory and load its .github skills and agents as trusted configuration", + "input": { + "hint": "directory" + } + }, + { + "name": "allow-all", + "description": "Enable all permissions", + "input": { + "hint": "[on|off|show]" + } + }, + { + "name": "permissions", + "description": "Switch between permission modes", + "input": { + "hint": "[default|allow-all|show]" + } + }, + { + "name": "autopilot", + "description": "Toggle autopilot mode, or set an autopilot objective with an optional AI-credit limit (--max-ai-credits)", + "input": { + "hint": "[on|off|] [--max-ai-credits ]" + } + }, + { + "name": "compact", + "description": "Summarize conversation history to reduce context window usage. Optionally provide focus instructions.", + "input": { + "hint": "focus instructions" + } + }, + { + "name": "blame", + "description": "Trace code history with git blame and linked Copilot sessions", + "input": { + "hint": "text | pull request | commit" + } + }, + { + "name": "chronicle", + "description": "Session history tools and insights", + "input": { + "hint": "standup|search|tips|cost-tips|improve|reindex" + } + }, + { + "name": "computer", + "description": "Show or toggle Computer Use", + "input": { + "hint": "" + } + }, + { + "name": "context", + "description": "Show context window token usage and visualization" + }, + { + "name": "cwd", + "description": "Change working directory or show current directory", + "input": { + "hint": "directory" + } + }, + { + "name": "env", + "description": "Show loaded environment details (instructions, MCP servers, skills, agents, hooks, plugins, LSPs, extensions)" + }, + { + "name": "every", + "description": "Schedule a recurring prompt or skill for this session", + "input": { + "hint": " " + } + }, + { + "name": "after", + "description": "Schedule a one-shot prompt or skill for this session", + "input": { + "hint": " " + } + }, + { + "name": "fleet", + "description": "Enable fleet mode for parallel subagent execution", + "input": { + "hint": "prompt" + } + }, + { + "name": "init", + "description": "Initialize Copilot instructions for this repository", + "input": { + "hint": "" + } + }, + { + "name": "list-dirs", + "description": "Display allowed directories and exact session path grants" + }, + { + "name": "mcp", + "description": "Manage MCP server configuration", + "input": { + "hint": "" + } + }, + { + "name": "memory", + "description": "Show memory status, or enable/disable memory across sessions", + "input": { + "hint": "" + } + }, + { + "name": "model", + "description": "Select the AI model for this session (use 'auto' to let Copilot pick automatically). Use /config model to set the user default, '--repo'/'--local' to set the repo default, or 'plan'/'--plan' to set the plan-mode model.", + "input": { + "hint": "model" + } + }, + { + "name": "plan", + "description": "Create an implementation plan before coding", + "input": { + "hint": "prompt" + } + }, + { + "name": "plugin", + "description": "Manage plugins and plugin marketplaces", + "input": { + "hint": "" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "name" + } + }, + { + "name": "research", + "description": "Run deep research investigation using GitHub search and web sources", + "input": { + "hint": "topic" + } + }, + { + "name": "remote", + "description": "Show remote status or toggle remote control from GitHub web and mobile", + "input": { + "hint": "" + } + }, + { + "name": "reset-allowed-tools", + "description": "Reset session tool and exact-path approvals" + }, + { + "name": "review", + "description": "Run code review agent to analyze changes", + "input": { + "hint": "additional instructions" + } + }, + { + "name": "sandbox", + "description": "Manage sandbox settings, including file path permissions, network permissions, and more", + "input": { + "hint": "" + } + }, + { + "name": "security-review", + "description": "Analyze staged and unstaged changes for security vulnerabilities.", + "input": { + "hint": "additional instructions" + } + }, + { + "name": "session", + "description": "View and manage sessions", + "input": { + "hint": "" + } + }, + { + "name": "share", + "description": "Share the session via GitHub and get a shareable link", + "input": { + "hint": "" + } + }, + { + "name": "skills", + "description": "Manage skills for enhanced capabilities", + "input": { + "hint": "" + } + }, + { + "name": "subconscious", + "description": "Manage Copilot Subconscious memory consolidation", + "input": { + "hint": "" + } + }, + { + "name": "usage", + "description": "Display session usage metrics and statistics" + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "add-dir", + "description": "Allow file access to a directory and load its .github skills and agents as trusted configuration", + "input": { + "hint": "directory" + } + }, + { + "name": "allow-all", + "description": "Enable all permissions", + "input": { + "hint": "[on|off|show]" + } + }, + { + "name": "permissions", + "description": "Switch between permission modes", + "input": { + "hint": "[default|allow-all|show]" + } + }, + { + "name": "autopilot", + "description": "Toggle autopilot mode, or set an autopilot objective with an optional AI-credit limit (--max-ai-credits)", + "input": { + "hint": "[on|off|] [--max-ai-credits ]" + } + }, + { + "name": "compact", + "description": "Summarize conversation history to reduce context window usage. Optionally provide focus instructions.", + "input": { + "hint": "focus instructions" + } + }, + { + "name": "blame", + "description": "Trace code history with git blame and linked Copilot sessions", + "input": { + "hint": "text | pull request | commit" + } + }, + { + "name": "chronicle", + "description": "Session history tools and insights", + "input": { + "hint": "standup|search|tips|cost-tips|improve|reindex" + } + }, + { + "name": "computer", + "description": "Show or toggle Computer Use", + "input": { + "hint": "" + } + }, + { + "name": "context", + "description": "Show context window token usage and visualization" + }, + { + "name": "cwd", + "description": "Change working directory or show current directory", + "input": { + "hint": "directory" + } + }, + { + "name": "env", + "description": "Show loaded environment details (instructions, MCP servers, skills, agents, hooks, plugins, LSPs, extensions)" + }, + { + "name": "every", + "description": "Schedule a recurring prompt or skill for this session", + "input": { + "hint": " " + } + }, + { + "name": "after", + "description": "Schedule a one-shot prompt or skill for this session", + "input": { + "hint": " " + } + }, + { + "name": "fleet", + "description": "Enable fleet mode for parallel subagent execution", + "input": { + "hint": "prompt" + } + }, + { + "name": "init", + "description": "Initialize Copilot instructions for this repository", + "input": { + "hint": "" + } + }, + { + "name": "list-dirs", + "description": "Display allowed directories and exact session path grants" + }, + { + "name": "mcp", + "description": "Manage MCP server configuration", + "input": { + "hint": "" + } + }, + { + "name": "memory", + "description": "Show memory status, or enable/disable memory across sessions", + "input": { + "hint": "" + } + }, + { + "name": "model", + "description": "Select the AI model for this session (use 'auto' to let Copilot pick automatically). Use /config model to set the user default, '--repo'/'--local' to set the repo default, or 'plan'/'--plan' to set the plan-mode model.", + "input": { + "hint": "model" + } + }, + { + "name": "plan", + "description": "Create an implementation plan before coding", + "input": { + "hint": "prompt" + } + }, + { + "name": "plugin", + "description": "Manage plugins and plugin marketplaces", + "input": { + "hint": "" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "name" + } + }, + { + "name": "research", + "description": "Run deep research investigation using GitHub search and web sources", + "input": { + "hint": "topic" + } + }, + { + "name": "remote", + "description": "Show remote status or toggle remote control from GitHub web and mobile", + "input": { + "hint": "" + } + }, + { + "name": "reset-allowed-tools", + "description": "Reset session tool and exact-path approvals" + }, + { + "name": "review", + "description": "Run code review agent to analyze changes", + "input": { + "hint": "additional instructions" + } + }, + { + "name": "sandbox", + "description": "Manage sandbox settings, including file path permissions, network permissions, and more", + "input": { + "hint": "" + } + }, + { + "name": "security-review", + "description": "Analyze staged and unstaged changes for security vulnerabilities.", + "input": { + "hint": "additional instructions" + } + }, + { + "name": "session", + "description": "View and manage sessions", + "input": { + "hint": "" + } + }, + { + "name": "share", + "description": "Share the session via GitHub and get a shareable link", + "input": { + "hint": "" + } + }, + { + "name": "skills", + "description": "Manage skills for enhanced capabilities", + "input": { + "hint": "" + } + }, + { + "name": "subconscious", + "description": "Manage Copilot Subconscious memory consolidation", + "input": { + "hint": "" + } + }, + { + "name": "usage", + "description": "Display session usage metrics and statistics" + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "session_info_update", + "title": "Reply with Fixture complete.", + "updatedAt": "2026-09-28T16:16:25.619Z" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "usage_update", + "used": 9943, + "size": 64000 + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "assistant.usage", + "timestamp": "2026-09-28T16:16:25.692Z", + "data": { + "model": "gpt-4.1", + "inputTokens": 0, + "outputTokens": 0, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "cost": 0, + "duration": 2, + "timeToFirstTokenMs": 1.564876, + "outputTtftMs": 1.564917, + "interTokenLatencyMs": 0.101458, + "initiator": "user", + "interactionType": "conversation-agent", + "isByok": true, + "isAuto": false, + "maxPromptTokens": 64000, + "transport": "http", + "apiCallId": "fixture-1", + "rte": false, + "apiEndpoint": "/chat/completions", + "quotaSnapshots": {}, + "availableToolCount": 16, + "toolTokenCount": 5446, + "frontierSource": "estimated", + "cacheDetailsReported": false, + "numToolCalls": 1, + "toolCounts": { + "bash": 1 + }, + "finishReason": "tool_calls", + "contentFilterTriggered": false + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "fixture-tool", + "title": "Bounded settlement fixture", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "sleep 2; printf ACP_SHELL_DONE > settlement.txt", + "description": "Bounded settlement fixture", + "mode": "async", + "detach": false + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.720Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.720Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.721Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.721Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "toolCall": { + "toolCallId": "fixture-tool", + "title": "Bounded settlement fixture", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "sleep 2; printf ACP_SHELL_DONE > settlement.txt", + "commands": [ + "sleep 2; printf ACP_SHELL_DONE > settlement.txt" + ] + } + }, + "options": [ + { + "optionId": "allow_once", + "kind": "allow_once", + "name": "Allow once" + }, + { + "optionId": "allow_always", + "kind": "allow_always", + "name": "Always allow" + }, + { + "optionId": "reject_once", + "kind": "reject_once", + "name": "Deny" + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.724Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.724Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.724Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.725Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.725Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.725Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.734Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.736Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:25.736Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "fixture-tool", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "" + } + } + ], + "rawOutput": { + "content": "", + "detailedContent": "" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "usage_update", + "used": 10012, + "size": 64000 + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Fixture complete." + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "assistant.usage", + "timestamp": "2026-09-28T16:16:25.743Z", + "data": { + "model": "gpt-4.1", + "inputTokens": 10, + "outputTokens": 3, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "cost": 0, + "duration": 1, + "timeToFirstTokenMs": 0.7036250000000001, + "outputTtftMs": 0.703667, + "interTokenLatencyMs": 0.12470800000000001, + "initiator": "agent", + "interactionType": "conversation-agent", + "isByok": true, + "isAuto": false, + "maxPromptTokens": 64000, + "transport": "http", + "apiCallId": "fixture-1", + "rte": false, + "apiEndpoint": "/chat/completions", + "quotaSnapshots": {}, + "availableToolCount": 16, + "toolTokenCount": 5446, + "frontierSource": "estimated", + "cacheDetailsReported": false, + "numToolCalls": 0, + "toolCounts": {}, + "finishReason": "stop", + "contentFilterTriggered": false + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "fixture-tool", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "" + } + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.747Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.748Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.748Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "d37cae34-0e97-44d6-8bd0-67b879d729d8", + "title": "Reading shell output", + "kind": "read", + "status": "pending", + "rawInput": { + "shellId": "0", + "delay": 0 + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "d37cae34-0e97-44d6-8bd0-67b879d729d8", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "\n" + } + } + ], + "rawOutput": { + "content": "\n", + "detailedContent": "\n" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "usage_update", + "used": 10142, + "size": 64000 + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Fixture complete." + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "assistant.usage", + "timestamp": "2026-09-28T16:16:27.768Z", + "data": { + "model": "gpt-4.1", + "inputTokens": 10, + "outputTokens": 3, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "reasoningTokens": 0, + "cost": 0, + "duration": 1, + "timeToFirstTokenMs": 0.794083, + "outputTtftMs": 0.7941239999999999, + "interTokenLatencyMs": 0.115041, + "initiator": "agent", + "interactionType": "conversation-agent", + "isByok": true, + "isAuto": false, + "maxPromptTokens": 64000, + "transport": "http", + "apiCallId": "fixture-1", + "rte": false, + "apiEndpoint": "/chat/completions", + "quotaSnapshots": {}, + "availableToolCount": 16, + "toolTokenCount": 5446, + "frontierSource": "estimated", + "cacheDetailsReported": false, + "numToolCalls": 0, + "toolCounts": {}, + "finishReason": "stop", + "contentFilterTriggered": false + } + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.772Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.773Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.773Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.background_tasks_changed", + "timestamp": "2026-09-28T16:16:27.773Z", + "data": {} + } + }, + { + "jsonrpc": "2.0", + "method": "github.com/copilot/sessionEvent", + "params": { + "sessionId": "08200935-7bdc-42de-8830-8ec1fc23b552", + "type": "session.idle", + "timestamp": "2026-09-28T16:16:27.775Z", + "data": { + "mode": "interactive" + } + } + }, + { + "jsonrpc": "2.0", + "id": 3, + "result": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 20, + "outputTokens": 6, + "totalTokens": 26, + "thoughtTokens": 0, + "cachedReadTokens": 0, + "cachedWriteTokens": 0 + } + } + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-authenticated-discovery-1.0.88.json b/packages/paperclip-runner/test/fixtures/copilot-authenticated-discovery-1.0.88.json new file mode 100644 index 0000000000..435eb1be99 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-authenticated-discovery-1.0.88.json @@ -0,0 +1,855 @@ +{ + "schema": "paperclip.copilot-authenticated-discovery/v1", + "observedAt": "2026-09-28T19:15:41.413Z", + "harnessVersion": "1.0.88", + "sourceRevision": "5272fc6398d42344d1888a3f97ca6909684eefbf", + "providerPackDigest": "sha256:4ba17b2455b0ab92cfe6ee223f77708379fe219792ccb66dbdef70060e6e22e1", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": true + }, + "sessionCapabilities": { + "close": {}, + "list": {} + } + }, + "models": { + "availableModels": [ + { + "modelId": "auto", + "name": "Auto", + "description": "Let Copilot pick the best model" + }, + { + "modelId": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "GPT-5.6 Terra", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "GPT-5.6 Luna", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "gpt-5.4", + "name": "GPT-5.4", + "description": "GPT-5.4", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "GPT-5.4 mini", + "_meta": { + "copilotUsage": "0.33x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "GPT-5.3-Codex", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "gpt-5-mini", + "name": "GPT-5 mini", + "description": "GPT-5 mini", + "_meta": { + "copilotUsage": "0x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "Claude Sonnet 5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "Claude Haiku 4.5", + "_meta": { + "copilotUsage": "0.33x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "mai-code-1.1-flash", + "name": "MAI-Code-1.1-Flash", + "description": "MAI-Code-1.1-Flash", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "description": "Gemini 3.8 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "description": "Gemini 3.7 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "Gemini 3.6 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "Gemini 3.5 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "grok-4.5", + "name": "Grok 4.5", + "description": "Grok 4.5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "kimi-k3", + "name": "Kimi K3", + "description": "Kimi K3", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "Kimi K2.7 Code", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "claude-sonnet-5.5", + "name": "Claude Sonnet 5.5", + "description": "Claude Sonnet 5.5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "gpt-6-luna", + "name": "GPT-6 Luna", + "description": "GPT-6 Luna", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "modelId": "grok-4.6", + "name": "Grok 4.6", + "description": "Grok 4.6", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "modelId": "grok-4.7", + "name": "Grok 4.7", + "description": "Grok 4.7", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + } + ], + "currentModelId": "gpt-5.6-terra" + }, + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "model", + "name": "Model", + "category": "model", + "description": "The AI model Copilot uses to generate responses.", + "currentValue": "gpt-5.6-terra", + "options": [ + { + "value": "auto", + "name": "Auto", + "description": "Let Copilot pick the best model", + "_meta": null + }, + { + "value": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "GPT-5.6 Terra", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "GPT-5.6 Luna", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gpt-5.4", + "name": "GPT-5.4", + "description": "GPT-5.4", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "GPT-5.4 mini", + "_meta": { + "copilotUsage": "0.33x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "GPT-5.3-Codex", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "gpt-5-mini", + "name": "GPT-5 mini", + "description": "GPT-5 mini", + "_meta": { + "copilotUsage": "0x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "Claude Sonnet 5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "Claude Haiku 4.5", + "_meta": { + "copilotUsage": "0.33x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "mai-code-1.1-flash", + "name": "MAI-Code-1.1-Flash", + "description": "MAI-Code-1.1-Flash", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "description": "Gemini 3.8 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "description": "Gemini 3.7 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "Gemini 3.6 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "Gemini 3.5 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "grok-4.5", + "name": "Grok 4.5", + "description": "Grok 4.5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "kimi-k3", + "name": "Kimi K3", + "description": "Kimi K3", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "Kimi K2.7 Code", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "claude-sonnet-5.5", + "name": "Claude Sonnet 5.5", + "description": "Claude Sonnet 5.5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "gpt-6-luna", + "name": "GPT-6 Luna", + "description": "GPT-6 Luna", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "grok-4.6", + "name": "Grok 4.6", + "description": "Grok 4.6", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "grok-4.7", + "name": "Grok 4.7", + "description": "Grok 4.7", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + } + ] + }, + { + "type": "select", + "id": "reasoning_effort", + "name": "Reasoning Effort", + "category": "thought_level", + "description": "How much reasoning the model performs before responding. Higher effort can improve quality at the cost of speed.", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "none", + "description": "No additional reasoning; fastest responses." + }, + { + "value": "low", + "name": "low", + "description": "Minimal reasoning before responding." + }, + { + "value": "medium", + "name": "medium", + "description": "Balanced reasoning and speed." + }, + { + "value": "high", + "name": "high", + "description": "More thorough reasoning; slower responses." + }, + { + "value": "xhigh", + "name": "xhigh", + "description": "Extensive reasoning for the hardest problems." + }, + { + "value": "max", + "name": "max", + "description": "Maximum reasoning; slowest but most thorough." + } + ] + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ], + "promptSent": false, + "qualification": "pending; metadata discovery only", + "observedMethods": { + "session/update": 3 + }, + "modelSelection": { + "requestedModel": "gpt-5.6-luna", + "succeeded": true, + "response": {}, + "configEcho": { + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "model", + "name": "Model", + "category": "model", + "description": "The AI model Copilot uses to generate responses.", + "currentValue": "gpt-5.6-luna", + "options": [ + { + "value": "auto", + "name": "Auto", + "description": "Let Copilot pick the best model", + "_meta": null + }, + { + "value": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "GPT-5.6 Terra", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "GPT-5.6 Luna", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gpt-5.4", + "name": "GPT-5.4", + "description": "GPT-5.4", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "GPT-5.4 mini", + "_meta": { + "copilotUsage": "0.33x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "GPT-5.3-Codex", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "gpt-5-mini", + "name": "GPT-5 mini", + "description": "GPT-5 mini", + "_meta": { + "copilotUsage": "0x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "Claude Sonnet 5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "Claude Haiku 4.5", + "_meta": { + "copilotUsage": "0.33x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "mai-code-1.1-flash", + "name": "MAI-Code-1.1-Flash", + "description": "MAI-Code-1.1-Flash", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "description": "Gemini 3.8 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "description": "Gemini 3.7 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "Gemini 3.6 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "Gemini 3.5 Flash", + "_meta": { + "copilotUsage": "14x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "grok-4.5", + "name": "Grok 4.5", + "description": "Grok 4.5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "kimi-k3", + "name": "Kimi K3", + "description": "Kimi K3", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "Kimi K2.7 Code", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "claude-sonnet-5.5", + "name": "Claude Sonnet 5.5", + "description": "Claude Sonnet 5.5", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "gpt-6-luna", + "name": "GPT-6 Luna", + "description": "GPT-6 Luna", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "low" + } + }, + { + "value": "grok-4.6", + "name": "Grok 4.6", + "description": "Grok 4.6", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + }, + { + "value": "grok-4.7", + "name": "Grok 4.7", + "description": "Grok 4.7", + "_meta": { + "copilotUsage": "1x", + "copilotEnablement": "enabled", + "copilotPriceCategory": "medium" + } + } + ] + }, + { + "type": "select", + "id": "reasoning_effort", + "name": "Reasoning Effort", + "category": "thought_level", + "description": "How much reasoning the model performs before responding. Higher effort can improve quality at the cost of speed.", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "none", + "description": "No additional reasoning; fastest responses." + }, + { + "value": "low", + "name": "low", + "description": "Minimal reasoning before responding." + }, + { + "value": "medium", + "name": "medium", + "description": "Balanced reasoning and speed." + }, + { + "value": "high", + "name": "high", + "description": "More thorough reasoning; slower responses." + }, + { + "value": "xhigh", + "name": "xhigh", + "description": "Extensive reasoning for the hardest problems." + }, + { + "value": "max", + "name": "max", + "description": "Maximum reasoning; slowest but most thorough." + } + ] + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + } + }, + "sessionClosed": true, + "promptRequestsSent": 0, + "inferenceVerified": false +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-canonical-live-proof-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-canonical-live-proof-2026-09-28.json new file mode 100644 index 0000000000..7e9f398e6b --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-canonical-live-proof-2026-09-28.json @@ -0,0 +1,79 @@ +{ + "schema": "paperclip.copilot-canonical-live-proof/v1", + "providerVersion": "1.0.88", + "model": "gpt-5.6-luna", + "modelProvider": "github", + "qualificationStatus": "pending", + "sourceRevision": "92fcaf0c721a7adb3bc4dcf7a9ee6e6784b81e71", + "runnerdSourceRevision": "f77ae83aeb6d802c73f1c955760017ae272eba29", + "runnerdSha256": "sha256:986060810ba7377c6ddd64a5d89e322d0a434e1a9c80400e6d4acf5934bfc421", + "providerPackDigest": "sha256:172199bd72d7cf171344dd1e4b9eca240f8bbe2969c5e7df30c38c717820f890", + "evalRevision": "b01676fdb7a2783f7b3635a90ba9583041e86338", + "caseId": "get-task-context", + "startedAt": "2026-09-28T19:29:25.165Z", + "durationMs": 33383, + "promptRequestsSent": 1, + "upstreamModelRequestCount": null, + "semanticOperations": [ + { + "operationId": "get_task_context", + "calls": 1, + "successfulResults": 1 + } + ], + "terminalStatus": "completed", + "runnerExited": true, + "credentialLeakDetected": false, + "usage": { + "agentTurns": 1, + "providerRequests": 1, + "inputTokens": 24258, + "outputTokens": 441, + "cachedInputTokens": 11781, + "reasoningTokens": 0, + "providerReportedCostNanodollars": null, + "providerReportedCostProvenance": "unavailable", + "estimatedCostNanodollars": 3260220, + "pricingVersion": "provider-list-prices-2026-09-07-openrouter", + "ratesUsdPerMillionTokens": { + "input": 0.2, + "cachedInput": 0.02, + "output": 1.2 + }, + "costCoverage": "estimated" + }, + "usageCountProvenance": "providerRequests=1 is the runner terminal receipt count; actual provider HTTP request count is unverified", + "costReconciliation": { + "dashboardCreditsUsed": 0, + "dashboardCreditsTotal": 1500, + "additionalUsageEnabled": false, + "cashBudgetUsd": 0, + "creditDelta": null, + "note": "Dashboard still shows zero after this small call; billing lag or rounding prevents exact credit attribution. The catalog estimate is not a GitHub USD receipt." + }, + "originalArtifactSha256": "sha256:49690c56fe0cb956e7fc6c946601d345667d1f8e7f91b551a7547df93203c75f", + "originalRosterExitCode": 1, + "offlineScore": { + "passed": true, + "checksPassed": 4, + "checksTotal": 4, + "artifactSha256": "sha256:17e3148fc1526574c59cb14377e5c28bec6305cb85847e61f5cdff43d0cbb644", + "semanticDigest": "sha256:19da8536da1f821121e15b7dc328c0f7557b19bfc52828c1c4bb4b3e16cff037", + "additionalProviderCalls": 0, + "reason": "Post-turn provenance packaging failed; exact source tarball rebuilt, same retained runtime artifact scored offline. Original attempt and recovery metadata preserved." + }, + "priorAttempt": { + "stage": "session.open", + "result": "infrastructure_failure_before_prompt", + "cause": "stale Rust artifact lacked explicit credential binding propagation", + "promptRequestsSent": 0 + }, + "pendingQualification": [ + "Product E2E local and Daytona", + "restrictive permissions denial with no side effects", + "interactive questions and applicable plan decisions", + "warm continuation and recovery", + "background settlement", + "exact credit reconciliation" + ] +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-event-inventory-1.0.88.json b/packages/paperclip-runner/test/fixtures/copilot-event-inventory-1.0.88.json new file mode 100644 index 0000000000..50a33de4ad --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-event-inventory-1.0.88.json @@ -0,0 +1,3312 @@ +{ + "schema": "paperclip.copilot-capability-inventory/v1", + "harnessVersion": "1.0.88", + "source": "Exact darwin-arm64 distribution embedded schemas/session-events.schema.json and app.js; no live model inference", + "sourceSchemaSha256": "d8cb713c05d5278a68dde5c5d4482574f836e922ae13aa06f82474c209a7c6e9", + "events": [ + { + "event": "agent.interrupted", + "fields": [ + "activity", + "apiEndpoint", + "cancelPhase", + "elapsedMs", + "interruptedAgentCount", + "model", + "outputTtftMs", + "reasoningEffort", + "safeToolNames", + "toolCallIds", + "toolNames", + "transport", + "turn" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "activity": "unused_native_passthrough_not_subscribed", + "apiEndpoint": "unused_native_passthrough_not_subscribed", + "cancelPhase": "unused_native_passthrough_not_subscribed", + "elapsedMs": "unused_native_passthrough_not_subscribed", + "interruptedAgentCount": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "outputTtftMs": "unused_native_passthrough_not_subscribed", + "reasoningEffort": "unused_native_passthrough_not_subscribed", + "safeToolNames": "unused_native_passthrough_not_subscribed", + "toolCallIds": "unused_native_passthrough_not_subscribed", + "toolNames": "unused_native_passthrough_not_subscribed", + "transport": "unused_native_passthrough_not_subscribed", + "turn": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.fusion_phase_activity", + "fields": [ + "activity", + "conversationScope", + "fusionId", + "pattern", + "phaseId", + "phaseKind", + "role", + "toolCallId", + "totalResponseSizeBytes" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "activity": "unused_native_passthrough_not_subscribed", + "conversationScope": "unused_native_passthrough_not_subscribed", + "fusionId": "unused_native_passthrough_not_subscribed", + "pattern": "unused_native_passthrough_not_subscribed", + "phaseId": "unused_native_passthrough_not_subscribed", + "phaseKind": "unused_native_passthrough_not_subscribed", + "role": "unused_native_passthrough_not_subscribed", + "toolCallId": "unused_native_passthrough_not_subscribed", + "totalResponseSizeBytes": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.fusion_phase_completed", + "fields": [ + "content", + "conversationScope", + "durationMs", + "fusionId", + "model", + "phaseId", + "phaseKind", + "projectionMessage", + "projectionMode", + "role", + "stagedTerminal", + "status", + "usage", + "verdict" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "content": "unused_native_passthrough_not_subscribed", + "conversationScope": "unused_native_passthrough_not_subscribed", + "durationMs": "unused_native_passthrough_not_subscribed", + "fusionId": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "phaseId": "unused_native_passthrough_not_subscribed", + "phaseKind": "unused_native_passthrough_not_subscribed", + "projectionMessage": "unused_native_passthrough_not_subscribed", + "projectionMode": "unused_native_passthrough_not_subscribed", + "role": "unused_native_passthrough_not_subscribed", + "stagedTerminal": "unused_native_passthrough_not_subscribed", + "status": "unused_native_passthrough_not_subscribed", + "usage": "unused_native_passthrough_not_subscribed", + "verdict": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.fusion_phase_failed", + "fields": [ + "conversationScope", + "degradedToPhaseId", + "durationMs", + "errorMessage", + "fusionId", + "model", + "phaseId", + "phaseKind", + "reason", + "role", + "status", + "usage" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "conversationScope": "unused_native_passthrough_not_subscribed", + "degradedToPhaseId": "unused_native_passthrough_not_subscribed", + "durationMs": "unused_native_passthrough_not_subscribed", + "errorMessage": "unused_native_passthrough_not_subscribed", + "fusionId": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "phaseId": "unused_native_passthrough_not_subscribed", + "phaseKind": "unused_native_passthrough_not_subscribed", + "reason": "unused_native_passthrough_not_subscribed", + "role": "unused_native_passthrough_not_subscribed", + "status": "unused_native_passthrough_not_subscribed", + "usage": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.fusion_phase_started", + "fields": [ + "conversationScope", + "fusionId", + "model", + "pattern", + "phaseId", + "phaseKind", + "role" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "conversationScope": "unused_native_passthrough_not_subscribed", + "fusionId": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "pattern": "unused_native_passthrough_not_subscribed", + "phaseId": "unused_native_passthrough_not_subscribed", + "phaseKind": "unused_native_passthrough_not_subscribed", + "role": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.idle", + "fields": [ + "aborted" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "aborted": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.intent", + "fields": [ + "intent" + ], + "standardAcp": "agent_thought_chunk", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "intent": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "assistant.message", + "fields": [ + "apiCallId", + "chunkCount", + "chunkIndex", + "citations", + "clientRequestId", + "content", + "encryptedContent", + "fusion", + "interactionId", + "messageId", + "model", + "originatingMessageId", + "outputTokens", + "parentToolCallId", + "phase", + "reasoningBlocks", + "reasoningOpaque", + "reasoningText", + "reasoningWireField", + "requestId", + "rte", + "serverTools", + "serviceRequestId", + "toolRequests", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "apiCallId": "unused_native_passthrough_not_subscribed", + "chunkCount": "unused_native_passthrough_not_subscribed", + "chunkIndex": "unused_native_passthrough_not_subscribed", + "citations": "unused_native_passthrough_not_subscribed", + "clientRequestId": "unused_native_passthrough_not_subscribed", + "content": "unused_native_passthrough_not_subscribed", + "encryptedContent": "unused_native_passthrough_not_subscribed", + "fusion": "unused_native_passthrough_not_subscribed", + "interactionId": "unused_native_passthrough_not_subscribed", + "messageId": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "originatingMessageId": "unused_native_passthrough_not_subscribed", + "outputTokens": "unused_native_passthrough_not_subscribed", + "parentToolCallId": "unused_native_passthrough_not_subscribed", + "phase": "unused_native_passthrough_not_subscribed", + "reasoningBlocks": "unused_native_passthrough_not_subscribed", + "reasoningOpaque": "unused_native_passthrough_not_subscribed", + "reasoningText": "unused_native_passthrough_not_subscribed", + "reasoningWireField": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "rte": "unused_native_passthrough_not_subscribed", + "serverTools": "unused_native_passthrough_not_subscribed", + "serviceRequestId": "unused_native_passthrough_not_subscribed", + "toolRequests": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.message_delta", + "fields": [ + "deltaContent", + "messageId", + "parentToolCallId" + ], + "standardAcp": "agent_message_chunk", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "deltaContent": "standard_projection_field_retention_not_individually_qualified", + "messageId": "standard_projection_field_retention_not_individually_qualified", + "parentToolCallId": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "assistant.message_start", + "fields": [ + "messageId", + "phase" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "messageId": "unused_native_passthrough_not_subscribed", + "phase": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.reasoning", + "fields": [ + "content", + "reasoningId", + "rte" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "content": "unused_native_passthrough_not_subscribed", + "reasoningId": "unused_native_passthrough_not_subscribed", + "rte": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.reasoning_delta", + "fields": [ + "deltaContent", + "reasoningId" + ], + "standardAcp": "agent_thought_chunk", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "deltaContent": "standard_projection_field_retention_not_individually_qualified", + "reasoningId": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "assistant.server_tool_progress", + "fields": [ + "kind", + "outputIndex", + "status" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "kind": "unused_native_passthrough_not_subscribed", + "outputIndex": "unused_native_passthrough_not_subscribed", + "status": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.streaming_delta", + "fields": [ + "totalResponseSizeBytes" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "totalResponseSizeBytes": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.tool_call_delta", + "fields": [ + "inputDelta", + "toolCallId", + "toolName", + "toolType" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "inputDelta": "unused_native_passthrough_not_subscribed", + "toolCallId": "unused_native_passthrough_not_subscribed", + "toolName": "unused_native_passthrough_not_subscribed", + "toolType": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.turn_end", + "fields": [ + "model", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "model": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.turn_retry", + "fields": [ + "model", + "reason", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "model": "unused_native_passthrough_not_subscribed", + "reason": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.turn_start", + "fields": [ + "interactionId", + "model", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "interactionId": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "assistant.usage", + "fields": [ + "acceptedPredictionTokens", + "apiCallId", + "apiEndpoint", + "availableToolCount", + "cacheDetailsReported", + "cacheExpiresAt", + "cacheReadTokens", + "cacheTtlSeconds", + "cacheWriteTokens", + "contentFilterTriggered", + "copilotUsage", + "cost", + "duration", + "finishReason", + "frontierSource", + "fusion", + "initiator", + "inputTokens", + "interTokenLatencyMs", + "interactionType", + "isAuto", + "isByok", + "maxOutputTokens", + "maxPromptTokens", + "model", + "numToolCalls", + "outputTokens", + "outputTtftMs", + "parentToolCallId", + "providerCallId", + "quotaSnapshots", + "reasoningEffort", + "reasoningSummary", + "reasoningTokens", + "rejectedPredictionTokens", + "rte", + "serviceRequestId", + "thinkingDroppedBlocks", + "thinkingDroppedReasons", + "timeToFirstTokenMs", + "toolCounts", + "toolTokenCount", + "transport" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "acceptedPredictionTokens": "projected", + "apiCallId": "projected", + "apiEndpoint": "projected", + "availableToolCount": "projected", + "cacheDetailsReported": "projected", + "cacheExpiresAt": "unused_declared_field", + "cacheReadTokens": "projected", + "cacheTtlSeconds": "projected", + "cacheWriteTokens": "projected", + "contentFilterTriggered": "projected", + "copilotUsage": "only_totalNanoAiu_projected_nested_remainder_unused", + "cost": "renamed_model_multiplier_not_usd", + "duration": "projected", + "finishReason": "projected", + "frontierSource": "unused_declared_field", + "fusion": "unused_declared_field", + "initiator": "projected", + "inputTokens": "projected", + "interTokenLatencyMs": "projected", + "interactionType": "projected", + "isAuto": "projected", + "isByok": "projected", + "maxOutputTokens": "projected", + "maxPromptTokens": "projected", + "model": "projected", + "numToolCalls": "projected", + "outputTokens": "projected", + "outputTtftMs": "projected", + "parentToolCallId": "projected", + "providerCallId": "unused_declared_field", + "quotaSnapshots": "unused_declared_field", + "reasoningEffort": "projected", + "reasoningSummary": "unused_declared_field", + "reasoningTokens": "projected", + "rejectedPredictionTokens": "projected", + "rte": "unused_declared_field", + "serviceRequestId": "unused_declared_field", + "thinkingDroppedBlocks": "projected", + "thinkingDroppedReasons": "unused_declared_field", + "timeToFirstTokenMs": "projected", + "toolCounts": "unused_declared_field", + "toolTokenCount": "projected", + "transport": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "auto_mode_switch.completed", + "fields": [ + "requestId", + "response" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "requestId": "unused_native_passthrough_not_subscribed", + "response": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "auto_mode_switch.requested", + "fields": [ + "errorCode", + "requestId", + "retryAfterSeconds" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "errorCode": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "retryAfterSeconds": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "capabilities.changed", + "fields": [ + "ui" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "ui": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "command.completed", + "fields": [ + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "command.execute", + "fields": [ + "args", + "command", + "commandName", + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "args": "unused_native_passthrough_not_subscribed", + "command": "unused_native_passthrough_not_subscribed", + "commandName": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "command.queued", + "fields": [ + "command", + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "command": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "commands.changed", + "fields": [ + "commands" + ], + "standardAcp": "available_commands_update", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "commands": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "elicitation.completed", + "fields": [ + "action", + "content", + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "action": "unused_native_passthrough_not_subscribed", + "content": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "elicitation.requested", + "fields": [ + "elicitationSource", + "message", + "mode", + "requestId", + "requestedSchema", + "toolCallId", + "url" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "elicitationSource": "unused_declared_field", + "message": "unused_declared_field", + "mode": "unused_declared_field", + "requestId": "projected", + "requestedSchema": "unused_declared_field", + "toolCallId": "projected", + "url": "unused_declared_field" + }, + "gapReason": "No qualified ACP responder; must prove no blocking request or implement responder before qualification.", + "followUpPriority": "P0" + }, + { + "event": "exit_plan_mode.completed", + "fields": [ + "approved", + "autoApproveEdits", + "feedback", + "requestId", + "selectedAction" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "approved": "unused_native_passthrough_not_subscribed", + "autoApproveEdits": "unused_native_passthrough_not_subscribed", + "feedback": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "selectedAction": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "exit_plan_mode.requested", + "fields": [ + "actions", + "model", + "planContent", + "recommendedAction", + "requestId", + "summary" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "actions": "unused_declared_field", + "model": "unused_declared_field", + "planContent": "unused_declared_field", + "recommendedAction": "unused_declared_field", + "requestId": "projected", + "summary": "unused_declared_field" + }, + "gapReason": "No qualified ACP responder; must prove no blocking request or implement responder before qualification.", + "followUpPriority": "P0" + }, + { + "event": "external_tool.completed", + "fields": [ + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "external_tool.requested", + "fields": [ + "arguments", + "providerId", + "requestId", + "sessionId", + "toolCallId", + "toolName", + "traceparent", + "tracestate", + "workingDirectory" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "arguments": "unused_native_passthrough_not_subscribed", + "providerId": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "sessionId": "unused_native_passthrough_not_subscribed", + "toolCallId": "unused_native_passthrough_not_subscribed", + "toolName": "unused_native_passthrough_not_subscribed", + "traceparent": "unused_native_passthrough_not_subscribed", + "tracestate": "unused_native_passthrough_not_subscribed", + "workingDirectory": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No qualified ACP responder; must prove no blocking request or implement responder before qualification.", + "followUpPriority": "P0" + }, + { + "event": "factory.run_settled", + "fields": [ + "consumedNanoAiu", + "consumedSubagents", + "elapsedMs", + "failureType", + "runId", + "status" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "consumedNanoAiu": "unused_native_passthrough_not_subscribed", + "consumedSubagents": "unused_native_passthrough_not_subscribed", + "elapsedMs": "unused_native_passthrough_not_subscribed", + "failureType": "unused_native_passthrough_not_subscribed", + "runId": "unused_native_passthrough_not_subscribed", + "status": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "factory.run_started", + "fields": [ + "attempt", + "factoryName", + "runId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "attempt": "unused_native_passthrough_not_subscribed", + "factoryName": "unused_native_passthrough_not_subscribed", + "runId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "factory.run_updated", + "fields": [ + "revision", + "runId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "revision": "unused_native_passthrough_not_subscribed", + "runId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "hook.end", + "fields": [ + "error", + "hookInvocationId", + "hookType", + "output", + "parentToolCallId", + "success" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "error": "unused_native_passthrough_not_subscribed", + "hookInvocationId": "unused_native_passthrough_not_subscribed", + "hookType": "unused_native_passthrough_not_subscribed", + "output": "unused_native_passthrough_not_subscribed", + "parentToolCallId": "unused_native_passthrough_not_subscribed", + "success": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Ambient hooks/extensions excluded; preserve runner-owned attribution separately.", + "followUpPriority": "P2" + }, + { + "event": "hook.progress", + "fields": [ + "message", + "temporary" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "message": "unused_native_passthrough_not_subscribed", + "temporary": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Ambient hooks/extensions excluded; preserve runner-owned attribution separately.", + "followUpPriority": "P2" + }, + { + "event": "hook.start", + "fields": [ + "hookInvocationId", + "hookType", + "input", + "parentToolCallId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "hookInvocationId": "unused_native_passthrough_not_subscribed", + "hookType": "unused_native_passthrough_not_subscribed", + "input": "unused_native_passthrough_not_subscribed", + "parentToolCallId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Ambient hooks/extensions excluded; preserve runner-owned attribution separately.", + "followUpPriority": "P2" + }, + { + "event": "mcp.headers_refresh_completed", + "fields": [ + "outcome", + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "outcome": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "mcp.headers_refresh_required", + "fields": [ + "reason", + "requestId", + "serverName", + "serverUrl" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "reason": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "serverName": "unused_native_passthrough_not_subscribed", + "serverUrl": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "mcp.oauth_completed", + "fields": [ + "outcome", + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "outcome": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "mcp.oauth_required", + "fields": [ + "httpResponse", + "reason", + "requestId", + "resourceMetadata", + "serverName", + "serverUrl", + "staticClientConfig", + "wwwAuthenticateParams" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "httpResponse": "unused_native_passthrough_not_subscribed", + "reason": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "resourceMetadata": "unused_native_passthrough_not_subscribed", + "serverName": "unused_native_passthrough_not_subscribed", + "serverUrl": "unused_native_passthrough_not_subscribed", + "staticClientConfig": "unused_native_passthrough_not_subscribed", + "wwwAuthenticateParams": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "mcp.prompts.list_changed", + "fields": [ + "serverName" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "serverName": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "mcp.resources.list_changed", + "fields": [ + "serverName" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "serverName": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "mcp.tools.list_changed", + "fields": [ + "serverName" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "serverName": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "mcp_app.tool_call_complete", + "fields": [ + "arguments", + "durationMs", + "error", + "result", + "serverName", + "success", + "toolMeta", + "toolName" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "arguments": "unused_native_passthrough_not_subscribed", + "durationMs": "unused_native_passthrough_not_subscribed", + "error": "unused_native_passthrough_not_subscribed", + "result": "unused_native_passthrough_not_subscribed", + "serverName": "unused_native_passthrough_not_subscribed", + "success": "unused_native_passthrough_not_subscribed", + "toolMeta": "unused_native_passthrough_not_subscribed", + "toolName": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "model.call_failure", + "fields": [ + "apiCallId", + "apiEndpoint", + "badRequestKind", + "durationMs", + "errorCode", + "errorMessage", + "errorType", + "failureKind", + "fusion", + "initiator", + "interactionType", + "isAuto", + "isByok", + "maxOutputTokens", + "maxPromptTokens", + "model", + "providerCallId", + "quotaSnapshots", + "reasoningEffort", + "requestFingerprint", + "rte", + "serviceRequestId", + "source", + "statusCode", + "transport" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "apiCallId": "unused_native_passthrough_not_subscribed", + "apiEndpoint": "unused_native_passthrough_not_subscribed", + "badRequestKind": "unused_native_passthrough_not_subscribed", + "durationMs": "unused_native_passthrough_not_subscribed", + "errorCode": "unused_native_passthrough_not_subscribed", + "errorMessage": "unused_native_passthrough_not_subscribed", + "errorType": "unused_native_passthrough_not_subscribed", + "failureKind": "unused_native_passthrough_not_subscribed", + "fusion": "unused_native_passthrough_not_subscribed", + "initiator": "unused_native_passthrough_not_subscribed", + "interactionType": "unused_native_passthrough_not_subscribed", + "isAuto": "unused_native_passthrough_not_subscribed", + "isByok": "unused_native_passthrough_not_subscribed", + "maxOutputTokens": "unused_native_passthrough_not_subscribed", + "maxPromptTokens": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "providerCallId": "unused_native_passthrough_not_subscribed", + "quotaSnapshots": "unused_native_passthrough_not_subscribed", + "reasoningEffort": "unused_native_passthrough_not_subscribed", + "requestFingerprint": "unused_native_passthrough_not_subscribed", + "rte": "unused_native_passthrough_not_subscribed", + "serviceRequestId": "unused_native_passthrough_not_subscribed", + "source": "unused_native_passthrough_not_subscribed", + "statusCode": "unused_native_passthrough_not_subscribed", + "transport": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "model.call_finished", + "fields": [ + "containsBuiltInFileEditRequest", + "dispatchDurationMs", + "editClassifierVersion", + "interactionId", + "outcome", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "containsBuiltInFileEditRequest": "unused_native_passthrough_not_subscribed", + "dispatchDurationMs": "unused_native_passthrough_not_subscribed", + "editClassifierVersion": "unused_native_passthrough_not_subscribed", + "interactionId": "unused_native_passthrough_not_subscribed", + "outcome": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "model.call_start", + "fields": [ + "fusion", + "model", + "previousResponseId", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "fusion": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "previousResponseId": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "pending_messages.modified", + "fields": [], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": {}, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "permission.assentDetected", + "fields": [ + "requestId", + "turnIndex" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "requestId": "unused_native_passthrough_not_subscribed", + "turnIndex": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "permission.carriedForward", + "fields": [ + "decisionSource", + "recordId", + "requestId", + "toolCallId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "decisionSource": "unused_native_passthrough_not_subscribed", + "recordId": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "toolCallId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "permission.completed", + "fields": [ + "blocker", + "decisionSource", + "recoveryEpisodeId", + "requestId", + "result", + "toolCallId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "blocker": "unused_native_passthrough_not_subscribed", + "decisionSource": "unused_native_passthrough_not_subscribed", + "recoveryEpisodeId": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "result": "unused_native_passthrough_not_subscribed", + "toolCallId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "permission.contextualAuthorization", + "fields": [ + "polarity", + "recordId", + "requestId", + "spanEnd", + "spanStart", + "turnIndex" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "polarity": "unused_native_passthrough_not_subscribed", + "recordId": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "spanEnd": "unused_native_passthrough_not_subscribed", + "spanStart": "unused_native_passthrough_not_subscribed", + "turnIndex": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "permission.messageAuthorization", + "fields": [ + "actionClass", + "polarity", + "recordId", + "spanEnd", + "spanStart", + "targetMembers", + "task", + "turnIndex", + "world" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "actionClass": "unused_native_passthrough_not_subscribed", + "polarity": "unused_native_passthrough_not_subscribed", + "recordId": "unused_native_passthrough_not_subscribed", + "spanEnd": "unused_native_passthrough_not_subscribed", + "spanStart": "unused_native_passthrough_not_subscribed", + "targetMembers": "unused_native_passthrough_not_subscribed", + "task": "unused_native_passthrough_not_subscribed", + "turnIndex": "unused_native_passthrough_not_subscribed", + "world": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "permission.messageAuthorizationDegraded", + "fields": [ + "turnIndex" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "turnIndex": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "permission.messageAuthorizationRead", + "fields": [ + "activatesExtraction", + "turnIndex" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "activatesExtraction": "unused_native_passthrough_not_subscribed", + "turnIndex": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "permission.requested", + "fields": [ + "agentMode", + "permissionMode", + "permissionRequest", + "promptRequest", + "recoveryEpisodeId", + "requestId", + "resolvedByHook", + "riskAssessment" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "agentMode": "unused_native_passthrough_not_subscribed", + "permissionMode": "unused_native_passthrough_not_subscribed", + "permissionRequest": "unused_native_passthrough_not_subscribed", + "promptRequest": "unused_native_passthrough_not_subscribed", + "recoveryEpisodeId": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "resolvedByHook": "unused_native_passthrough_not_subscribed", + "riskAssessment": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "sampling.completed", + "fields": [ + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "sampling.requested", + "fields": [ + "mcpRequestId", + "requestId", + "serverName" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "mcpRequestId": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "serverName": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No qualified ACP responder; must prove no blocking request or implement responder before qualification.", + "followUpPriority": "P0" + }, + { + "event": "sandbox.decision", + "fields": [], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": {}, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "session.auto_mode_resolved", + "fields": [ + "availableModels", + "candidateModels", + "categoryScores", + "chosenModel", + "chosenShortfall", + "confidence", + "endToEndLatencyMs", + "fallback", + "fallbackReason", + "hasImage", + "predictedLabel", + "reasoningBucket", + "routerLatencyMs", + "routingMethod", + "stickyOverride" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "availableModels": "unused_native_passthrough_not_subscribed", + "candidateModels": "unused_native_passthrough_not_subscribed", + "categoryScores": "unused_native_passthrough_not_subscribed", + "chosenModel": "unused_native_passthrough_not_subscribed", + "chosenShortfall": "unused_native_passthrough_not_subscribed", + "confidence": "unused_native_passthrough_not_subscribed", + "endToEndLatencyMs": "unused_native_passthrough_not_subscribed", + "fallback": "unused_native_passthrough_not_subscribed", + "fallbackReason": "unused_native_passthrough_not_subscribed", + "hasImage": "unused_native_passthrough_not_subscribed", + "predictedLabel": "unused_native_passthrough_not_subscribed", + "reasoningBucket": "unused_native_passthrough_not_subscribed", + "routerLatencyMs": "unused_native_passthrough_not_subscribed", + "routingMethod": "unused_native_passthrough_not_subscribed", + "stickyOverride": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.auto_tier_recommendation", + "fields": [ + "recommendedAutoTier" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "recommendedAutoTier": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.auto_tier_switch_failed", + "fields": [ + "effectiveAutoTier", + "reason", + "requestedAutoTier" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "effectiveAutoTier": "unused_native_passthrough_not_subscribed", + "reason": "unused_native_passthrough_not_subscribed", + "requestedAutoTier": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.autopilot_objective_changed", + "fields": [ + "id", + "operation", + "status" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "id": "unused_native_passthrough_not_subscribed", + "operation": "unused_native_passthrough_not_subscribed", + "status": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.background_tasks_changed", + "fields": [], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": {}, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.binary_asset", + "fields": [ + "assetId", + "byteLength", + "data", + "description", + "metadata", + "mimeType", + "type" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "assetId": "projected", + "byteLength": "projected", + "data": "digest_verified_bytes_omitted_pending_artifact_upload", + "description": "projected", + "metadata": "unused_declared_field", + "mimeType": "projected", + "type": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.canvas.closed", + "fields": [ + "canvasId", + "extensionId", + "instanceId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "canvasId": "unused_native_passthrough_not_subscribed", + "extensionId": "unused_native_passthrough_not_subscribed", + "instanceId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.canvas.opened", + "fields": [ + "canvasId", + "extensionId", + "extensionName", + "icon", + "input", + "instanceId", + "status", + "title", + "url" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "canvasId": "unused_native_passthrough_not_subscribed", + "extensionId": "unused_native_passthrough_not_subscribed", + "extensionName": "unused_native_passthrough_not_subscribed", + "icon": "unused_native_passthrough_not_subscribed", + "input": "unused_native_passthrough_not_subscribed", + "instanceId": "unused_native_passthrough_not_subscribed", + "status": "unused_native_passthrough_not_subscribed", + "title": "unused_native_passthrough_not_subscribed", + "url": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.canvas.recorded", + "fields": [ + "canvasId", + "extensionId", + "input", + "instanceId", + "title" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "canvasId": "unused_native_passthrough_not_subscribed", + "extensionId": "unused_native_passthrough_not_subscribed", + "input": "unused_native_passthrough_not_subscribed", + "instanceId": "unused_native_passthrough_not_subscribed", + "title": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.canvas.registry_changed", + "fields": [ + "canvases" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "canvases": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.canvas.removed", + "fields": [ + "canvasId", + "extensionId", + "instanceId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "canvasId": "unused_native_passthrough_not_subscribed", + "extensionId": "unused_native_passthrough_not_subscribed", + "instanceId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.canvas.unavailable", + "fields": [ + "canvasId", + "extensionId", + "instanceId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "canvasId": "unused_native_passthrough_not_subscribed", + "extensionId": "unused_native_passthrough_not_subscribed", + "instanceId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.compaction_complete", + "fields": [ + "activeFactorySummary", + "behaviorModelId", + "checkpointNumber", + "checkpointPath", + "compactionTokensUsed", + "conversationTokens", + "customInstructions", + "error", + "messagesRemoved", + "postCompactionTokens", + "preCompactionMessagesLength", + "preCompactionTokens", + "requestId", + "responsesReasoning", + "serviceRequestId", + "statusCode", + "success", + "summaryContent", + "systemTokens", + "tokenLimit", + "tokensRemoved", + "toolDefinitionsTokens", + "trigger" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "activeFactorySummary": "unused_declared_field", + "behaviorModelId": "projected", + "checkpointNumber": "projected", + "checkpointPath": "unused_declared_field", + "compactionTokensUsed": "projected", + "conversationTokens": "projected", + "customInstructions": "unused_declared_field", + "error": "projected", + "messagesRemoved": "projected", + "postCompactionTokens": "projected", + "preCompactionMessagesLength": "projected", + "preCompactionTokens": "projected", + "requestId": "unused_declared_field", + "responsesReasoning": "unused_declared_field", + "serviceRequestId": "unused_declared_field", + "statusCode": "projected", + "success": "projected", + "summaryContent": "unused_declared_field", + "systemTokens": "projected", + "tokenLimit": "projected", + "tokensRemoved": "projected", + "toolDefinitionsTokens": "projected", + "trigger": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.compaction_start", + "fields": [ + "conversationTokens", + "currentTokens", + "model", + "systemTokens", + "tokenLimit", + "toolDefinitionsTokens", + "trigger" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "conversationTokens": "projected", + "currentTokens": "projected", + "model": "projected", + "systemTokens": "projected", + "tokenLimit": "projected", + "toolDefinitionsTokens": "projected", + "trigger": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.completion_receipt", + "fields": [ + "attempt", + "eventRange", + "failedToolCount", + "finalTool", + "schemaVersion", + "sourceEventId", + "stopReason", + "successfulToolCount" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "attempt": "projected", + "eventRange": "projected", + "failedToolCount": "projected", + "finalTool": "unused_declared_field", + "schemaVersion": "projected", + "sourceEventId": "projected", + "stopReason": "projected", + "successfulToolCount": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.context_changed", + "fields": [ + "baseCommit", + "branch", + "cwd", + "gitRoot", + "headCommit", + "hostType", + "pendingGitContext", + "repository", + "repositoryHost" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "baseCommit": "projected", + "branch": "projected", + "cwd": "projected", + "gitRoot": "unused_declared_field", + "headCommit": "projected", + "hostType": "projected", + "pendingGitContext": "unused_declared_field", + "repository": "unused_declared_field", + "repositoryHost": "unused_declared_field" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.context_cleared", + "fields": [ + "initialMessage", + "messagesCleared" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "initialMessage": "unused_native_passthrough_not_subscribed", + "messagesCleared": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.custom_agents_updated", + "fields": [ + "agents", + "errors", + "warnings" + ], + "standardAcp": "config_option_update", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "agents": "standard_projection_field_retention_not_individually_qualified", + "errors": "standard_projection_field_retention_not_individually_qualified", + "warnings": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "session.custom_notification", + "fields": [ + "name", + "payload", + "source", + "subject", + "version" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "name": "unused_native_passthrough_not_subscribed", + "payload": "unused_native_passthrough_not_subscribed", + "source": "unused_native_passthrough_not_subscribed", + "subject": "unused_native_passthrough_not_subscribed", + "version": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.error", + "fields": [ + "eligibleForAutoSwitch", + "errorCode", + "errorType", + "message", + "providerCallId", + "remediation", + "serviceRequestId", + "stack", + "statusCode", + "url" + ], + "standardAcp": "agent_message_chunk", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "eligibleForAutoSwitch": "standard_projection_field_retention_not_individually_qualified", + "errorCode": "standard_projection_field_retention_not_individually_qualified", + "errorType": "standard_projection_field_retention_not_individually_qualified", + "message": "standard_projection_field_retention_not_individually_qualified", + "providerCallId": "standard_projection_field_retention_not_individually_qualified", + "remediation": "standard_projection_field_retention_not_individually_qualified", + "serviceRequestId": "standard_projection_field_retention_not_individually_qualified", + "stack": "standard_projection_field_retention_not_individually_qualified", + "statusCode": "standard_projection_field_retention_not_individually_qualified", + "url": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "session.extensions.attachments_pushed", + "fields": [ + "attachments" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "attachments": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Ambient hooks/extensions excluded; preserve runner-owned attribution separately.", + "followUpPriority": "P2" + }, + { + "event": "session.extensions_loaded", + "fields": [ + "extensions" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "extensions": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Ambient hooks/extensions excluded; preserve runner-owned attribution separately.", + "followUpPriority": "P2" + }, + { + "event": "session.fusion_commit_started", + "fields": [ + "commitId", + "fusionId", + "kind", + "sourceModel", + "sourcePhaseId", + "toolCallId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "commitId": "unused_native_passthrough_not_subscribed", + "fusionId": "unused_native_passthrough_not_subscribed", + "kind": "unused_native_passthrough_not_subscribed", + "sourceModel": "unused_native_passthrough_not_subscribed", + "sourcePhaseId": "unused_native_passthrough_not_subscribed", + "toolCallId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.fusion_completed", + "fields": [ + "cacheWriteTokens", + "cachedTokens", + "commitId", + "degradedReason", + "durationMs", + "finalSourceModel", + "finalSourcePhaseId", + "followUpModel", + "fusionId", + "inputTokens", + "outcome", + "outputTokens", + "pattern", + "phaseCount", + "requestCount", + "syntheticModel", + "totalNanoAiu", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "cacheWriteTokens": "unused_native_passthrough_not_subscribed", + "cachedTokens": "unused_native_passthrough_not_subscribed", + "commitId": "unused_native_passthrough_not_subscribed", + "degradedReason": "unused_native_passthrough_not_subscribed", + "durationMs": "unused_native_passthrough_not_subscribed", + "finalSourceModel": "unused_native_passthrough_not_subscribed", + "finalSourcePhaseId": "unused_native_passthrough_not_subscribed", + "followUpModel": "unused_native_passthrough_not_subscribed", + "fusionId": "unused_native_passthrough_not_subscribed", + "inputTokens": "unused_native_passthrough_not_subscribed", + "outcome": "unused_native_passthrough_not_subscribed", + "outputTokens": "unused_native_passthrough_not_subscribed", + "pattern": "unused_native_passthrough_not_subscribed", + "phaseCount": "unused_native_passthrough_not_subscribed", + "requestCount": "unused_native_passthrough_not_subscribed", + "syntheticModel": "unused_native_passthrough_not_subscribed", + "totalNanoAiu": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.fusion_handoff", + "fields": [ + "fusionId", + "message", + "sourcePhaseId", + "targetModel", + "targetPhaseId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "fusionId": "unused_native_passthrough_not_subscribed", + "message": "unused_native_passthrough_not_subscribed", + "sourcePhaseId": "unused_native_passthrough_not_subscribed", + "targetModel": "unused_native_passthrough_not_subscribed", + "targetPhaseId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.fusion_resolved", + "fields": [ + "contractVersion", + "fallbackModel", + "followUp", + "followUpModel", + "fusionId", + "modelUniverseVersion", + "pattern", + "phasePlan", + "planVersion", + "policy", + "policyVersion", + "primaryModel", + "routeSource", + "routingLatencyMs", + "ruleId", + "ruleIndex", + "ruleName", + "scores", + "secondaryModel", + "syntheticModel", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "contractVersion": "unused_native_passthrough_not_subscribed", + "fallbackModel": "unused_native_passthrough_not_subscribed", + "followUp": "unused_native_passthrough_not_subscribed", + "followUpModel": "unused_native_passthrough_not_subscribed", + "fusionId": "unused_native_passthrough_not_subscribed", + "modelUniverseVersion": "unused_native_passthrough_not_subscribed", + "pattern": "unused_native_passthrough_not_subscribed", + "phasePlan": "unused_native_passthrough_not_subscribed", + "planVersion": "unused_native_passthrough_not_subscribed", + "policy": "unused_native_passthrough_not_subscribed", + "policyVersion": "unused_native_passthrough_not_subscribed", + "primaryModel": "unused_native_passthrough_not_subscribed", + "routeSource": "unused_native_passthrough_not_subscribed", + "routingLatencyMs": "unused_native_passthrough_not_subscribed", + "ruleId": "unused_native_passthrough_not_subscribed", + "ruleIndex": "unused_native_passthrough_not_subscribed", + "ruleName": "unused_native_passthrough_not_subscribed", + "scores": "unused_native_passthrough_not_subscribed", + "secondaryModel": "unused_native_passthrough_not_subscribed", + "syntheticModel": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.fusion_route_failed", + "fields": [ + "attemptId", + "errorMessage", + "fallbackModel", + "policy", + "reason", + "routingLatencyMs", + "syntheticModel" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "attemptId": "unused_native_passthrough_not_subscribed", + "errorMessage": "unused_native_passthrough_not_subscribed", + "fallbackModel": "unused_native_passthrough_not_subscribed", + "policy": "unused_native_passthrough_not_subscribed", + "reason": "unused_native_passthrough_not_subscribed", + "routingLatencyMs": "unused_native_passthrough_not_subscribed", + "syntheticModel": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.fusion_route_started", + "fields": [ + "attemptId", + "policy", + "syntheticModel", + "turnKind" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "attemptId": "unused_native_passthrough_not_subscribed", + "policy": "unused_native_passthrough_not_subscribed", + "syntheticModel": "unused_native_passthrough_not_subscribed", + "turnKind": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.handoff", + "fields": [ + "context", + "handoffTime", + "host", + "remoteSessionId", + "repository", + "sourceType", + "summary" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "context": "unused_native_passthrough_not_subscribed", + "handoffTime": "unused_native_passthrough_not_subscribed", + "host": "unused_native_passthrough_not_subscribed", + "remoteSessionId": "unused_native_passthrough_not_subscribed", + "repository": "unused_native_passthrough_not_subscribed", + "sourceType": "unused_native_passthrough_not_subscribed", + "summary": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.idle", + "fields": [ + "aborted", + "mode" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "aborted": "projected", + "mode": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.indexed_search", + "fields": [], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": {}, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.info", + "fields": [ + "infoType", + "message", + "tip", + "url" + ], + "standardAcp": "agent_message_chunk", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "infoType": "standard_projection_field_retention_not_individually_qualified", + "message": "standard_projection_field_retention_not_individually_qualified", + "tip": "standard_projection_field_retention_not_individually_qualified", + "url": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "session.managed_settings_enforced", + "fields": [ + "action", + "escalation", + "failClosed", + "message", + "setting" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "action": "unused_native_passthrough_not_subscribed", + "escalation": "unused_native_passthrough_not_subscribed", + "failClosed": "unused_native_passthrough_not_subscribed", + "message": "unused_native_passthrough_not_subscribed", + "setting": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.managed_settings_resolved", + "fields": [ + "bypassPermissionsDisabled", + "clientManaged", + "deviceManaged", + "failClosed", + "managedKeys", + "permissionsAllowIntersected", + "policyHelperManaged", + "sandboxEnabledByUndeterminedPolicy", + "serverManaged", + "settings", + "source" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "bypassPermissionsDisabled": "unused_native_passthrough_not_subscribed", + "clientManaged": "unused_native_passthrough_not_subscribed", + "deviceManaged": "unused_native_passthrough_not_subscribed", + "failClosed": "unused_native_passthrough_not_subscribed", + "managedKeys": "unused_native_passthrough_not_subscribed", + "permissionsAllowIntersected": "unused_native_passthrough_not_subscribed", + "policyHelperManaged": "unused_native_passthrough_not_subscribed", + "sandboxEnabledByUndeterminedPolicy": "unused_native_passthrough_not_subscribed", + "serverManaged": "unused_native_passthrough_not_subscribed", + "settings": "unused_native_passthrough_not_subscribed", + "source": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.mcp_server_needs_reconnect", + "fields": [ + "serverName" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "serverName": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "session.mcp_server_removed", + "fields": [ + "serverName" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "serverName": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "session.mcp_server_status_changed", + "fields": [ + "error", + "serverName", + "status" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "error": "unused_native_passthrough_not_subscribed", + "serverName": "unused_native_passthrough_not_subscribed", + "status": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "session.mcp_servers_loaded", + "fields": [ + "servers" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "servers": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Only runner-owned MCP admitted; do not normalize ambient auth/headers as authority. Add sanitized bridge diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "session.memory_changed", + "fields": [], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": {}, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.mode_changed", + "fields": [ + "newMode", + "previousMode" + ], + "standardAcp": "current_mode_update", + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "newMode": "projected", + "previousMode": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.mode_notice_delivered", + "fields": [ + "content", + "mode" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "content": "unused_native_passthrough_not_subscribed", + "mode": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.model_change", + "fields": [ + "autoTier", + "cause", + "contextTier", + "newModel", + "previousAutoTier", + "previousModel", + "previousReasoningEffort", + "previousReasoningSummary", + "previousVerbosity", + "reasoningEffort", + "reasoningSummary", + "source", + "verbosity" + ], + "standardAcp": "config_option_update", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "autoTier": "standard_projection_field_retention_not_individually_qualified", + "cause": "standard_projection_field_retention_not_individually_qualified", + "contextTier": "standard_projection_field_retention_not_individually_qualified", + "newModel": "standard_projection_field_retention_not_individually_qualified", + "previousAutoTier": "standard_projection_field_retention_not_individually_qualified", + "previousModel": "standard_projection_field_retention_not_individually_qualified", + "previousReasoningEffort": "standard_projection_field_retention_not_individually_qualified", + "previousReasoningSummary": "standard_projection_field_retention_not_individually_qualified", + "previousVerbosity": "standard_projection_field_retention_not_individually_qualified", + "reasoningEffort": "standard_projection_field_retention_not_individually_qualified", + "reasoningSummary": "standard_projection_field_retention_not_individually_qualified", + "source": "standard_projection_field_retention_not_individually_qualified", + "verbosity": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "session.model_deselected", + "fields": [ + "previousModel", + "reason" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "previousModel": "unused_native_passthrough_not_subscribed", + "reason": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.permission_recovery", + "fields": [ + "attempts", + "episodeId", + "maxAttempts", + "onBlocked", + "reason", + "status" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "attempts": "unused_native_passthrough_not_subscribed", + "episodeId": "unused_native_passthrough_not_subscribed", + "maxAttempts": "unused_native_passthrough_not_subscribed", + "onBlocked": "unused_native_passthrough_not_subscribed", + "reason": "unused_native_passthrough_not_subscribed", + "status": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "session.permissions_changed", + "fields": [ + "assistedApprovalModel", + "mode", + "previousMode" + ], + "standardAcp": "config_option_update", + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "assistedApprovalModel": "projected", + "mode": "projected", + "previousMode": "projected" + }, + "gapReason": "Standard permission callback owns authorization; native notices cannot grant authority. Add sanitized diagnostics.", + "followUpPriority": "P1" + }, + { + "event": "session.plan_changed", + "fields": [ + "operation" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "operation": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.remote_steerable_changed", + "fields": [ + "remoteSteerable" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "remoteSteerable": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.resume", + "fields": [ + "alreadyInUse", + "autoTier", + "context", + "contextTier", + "continuePendingWork", + "eventCount", + "eventsFileSizeBytes", + "reasoningEffort", + "reasoningSummary", + "remoteSteerable", + "resumeTime", + "selectedModel", + "sessionLimits", + "sessionWasActive", + "verbosity" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "alreadyInUse": "unused_native_passthrough_not_subscribed", + "autoTier": "unused_native_passthrough_not_subscribed", + "context": "unused_native_passthrough_not_subscribed", + "contextTier": "unused_native_passthrough_not_subscribed", + "continuePendingWork": "unused_native_passthrough_not_subscribed", + "eventCount": "unused_native_passthrough_not_subscribed", + "eventsFileSizeBytes": "unused_native_passthrough_not_subscribed", + "reasoningEffort": "unused_native_passthrough_not_subscribed", + "reasoningSummary": "unused_native_passthrough_not_subscribed", + "remoteSteerable": "unused_native_passthrough_not_subscribed", + "resumeTime": "unused_native_passthrough_not_subscribed", + "selectedModel": "unused_native_passthrough_not_subscribed", + "sessionLimits": "unused_native_passthrough_not_subscribed", + "sessionWasActive": "unused_native_passthrough_not_subscribed", + "verbosity": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.schedule_cancelled", + "fields": [ + "id" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "id": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.schedule_created", + "fields": [ + "at", + "cron", + "displayPrompt", + "id", + "intervalMs", + "origin", + "prompt", + "recurring", + "selfPaced", + "tz" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "at": "unused_native_passthrough_not_subscribed", + "cron": "unused_native_passthrough_not_subscribed", + "displayPrompt": "unused_native_passthrough_not_subscribed", + "id": "unused_native_passthrough_not_subscribed", + "intervalMs": "unused_native_passthrough_not_subscribed", + "origin": "unused_native_passthrough_not_subscribed", + "prompt": "unused_native_passthrough_not_subscribed", + "recurring": "unused_native_passthrough_not_subscribed", + "selfPaced": "unused_native_passthrough_not_subscribed", + "tz": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.schedule_rearmed", + "fields": [ + "id", + "nextRunAt" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "id": "unused_native_passthrough_not_subscribed", + "nextRunAt": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.session_limits_changed", + "fields": [ + "sessionLimits" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "sessionLimits": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.shutdown", + "fields": [ + "agentMetrics", + "codeChanges", + "conversationTokens", + "currentModel", + "currentTokens", + "errorReason", + "eventsFileSizeBytes", + "modelMetrics", + "sessionStartTime", + "shutdownType", + "systemTokens", + "tokenDetails", + "toolDefinitionsTokens", + "totalApiDurationMs", + "totalNanoAiu", + "totalPremiumRequests" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "agentMetrics": "unused_native_passthrough_not_subscribed", + "codeChanges": "unused_native_passthrough_not_subscribed", + "conversationTokens": "unused_native_passthrough_not_subscribed", + "currentModel": "unused_native_passthrough_not_subscribed", + "currentTokens": "unused_native_passthrough_not_subscribed", + "errorReason": "unused_native_passthrough_not_subscribed", + "eventsFileSizeBytes": "unused_native_passthrough_not_subscribed", + "modelMetrics": "unused_native_passthrough_not_subscribed", + "sessionStartTime": "unused_native_passthrough_not_subscribed", + "shutdownType": "unused_native_passthrough_not_subscribed", + "systemTokens": "unused_native_passthrough_not_subscribed", + "tokenDetails": "unused_native_passthrough_not_subscribed", + "toolDefinitionsTokens": "unused_native_passthrough_not_subscribed", + "totalApiDurationMs": "unused_native_passthrough_not_subscribed", + "totalNanoAiu": "unused_native_passthrough_not_subscribed", + "totalPremiumRequests": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.skills_loaded", + "fields": [ + "skills" + ], + "standardAcp": "available_commands_update", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "skills": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "session.snapshot_rewind", + "fields": [ + "eventsRemoved", + "upToEventId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "eventsRemoved": "unused_native_passthrough_not_subscribed", + "upToEventId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.start", + "fields": [ + "alreadyInUse", + "autoTier", + "context", + "contextTier", + "copilotVersion", + "detachedFromSpawningParentSessionId", + "githubMcpToolConfig", + "producer", + "reasoningEffort", + "reasoningSummary", + "remoteSteerable", + "selectedModel", + "sessionId", + "sessionLimits", + "startTime", + "verbosity", + "version" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "alreadyInUse": "unused_native_passthrough_not_subscribed", + "autoTier": "unused_native_passthrough_not_subscribed", + "context": "unused_native_passthrough_not_subscribed", + "contextTier": "unused_native_passthrough_not_subscribed", + "copilotVersion": "unused_native_passthrough_not_subscribed", + "detachedFromSpawningParentSessionId": "unused_native_passthrough_not_subscribed", + "githubMcpToolConfig": "unused_native_passthrough_not_subscribed", + "producer": "unused_native_passthrough_not_subscribed", + "reasoningEffort": "unused_native_passthrough_not_subscribed", + "reasoningSummary": "unused_native_passthrough_not_subscribed", + "remoteSteerable": "unused_native_passthrough_not_subscribed", + "selectedModel": "unused_native_passthrough_not_subscribed", + "sessionId": "unused_native_passthrough_not_subscribed", + "sessionLimits": "unused_native_passthrough_not_subscribed", + "startTime": "unused_native_passthrough_not_subscribed", + "verbosity": "unused_native_passthrough_not_subscribed", + "version": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.task_complete", + "fields": [ + "blocker", + "objectiveId", + "outcome", + "reason", + "success", + "summary" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "blocker": "unused_native_passthrough_not_subscribed", + "objectiveId": "unused_native_passthrough_not_subscribed", + "outcome": "unused_native_passthrough_not_subscribed", + "reason": "unused_native_passthrough_not_subscribed", + "success": "unused_native_passthrough_not_subscribed", + "summary": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.title_changed", + "fields": [ + "title" + ], + "standardAcp": "session_info_update", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "title": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "session.todos_changed", + "fields": [], + "standardAcp": "plan", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": {}, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "session.tools_updated", + "fields": [ + "model" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "model": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.truncation", + "fields": [ + "messagesRemovedDuringTruncation", + "performedBy", + "postTruncationMessagesLength", + "postTruncationTokensInMessages", + "preTruncationMessagesLength", + "preTruncationTokensInMessages", + "tokenLimit", + "tokensRemovedDuringTruncation" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "messagesRemovedDuringTruncation": "unused_native_passthrough_not_subscribed", + "performedBy": "unused_native_passthrough_not_subscribed", + "postTruncationMessagesLength": "unused_native_passthrough_not_subscribed", + "postTruncationTokensInMessages": "unused_native_passthrough_not_subscribed", + "preTruncationMessagesLength": "unused_native_passthrough_not_subscribed", + "preTruncationTokensInMessages": "unused_native_passthrough_not_subscribed", + "tokenLimit": "unused_native_passthrough_not_subscribed", + "tokensRemovedDuringTruncation": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session.usage_checkpoint", + "fields": [ + "modelCacheState", + "promptCacheBreakState", + "totalNanoAiu", + "totalPremiumRequests" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "modelCacheState": "unused_declared_field", + "promptCacheBreakState": "unused_declared_field", + "totalNanoAiu": "projected", + "totalPremiumRequests": "unused_declared_field" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.usage_info", + "fields": [ + "conversationTokens", + "currentTokens", + "isInitial", + "messagesLength", + "systemTokens", + "tokenLimit", + "toolDefinitionsTokens" + ], + "standardAcp": "usage_update", + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "conversationTokens": "projected", + "currentTokens": "projected", + "isInitial": "projected", + "messagesLength": "projected", + "systemTokens": "projected", + "tokenLimit": "projected", + "toolDefinitionsTokens": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session.warning", + "fields": [ + "message", + "remediation", + "url", + "warningType" + ], + "standardAcp": "agent_message_chunk", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "message": "standard_projection_field_retention_not_individually_qualified", + "remediation": "standard_projection_field_retention_not_individually_qualified", + "url": "standard_projection_field_retention_not_individually_qualified", + "warningType": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "session.workspace_file_changed", + "fields": [ + "operation", + "path" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "operation": "projected", + "path": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "session_limits_exhausted.completed", + "fields": [ + "requestId", + "response" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "requestId": "unused_native_passthrough_not_subscribed", + "response": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "session_limits_exhausted.requested", + "fields": [ + "maxAiCredits", + "requestId", + "usedAiCredits" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "maxAiCredits": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "usedAiCredits": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No qualified ACP responder; must prove no blocking request or implement responder before qualification.", + "followUpPriority": "P0" + }, + { + "event": "skill.context_delivered", + "fields": [ + "content", + "interactionId", + "source" + ], + "standardAcp": null, + "paperclipDisposition": "blocked_by_provider", + "fieldCoverage": { + "content": "provider_blocks_passthrough", + "interactionId": "provider_blocks_passthrough", + "source": "provider_blocks_passthrough" + }, + "gapReason": "Pinned provider explicitly blocks these event types.", + "followUpPriority": "upstream" + }, + { + "event": "skill.context_delivered_ref", + "fields": [ + "contentId", + "interactionId", + "prefix", + "source", + "suffix" + ], + "standardAcp": null, + "paperclipDisposition": "blocked_by_provider", + "fieldCoverage": { + "contentId": "provider_blocks_passthrough", + "interactionId": "provider_blocks_passthrough", + "prefix": "provider_blocks_passthrough", + "source": "provider_blocks_passthrough", + "suffix": "provider_blocks_passthrough" + }, + "gapReason": "Pinned provider explicitly blocks these event types.", + "followUpPriority": "upstream" + }, + { + "event": "skill.invoked", + "fields": [ + "allowedTools", + "content", + "description", + "disableModelInvocation", + "invokedAtTurn", + "model", + "name", + "path", + "pluginName", + "pluginVersion", + "source", + "trigger" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "allowedTools": "unused_native_passthrough_not_subscribed", + "content": "unused_native_passthrough_not_subscribed", + "description": "unused_native_passthrough_not_subscribed", + "disableModelInvocation": "unused_native_passthrough_not_subscribed", + "invokedAtTurn": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "name": "unused_native_passthrough_not_subscribed", + "path": "unused_native_passthrough_not_subscribed", + "pluginName": "unused_native_passthrough_not_subscribed", + "pluginVersion": "unused_native_passthrough_not_subscribed", + "source": "unused_native_passthrough_not_subscribed", + "trigger": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Ambient hooks/extensions excluded; preserve runner-owned attribution separately.", + "followUpPriority": "P2" + }, + { + "event": "skill.invoked_ref", + "fields": [ + "allowedTools", + "contentId", + "contentLength", + "description", + "disableModelInvocation", + "invokedAtTurn", + "model", + "name", + "path", + "pluginName", + "pluginVersion", + "source", + "trigger" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "allowedTools": "unused_native_passthrough_not_subscribed", + "contentId": "unused_native_passthrough_not_subscribed", + "contentLength": "unused_native_passthrough_not_subscribed", + "description": "unused_native_passthrough_not_subscribed", + "disableModelInvocation": "unused_native_passthrough_not_subscribed", + "invokedAtTurn": "unused_native_passthrough_not_subscribed", + "model": "unused_native_passthrough_not_subscribed", + "name": "unused_native_passthrough_not_subscribed", + "path": "unused_native_passthrough_not_subscribed", + "pluginName": "unused_native_passthrough_not_subscribed", + "pluginVersion": "unused_native_passthrough_not_subscribed", + "source": "unused_native_passthrough_not_subscribed", + "trigger": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "Ambient hooks/extensions excluded; preserve runner-owned attribution separately.", + "followUpPriority": "P2" + }, + { + "event": "subagent.completed", + "fields": [ + "agentDisplayName", + "agentName", + "cancelled", + "configuredModelMatchesActual", + "configuredModelPreference", + "durationMs", + "explicitModelMatchesPreference", + "explicitModelOverride", + "firstDispatchedModel", + "model", + "modelOverrideReason", + "modelSelectionSource", + "toolCallId", + "totalTokens", + "totalToolCalls" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "agentDisplayName": "projected", + "agentName": "projected", + "cancelled": "projected", + "configuredModelMatchesActual": "projected", + "configuredModelPreference": "projected", + "durationMs": "projected", + "explicitModelMatchesPreference": "projected", + "explicitModelOverride": "projected", + "firstDispatchedModel": "projected", + "model": "projected", + "modelOverrideReason": "projected", + "modelSelectionSource": "projected", + "toolCallId": "projected", + "totalTokens": "projected", + "totalToolCalls": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "subagent.configured", + "fields": [ + "contextTier", + "model", + "multiTurn", + "reasoningEffort" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "contextTier": "projected", + "model": "projected", + "multiTurn": "projected", + "reasoningEffort": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "subagent.deselected", + "fields": [], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": {}, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "subagent.failed", + "fields": [ + "agentDisplayName", + "agentName", + "configuredModelMatchesActual", + "configuredModelPreference", + "durationMs", + "error", + "explicitModelMatchesPreference", + "explicitModelOverride", + "firstDispatchedModel", + "model", + "modelOverrideReason", + "modelSelectionSource", + "toolCallId", + "totalTokens", + "totalToolCalls" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "agentDisplayName": "projected", + "agentName": "projected", + "configuredModelMatchesActual": "projected", + "configuredModelPreference": "projected", + "durationMs": "projected", + "error": "projected", + "explicitModelMatchesPreference": "projected", + "explicitModelOverride": "projected", + "firstDispatchedModel": "projected", + "model": "projected", + "modelOverrideReason": "projected", + "modelSelectionSource": "projected", + "toolCallId": "projected", + "totalTokens": "projected", + "totalToolCalls": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "subagent.selected", + "fields": [ + "agentDisplayName", + "agentName", + "tools" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "agentDisplayName": "unused_native_passthrough_not_subscribed", + "agentName": "unused_native_passthrough_not_subscribed", + "tools": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "subagent.started", + "fields": [ + "agentDescription", + "agentDisplayName", + "agentName", + "agentType", + "executionMode", + "factoryRunId", + "model", + "modelSelectionSource", + "parentId", + "resumable", + "taskModelSource", + "toolCallId" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "agentDescription": "projected", + "agentDisplayName": "projected", + "agentName": "projected", + "agentType": "projected", + "executionMode": "projected", + "factoryRunId": "projected", + "model": "projected", + "modelSelectionSource": "projected", + "parentId": "projected", + "resumable": "projected", + "taskModelSource": "projected", + "toolCallId": "projected" + }, + "gapReason": "Only bounded typed metadata projected. Private bodies/nested diagnostics or provider-only resources require an explicit safe contract; see capability report.", + "followUpPriority": "P1" + }, + { + "event": "system.message", + "fields": [ + "content", + "contentBlocks", + "interactionId", + "metadata", + "name", + "role" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "content": "unused_native_passthrough_not_subscribed", + "contentBlocks": "unused_native_passthrough_not_subscribed", + "interactionId": "unused_native_passthrough_not_subscribed", + "metadata": "unused_native_passthrough_not_subscribed", + "name": "unused_native_passthrough_not_subscribed", + "role": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "system.notification", + "fields": [ + "content", + "kind", + "responsesReasoning" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "content": "unused_native_passthrough_not_subscribed", + "kind": "unused_native_passthrough_not_subscribed", + "responsesReasoning": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "tool.execution_complete", + "fields": [ + "error", + "fusion", + "interactionId", + "isUserRequested", + "mcpMeta", + "model", + "parentToolCallId", + "result", + "rte", + "sandboxed", + "shellExecution", + "success", + "toolCallId", + "toolDescription", + "toolTelemetry", + "turnId" + ], + "standardAcp": "tool_call_update", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "error": "standard_projection_field_retention_not_individually_qualified", + "fusion": "standard_projection_field_retention_not_individually_qualified", + "interactionId": "standard_projection_field_retention_not_individually_qualified", + "isUserRequested": "standard_projection_field_retention_not_individually_qualified", + "mcpMeta": "standard_projection_field_retention_not_individually_qualified", + "model": "standard_projection_field_retention_not_individually_qualified", + "parentToolCallId": "standard_projection_field_retention_not_individually_qualified", + "result": "standard_projection_field_retention_not_individually_qualified", + "rte": "standard_projection_field_retention_not_individually_qualified", + "sandboxed": "standard_projection_field_retention_not_individually_qualified", + "shellExecution": "standard_projection_field_retention_not_individually_qualified", + "success": "standard_projection_field_retention_not_individually_qualified", + "toolCallId": "standard_projection_field_retention_not_individually_qualified", + "toolDescription": "standard_projection_field_retention_not_individually_qualified", + "toolTelemetry": "standard_projection_field_retention_not_individually_qualified", + "turnId": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "tool.execution_partial_result", + "fields": [ + "partialOutput", + "toolCallId" + ], + "standardAcp": "tool_call_update", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "partialOutput": "standard_projection_field_retention_not_individually_qualified", + "toolCallId": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "tool.execution_progress", + "fields": [ + "progressMessage", + "toolCallId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "progressMessage": "unused_native_passthrough_not_subscribed", + "toolCallId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "tool.execution_start", + "fields": [ + "arguments", + "displayVerbatim", + "fusion", + "mcpConfigServerName", + "mcpConfigSource", + "mcpServerName", + "mcpToolName", + "mcpTransport", + "model", + "parentToolCallId", + "rte", + "shellToolInfo", + "toolCallId", + "toolDescription", + "toolName", + "toolTitle", + "turnId" + ], + "standardAcp": "tool_call", + "paperclipDisposition": "standard_acp_projection", + "fieldCoverage": { + "arguments": "standard_projection_field_retention_not_individually_qualified", + "displayVerbatim": "standard_projection_field_retention_not_individually_qualified", + "fusion": "standard_projection_field_retention_not_individually_qualified", + "mcpConfigServerName": "standard_projection_field_retention_not_individually_qualified", + "mcpConfigSource": "standard_projection_field_retention_not_individually_qualified", + "mcpServerName": "standard_projection_field_retention_not_individually_qualified", + "mcpToolName": "standard_projection_field_retention_not_individually_qualified", + "mcpTransport": "standard_projection_field_retention_not_individually_qualified", + "model": "standard_projection_field_retention_not_individually_qualified", + "parentToolCallId": "standard_projection_field_retention_not_individually_qualified", + "rte": "standard_projection_field_retention_not_individually_qualified", + "shellToolInfo": "standard_projection_field_retention_not_individually_qualified", + "toolCallId": "standard_projection_field_retention_not_individually_qualified", + "toolDescription": "standard_projection_field_retention_not_individually_qualified", + "toolName": "standard_projection_field_retention_not_individually_qualified", + "toolTitle": "standard_projection_field_retention_not_individually_qualified", + "turnId": "standard_projection_field_retention_not_individually_qualified" + }, + "gapReason": "Primary content travels standard ACP. Native extra field retention requires direct field-by-field normalization qualification.", + "followUpPriority": "P2" + }, + { + "event": "tool.user_requested", + "fields": [ + "arguments", + "toolCallId", + "toolName" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "arguments": "unused_native_passthrough_not_subscribed", + "toolCallId": "unused_native_passthrough_not_subscribed", + "toolName": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "tool_search.activated", + "fields": [ + "strategy", + "toolNames" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "strategy": "unused_native_passthrough_not_subscribed", + "toolNames": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "ui.ephemeral_query", + "fields": [ + "answer", + "chunk", + "error", + "phase", + "requestId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "answer": "unused_native_passthrough_not_subscribed", + "chunk": "unused_native_passthrough_not_subscribed", + "error": "unused_native_passthrough_not_subscribed", + "phase": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "user.message", + "fields": [ + "agentMode", + "attachments", + "content", + "delivery", + "interactionId", + "isAutopilotContinuation", + "messageId", + "nativeDocumentPathFallbackPaths", + "parentAgentTaskId", + "responsesReasoning", + "source", + "supportedNativeDocumentMimeTypes", + "transformedContent", + "turnId" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "agentMode": "unused_native_passthrough_not_subscribed", + "attachments": "unused_native_passthrough_not_subscribed", + "content": "unused_native_passthrough_not_subscribed", + "delivery": "unused_native_passthrough_not_subscribed", + "interactionId": "unused_native_passthrough_not_subscribed", + "isAutopilotContinuation": "unused_native_passthrough_not_subscribed", + "messageId": "unused_native_passthrough_not_subscribed", + "nativeDocumentPathFallbackPaths": "unused_native_passthrough_not_subscribed", + "parentAgentTaskId": "unused_native_passthrough_not_subscribed", + "responsesReasoning": "unused_native_passthrough_not_subscribed", + "source": "unused_native_passthrough_not_subscribed", + "supportedNativeDocumentMimeTypes": "unused_native_passthrough_not_subscribed", + "transformedContent": "unused_native_passthrough_not_subscribed", + "turnId": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "user_input.completed", + "fields": [ + "answer", + "requestId", + "wasFreeform" + ], + "standardAcp": null, + "paperclipDisposition": "native_passthrough_not_subscribed", + "fieldCoverage": { + "answer": "unused_native_passthrough_not_subscribed", + "requestId": "unused_native_passthrough_not_subscribed", + "wasFreeform": "unused_native_passthrough_not_subscribed" + }, + "gapReason": "No admitted typed native control/resource surface, or standard ACP already transports primary output; investigate useful metadata before subscription.", + "followUpPriority": "P2" + }, + { + "event": "user_input.requested", + "fields": [ + "allowFreeform", + "choices", + "question", + "requestId", + "toolCallId" + ], + "standardAcp": null, + "paperclipDisposition": "subscribed_and_projected", + "fieldCoverage": { + "allowFreeform": "unused_declared_field", + "choices": "unused_declared_field", + "question": "unused_declared_field", + "requestId": "projected", + "toolCallId": "projected" + }, + "gapReason": "No qualified ACP responder; must prove no blocking request or implement responder before qualification.", + "followUpPriority": "P0" + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-live-denial-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-live-denial-2026-09-28.json new file mode 100644 index 0000000000..a806c4cefa --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-live-denial-2026-09-28.json @@ -0,0 +1,124 @@ +{ + "schema": "paperclip.copilot-live-denial-proof/v1", + "providerVersion": "1.0.88", + "model": "gpt-5.6-luna", + "qualificationStatus": "pending", + "scenario": "deny-write", + "probeSourceRevision": "47eed960b380e8c8054eb19985aaefeabc6336c3", + "providerSourceRevision": "8aa867b64d5fc2fd62cff110bd000addf5dc54de", + "providerPackDigest": "sha256:627992ea80e8be7154d07cbc9925b781519199e25690b02d4a044f44342e6bd8", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "privateEvidenceSha256": "sha256:f5cd355cbed796e3ed1564f6201e55e9e90e0419197912a6bb89948c4d0eb340", + "promptRequestsSent": 1, + "upstreamModelRequestCount": null, + "activeTurnDeadlineSeconds": 120, + "outerDeadlineSeconds": 180, + "automaticRetries": 0, + "reservationUsd": 2, + "stopReason": "end_turn", + "terminalElapsedMs": 7187, + "permissions": [ + { + "elapsedMs": 7177, + "requestId": 0, + "offeredOptions": [ + { + "optionId": "allow_once", + "kind": "allow_once", + "name": "Allow once" + }, + { + "optionId": "allow_always", + "kind": "allow_always", + "name": "Always allow" + }, + { + "optionId": "reject_once", + "kind": "reject_once", + "name": "Deny" + } + ], + "outcome": { + "outcome": "selected", + "optionId": "reject_once" + }, + "kind": "reject_once", + "exactSession": true, + "exactCommand": false, + "writeAttempt": true + } + ], + "nativeToolCall": { + "title": "Create file", + "kind": "edit", + "rawInput": { + "fileName": "/fixture/workspace/qualification-marker.txt", + "diff": "\ndiff --git a/fixture/workspace/qualification-marker.txt b/fixture/workspace/qualification-marker.txt\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/fixture/workspace/qualification-marker.txt\n@@ -1,0 +1,2 @@\n+MUST_NOT_EXIST\n+\n\n" + }, + "locations": [ + { + "path": "/fixture/workspace/qualification-marker.txt" + } + ] + }, + "markerOracle": { + "sampleCount": 79, + "anySampleExisted": false, + "terminal": { + "elapsedMs": 7187, + "phase": "terminal", + "exists": false, + "matches": false + }, + "afterCleanup": { + "elapsedMs": 12837, + "phase": "after_process_cleanup", + "exists": false, + "matches": false + } + }, + "providerExit": { + "code": 0, + "signal": null + }, + "cleanupComplete": true, + "result": { + "passed": true, + "failures": [] + }, + "nativeUsage": { + "inputTokens": 10989, + "outputTokens": 65, + "totalTokens": 11054, + "thoughtTokens": 30, + "cachedReadTokens": 0, + "cachedWriteTokens": 10986 + }, + "providerReportedCostUsd": null, + "externalBilling": { + "baselineDisplayedIncludedAiCreditsUsed": 3, + "displayedIncludedAiCreditsTotal": 1500, + "postProbeReconciliation": "parent refreshed GitHub dashboard", + "exactCreditConsumption": null, + "additionalUsageEnabled": false, + "additionalCashBudgetUsd": 0, + "postProbeDisplayedIncludedAiCreditsUsed": 3, + "additionalCashChargesUsd": 0, + "note": "Unchanged display can reflect granularity or delay; it is not a zero-credit per-run receipt." + }, + "scope": "One real GitHub inference request through the verified native ACP command lease. This proves this denied file edit only, not every tool or full Product/Daytona qualification.", + "offlineExactTargetRevalidation": { + "originalPrivateEvidenceSha256": "sha256:f5cd355cbed796e3ed1564f6201e55e9e90e0419197912a6bb89948c4d0eb340", + "oracleScriptSha256": "sha256:0dc70ffd88abf502a68f8b4974b799d105c928bf250be615532e71cd919a765f", + "method": "Offline replay of retained permission rawInput.fileName and original marker observations through tightened exact-target oracle", + "requestIds": [ + 0 + ], + "allDeniedWriteTargetsMatchMarker": true, + "result": { + "passed": true, + "failures": [] + }, + "newProviderPrompts": 0 + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-live-detached-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-live-detached-2026-09-28.json new file mode 100644 index 0000000000..1226b42b30 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-live-detached-2026-09-28.json @@ -0,0 +1,123 @@ +{ + "schema": "paperclip.copilot-live-detached-proof/v1", + "providerVersion": "1.0.88", + "model": "gpt-5.6-luna", + "qualificationStatus": "pending", + "scenario": "detached-shell", + "probeSourceRevision": "47eed960b380e8c8054eb19985aaefeabc6336c3", + "providerSourceRevision": "8aa867b64d5fc2fd62cff110bd000addf5dc54de", + "providerPackDigest": "sha256:627992ea80e8be7154d07cbc9925b781519199e25690b02d4a044f44342e6bd8", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "privateEvidenceSha256": "sha256:9943538f40e35b36438266f3ddaa977f9a74c12b9df928888bf24d70bb2d6b18", + "promptRequestsSent": 1, + "upstreamModelRequestCount": null, + "activeTurnDeadlineSeconds": 120, + "outerDeadlineSeconds": 180, + "automaticRetries": 0, + "reservationUsd": 2, + "stopReason": "end_turn", + "terminalElapsedMs": 10675, + "permissions": [ + { + "elapsedMs": 6938, + "requestId": 0, + "offeredOptions": [ + { + "optionId": "allow_once", + "kind": "allow_once", + "name": "Allow once" + }, + { + "optionId": "allow_always", + "kind": "allow_always", + "name": "Always allow" + }, + { + "optionId": "reject_once", + "kind": "reject_once", + "name": "Deny" + } + ], + "outcome": { + "outcome": "selected", + "optionId": "allow_once" + }, + "kind": "allow_once", + "exactSession": true, + "exactCommand": true, + "writeAttempt": true + } + ], + "nativeToolCall": { + "elapsedMs": 6884, + "title": "Run the requested background marker command", + "kind": "execute", + "rawInput": { + "command": "sleep 3; printf 'ACP_BACKGROUND_DONE' > qualification-marker.txt", + "description": "Run the requested background marker command", + "mode": "async", + "detach": true, + "initial_wait": 30 + } + }, + "nativeCompletion": { + "elapsedMs": 10083, + "rawOutput": { + "content": "", + "detailedContent": "" + } + }, + "detachedToolObserved": true, + "backgroundCompletionObservedBeforeTerminal": true, + "markerOracle": { + "sampleCount": 118, + "firstObserved": { + "elapsedMs": 10098, + "phase": "during_turn", + "exists": true, + "matches": true + }, + "terminal": { + "elapsedMs": 10675, + "phase": "terminal", + "exists": true, + "matches": true + }, + "afterCleanup": { + "elapsedMs": 16147, + "phase": "after_process_cleanup", + "exists": true, + "matches": true + } + }, + "providerExit": { + "code": 0, + "signal": null + }, + "cleanupComplete": true, + "result": { + "passed": true, + "failures": [] + }, + "nativeUsage": { + "inputTokens": 33273, + "outputTokens": 126, + "totalTokens": 33399, + "thoughtTokens": 38, + "cachedReadTokens": 22096, + "cachedWriteTokens": 11168 + }, + "providerReportedCostUsd": null, + "externalBilling": { + "baselineDisplayedIncludedAiCreditsUsed": 3, + "displayedIncludedAiCreditsTotal": 1500, + "postProbeReconciliation": "parent refreshed GitHub billing dashboard", + "exactCreditConsumption": null, + "additionalUsageEnabled": false, + "additionalCashBudgetUsd": 0, + "postProbeDisplayedIncludedAiCreditsUsed": 3, + "additionalCashChargesUsd": 0, + "note": "Unchanged display may reflect credit granularity or delay; exact credit consumption remains unknown." + }, + "scope": "One real GitHub inference turn waited for this finite detached command. This does not prove every background lifecycle or Product/Daytona qualification." +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-message-identity-distributions-1.0.88.json b/packages/paperclip-runner/test/fixtures/copilot-message-identity-distributions-1.0.88.json new file mode 100644 index 0000000000..5f78f212e4 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-message-identity-distributions-1.0.88.json @@ -0,0 +1,4107 @@ +{ + "schema": "paperclip.copilot_message_identity_distributions.v1", + "upstreamVersion": "1.0.88", + "status": "offline_candidate_not_paid_qualified", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "platforms": { + "darwin-arm64": { + "executableSha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "closureSha256": "362f2663e967fb9e34a814bac4619cbf89e6b23069c4051ab1f2f686852d0a32", + "entries": [ + { + "path": "copilot", + "sha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "size": 152595280, + "executable": true + }, + { + "path": "distribution/LICENSE.md", + "sha256": "b9680937e10425bf19862908856c16ed274e6b81a1368bd62be7a757eab21628", + "size": 2955, + "executable": false + }, + { + "path": "distribution/animations/app-install-nudge.json.gz", + "sha256": "8a4165e6e95f127fe3123e647580c31def2d52b7824f2c4e48b505339132543e", + "size": 9900, + "executable": false + }, + { + "path": "distribution/animations/banner.json.gz", + "sha256": "cc6dcef840f5f63c9683e84bd55478e6fd2a22130bf1be6f53dece870e996446", + "size": 4887, + "executable": false + }, + { + "path": "distribution/app.js", + "sha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "size": 7856755, + "executable": true + }, + { + "path": "distribution/assets/copilot.ico", + "sha256": "13c6005245ba982d54fce8a8fa7a5a517c35e68322f2bcc4d4cd5366d5aa4ac4", + "size": 315320, + "executable": false + }, + { + "path": "distribution/assets/copilot.png", + "sha256": "601aea9338888b3e0065b6b6bcb5793bf1215f205b6e781a47b1893d96fd67d1", + "size": 51687, + "executable": false + }, + { + "path": "distribution/assets/copilot.svg", + "sha256": "ff370a742c271f57810569f80d6202e1fdfdd5c1a270e691f18cecc0bd08a738", + "size": 3940, + "executable": false + }, + { + "path": "distribution/assets/copilot_win.png", + "sha256": "5977b25558f3a25e858f1e9d74dd9d621cd02044cecd95fd3858ae425f715fe5", + "size": 2831, + "executable": false + }, + { + "path": "distribution/builtin-skills/customize-cloud-agent/SKILL.md", + "sha256": "6acee791cd48a31eb881431c0f1394d3c79d33d62f0d58b2d9b699e4a275ea93", + "size": 18679, + "executable": false + }, + { + "path": "distribution/builtin-skills/discover-resources/SKILL.md", + "sha256": "9afbbacc30095ee4b4a70da34471781be9ad1df46f6c2df1ddbdc0473b34c6e7", + "size": 3647, + "executable": false + }, + { + "path": "distribution/builtin-skills/github-pr-media/SKILL.md", + "sha256": "eba36e66d053b3db19e8f51cfb7d28186ef2baf90159f51bf2333747b150070b", + "size": 4938, + "executable": false + }, + { + "path": "distribution/builtin/customize-cloud-agent/SKILL.md", + "sha256": "6acee791cd48a31eb881431c0f1394d3c79d33d62f0d58b2d9b699e4a275ea93", + "size": 18679, + "executable": false + }, + { + "path": "distribution/builtin/discover-resources/SKILL.md", + "sha256": "9afbbacc30095ee4b4a70da34471781be9ad1df46f6c2df1ddbdc0473b34c6e7", + "size": 3647, + "executable": false + }, + { + "path": "distribution/builtin/github-pr-media/SKILL.md", + "sha256": "eba36e66d053b3db19e8f51cfb7d28186ef2baf90159f51bf2333747b150070b", + "size": 4938, + "executable": false + }, + { + "path": "distribution/changelog.json", + "sha256": "63d808aa73c260039e8e8a8f66523af06ac3e1d0ad4a7ca0a85e3761d7502ddf", + "size": 1088362, + "executable": false + }, + { + "path": "distribution/copilot-sdk/canvas.d.ts", + "sha256": "2f5c82d47a8a1abd27466f9dc093106a59d83f685d4f6c29367e2ce25f44b700", + "size": 5858, + "executable": false + }, + { + "path": "distribution/copilot-sdk/cliVersion.d.ts", + "sha256": "560db20a242a671969bc05d32121b5c5bb3b0d88b0ba6384f6222d9bbb91fd7f", + "size": 113, + "executable": false + }, + { + "path": "distribution/copilot-sdk/client.d.ts", + "sha256": "734625086b3ef39b53c494c922b069a84eca64b7181509b42a7762cac35f9d68", + "size": 18301, + "executable": false + }, + { + "path": "distribution/copilot-sdk/copilotRequestHandler.d.ts", + "sha256": "730762ae75ad87be8cbef0080a2f5150919f0a9c53e52b6aaaa3f366f2c17d79", + "size": 3443, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/agent-author.md", + "sha256": "6280a2b6684a83ca7d17871be6666f5d979f5d3076ddbb9680edc83394abb810", + "size": 10352, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/examples.md", + "sha256": "7bc5fea049219a6d2ddd444dfcfee9b629ca99b22f4bad916086f6e7180ac3df", + "size": 21076, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/extensions.md", + "sha256": "c6b21c9e7f551fcd2cc3a541627a031fd073abc68c3450855687c871b97f9898", + "size": 4406, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/factories.md", + "sha256": "360834bd122edc4e2c9764d2db4d18712dbe1d5be3cccc39680eead3c0c8bd11", + "size": 21443, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/factory-patterns.md", + "sha256": "c37bae44a6cbabc4b70a5d0ccde3e04eb15a4c1111b2ed1a053efb1bbd254c33", + "size": 8201, + "executable": false + }, + { + "path": "distribution/copilot-sdk/extension.d.ts", + "sha256": "a83bc2d74059e6d56331671a33e0d0b6fc12563384e0dd6d279a6ebe45582384", + "size": 3303, + "executable": false + }, + { + "path": "distribution/copilot-sdk/extension.js", + "sha256": "cfb092cd6a7686833d02b832bdb47b13b8e325bfa864acb9ca692eb6d3443032", + "size": 494981, + "executable": false + }, + { + "path": "distribution/copilot-sdk/factory.d.ts", + "sha256": "1d23bbb4ebc8c1307182bd41b7f41f77ae83056cbf1579c072b976935c2afdf8", + "size": 15089, + "executable": false + }, + { + "path": "distribution/copilot-sdk/ffiRuntimeHost.d.ts", + "sha256": "519ac132a1d43f7f661d30cd1c3896d367a22639cbef526dfabe62f773bd4df7", + "size": 1720, + "executable": false + }, + { + "path": "distribution/copilot-sdk/generated/rpc.d.ts", + "sha256": "3b0d0ce9b73f657a453c64e94288d63b5f606f13dc599f0095e43213bcbf4e5a", + "size": 915008, + "executable": false + }, + { + "path": "distribution/copilot-sdk/generated/session-events.d.ts", + "sha256": "bba0f7bd4d34e586011f5e52945514902ccaf951553283fd8b0770eb37155717", + "size": 382125, + "executable": false + }, + { + "path": "distribution/copilot-sdk/index.d.ts", + "sha256": "a0c0eb9395e0abfba8b49ce5304cd44a7960586f7cb2529676392feab877f26c", + "size": 4685, + "executable": false + }, + { + "path": "distribution/copilot-sdk/index.js", + "sha256": "f8594ddbd0ad841395bd1fb0f02195094708aaef77bdaf767ddb617e19470571", + "size": 500179, + "executable": false + }, + { + "path": "distribution/copilot-sdk/runtimeArtifacts.d.ts", + "sha256": "1d78a2fd37b65e793e553db9a8323a6727b77e7ffbd6b61c462e778794dd76e2", + "size": 1248, + "executable": false + }, + { + "path": "distribution/copilot-sdk/sdkProtocolVersion.d.ts", + "sha256": "344a1c9669d1617346e2bb71d24081797b69b6cfd37c17b04b2aebf8e7a87260", + "size": 300, + "executable": false + }, + { + "path": "distribution/copilot-sdk/session.d.ts", + "sha256": "12ff3bcce06347e7d8810fa05a6b056c728281173723def01805a498c691fcf4", + "size": 15291, + "executable": false + }, + { + "path": "distribution/copilot-sdk/sessionFsProvider.d.ts", + "sha256": "6aa732885a832f7117a5d85853eb05b26052bc446a278c17b9463b7bdb06094d", + "size": 5545, + "executable": false + }, + { + "path": "distribution/copilot-sdk/telemetry.d.ts", + "sha256": "0721ce90ecd3a15eeb86d7787ca62236b1b545b0e93139ed5ef4419b16544722", + "size": 528, + "executable": false + }, + { + "path": "distribution/copilot-sdk/toolSet.d.ts", + "sha256": "41e53ae25809c90fd1a78f493cb912c6a9194c6e0a02e3e32545971c41a1cb56", + "size": 2725, + "executable": false + }, + { + "path": "distribution/copilot-sdk/types.d.ts", + "sha256": "2911ecb4aac830b7c129f2accf01086ae47a5250700dc050e95dfa33def862c4", + "size": 118473, + "executable": false + }, + { + "path": "distribution/definitions/code-review.agent.yaml", + "sha256": "1290258ee70b44faca1f0c74be26254f13e8401e825713ff3f5523b848b361f9", + "size": 4604, + "executable": false + }, + { + "path": "distribution/definitions/explore.agent.yaml", + "sha256": "83b4ed34c2fd8549dc5a557203c9d0db7dde5fc118b1eea8476585d05afa13ef", + "size": 2913, + "executable": false + }, + { + "path": "distribution/definitions/rem-agent.agent.yaml", + "sha256": "895ac0f7d0ce5cd006501eed7f1abfd38405ba93c30d16be9c7205bed6f335bf", + "size": 968, + "executable": false + }, + { + "path": "distribution/definitions/research.agent.yaml", + "sha256": "a32495c36fa076ded05b84dd04a93c2739e9be922ad77221beb519e3fb3d733a", + "size": 5594, + "executable": false + }, + { + "path": "distribution/definitions/rubber-duck.agent.yaml", + "sha256": "eb624f30ef69f8cfd18e7f3f14bcca63e13d1a0d3e05af006372b8d537bbd15d", + "size": 4820, + "executable": false + }, + { + "path": "distribution/definitions/security-review.agent.yaml", + "sha256": "7b6769eb585913a0ad59dadb3bf2e42cd9cfadb1540b341f23a28e5728be2d53", + "size": 14222, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/cloud-session-search.yaml", + "sha256": "0eaa7fe8f8cecb923c87c9598e22c4e338bfdc069cc9c78325eb6b589fa94bcd", + "size": 1809, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/github-context-memory.yaml", + "sha256": "22b699a6dd5b2d7964f8b2a913618ae478cf6910df2fc3ba336cf38b1be3b2ef", + "size": 4114, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/github-context.yaml", + "sha256": "9b7692e969e9ab36d7053fb2ef99ba9dd2ae78f6df75e645f54eb3cdb3fc6252", + "size": 2200, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/session-search.yaml", + "sha256": "8b6f48742fe847002e5ba6e00b05abc5339a5891387cd306790eb3b8c7dd8c11", + "size": 1889, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/subconscious-agent.yaml", + "sha256": "9fb4cb881677adc08c69818ecdde127276b1afe006817802c381a3ff5fb5887f", + "size": 2616, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-context-changed.yaml", + "sha256": "117edd3750a403c6cabf7964f217ae8129b16f9d0a6d2503e09ebe79d64f9d50", + "size": 1029, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-persistent.yaml", + "sha256": "a5ab1c16dd5f9a030010a375f7f26bca56aa196b212ea52ac2c5736cd80f3989", + "size": 861, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-restart.yaml", + "sha256": "21e44442b8380a758793d637a60fe94c226a6547d23c5bea3a14546f7f3e60ec", + "size": 831, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-trigger-once.yaml", + "sha256": "14a7cedf6ead133828280744b293e90402a2d47568c98a67273c1d7c3acdd53e", + "size": 828, + "executable": false + }, + { + "path": "distribution/definitions/task.agent.yaml", + "sha256": "1780e87f638e5c195b66173438565fd1af97eb0b9e2b1f2ebbd9d4e5b875f3af", + "size": 2112, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/README.md", + "sha256": "ff79555febdbf1ea12b10bef4f6bdcb08812205ba2efe568b048b0ecdd127bf4", + "size": 12375, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/deps_versions.json", + "sha256": "43ccda9b18ba2577033d7bf24580785fd1227828d9ab06b8011ddfeee57af8e9", + "size": 370, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/catalog.d.ts", + "sha256": "aa0db4dee8e54431e2e01d14fd7977d28cd3032f9c4940cc5b926b77d5469dd5", + "size": 3613, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/catalog.js", + "sha256": "c2cb5abd5e01162186cb43e67a0a6e49d4d9a556a7382b7dc1840d6c5c2a6b6b", + "size": 11418, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/configuration.d.ts", + "sha256": "900ba0e99ac907a92d07c08b179ba7d4379326fb71de2d338eb8434860338973", + "size": 2067, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/configuration.js", + "sha256": "7b01b6d5e18b7a8a2e465f0d75214a0b9884ffe57df95af1b2708a5a4c4d7bc5", + "size": 1645, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/coreInterop.d.ts", + "sha256": "80bb839431d2ee4c935d2def128faa4993acbe8d75fa403df5b50a61c2f92ff8", + "size": 980, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/coreInterop.js", + "sha256": "466e3287e579f2b4180fafcb33479549dd2bd683b687456e1b664cd84da524d1", + "size": 5557, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/model.d.ts", + "sha256": "8f219d0af8f00e9a0119ea8d69eb3d92bc2b5e25ce9e17ac218fcb4a8f88355c", + "size": 5311, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/model.js", + "sha256": "8f553a823eb0dda46eacdd53835449abd5f6e030d66d684d9a4e9c88396317ec", + "size": 6663, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelLoadManager.d.ts", + "sha256": "1a2e8b44486f02548f6d30a942f074d052175d7d14d6f5cd7918151cc1ff2552", + "size": 1045, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelLoadManager.js", + "sha256": "1a7888fe6fb620acc9b1d7ff8d17c5563139b0bd32834bdaabc4d36cc54343c2", + "size": 3288, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelVariant.d.ts", + "sha256": "a7af8a0359d36ed90322738bde975c534b9a3b69dcb0e0393cf6f88da7e1b486", + "size": 4649, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelVariant.js", + "sha256": "d62acc4da20f11fb40102f79ded6f45e3cf2f684db1f97c7888c26e84f86eef7", + "size": 7217, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/foundryLocalManager.d.ts", + "sha256": "812e9e2eec43997439392d139b995793aa3839f573aea38d115e90573f986cf5", + "size": 7846, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/foundryLocalManager.js", + "sha256": "bfbca8d6235ba998ce3e82ddc9970900a7e2511653341e1315786c6851c1f380", + "size": 9451, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/imodel.d.ts", + "sha256": "26244ca6cdfb41849ca8e8edef23c5a6681ed18a4acfb2b714c7d2beb0f592fb", + "size": 2156, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/imodel.js", + "sha256": "6ab1971ca21097ea33a2b7b423aee408c093fec343292bcbbc59971d3e253713", + "size": 45, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/index.d.ts", + "sha256": "977e345d9eb116eaf3d4db1f972222480fd0fc6e59d3ac273aef1801dbead4f0", + "size": 1109, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/index.js", + "sha256": "e030b97bdb052ed99f00df928c2fc42f7ac165021d86df792290225c30c5376f", + "size": 929, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/audioClient.d.ts", + "sha256": "7a0d9fe37f7e6f253be258a7938667334deadeb930c2b8872f22e517d1963762", + "size": 2253, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/audioClient.js", + "sha256": "04264c41f3567811940ef28b48d97684b333ccbac9829cce8b3d0db8e763b51f", + "size": 9895, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/chatClient.d.ts", + "sha256": "a9da4a98dc50b1425e2ace9ffb044a3aa872e678947bf035339c9b8703dd6d1e", + "size": 3283, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/chatClient.js", + "sha256": "c59a093318d05a3e38a2f68693f3b64a24603472c73517743302d5a507331063", + "size": 13835, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/embeddingClient.d.ts", + "sha256": "a1875a29cd2825fa23c6fe7bf7f4e04c4e2e574e142e0e5fb0c0291b2206528d", + "size": 1470, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/embeddingClient.js", + "sha256": "bd55fbceb7fea625d30144a462d1c18dd857e6a2539a4179734d3b3237656428", + "size": 2554, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioSession.d.ts", + "sha256": "f2ed7df5665add334b18ad793866c814333863345f8077b8c925cb9b0aed66e4", + "size": 3441, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioSession.js", + "sha256": "889d7adcb9a64858113cd881a77f56962fa85266d2a7ac58c319ecc45be745bf", + "size": 12816, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioTypes.d.ts", + "sha256": "831434a52d2643186dd76bbb657c3acf2f0635cafaa94763a0ff2f728242146f", + "size": 2387, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioTypes.js", + "sha256": "f78e54d6ed3b9b0fba913e7ceec41b36364279e1d93d8b006b62af55806d027c", + "size": 1840, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/responsesClient.d.ts", + "sha256": "3126a17df66edb893fb5d26bd6ea80a05f5bb516e47da661e927c0936e86d0d2", + "size": 5737, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/responsesClient.js", + "sha256": "76b477538feb61c41bc0ff7cf8f23b7dbe75bc2fda4720e29c70ff3cac9afc7f", + "size": 15246, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/types.d.ts", + "sha256": "cca44657b82f9b3c0cd7528e6dbddf5f949e42f5d34afe60c0d566f28e3fc0b0", + "size": 9844, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/types.js", + "sha256": "ca23f484beade559e7ed612347ac8f615c2c4cc97c2e1b0a74b9c882db7f6f38", + "size": 297, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/LICENSE", + "sha256": "6bb5b2d4c07d793ca928daa63a8899c6914fafb5ac3aa04ec10cae07f3d57dca", + "size": 1106, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/README.md", + "sha256": "9d05e156889b1475cdee97a13ed84a4abee0d752b2aaa28ded5359fcf45df901", + "size": 2894, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/adm-zip.js", + "sha256": "f4feddb21980597cd0248c8790922534abfa90c17a483b00ece15e392074ee27", + "size": 40794, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/entryHeader.js", + "sha256": "6ed1ec82e6124d71dbfb4ee6e5dd4c975657854354cf74741427a1862010a664", + "size": 12717, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/index.js", + "sha256": "536e4b5bf009a3d9f6eccfbbc4157cb6de663d889e0826ea5f6e5fa17aaeb8bf", + "size": 94, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/mainHeader.js", + "sha256": "ed5a2b3fc7c8a6937e07447eb3e55e08a0655d2f24fe04039e7008845d221a25", + "size": 7141, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/deflater.js", + "sha256": "6dc41b2460594cfa5136b797653c166b2f7403820a40f2fca17cca35a5de1b5f", + "size": 1021, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/index.js", + "sha256": "d67714f1a04be942f90be77069af3ff4214aa8ee84b26edeff3a87eb0d8e2dc0", + "size": 128, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/inflater.js", + "sha256": "7b62c190669eeefd6b2810a3339d9109ecb219f845fcd12ae93518beec35dc49", + "size": 1146, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/zipcrypto.js", + "sha256": "23365c7eda0ea098385dc7ec649517fc110ce2764d2e707c37bf6b528604e25a", + "size": 5806, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/package.json", + "sha256": "76a6fc3e9bf811c9ada1173b7a7c0621d36329c29c221428f46f75762d3aeb5a", + "size": 1387, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/types.d.ts", + "sha256": "98fae6ae2e5d026a91e0316209813ce304069ab85d0471f35152d5db500d23c1", + "size": 9641, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/constants.js", + "sha256": "208e943a2e5faad056047f3c7991cce3cde637d8e272a564f2546210ebdf2069", + "size": 6374, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/decoder.js", + "sha256": "73a0ebb00c4dce2124f07acf0b34374cb03a4384cccd1cd6f58aee27c35953d9", + "size": 130, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/errors.js", + "sha256": "d2d243647737c795c2db8aeba2e1f3841d5f76370b521d436cf465322dd4aab7", + "size": 2868, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/fattr.js", + "sha256": "31c93eb386a2bfbf19ad92a6bf20d510a8f1e7e90cc71d33dd888f89da12362d", + "size": 1892, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/index.js", + "sha256": "dc5b230ed853947ea55c0bf69f0e525fbeffefff09aa3da296d541bb8898314e", + "size": 226, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/utils.js", + "sha256": "f50d229943cce6067a7f09f5e97b4a69157ec11759777382e5ca0ba03671d1e9", + "size": 13011, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/zipEntry.js", + "sha256": "874e9510d362ba7d2cd85e2dcc7a0e7a6caa4de8ddb37fb4d5e68a018cd87a39", + "size": 15173, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/zipFile.js", + "sha256": "a709d4f3ee218f457b7e71dd6b22dcf58858cc5410eece4dc4ecd7c4e9a2c027", + "size": 16516, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/package.json", + "sha256": "07d0242f84fdcfc8f4a0e601dc35f01fa153535c67f95262e0bd750ba7cfa418", + "size": 1296, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/prebuilds/darwin-arm64/foundry_local_napi.node", + "sha256": "1cb33a3264bd3843f092be626ee0b4181646ef632cbf7f671b3624b1d44c283c", + "size": 55032, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/script/install-utils.cjs", + "sha256": "0831c932b10389283e805f88a204b0f6a5a8053f2ee520e56a0f0adf1352aa8b", + "size": 10443, + "executable": false + }, + { + "path": "distribution/index.js", + "sha256": "24ec5cfb8f9bcb6559d8c187fbaaae3a9e964a570bf212fa646cff825bc47b78", + "size": 41547, + "executable": true + }, + { + "path": "distribution/npm-loader.js", + "sha256": "0ea824a86be5757533fdb092eff7050871bd7a711a46babde0ffe0e44ac5ad88", + "size": 1185, + "executable": true + }, + { + "path": "distribution/package.json", + "sha256": "d4a592ea2cd06fc3be13dd46cf4d41b2277516efa3dd3f24e438bde3b4cd523a", + "size": 2188, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/.release-target", + "sha256": "8d881856b9e9a2991794a6cb91599bddc227db867a3ff50402438c3d5a02cb57", + "size": 13, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/CodeResources", + "sha256": "cdd2874058dbebc37dd2c284b1ebf49aca0903741483dd61946f7e2afc60e4a9", + "size": 1653, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/Info.plist", + "sha256": "68318792b18f1e92ec2784b054489023727146e03e7926a1bd3798fe95629ef3", + "size": 1320, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/MacOS/Copilot Computer Use", + "sha256": "114430b2babd63194571f060961490d4a29f39d44796557277bf49c1bb547a05", + "size": 2591840, + "executable": true + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/PkgInfo", + "sha256": "82502191c9484b04d685374f9879a0066069c49b8acae7a04b01d38d07e8eca0", + "size": 8, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/Resources/Assets.car", + "sha256": "ce5c0f133fdae3c24ed7e93a39f51d5e133025cad44c1c89b7755e75a7f284b0", + "size": 1593544, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/Resources/icon.icns", + "sha256": "44d787b9159291ec5d862d257b5d1c9178c2264d1a1aba7c21d75ae8c1e3613a", + "size": 815442, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/_CodeSignature/CodeResources", + "sha256": "e994e1e722bdf4f8ecdc7ca3e7d2d5dff4c0170f20ea43012c78b55cc27aacd8", + "size": 2666, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/computer-use-mcp", + "sha256": "d43ca16788e53034aa12737fd05d08f91d812e1398f999d3d9d863f6a411e5f8", + "size": 2292944, + "executable": true + }, + { + "path": "distribution/plugins/computer-use/mcp.json", + "sha256": "37a1f153978a85cc2b112f133eb8a430b647014991c4475b79148f28ee224fbf", + "size": 236, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/plugin.json", + "sha256": "256f4eeb66f714ee6db8a10aba969f266094d537b9c7b273254da0b39326e834", + "size": 185, + "executable": false + }, + { + "path": "distribution/prebuilds/darwin-arm64/cli-native.node", + "sha256": "e07a3b5c5636349d7bf669ee5e6efb303d36ccb92784b98aeb064b2734cdebca", + "size": 4192464, + "executable": true + }, + { + "path": "distribution/prebuilds/darwin-arm64/copilot-runtime", + "sha256": "502ee556ebd401c06e005aed180fa0ea35060ca0ddee0bf15977164640348a42", + "size": 387056, + "executable": true + }, + { + "path": "distribution/prebuilds/darwin-arm64/mediaremote-adapter/LICENSE", + "sha256": "a489c16a6be195584fa0b35e28fca54abc8492eb0cc05d851dd24f522f4900a2", + "size": 1521, + "executable": false + }, + { + "path": "distribution/prebuilds/darwin-arm64/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter", + "sha256": "b21ff01b421f556664dea3ee3e801a311a129244c23691163681f0f35d6656d1", + "size": 168112, + "executable": true + }, + { + "path": "distribution/prebuilds/darwin-arm64/mediaremote-adapter/mediaremote-adapter.pl", + "sha256": "984d622eeebbcb17656d157a49272b02fb741593ae2ec624d1926c12d955c8a1", + "size": 8444, + "executable": true + }, + { + "path": "distribution/prebuilds/darwin-arm64/runtime.node", + "sha256": "f14b974c47d10d2ff207f6b3e72fb7a33ba207809d91873ad640a81b0f25139c", + "size": 75019696, + "executable": true + }, + { + "path": "distribution/preloads/extension_bootstrap.mjs", + "sha256": "ebf4edc7cc2195fc1b7c5c26863e08137137ec075cc73ff335331534115a1be6", + "size": 2811, + "executable": false + }, + { + "path": "distribution/preloads/extension_sdk_resolver.mjs", + "sha256": "e57d71730c8f937315a72b66187c56cc4fa78ecbd13590795136996e2851bf19", + "size": 1331, + "executable": false + }, + { + "path": "distribution/pvrecorder/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/README.md", + "sha256": "7eb46bc4bdba8002da1165107fc729ee816f58063a8e8d6d0083a3fb81898683", + "size": 1675, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/errors.js", + "sha256": "14b373ef00a0bf0fceb5abe86a6628f914a466b2403ba376f78b025cff05e92b", + "size": 2528, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/index.js", + "sha256": "a99551364022401340991df2d74e1c949290e920aa9247d22c262e26b768a0f0", + "size": 748, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/platforms.js", + "sha256": "9eb9c4c2af151391c4966bdaf15364d15362996915e8f5fa685daca1c3f81359", + "size": 6404, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/pv_recorder.js", + "sha256": "3323ebdbad3d524dc4330d25cb226cc17a0f7f365b738f471faf883d71de658d", + "size": 6154, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/pv_recorder_status_t.js", + "sha256": "c7c94cd144fb0fcf5f844e76ff83d754dc6b835e45893c2cc81de5f0c3ddccbb", + "size": 1519, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/errors.d.ts", + "sha256": "5250616ee995097ea36daa3285d957e3adcdd9c76a6dd13ea3d10174c38a8668", + "size": 236, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/index.d.ts", + "sha256": "6b27f29a807ef113c326fcc2139ddde257e74585cb58591ad9753dcbfd00c03f", + "size": 98, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/platforms.d.ts", + "sha256": "a11acc208b8d403db8882b97e0101f549a5dd7a1ca656ff46d9378277a100064", + "size": 142, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/pv_recorder.d.ts", + "sha256": "6b06821393cd5fbab4874a11c46634e7bb1ff91848bbf34a1e5008ccea6929bf", + "size": 2741, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/pv_recorder_status_t.d.ts", + "sha256": "b35985d2b520dc2570f90f23e6db7e6ce37369db6e35c27bc532de349421175e", + "size": 337, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/lib/mac/arm64/pv_recorder.node", + "sha256": "f5bc73a9e4e4506bc1882a1c3eb318f51848621c2620fb6eaa177faefc2fa648", + "size": 625648, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/package.json", + "sha256": "e44a9dde00f1344251f22bce37520ea1bbbde5af2965cab7e612758854ee3a99", + "size": 1197, + "executable": false + }, + { + "path": "distribution/queries/bash-highlights.scm", + "sha256": "77860b9f127ca82681e3691bba003e62d1d8dbced05e4fe96041d26fd8944656", + "size": 5776, + "executable": false + }, + { + "path": "distribution/queries/c-highlights.scm", + "sha256": "a99c4c0bf9d9b9f7de0124c529a4229fddada96d65bf5fb0c9d487266e15d483", + "size": 6332, + "executable": false + }, + { + "path": "distribution/queries/cpp-highlights.scm", + "sha256": "69d1dbed8d49c498e93a75db6afedfb8769fa2fed455a64d588dc347281d642f", + "size": 4904, + "executable": false + }, + { + "path": "distribution/queries/csharp-highlights.scm", + "sha256": "4b79059a52926e8cd08a1d80af70ba02acefb86bc8ed82315897fb104ed1a338", + "size": 8302, + "executable": false + }, + { + "path": "distribution/queries/css-highlights.scm", + "sha256": "6641fed120530bfbce5682ab88b2a8d14d99bfcd971ac9f78cd91738b4b0578a", + "size": 1177, + "executable": false + }, + { + "path": "distribution/queries/go-highlights.scm", + "sha256": "c58a7336d4834ebfbb038002c6ab033f792cd1b8d3e7e1682bdaa982bee7f6b1", + "size": 3934, + "executable": false + }, + { + "path": "distribution/queries/html-highlights.scm", + "sha256": "1ebb3811a8cdc054385b847a3aac6fbf7079faefd5b3dfab5bbad256cd5afdcf", + "size": 197, + "executable": false + }, + { + "path": "distribution/queries/java-highlights.scm", + "sha256": "c68c0e7e9ae36d94c9e5ab06556b34c5e8568d1ce1867043d92bbe4b3b4dcf2f", + "size": 4425, + "executable": false + }, + { + "path": "distribution/queries/javascript-highlights.scm", + "sha256": "d3630ae6dc9b2b27b230b5f8bb92b05cd491fb12bff353dae62a0a6d780461ee", + "size": 2739, + "executable": false + }, + { + "path": "distribution/queries/json-highlights.scm", + "sha256": "6ab09656820f0e8dac860c96dced341221284544c05308ee337727f5db937101", + "size": 412, + "executable": false + }, + { + "path": "distribution/queries/php-highlights.scm", + "sha256": "a2a7367659cff3b4be09961c8117d69a9b8703bfc266f8092e089b1760f197e9", + "size": 3207, + "executable": false + }, + { + "path": "distribution/queries/python-highlights.scm", + "sha256": "a6708f209381618e2b398972c8f1ccd892f0c064eab35a2a3f911c3e22e79a7e", + "size": 1957, + "executable": false + }, + { + "path": "distribution/queries/ruby-highlights.scm", + "sha256": "1cc8fc205e4cce4fe9e0d63bb1be903e49ea0f2eda77ae9368898a31ff98bc18", + "size": 4068, + "executable": false + }, + { + "path": "distribution/queries/rust-highlights.scm", + "sha256": "c649a4785322ab72af84874b9dc03c7a4f5d9a05de45a85919a60770d7d5457d", + "size": 8610, + "executable": false + }, + { + "path": "distribution/queries/scala-highlights.scm", + "sha256": "e9e89654a7df214f4c79c28af7bc50bdb5c6cc5abb9690e3e1ce19c35b797991", + "size": 4858, + "executable": false + }, + { + "path": "distribution/queries/typescript-highlights.scm", + "sha256": "e0c35adb819127bfd4f853fac5419e7d8ba44760246201d04a4a5ce0228a10c5", + "size": 515, + "executable": false + }, + { + "path": "distribution/ripgrep/bin/darwin-arm64/rg", + "sha256": "24a06598aca017914d5f8849a9d855d412a6a89c39b428d3180dea9b69f8eee4", + "size": 4520448, + "executable": true + }, + { + "path": "distribution/schemas/api.schema.json", + "sha256": "032a7784171eb2c4eb8260f0c0b67f05e9b1c76d0e74fe507871ba6a36d9a3ab", + "size": 1914299, + "executable": false + }, + { + "path": "distribution/schemas/session-events.schema.json", + "sha256": "d8cb713c05d5278a68dde5c5d4482574f836e922ae13aa06f82474c209a7c6e9", + "size": 895984, + "executable": false + }, + { + "path": "distribution/sdk/index.js", + "sha256": "a0718e6376a1146da09b4cf6ef2a332d447cd22f7dfffdedc000c7b622309ef4", + "size": 1165545, + "executable": false + }, + { + "path": "distribution/sea-loader.js", + "sha256": "28b90bfebd03f86b7d6ffc22957cb5251adaa0358c3c01e7d1bb35b20c853a5f", + "size": 122246, + "executable": false + }, + { + "path": "distribution/tgrep/bin/darwin-arm64/tgrep", + "sha256": "a0713b25b844a4640a990cb270255ff616af330836664f482450c59cde64fde3", + "size": 6336704, + "executable": true + }, + { + "path": "distribution/tree-sitter-bash.wasm", + "sha256": "8292919c88a0f7d3fb31d0cd0253ca5a9531bc1ede82b0537f2c63dd8abe6a7a", + "size": 1358224, + "executable": false + }, + { + "path": "distribution/tree-sitter-c.wasm", + "sha256": "c852c2a85ebf2beb636aa3b0ef7f7e70458684d74f6741b20dcb296885bed9f9", + "size": 625918, + "executable": false + }, + { + "path": "distribution/tree-sitter-c_sharp.wasm", + "sha256": "6f69e1cae44e1c32c1eccc170dc5a9778fb94ff716f71113fe1f8c4299aa2f40", + "size": 5350581, + "executable": false + }, + { + "path": "distribution/tree-sitter-cpp.wasm", + "sha256": "174eb0deb75b2ec7881bcacda9f995648d8e683956e5c2267e69ab6dc503fcbf", + "size": 3434931, + "executable": false + }, + { + "path": "distribution/tree-sitter-css.wasm", + "sha256": "8a23977fe271357cce6f254ef88c9bebf3854602d8046605aef6a45c02135c59", + "size": 128668, + "executable": false + }, + { + "path": "distribution/tree-sitter-go.wasm", + "sha256": "9504573f352b20be7f2f1911754d710622aedc15afff16d5ed8fb5645681aee7", + "size": 217182, + "executable": false + }, + { + "path": "distribution/tree-sitter-html.wasm", + "sha256": "c48fcd82c7ea8bf943180088ba7f28c48b2bb5287874179168bf9d31e394cf85", + "size": 18385, + "executable": false + }, + { + "path": "distribution/tree-sitter-java.wasm", + "sha256": "4fdeac4ca6ca089f06c6f7e562abcac1733cd465728cc7031ebb73c2019122c4", + "size": 414641, + "executable": false + }, + { + "path": "distribution/tree-sitter-javascript.wasm", + "sha256": "5fb488d0cabb4775a594bab85682de5ad6ce83c0d6ac997a9f82dd084d571240", + "size": 411770, + "executable": false + }, + { + "path": "distribution/tree-sitter-json.wasm", + "sha256": "d2119fb98d5912719b13f9458574f8608d2d29dfbe45f6be1f860ea1fe2a2405", + "size": 5596, + "executable": false + }, + { + "path": "distribution/tree-sitter-php.wasm", + "sha256": "d4df6a6ff08c87c3ec4f9cbb785fe09998a0cb570e03f57d7b19b3acfb146aa7", + "size": 1058041, + "executable": false + }, + { + "path": "distribution/tree-sitter-python.wasm", + "sha256": "16108b50df4ee9a30168794252ab55e7c93bfc5765d7fa0aa3e335752c515f47", + "size": 457883, + "executable": false + }, + { + "path": "distribution/tree-sitter-ruby.wasm", + "sha256": "09a96427d7c72f0613ed470cd9812223fc4a91d6a9c025c0235cc6bd59ff96f4", + "size": 2106352, + "executable": false + }, + { + "path": "distribution/tree-sitter-rust.wasm", + "sha256": "f65f354215611fd94ad34134b3427eb3d58cbb745df7b6509ba722184db73d57", + "size": 1102547, + "executable": false + }, + { + "path": "distribution/tree-sitter-scala.wasm", + "sha256": "b7ec2bb29c19827abcefd18ed5cb5a43596009f96a5d53c5b9d1f9676d7521c3", + "size": 3786700, + "executable": false + }, + { + "path": "distribution/tree-sitter-tsx.wasm", + "sha256": "79e5da75ea62855a0cd67177685f0164eac87d5f630b3cbe1e0a099751ad30f8", + "size": 1445638, + "executable": false + }, + { + "path": "distribution/tree-sitter-typescript.wasm", + "sha256": "778025db5a8be0e70f8ccc3671e486dfeddd048c25d9e8a70c26de2e1bf6f97d", + "size": 1413849, + "executable": false + }, + { + "path": "distribution/tree-sitter.wasm", + "sha256": "f38dcc4b43b818f9a0785bc1c6d5611a75ac4cdd428ff3f02757c34ca4e46d7f", + "size": 205488, + "executable": true + }, + { + "path": "distribution/voice-engine.worker.js", + "sha256": "bf601f1092d1b8734a582d8182c223855c7f5cfb17f8fc529f1791dfd470d5d2", + "size": 112985, + "executable": false + }, + { + "path": "distribution/voice-installer.worker.js", + "sha256": "4a843b379310d44f422271e75d3246ada24f567587850c3ef501f565c905b493", + "size": 11639, + "executable": false + }, + { + "path": "distribution/voice-server.js", + "sha256": "c6b2c140027ef6c7b283ebbb4cbde1dc32f12f97901ff45dfdfd536269bac1d2", + "size": 130952, + "executable": false + }, + { + "path": "distribution/webview/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-darwin-arm64/README.md", + "sha256": "f7fac36538af09475752019eedaf6299ff21f36cf9e7cb4a6c676c7974afff0b", + "size": 106, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-darwin-arm64/package.json", + "sha256": "e9241663ed145cdb282dbc65933b7c14cb30a3da2cb47a0b5f657e23d5889084", + "size": 638, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-darwin-arm64/webview.darwin-arm64.node", + "sha256": "85089cd40b59cd4cb087ad43d448c308b3461106fae78795d5852d5eedea585f", + "size": 3997792, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/LICENSE", + "sha256": "9cac72e0573f14d3f9bcb8f70922d589ba07dd1051a5c12229f111e154923f0a", + "size": 1065, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/README.md", + "sha256": "69c2e106db45187b981358c938e41b8bfb67fb3a451930f102a63b471c0da675", + "size": 16477, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/build.mjs", + "sha256": "bcb69c0cc8356b78d07c53719ccaa372f1d6b6c381973b76508681573a7b1db4", + "size": 3640, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/index.mjs", + "sha256": "3d6d86fc751897b67e1b94f0b1d0a667d79e6256b278a7e801467bfd67ad2689", + "size": 3837, + "executable": true + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/utils.mjs", + "sha256": "fb7a2fc54e53c777de835f50c7d4483bee60d1aece0c1deddbe2f232f571d8f1", + "size": 193, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/CNAME", + "sha256": "405d5f5a242885480dc01db6227484ed4b6d723fd7c3b2547f9f36e46decac15", + "size": 14, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/README.md", + "sha256": "65578b7f73cc6c145a9d962ca6f2ee56b06153a8277eea8c3c3cadc7d602f155", + "size": 15717, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/application.md", + "sha256": "44227367accc4a9f5b8468e19680ffd1229891d59c0652e8f45370c0872160bd", + "size": 6074, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/browser-window.md", + "sha256": "597920a56d3ff8c87b6990473e5150a454f109bd7e639125c8a82d039ade7487", + "size": 11107, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/menu.md", + "sha256": "838a998eba786e959bec89e148fae7d547df1b13e490b55c890d58100fd77b98", + "size": 3828, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/notification.md", + "sha256": "8e90225a0f40ebcd2a69bc9e0b8d51c645810faa4fcaae3651101bcaa686d101", + "size": 5622, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/tray.md", + "sha256": "f071ca7d3a5cc7cb271ceb9e2712db9c6c80d5c8ab33b4a8fc3b168e5d9dd3aa", + "size": 1628, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/types.md", + "sha256": "e006fb8428d0e58943468f62c77ec78bcba1bb7b0bc41ce3e2258417fe51320c", + "size": 5711, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/web-context.md", + "sha256": "92b9ed94a8868a1ff8b0f3e4ef92be2a0b2564f7beb1fc8b6107b7f1bca96429", + "size": 1774, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/webview.md", + "sha256": "78ade1e288c7a6169678d4bb64f9e450ed4e855324c310354686c11f127bd494", + "size": 6279, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/event-loop.md", + "sha256": "28b1f847d6ff3c71c5b3fbf9f46d531b6fbd9ba585365cfbe9a2292e698171bb", + "size": 2511, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/installation.md", + "sha256": "06acf9a3d61c0d84f0dcc65153f43d288e1bb139a0e16df69253fae68c2c6c76", + "size": 1173, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/quick-start.md", + "sha256": "55cdf7992b7ad793b204ac80268c66600975ee20c474c2ca029cdd6db58073b6", + "size": 1995, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/building-executables.md", + "sha256": "18a1d5d2596e0e0b05758643981d7ffe5b9d9f32bf0884c84978c647bf2419e9", + "size": 5026, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/cookies-and-storage.md", + "sha256": "0eb9abdd246e5a2a0d4a8578c6b565781fdb9d7b99463b50c05a6851c3eed365", + "size": 1783, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/custom-protocols.md", + "sha256": "1c72110266d09b04f544c0d20aa7fe1f4b6ad84ffa9e171b7f6e55832d39c4c1", + "size": 3148, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/ipc-messaging.md", + "sha256": "d03b307d46cafe92ad0297cad0a0ee1b3af410143281be4be5ab2dbc31e2e74e", + "size": 1790, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/menus.md", + "sha256": "88b791fa71cbb51351cd3a220184ea54a53426cca00eab0c185acd139053fe6d", + "size": 2764, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/multiple-windows.md", + "sha256": "6d05fa117efc95207d4707bbf2728ae31f482bc6e9f29a8a436389fdaae003cd", + "size": 1835, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/android.md", + "sha256": "646dc6f1ac30a4f2bd25faec682a2d6f0262ea59193ac44490d5914cf1294cd0", + "size": 326, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/ios.md", + "sha256": "b8b1c2bc59611d633971556fb94720bdb411e2de89771fb372217d1a17b53cb7", + "size": 420, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/linux.md", + "sha256": "cbd1df68b7c899f826aff9e0fd2d13e9f9e19892ad16fd47a6fea5c625dc0e8b", + "size": 1986, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/macos.md", + "sha256": "9b5b1d9c4065d7904c02e3dc79517b6df4f8a44dd95902e30502d130cdef5d71", + "size": 2260, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/windows.md", + "sha256": "5a84a310a20d42e0b24492472efd5512aae11c68668620f071a3d85c3583512a", + "size": 2136, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/index.d.ts", + "sha256": "49610270556eaff855f8efc8026a595c354309ea325d846d33a617ad54039a39", + "size": 13502, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/index.js", + "sha256": "9bce1f609372abd7da4973967276ba591c43f9b933bcd52030edb1ad6ee3b8db", + "size": 22727, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/js-bindings.d.ts", + "sha256": "f2f384ab7913a7e38369155a5abda97c0af3b72db6021df341e8da2288ceceaf", + "size": 20365, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/js-bindings.js", + "sha256": "e6a970a01839ed97c9a9636a01bf1c77c298a8270b3f1708c4927191856a3bcc", + "size": 28518, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/package.json", + "sha256": "0a71bc7c23521db3890aadb13763297422e1988e9a68f95ec8b8832c07d33b89", + "size": 3442, + "executable": false + } + ] + }, + "darwin-x64": { + "executableSha256": "85eb919f6b9b9dd833ce5e326cbf974b3ee2d4a9ac525c59d4ec9c9ec085715b", + "closureSha256": "c08b7c3dd4e7bcf9a3e16ec6eba29cfa10308e865d196c5f120a3a15f6b3116a", + "entries": [ + { + "path": "copilot", + "sha256": "85eb919f6b9b9dd833ce5e326cbf974b3ee2d4a9ac525c59d4ec9c9ec085715b", + "size": 165041200, + "executable": true + }, + { + "path": "distribution/LICENSE.md", + "sha256": "b9680937e10425bf19862908856c16ed274e6b81a1368bd62be7a757eab21628", + "size": 2955, + "executable": false + }, + { + "path": "distribution/animations/app-install-nudge.json.gz", + "sha256": "8a4165e6e95f127fe3123e647580c31def2d52b7824f2c4e48b505339132543e", + "size": 9900, + "executable": false + }, + { + "path": "distribution/animations/banner.json.gz", + "sha256": "cc6dcef840f5f63c9683e84bd55478e6fd2a22130bf1be6f53dece870e996446", + "size": 4887, + "executable": false + }, + { + "path": "distribution/app.js", + "sha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "size": 7856755, + "executable": true + }, + { + "path": "distribution/assets/copilot.ico", + "sha256": "13c6005245ba982d54fce8a8fa7a5a517c35e68322f2bcc4d4cd5366d5aa4ac4", + "size": 315320, + "executable": false + }, + { + "path": "distribution/assets/copilot.png", + "sha256": "601aea9338888b3e0065b6b6bcb5793bf1215f205b6e781a47b1893d96fd67d1", + "size": 51687, + "executable": false + }, + { + "path": "distribution/assets/copilot.svg", + "sha256": "ff370a742c271f57810569f80d6202e1fdfdd5c1a270e691f18cecc0bd08a738", + "size": 3940, + "executable": false + }, + { + "path": "distribution/assets/copilot_win.png", + "sha256": "5977b25558f3a25e858f1e9d74dd9d621cd02044cecd95fd3858ae425f715fe5", + "size": 2831, + "executable": false + }, + { + "path": "distribution/builtin-skills/customize-cloud-agent/SKILL.md", + "sha256": "6acee791cd48a31eb881431c0f1394d3c79d33d62f0d58b2d9b699e4a275ea93", + "size": 18679, + "executable": false + }, + { + "path": "distribution/builtin-skills/discover-resources/SKILL.md", + "sha256": "9afbbacc30095ee4b4a70da34471781be9ad1df46f6c2df1ddbdc0473b34c6e7", + "size": 3647, + "executable": false + }, + { + "path": "distribution/builtin-skills/github-pr-media/SKILL.md", + "sha256": "eba36e66d053b3db19e8f51cfb7d28186ef2baf90159f51bf2333747b150070b", + "size": 4938, + "executable": false + }, + { + "path": "distribution/builtin/customize-cloud-agent/SKILL.md", + "sha256": "6acee791cd48a31eb881431c0f1394d3c79d33d62f0d58b2d9b699e4a275ea93", + "size": 18679, + "executable": false + }, + { + "path": "distribution/builtin/discover-resources/SKILL.md", + "sha256": "9afbbacc30095ee4b4a70da34471781be9ad1df46f6c2df1ddbdc0473b34c6e7", + "size": 3647, + "executable": false + }, + { + "path": "distribution/builtin/github-pr-media/SKILL.md", + "sha256": "eba36e66d053b3db19e8f51cfb7d28186ef2baf90159f51bf2333747b150070b", + "size": 4938, + "executable": false + }, + { + "path": "distribution/changelog.json", + "sha256": "63d808aa73c260039e8e8a8f66523af06ac3e1d0ad4a7ca0a85e3761d7502ddf", + "size": 1088362, + "executable": false + }, + { + "path": "distribution/copilot-sdk/canvas.d.ts", + "sha256": "2f5c82d47a8a1abd27466f9dc093106a59d83f685d4f6c29367e2ce25f44b700", + "size": 5858, + "executable": false + }, + { + "path": "distribution/copilot-sdk/cliVersion.d.ts", + "sha256": "560db20a242a671969bc05d32121b5c5bb3b0d88b0ba6384f6222d9bbb91fd7f", + "size": 113, + "executable": false + }, + { + "path": "distribution/copilot-sdk/client.d.ts", + "sha256": "734625086b3ef39b53c494c922b069a84eca64b7181509b42a7762cac35f9d68", + "size": 18301, + "executable": false + }, + { + "path": "distribution/copilot-sdk/copilotRequestHandler.d.ts", + "sha256": "730762ae75ad87be8cbef0080a2f5150919f0a9c53e52b6aaaa3f366f2c17d79", + "size": 3443, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/agent-author.md", + "sha256": "6280a2b6684a83ca7d17871be6666f5d979f5d3076ddbb9680edc83394abb810", + "size": 10352, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/examples.md", + "sha256": "7bc5fea049219a6d2ddd444dfcfee9b629ca99b22f4bad916086f6e7180ac3df", + "size": 21076, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/extensions.md", + "sha256": "c6b21c9e7f551fcd2cc3a541627a031fd073abc68c3450855687c871b97f9898", + "size": 4406, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/factories.md", + "sha256": "360834bd122edc4e2c9764d2db4d18712dbe1d5be3cccc39680eead3c0c8bd11", + "size": 21443, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/factory-patterns.md", + "sha256": "c37bae44a6cbabc4b70a5d0ccde3e04eb15a4c1111b2ed1a053efb1bbd254c33", + "size": 8201, + "executable": false + }, + { + "path": "distribution/copilot-sdk/extension.d.ts", + "sha256": "a83bc2d74059e6d56331671a33e0d0b6fc12563384e0dd6d279a6ebe45582384", + "size": 3303, + "executable": false + }, + { + "path": "distribution/copilot-sdk/extension.js", + "sha256": "cfb092cd6a7686833d02b832bdb47b13b8e325bfa864acb9ca692eb6d3443032", + "size": 494981, + "executable": false + }, + { + "path": "distribution/copilot-sdk/factory.d.ts", + "sha256": "1d23bbb4ebc8c1307182bd41b7f41f77ae83056cbf1579c072b976935c2afdf8", + "size": 15089, + "executable": false + }, + { + "path": "distribution/copilot-sdk/ffiRuntimeHost.d.ts", + "sha256": "519ac132a1d43f7f661d30cd1c3896d367a22639cbef526dfabe62f773bd4df7", + "size": 1720, + "executable": false + }, + { + "path": "distribution/copilot-sdk/generated/rpc.d.ts", + "sha256": "3b0d0ce9b73f657a453c64e94288d63b5f606f13dc599f0095e43213bcbf4e5a", + "size": 915008, + "executable": false + }, + { + "path": "distribution/copilot-sdk/generated/session-events.d.ts", + "sha256": "bba0f7bd4d34e586011f5e52945514902ccaf951553283fd8b0770eb37155717", + "size": 382125, + "executable": false + }, + { + "path": "distribution/copilot-sdk/index.d.ts", + "sha256": "a0c0eb9395e0abfba8b49ce5304cd44a7960586f7cb2529676392feab877f26c", + "size": 4685, + "executable": false + }, + { + "path": "distribution/copilot-sdk/index.js", + "sha256": "f8594ddbd0ad841395bd1fb0f02195094708aaef77bdaf767ddb617e19470571", + "size": 500179, + "executable": false + }, + { + "path": "distribution/copilot-sdk/runtimeArtifacts.d.ts", + "sha256": "1d78a2fd37b65e793e553db9a8323a6727b77e7ffbd6b61c462e778794dd76e2", + "size": 1248, + "executable": false + }, + { + "path": "distribution/copilot-sdk/sdkProtocolVersion.d.ts", + "sha256": "344a1c9669d1617346e2bb71d24081797b69b6cfd37c17b04b2aebf8e7a87260", + "size": 300, + "executable": false + }, + { + "path": "distribution/copilot-sdk/session.d.ts", + "sha256": "12ff3bcce06347e7d8810fa05a6b056c728281173723def01805a498c691fcf4", + "size": 15291, + "executable": false + }, + { + "path": "distribution/copilot-sdk/sessionFsProvider.d.ts", + "sha256": "6aa732885a832f7117a5d85853eb05b26052bc446a278c17b9463b7bdb06094d", + "size": 5545, + "executable": false + }, + { + "path": "distribution/copilot-sdk/telemetry.d.ts", + "sha256": "0721ce90ecd3a15eeb86d7787ca62236b1b545b0e93139ed5ef4419b16544722", + "size": 528, + "executable": false + }, + { + "path": "distribution/copilot-sdk/toolSet.d.ts", + "sha256": "41e53ae25809c90fd1a78f493cb912c6a9194c6e0a02e3e32545971c41a1cb56", + "size": 2725, + "executable": false + }, + { + "path": "distribution/copilot-sdk/types.d.ts", + "sha256": "2911ecb4aac830b7c129f2accf01086ae47a5250700dc050e95dfa33def862c4", + "size": 118473, + "executable": false + }, + { + "path": "distribution/definitions/code-review.agent.yaml", + "sha256": "1290258ee70b44faca1f0c74be26254f13e8401e825713ff3f5523b848b361f9", + "size": 4604, + "executable": false + }, + { + "path": "distribution/definitions/explore.agent.yaml", + "sha256": "83b4ed34c2fd8549dc5a557203c9d0db7dde5fc118b1eea8476585d05afa13ef", + "size": 2913, + "executable": false + }, + { + "path": "distribution/definitions/rem-agent.agent.yaml", + "sha256": "895ac0f7d0ce5cd006501eed7f1abfd38405ba93c30d16be9c7205bed6f335bf", + "size": 968, + "executable": false + }, + { + "path": "distribution/definitions/research.agent.yaml", + "sha256": "a32495c36fa076ded05b84dd04a93c2739e9be922ad77221beb519e3fb3d733a", + "size": 5594, + "executable": false + }, + { + "path": "distribution/definitions/rubber-duck.agent.yaml", + "sha256": "eb624f30ef69f8cfd18e7f3f14bcca63e13d1a0d3e05af006372b8d537bbd15d", + "size": 4820, + "executable": false + }, + { + "path": "distribution/definitions/security-review.agent.yaml", + "sha256": "7b6769eb585913a0ad59dadb3bf2e42cd9cfadb1540b341f23a28e5728be2d53", + "size": 14222, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/cloud-session-search.yaml", + "sha256": "0eaa7fe8f8cecb923c87c9598e22c4e338bfdc069cc9c78325eb6b589fa94bcd", + "size": 1809, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/github-context-memory.yaml", + "sha256": "22b699a6dd5b2d7964f8b2a913618ae478cf6910df2fc3ba336cf38b1be3b2ef", + "size": 4114, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/github-context.yaml", + "sha256": "9b7692e969e9ab36d7053fb2ef99ba9dd2ae78f6df75e645f54eb3cdb3fc6252", + "size": 2200, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/session-search.yaml", + "sha256": "8b6f48742fe847002e5ba6e00b05abc5339a5891387cd306790eb3b8c7dd8c11", + "size": 1889, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/subconscious-agent.yaml", + "sha256": "9fb4cb881677adc08c69818ecdde127276b1afe006817802c381a3ff5fb5887f", + "size": 2616, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-context-changed.yaml", + "sha256": "117edd3750a403c6cabf7964f217ae8129b16f9d0a6d2503e09ebe79d64f9d50", + "size": 1029, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-persistent.yaml", + "sha256": "a5ab1c16dd5f9a030010a375f7f26bca56aa196b212ea52ac2c5736cd80f3989", + "size": 861, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-restart.yaml", + "sha256": "21e44442b8380a758793d637a60fe94c226a6547d23c5bea3a14546f7f3e60ec", + "size": 831, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-trigger-once.yaml", + "sha256": "14a7cedf6ead133828280744b293e90402a2d47568c98a67273c1d7c3acdd53e", + "size": 828, + "executable": false + }, + { + "path": "distribution/definitions/task.agent.yaml", + "sha256": "1780e87f638e5c195b66173438565fd1af97eb0b9e2b1f2ebbd9d4e5b875f3af", + "size": 2112, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/README.md", + "sha256": "ff79555febdbf1ea12b10bef4f6bdcb08812205ba2efe568b048b0ecdd127bf4", + "size": 12375, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/deps_versions.json", + "sha256": "43ccda9b18ba2577033d7bf24580785fd1227828d9ab06b8011ddfeee57af8e9", + "size": 370, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/catalog.d.ts", + "sha256": "aa0db4dee8e54431e2e01d14fd7977d28cd3032f9c4940cc5b926b77d5469dd5", + "size": 3613, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/catalog.js", + "sha256": "c2cb5abd5e01162186cb43e67a0a6e49d4d9a556a7382b7dc1840d6c5c2a6b6b", + "size": 11418, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/configuration.d.ts", + "sha256": "900ba0e99ac907a92d07c08b179ba7d4379326fb71de2d338eb8434860338973", + "size": 2067, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/configuration.js", + "sha256": "7b01b6d5e18b7a8a2e465f0d75214a0b9884ffe57df95af1b2708a5a4c4d7bc5", + "size": 1645, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/coreInterop.d.ts", + "sha256": "80bb839431d2ee4c935d2def128faa4993acbe8d75fa403df5b50a61c2f92ff8", + "size": 980, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/coreInterop.js", + "sha256": "466e3287e579f2b4180fafcb33479549dd2bd683b687456e1b664cd84da524d1", + "size": 5557, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/model.d.ts", + "sha256": "8f219d0af8f00e9a0119ea8d69eb3d92bc2b5e25ce9e17ac218fcb4a8f88355c", + "size": 5311, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/model.js", + "sha256": "8f553a823eb0dda46eacdd53835449abd5f6e030d66d684d9a4e9c88396317ec", + "size": 6663, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelLoadManager.d.ts", + "sha256": "1a2e8b44486f02548f6d30a942f074d052175d7d14d6f5cd7918151cc1ff2552", + "size": 1045, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelLoadManager.js", + "sha256": "1a7888fe6fb620acc9b1d7ff8d17c5563139b0bd32834bdaabc4d36cc54343c2", + "size": 3288, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelVariant.d.ts", + "sha256": "a7af8a0359d36ed90322738bde975c534b9a3b69dcb0e0393cf6f88da7e1b486", + "size": 4649, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelVariant.js", + "sha256": "d62acc4da20f11fb40102f79ded6f45e3cf2f684db1f97c7888c26e84f86eef7", + "size": 7217, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/foundryLocalManager.d.ts", + "sha256": "812e9e2eec43997439392d139b995793aa3839f573aea38d115e90573f986cf5", + "size": 7846, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/foundryLocalManager.js", + "sha256": "bfbca8d6235ba998ce3e82ddc9970900a7e2511653341e1315786c6851c1f380", + "size": 9451, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/imodel.d.ts", + "sha256": "26244ca6cdfb41849ca8e8edef23c5a6681ed18a4acfb2b714c7d2beb0f592fb", + "size": 2156, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/imodel.js", + "sha256": "6ab1971ca21097ea33a2b7b423aee408c093fec343292bcbbc59971d3e253713", + "size": 45, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/index.d.ts", + "sha256": "977e345d9eb116eaf3d4db1f972222480fd0fc6e59d3ac273aef1801dbead4f0", + "size": 1109, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/index.js", + "sha256": "e030b97bdb052ed99f00df928c2fc42f7ac165021d86df792290225c30c5376f", + "size": 929, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/audioClient.d.ts", + "sha256": "7a0d9fe37f7e6f253be258a7938667334deadeb930c2b8872f22e517d1963762", + "size": 2253, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/audioClient.js", + "sha256": "04264c41f3567811940ef28b48d97684b333ccbac9829cce8b3d0db8e763b51f", + "size": 9895, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/chatClient.d.ts", + "sha256": "a9da4a98dc50b1425e2ace9ffb044a3aa872e678947bf035339c9b8703dd6d1e", + "size": 3283, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/chatClient.js", + "sha256": "c59a093318d05a3e38a2f68693f3b64a24603472c73517743302d5a507331063", + "size": 13835, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/embeddingClient.d.ts", + "sha256": "a1875a29cd2825fa23c6fe7bf7f4e04c4e2e574e142e0e5fb0c0291b2206528d", + "size": 1470, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/embeddingClient.js", + "sha256": "bd55fbceb7fea625d30144a462d1c18dd857e6a2539a4179734d3b3237656428", + "size": 2554, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioSession.d.ts", + "sha256": "f2ed7df5665add334b18ad793866c814333863345f8077b8c925cb9b0aed66e4", + "size": 3441, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioSession.js", + "sha256": "889d7adcb9a64858113cd881a77f56962fa85266d2a7ac58c319ecc45be745bf", + "size": 12816, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioTypes.d.ts", + "sha256": "831434a52d2643186dd76bbb657c3acf2f0635cafaa94763a0ff2f728242146f", + "size": 2387, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioTypes.js", + "sha256": "f78e54d6ed3b9b0fba913e7ceec41b36364279e1d93d8b006b62af55806d027c", + "size": 1840, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/responsesClient.d.ts", + "sha256": "3126a17df66edb893fb5d26bd6ea80a05f5bb516e47da661e927c0936e86d0d2", + "size": 5737, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/responsesClient.js", + "sha256": "76b477538feb61c41bc0ff7cf8f23b7dbe75bc2fda4720e29c70ff3cac9afc7f", + "size": 15246, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/types.d.ts", + "sha256": "cca44657b82f9b3c0cd7528e6dbddf5f949e42f5d34afe60c0d566f28e3fc0b0", + "size": 9844, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/types.js", + "sha256": "ca23f484beade559e7ed612347ac8f615c2c4cc97c2e1b0a74b9c882db7f6f38", + "size": 297, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/LICENSE", + "sha256": "6bb5b2d4c07d793ca928daa63a8899c6914fafb5ac3aa04ec10cae07f3d57dca", + "size": 1106, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/README.md", + "sha256": "9d05e156889b1475cdee97a13ed84a4abee0d752b2aaa28ded5359fcf45df901", + "size": 2894, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/adm-zip.js", + "sha256": "f4feddb21980597cd0248c8790922534abfa90c17a483b00ece15e392074ee27", + "size": 40794, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/entryHeader.js", + "sha256": "6ed1ec82e6124d71dbfb4ee6e5dd4c975657854354cf74741427a1862010a664", + "size": 12717, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/index.js", + "sha256": "536e4b5bf009a3d9f6eccfbbc4157cb6de663d889e0826ea5f6e5fa17aaeb8bf", + "size": 94, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/mainHeader.js", + "sha256": "ed5a2b3fc7c8a6937e07447eb3e55e08a0655d2f24fe04039e7008845d221a25", + "size": 7141, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/deflater.js", + "sha256": "6dc41b2460594cfa5136b797653c166b2f7403820a40f2fca17cca35a5de1b5f", + "size": 1021, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/index.js", + "sha256": "d67714f1a04be942f90be77069af3ff4214aa8ee84b26edeff3a87eb0d8e2dc0", + "size": 128, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/inflater.js", + "sha256": "7b62c190669eeefd6b2810a3339d9109ecb219f845fcd12ae93518beec35dc49", + "size": 1146, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/zipcrypto.js", + "sha256": "23365c7eda0ea098385dc7ec649517fc110ce2764d2e707c37bf6b528604e25a", + "size": 5806, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/package.json", + "sha256": "76a6fc3e9bf811c9ada1173b7a7c0621d36329c29c221428f46f75762d3aeb5a", + "size": 1387, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/types.d.ts", + "sha256": "98fae6ae2e5d026a91e0316209813ce304069ab85d0471f35152d5db500d23c1", + "size": 9641, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/constants.js", + "sha256": "208e943a2e5faad056047f3c7991cce3cde637d8e272a564f2546210ebdf2069", + "size": 6374, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/decoder.js", + "sha256": "73a0ebb00c4dce2124f07acf0b34374cb03a4384cccd1cd6f58aee27c35953d9", + "size": 130, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/errors.js", + "sha256": "d2d243647737c795c2db8aeba2e1f3841d5f76370b521d436cf465322dd4aab7", + "size": 2868, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/fattr.js", + "sha256": "31c93eb386a2bfbf19ad92a6bf20d510a8f1e7e90cc71d33dd888f89da12362d", + "size": 1892, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/index.js", + "sha256": "dc5b230ed853947ea55c0bf69f0e525fbeffefff09aa3da296d541bb8898314e", + "size": 226, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/utils.js", + "sha256": "f50d229943cce6067a7f09f5e97b4a69157ec11759777382e5ca0ba03671d1e9", + "size": 13011, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/zipEntry.js", + "sha256": "874e9510d362ba7d2cd85e2dcc7a0e7a6caa4de8ddb37fb4d5e68a018cd87a39", + "size": 15173, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/zipFile.js", + "sha256": "a709d4f3ee218f457b7e71dd6b22dcf58858cc5410eece4dc4ecd7c4e9a2c027", + "size": 16516, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/package.json", + "sha256": "07d0242f84fdcfc8f4a0e601dc35f01fa153535c67f95262e0bd750ba7cfa418", + "size": 1296, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/script/install-utils.cjs", + "sha256": "0831c932b10389283e805f88a204b0f6a5a8053f2ee520e56a0f0adf1352aa8b", + "size": 10443, + "executable": false + }, + { + "path": "distribution/index.js", + "sha256": "24ec5cfb8f9bcb6559d8c187fbaaae3a9e964a570bf212fa646cff825bc47b78", + "size": 41547, + "executable": true + }, + { + "path": "distribution/npm-loader.js", + "sha256": "0ea824a86be5757533fdb092eff7050871bd7a711a46babde0ffe0e44ac5ad88", + "size": 1185, + "executable": true + }, + { + "path": "distribution/package.json", + "sha256": "d4a592ea2cd06fc3be13dd46cf4d41b2277516efa3dd3f24e438bde3b4cd523a", + "size": 2188, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/.release-target", + "sha256": "2c5ace2d43ad1f7672ed7d113ccf72b831ea468fa659cca1592d901c7066e5ec", + "size": 11, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/CodeResources", + "sha256": "4df5134f4ed0769b61e416e300eba091b97bbaee961aef495ff726cbfb56f639", + "size": 1653, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/Info.plist", + "sha256": "68318792b18f1e92ec2784b054489023727146e03e7926a1bd3798fe95629ef3", + "size": 1320, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/MacOS/Copilot Computer Use", + "sha256": "65a164fd4fc7afd1447a96a44c878aa21e7569c1de5e6da6e6dcfbe1e9db9ba9", + "size": 2702736, + "executable": true + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/PkgInfo", + "sha256": "82502191c9484b04d685374f9879a0066069c49b8acae7a04b01d38d07e8eca0", + "size": 8, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/Resources/Assets.car", + "sha256": "ce5c0f133fdae3c24ed7e93a39f51d5e133025cad44c1c89b7755e75a7f284b0", + "size": 1593544, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/Resources/icon.icns", + "sha256": "44d787b9159291ec5d862d257b5d1c9178c2264d1a1aba7c21d75ae8c1e3613a", + "size": 815442, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/Copilot Computer Use.app/Contents/_CodeSignature/CodeResources", + "sha256": "e994e1e722bdf4f8ecdc7ca3e7d2d5dff4c0170f20ea43012c78b55cc27aacd8", + "size": 2666, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/computer-use-mcp", + "sha256": "4900c5eab9d6e4554501c8627eab7289e3a2788a7f2246c052d39a4cf80dd140", + "size": 2389392, + "executable": true + }, + { + "path": "distribution/plugins/computer-use/mcp.json", + "sha256": "37a1f153978a85cc2b112f133eb8a430b647014991c4475b79148f28ee224fbf", + "size": 236, + "executable": false + }, + { + "path": "distribution/plugins/computer-use/plugin.json", + "sha256": "256f4eeb66f714ee6db8a10aba969f266094d537b9c7b273254da0b39326e834", + "size": 185, + "executable": false + }, + { + "path": "distribution/prebuilds/darwin-x64/cli-native.node", + "sha256": "59672a77dfa2dbbb7712841554e9cedcc0c3e8528064dfc049989d2a651affad", + "size": 4728944, + "executable": true + }, + { + "path": "distribution/prebuilds/darwin-x64/copilot-runtime", + "sha256": "db0d01057beca8dcab94a29714cd5c0b90042eed47b264c7cf1603ad89349a84", + "size": 381312, + "executable": true + }, + { + "path": "distribution/prebuilds/darwin-x64/mediaremote-adapter/LICENSE", + "sha256": "a489c16a6be195584fa0b35e28fca54abc8492eb0cc05d851dd24f522f4900a2", + "size": 1521, + "executable": false + }, + { + "path": "distribution/prebuilds/darwin-x64/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter", + "sha256": "911dde39b16a38617ddb9ab3d45394192565b755572cf27223e0cb6dc480c617", + "size": 126064, + "executable": true + }, + { + "path": "distribution/prebuilds/darwin-x64/mediaremote-adapter/mediaremote-adapter.pl", + "sha256": "984d622eeebbcb17656d157a49272b02fb741593ae2ec624d1926c12d955c8a1", + "size": 8444, + "executable": true + }, + { + "path": "distribution/prebuilds/darwin-x64/runtime.node", + "sha256": "61ca411cc8fe41425da8dc8c05e5c24120927f705bd8d4b5b702b31e4cbd4fc4", + "size": 91198304, + "executable": true + }, + { + "path": "distribution/preloads/extension_bootstrap.mjs", + "sha256": "ebf4edc7cc2195fc1b7c5c26863e08137137ec075cc73ff335331534115a1be6", + "size": 2811, + "executable": false + }, + { + "path": "distribution/preloads/extension_sdk_resolver.mjs", + "sha256": "e57d71730c8f937315a72b66187c56cc4fa78ecbd13590795136996e2851bf19", + "size": 1331, + "executable": false + }, + { + "path": "distribution/pvrecorder/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/README.md", + "sha256": "7eb46bc4bdba8002da1165107fc729ee816f58063a8e8d6d0083a3fb81898683", + "size": 1675, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/errors.js", + "sha256": "14b373ef00a0bf0fceb5abe86a6628f914a466b2403ba376f78b025cff05e92b", + "size": 2528, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/index.js", + "sha256": "a99551364022401340991df2d74e1c949290e920aa9247d22c262e26b768a0f0", + "size": 748, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/platforms.js", + "sha256": "9eb9c4c2af151391c4966bdaf15364d15362996915e8f5fa685daca1c3f81359", + "size": 6404, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/pv_recorder.js", + "sha256": "3323ebdbad3d524dc4330d25cb226cc17a0f7f365b738f471faf883d71de658d", + "size": 6154, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/pv_recorder_status_t.js", + "sha256": "c7c94cd144fb0fcf5f844e76ff83d754dc6b835e45893c2cc81de5f0c3ddccbb", + "size": 1519, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/errors.d.ts", + "sha256": "5250616ee995097ea36daa3285d957e3adcdd9c76a6dd13ea3d10174c38a8668", + "size": 236, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/index.d.ts", + "sha256": "6b27f29a807ef113c326fcc2139ddde257e74585cb58591ad9753dcbfd00c03f", + "size": 98, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/platforms.d.ts", + "sha256": "a11acc208b8d403db8882b97e0101f549a5dd7a1ca656ff46d9378277a100064", + "size": 142, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/pv_recorder.d.ts", + "sha256": "6b06821393cd5fbab4874a11c46634e7bb1ff91848bbf34a1e5008ccea6929bf", + "size": 2741, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/pv_recorder_status_t.d.ts", + "sha256": "b35985d2b520dc2570f90f23e6db7e6ce37369db6e35c27bc532de349421175e", + "size": 337, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/lib/mac/x86_64/pv_recorder.node", + "sha256": "5eb602765ad5ac29f3dc91f9f24fe92a0686c18e4b767cd802d9d6e8d19e9a1d", + "size": 687080, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/package.json", + "sha256": "e44a9dde00f1344251f22bce37520ea1bbbde5af2965cab7e612758854ee3a99", + "size": 1197, + "executable": false + }, + { + "path": "distribution/queries/bash-highlights.scm", + "sha256": "77860b9f127ca82681e3691bba003e62d1d8dbced05e4fe96041d26fd8944656", + "size": 5776, + "executable": false + }, + { + "path": "distribution/queries/c-highlights.scm", + "sha256": "a99c4c0bf9d9b9f7de0124c529a4229fddada96d65bf5fb0c9d487266e15d483", + "size": 6332, + "executable": false + }, + { + "path": "distribution/queries/cpp-highlights.scm", + "sha256": "69d1dbed8d49c498e93a75db6afedfb8769fa2fed455a64d588dc347281d642f", + "size": 4904, + "executable": false + }, + { + "path": "distribution/queries/csharp-highlights.scm", + "sha256": "4b79059a52926e8cd08a1d80af70ba02acefb86bc8ed82315897fb104ed1a338", + "size": 8302, + "executable": false + }, + { + "path": "distribution/queries/css-highlights.scm", + "sha256": "6641fed120530bfbce5682ab88b2a8d14d99bfcd971ac9f78cd91738b4b0578a", + "size": 1177, + "executable": false + }, + { + "path": "distribution/queries/go-highlights.scm", + "sha256": "c58a7336d4834ebfbb038002c6ab033f792cd1b8d3e7e1682bdaa982bee7f6b1", + "size": 3934, + "executable": false + }, + { + "path": "distribution/queries/html-highlights.scm", + "sha256": "1ebb3811a8cdc054385b847a3aac6fbf7079faefd5b3dfab5bbad256cd5afdcf", + "size": 197, + "executable": false + }, + { + "path": "distribution/queries/java-highlights.scm", + "sha256": "c68c0e7e9ae36d94c9e5ab06556b34c5e8568d1ce1867043d92bbe4b3b4dcf2f", + "size": 4425, + "executable": false + }, + { + "path": "distribution/queries/javascript-highlights.scm", + "sha256": "d3630ae6dc9b2b27b230b5f8bb92b05cd491fb12bff353dae62a0a6d780461ee", + "size": 2739, + "executable": false + }, + { + "path": "distribution/queries/json-highlights.scm", + "sha256": "6ab09656820f0e8dac860c96dced341221284544c05308ee337727f5db937101", + "size": 412, + "executable": false + }, + { + "path": "distribution/queries/php-highlights.scm", + "sha256": "a2a7367659cff3b4be09961c8117d69a9b8703bfc266f8092e089b1760f197e9", + "size": 3207, + "executable": false + }, + { + "path": "distribution/queries/python-highlights.scm", + "sha256": "a6708f209381618e2b398972c8f1ccd892f0c064eab35a2a3f911c3e22e79a7e", + "size": 1957, + "executable": false + }, + { + "path": "distribution/queries/ruby-highlights.scm", + "sha256": "1cc8fc205e4cce4fe9e0d63bb1be903e49ea0f2eda77ae9368898a31ff98bc18", + "size": 4068, + "executable": false + }, + { + "path": "distribution/queries/rust-highlights.scm", + "sha256": "c649a4785322ab72af84874b9dc03c7a4f5d9a05de45a85919a60770d7d5457d", + "size": 8610, + "executable": false + }, + { + "path": "distribution/queries/scala-highlights.scm", + "sha256": "e9e89654a7df214f4c79c28af7bc50bdb5c6cc5abb9690e3e1ce19c35b797991", + "size": 4858, + "executable": false + }, + { + "path": "distribution/queries/typescript-highlights.scm", + "sha256": "e0c35adb819127bfd4f853fac5419e7d8ba44760246201d04a4a5ce0228a10c5", + "size": 515, + "executable": false + }, + { + "path": "distribution/ripgrep/bin/darwin-arm64/rg", + "sha256": "ff10c231ae906e475e8a52c33fed8fe1431941bf26b6f868c53590c0e8be30d6", + "size": 4520448, + "executable": true + }, + { + "path": "distribution/ripgrep/bin/darwin-x64/rg", + "sha256": "13713f342c10142deec24fd72a61f2abfc10d76ced6c0cffe4063fd7e60b667e", + "size": 4798128, + "executable": true + }, + { + "path": "distribution/schemas/api.schema.json", + "sha256": "032a7784171eb2c4eb8260f0c0b67f05e9b1c76d0e74fe507871ba6a36d9a3ab", + "size": 1914299, + "executable": false + }, + { + "path": "distribution/schemas/session-events.schema.json", + "sha256": "d8cb713c05d5278a68dde5c5d4482574f836e922ae13aa06f82474c209a7c6e9", + "size": 895984, + "executable": false + }, + { + "path": "distribution/sdk/index.js", + "sha256": "a0718e6376a1146da09b4cf6ef2a332d447cd22f7dfffdedc000c7b622309ef4", + "size": 1165545, + "executable": false + }, + { + "path": "distribution/sea-loader.js", + "sha256": "1d779bfcb6ee73500b071b8b0b954997d3e17bd32ad216276390ffbab06ca47b", + "size": 122238, + "executable": false + }, + { + "path": "distribution/tgrep/bin/darwin-arm64/tgrep", + "sha256": "74133bb1c640e32214575d8441bbbf0fe4ea3e6f6e0d784388fee425993b15c5", + "size": 6336704, + "executable": true + }, + { + "path": "distribution/tgrep/bin/darwin-x64/tgrep", + "sha256": "35d3de7014ac669d7c71053c63c57142c1605288954141b148694f423f463f9f", + "size": 6838208, + "executable": true + }, + { + "path": "distribution/tree-sitter-bash.wasm", + "sha256": "8292919c88a0f7d3fb31d0cd0253ca5a9531bc1ede82b0537f2c63dd8abe6a7a", + "size": 1358224, + "executable": false + }, + { + "path": "distribution/tree-sitter-c.wasm", + "sha256": "c852c2a85ebf2beb636aa3b0ef7f7e70458684d74f6741b20dcb296885bed9f9", + "size": 625918, + "executable": false + }, + { + "path": "distribution/tree-sitter-c_sharp.wasm", + "sha256": "6f69e1cae44e1c32c1eccc170dc5a9778fb94ff716f71113fe1f8c4299aa2f40", + "size": 5350581, + "executable": false + }, + { + "path": "distribution/tree-sitter-cpp.wasm", + "sha256": "174eb0deb75b2ec7881bcacda9f995648d8e683956e5c2267e69ab6dc503fcbf", + "size": 3434931, + "executable": false + }, + { + "path": "distribution/tree-sitter-css.wasm", + "sha256": "8a23977fe271357cce6f254ef88c9bebf3854602d8046605aef6a45c02135c59", + "size": 128668, + "executable": false + }, + { + "path": "distribution/tree-sitter-go.wasm", + "sha256": "9504573f352b20be7f2f1911754d710622aedc15afff16d5ed8fb5645681aee7", + "size": 217182, + "executable": false + }, + { + "path": "distribution/tree-sitter-html.wasm", + "sha256": "c48fcd82c7ea8bf943180088ba7f28c48b2bb5287874179168bf9d31e394cf85", + "size": 18385, + "executable": false + }, + { + "path": "distribution/tree-sitter-java.wasm", + "sha256": "4fdeac4ca6ca089f06c6f7e562abcac1733cd465728cc7031ebb73c2019122c4", + "size": 414641, + "executable": false + }, + { + "path": "distribution/tree-sitter-javascript.wasm", + "sha256": "5fb488d0cabb4775a594bab85682de5ad6ce83c0d6ac997a9f82dd084d571240", + "size": 411770, + "executable": false + }, + { + "path": "distribution/tree-sitter-json.wasm", + "sha256": "d2119fb98d5912719b13f9458574f8608d2d29dfbe45f6be1f860ea1fe2a2405", + "size": 5596, + "executable": false + }, + { + "path": "distribution/tree-sitter-php.wasm", + "sha256": "d4df6a6ff08c87c3ec4f9cbb785fe09998a0cb570e03f57d7b19b3acfb146aa7", + "size": 1058041, + "executable": false + }, + { + "path": "distribution/tree-sitter-python.wasm", + "sha256": "16108b50df4ee9a30168794252ab55e7c93bfc5765d7fa0aa3e335752c515f47", + "size": 457883, + "executable": false + }, + { + "path": "distribution/tree-sitter-ruby.wasm", + "sha256": "09a96427d7c72f0613ed470cd9812223fc4a91d6a9c025c0235cc6bd59ff96f4", + "size": 2106352, + "executable": false + }, + { + "path": "distribution/tree-sitter-rust.wasm", + "sha256": "f65f354215611fd94ad34134b3427eb3d58cbb745df7b6509ba722184db73d57", + "size": 1102547, + "executable": false + }, + { + "path": "distribution/tree-sitter-scala.wasm", + "sha256": "b7ec2bb29c19827abcefd18ed5cb5a43596009f96a5d53c5b9d1f9676d7521c3", + "size": 3786700, + "executable": false + }, + { + "path": "distribution/tree-sitter-tsx.wasm", + "sha256": "79e5da75ea62855a0cd67177685f0164eac87d5f630b3cbe1e0a099751ad30f8", + "size": 1445638, + "executable": false + }, + { + "path": "distribution/tree-sitter-typescript.wasm", + "sha256": "778025db5a8be0e70f8ccc3671e486dfeddd048c25d9e8a70c26de2e1bf6f97d", + "size": 1413849, + "executable": false + }, + { + "path": "distribution/tree-sitter.wasm", + "sha256": "f38dcc4b43b818f9a0785bc1c6d5611a75ac4cdd428ff3f02757c34ca4e46d7f", + "size": 205488, + "executable": true + }, + { + "path": "distribution/voice-engine.worker.js", + "sha256": "bf601f1092d1b8734a582d8182c223855c7f5cfb17f8fc529f1791dfd470d5d2", + "size": 112985, + "executable": false + }, + { + "path": "distribution/voice-installer.worker.js", + "sha256": "4a843b379310d44f422271e75d3246ada24f567587850c3ef501f565c905b493", + "size": 11639, + "executable": false + }, + { + "path": "distribution/voice-server.js", + "sha256": "c6b2c140027ef6c7b283ebbb4cbde1dc32f12f97901ff45dfdfd536269bac1d2", + "size": 130952, + "executable": false + }, + { + "path": "distribution/webview/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-darwin-x64/README.md", + "sha256": "8bf97f7d3dd1931adcbcad93373bd1cc24a5d754262ce6a93f588c3774500a78", + "size": 103, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-darwin-x64/package.json", + "sha256": "bfb412482eaa0b177eb2575df08ef1e1b2ce73b4bebdd977bf3462fe7669262e", + "size": 630, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-darwin-x64/webview.darwin-x64.node", + "sha256": "1e51ca0870a35345d5ba2b1d030ce7e025e4e44cad437122963d3e2261b23479", + "size": 4423208, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/LICENSE", + "sha256": "9cac72e0573f14d3f9bcb8f70922d589ba07dd1051a5c12229f111e154923f0a", + "size": 1065, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/README.md", + "sha256": "69c2e106db45187b981358c938e41b8bfb67fb3a451930f102a63b471c0da675", + "size": 16477, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/build.mjs", + "sha256": "bcb69c0cc8356b78d07c53719ccaa372f1d6b6c381973b76508681573a7b1db4", + "size": 3640, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/index.mjs", + "sha256": "3d6d86fc751897b67e1b94f0b1d0a667d79e6256b278a7e801467bfd67ad2689", + "size": 3837, + "executable": true + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/utils.mjs", + "sha256": "fb7a2fc54e53c777de835f50c7d4483bee60d1aece0c1deddbe2f232f571d8f1", + "size": 193, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/CNAME", + "sha256": "405d5f5a242885480dc01db6227484ed4b6d723fd7c3b2547f9f36e46decac15", + "size": 14, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/README.md", + "sha256": "65578b7f73cc6c145a9d962ca6f2ee56b06153a8277eea8c3c3cadc7d602f155", + "size": 15717, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/application.md", + "sha256": "44227367accc4a9f5b8468e19680ffd1229891d59c0652e8f45370c0872160bd", + "size": 6074, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/browser-window.md", + "sha256": "597920a56d3ff8c87b6990473e5150a454f109bd7e639125c8a82d039ade7487", + "size": 11107, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/menu.md", + "sha256": "838a998eba786e959bec89e148fae7d547df1b13e490b55c890d58100fd77b98", + "size": 3828, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/notification.md", + "sha256": "8e90225a0f40ebcd2a69bc9e0b8d51c645810faa4fcaae3651101bcaa686d101", + "size": 5622, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/tray.md", + "sha256": "f071ca7d3a5cc7cb271ceb9e2712db9c6c80d5c8ab33b4a8fc3b168e5d9dd3aa", + "size": 1628, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/types.md", + "sha256": "e006fb8428d0e58943468f62c77ec78bcba1bb7b0bc41ce3e2258417fe51320c", + "size": 5711, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/web-context.md", + "sha256": "92b9ed94a8868a1ff8b0f3e4ef92be2a0b2564f7beb1fc8b6107b7f1bca96429", + "size": 1774, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/webview.md", + "sha256": "78ade1e288c7a6169678d4bb64f9e450ed4e855324c310354686c11f127bd494", + "size": 6279, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/event-loop.md", + "sha256": "28b1f847d6ff3c71c5b3fbf9f46d531b6fbd9ba585365cfbe9a2292e698171bb", + "size": 2511, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/installation.md", + "sha256": "06acf9a3d61c0d84f0dcc65153f43d288e1bb139a0e16df69253fae68c2c6c76", + "size": 1173, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/quick-start.md", + "sha256": "55cdf7992b7ad793b204ac80268c66600975ee20c474c2ca029cdd6db58073b6", + "size": 1995, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/building-executables.md", + "sha256": "18a1d5d2596e0e0b05758643981d7ffe5b9d9f32bf0884c84978c647bf2419e9", + "size": 5026, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/cookies-and-storage.md", + "sha256": "0eb9abdd246e5a2a0d4a8578c6b565781fdb9d7b99463b50c05a6851c3eed365", + "size": 1783, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/custom-protocols.md", + "sha256": "1c72110266d09b04f544c0d20aa7fe1f4b6ad84ffa9e171b7f6e55832d39c4c1", + "size": 3148, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/ipc-messaging.md", + "sha256": "d03b307d46cafe92ad0297cad0a0ee1b3af410143281be4be5ab2dbc31e2e74e", + "size": 1790, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/menus.md", + "sha256": "88b791fa71cbb51351cd3a220184ea54a53426cca00eab0c185acd139053fe6d", + "size": 2764, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/multiple-windows.md", + "sha256": "6d05fa117efc95207d4707bbf2728ae31f482bc6e9f29a8a436389fdaae003cd", + "size": 1835, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/android.md", + "sha256": "646dc6f1ac30a4f2bd25faec682a2d6f0262ea59193ac44490d5914cf1294cd0", + "size": 326, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/ios.md", + "sha256": "b8b1c2bc59611d633971556fb94720bdb411e2de89771fb372217d1a17b53cb7", + "size": 420, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/linux.md", + "sha256": "cbd1df68b7c899f826aff9e0fd2d13e9f9e19892ad16fd47a6fea5c625dc0e8b", + "size": 1986, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/macos.md", + "sha256": "9b5b1d9c4065d7904c02e3dc79517b6df4f8a44dd95902e30502d130cdef5d71", + "size": 2260, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/windows.md", + "sha256": "5a84a310a20d42e0b24492472efd5512aae11c68668620f071a3d85c3583512a", + "size": 2136, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/index.d.ts", + "sha256": "49610270556eaff855f8efc8026a595c354309ea325d846d33a617ad54039a39", + "size": 13502, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/index.js", + "sha256": "9bce1f609372abd7da4973967276ba591c43f9b933bcd52030edb1ad6ee3b8db", + "size": 22727, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/js-bindings.d.ts", + "sha256": "f2f384ab7913a7e38369155a5abda97c0af3b72db6021df341e8da2288ceceaf", + "size": 20365, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/js-bindings.js", + "sha256": "e6a970a01839ed97c9a9636a01bf1c77c298a8270b3f1708c4927191856a3bcc", + "size": 28518, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/package.json", + "sha256": "0a71bc7c23521db3890aadb13763297422e1988e9a68f95ec8b8832c07d33b89", + "size": 3442, + "executable": false + } + ] + }, + "linux-x64": { + "executableSha256": "0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748", + "closureSha256": "a3d8f4367cfa1694d79e3f8b7930b6fbfe42a229c272b375b11951d631db8d07", + "entries": [ + { + "path": "copilot", + "sha256": "0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748", + "size": 169544512, + "executable": true + }, + { + "path": "distribution/LICENSE.md", + "sha256": "b9680937e10425bf19862908856c16ed274e6b81a1368bd62be7a757eab21628", + "size": 2955, + "executable": false + }, + { + "path": "distribution/animations/app-install-nudge.json.gz", + "sha256": "8a4165e6e95f127fe3123e647580c31def2d52b7824f2c4e48b505339132543e", + "size": 9900, + "executable": false + }, + { + "path": "distribution/animations/banner.json.gz", + "sha256": "cc6dcef840f5f63c9683e84bd55478e6fd2a22130bf1be6f53dece870e996446", + "size": 4887, + "executable": false + }, + { + "path": "distribution/app.js", + "sha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "size": 7856755, + "executable": true + }, + { + "path": "distribution/assets/copilot.ico", + "sha256": "13c6005245ba982d54fce8a8fa7a5a517c35e68322f2bcc4d4cd5366d5aa4ac4", + "size": 315320, + "executable": false + }, + { + "path": "distribution/assets/copilot.png", + "sha256": "601aea9338888b3e0065b6b6bcb5793bf1215f205b6e781a47b1893d96fd67d1", + "size": 51687, + "executable": false + }, + { + "path": "distribution/assets/copilot.svg", + "sha256": "ff370a742c271f57810569f80d6202e1fdfdd5c1a270e691f18cecc0bd08a738", + "size": 3940, + "executable": false + }, + { + "path": "distribution/assets/copilot_win.png", + "sha256": "5977b25558f3a25e858f1e9d74dd9d621cd02044cecd95fd3858ae425f715fe5", + "size": 2831, + "executable": false + }, + { + "path": "distribution/builtin-skills/customize-cloud-agent/SKILL.md", + "sha256": "6acee791cd48a31eb881431c0f1394d3c79d33d62f0d58b2d9b699e4a275ea93", + "size": 18679, + "executable": false + }, + { + "path": "distribution/builtin-skills/discover-resources/SKILL.md", + "sha256": "9afbbacc30095ee4b4a70da34471781be9ad1df46f6c2df1ddbdc0473b34c6e7", + "size": 3647, + "executable": false + }, + { + "path": "distribution/builtin-skills/github-pr-media/SKILL.md", + "sha256": "eba36e66d053b3db19e8f51cfb7d28186ef2baf90159f51bf2333747b150070b", + "size": 4938, + "executable": false + }, + { + "path": "distribution/builtin/customize-cloud-agent/SKILL.md", + "sha256": "6acee791cd48a31eb881431c0f1394d3c79d33d62f0d58b2d9b699e4a275ea93", + "size": 18679, + "executable": false + }, + { + "path": "distribution/builtin/discover-resources/SKILL.md", + "sha256": "9afbbacc30095ee4b4a70da34471781be9ad1df46f6c2df1ddbdc0473b34c6e7", + "size": 3647, + "executable": false + }, + { + "path": "distribution/builtin/github-pr-media/SKILL.md", + "sha256": "eba36e66d053b3db19e8f51cfb7d28186ef2baf90159f51bf2333747b150070b", + "size": 4938, + "executable": false + }, + { + "path": "distribution/changelog.json", + "sha256": "63d808aa73c260039e8e8a8f66523af06ac3e1d0ad4a7ca0a85e3761d7502ddf", + "size": 1088362, + "executable": false + }, + { + "path": "distribution/copilot-sdk/canvas.d.ts", + "sha256": "2f5c82d47a8a1abd27466f9dc093106a59d83f685d4f6c29367e2ce25f44b700", + "size": 5858, + "executable": false + }, + { + "path": "distribution/copilot-sdk/cliVersion.d.ts", + "sha256": "560db20a242a671969bc05d32121b5c5bb3b0d88b0ba6384f6222d9bbb91fd7f", + "size": 113, + "executable": false + }, + { + "path": "distribution/copilot-sdk/client.d.ts", + "sha256": "734625086b3ef39b53c494c922b069a84eca64b7181509b42a7762cac35f9d68", + "size": 18301, + "executable": false + }, + { + "path": "distribution/copilot-sdk/copilotRequestHandler.d.ts", + "sha256": "730762ae75ad87be8cbef0080a2f5150919f0a9c53e52b6aaaa3f366f2c17d79", + "size": 3443, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/agent-author.md", + "sha256": "6280a2b6684a83ca7d17871be6666f5d979f5d3076ddbb9680edc83394abb810", + "size": 10352, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/examples.md", + "sha256": "7bc5fea049219a6d2ddd444dfcfee9b629ca99b22f4bad916086f6e7180ac3df", + "size": 21076, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/extensions.md", + "sha256": "c6b21c9e7f551fcd2cc3a541627a031fd073abc68c3450855687c871b97f9898", + "size": 4406, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/factories.md", + "sha256": "360834bd122edc4e2c9764d2db4d18712dbe1d5be3cccc39680eead3c0c8bd11", + "size": 21443, + "executable": false + }, + { + "path": "distribution/copilot-sdk/docs/factory-patterns.md", + "sha256": "c37bae44a6cbabc4b70a5d0ccde3e04eb15a4c1111b2ed1a053efb1bbd254c33", + "size": 8201, + "executable": false + }, + { + "path": "distribution/copilot-sdk/extension.d.ts", + "sha256": "a83bc2d74059e6d56331671a33e0d0b6fc12563384e0dd6d279a6ebe45582384", + "size": 3303, + "executable": false + }, + { + "path": "distribution/copilot-sdk/extension.js", + "sha256": "cfb092cd6a7686833d02b832bdb47b13b8e325bfa864acb9ca692eb6d3443032", + "size": 494981, + "executable": false + }, + { + "path": "distribution/copilot-sdk/factory.d.ts", + "sha256": "1d23bbb4ebc8c1307182bd41b7f41f77ae83056cbf1579c072b976935c2afdf8", + "size": 15089, + "executable": false + }, + { + "path": "distribution/copilot-sdk/ffiRuntimeHost.d.ts", + "sha256": "519ac132a1d43f7f661d30cd1c3896d367a22639cbef526dfabe62f773bd4df7", + "size": 1720, + "executable": false + }, + { + "path": "distribution/copilot-sdk/generated/rpc.d.ts", + "sha256": "3b0d0ce9b73f657a453c64e94288d63b5f606f13dc599f0095e43213bcbf4e5a", + "size": 915008, + "executable": false + }, + { + "path": "distribution/copilot-sdk/generated/session-events.d.ts", + "sha256": "bba0f7bd4d34e586011f5e52945514902ccaf951553283fd8b0770eb37155717", + "size": 382125, + "executable": false + }, + { + "path": "distribution/copilot-sdk/index.d.ts", + "sha256": "a0c0eb9395e0abfba8b49ce5304cd44a7960586f7cb2529676392feab877f26c", + "size": 4685, + "executable": false + }, + { + "path": "distribution/copilot-sdk/index.js", + "sha256": "f8594ddbd0ad841395bd1fb0f02195094708aaef77bdaf767ddb617e19470571", + "size": 500179, + "executable": false + }, + { + "path": "distribution/copilot-sdk/runtimeArtifacts.d.ts", + "sha256": "1d78a2fd37b65e793e553db9a8323a6727b77e7ffbd6b61c462e778794dd76e2", + "size": 1248, + "executable": false + }, + { + "path": "distribution/copilot-sdk/sdkProtocolVersion.d.ts", + "sha256": "344a1c9669d1617346e2bb71d24081797b69b6cfd37c17b04b2aebf8e7a87260", + "size": 300, + "executable": false + }, + { + "path": "distribution/copilot-sdk/session.d.ts", + "sha256": "12ff3bcce06347e7d8810fa05a6b056c728281173723def01805a498c691fcf4", + "size": 15291, + "executable": false + }, + { + "path": "distribution/copilot-sdk/sessionFsProvider.d.ts", + "sha256": "6aa732885a832f7117a5d85853eb05b26052bc446a278c17b9463b7bdb06094d", + "size": 5545, + "executable": false + }, + { + "path": "distribution/copilot-sdk/telemetry.d.ts", + "sha256": "0721ce90ecd3a15eeb86d7787ca62236b1b545b0e93139ed5ef4419b16544722", + "size": 528, + "executable": false + }, + { + "path": "distribution/copilot-sdk/toolSet.d.ts", + "sha256": "41e53ae25809c90fd1a78f493cb912c6a9194c6e0a02e3e32545971c41a1cb56", + "size": 2725, + "executable": false + }, + { + "path": "distribution/copilot-sdk/types.d.ts", + "sha256": "2911ecb4aac830b7c129f2accf01086ae47a5250700dc050e95dfa33def862c4", + "size": 118473, + "executable": false + }, + { + "path": "distribution/definitions/code-review.agent.yaml", + "sha256": "1290258ee70b44faca1f0c74be26254f13e8401e825713ff3f5523b848b361f9", + "size": 4604, + "executable": false + }, + { + "path": "distribution/definitions/explore.agent.yaml", + "sha256": "83b4ed34c2fd8549dc5a557203c9d0db7dde5fc118b1eea8476585d05afa13ef", + "size": 2913, + "executable": false + }, + { + "path": "distribution/definitions/rem-agent.agent.yaml", + "sha256": "895ac0f7d0ce5cd006501eed7f1abfd38405ba93c30d16be9c7205bed6f335bf", + "size": 968, + "executable": false + }, + { + "path": "distribution/definitions/research.agent.yaml", + "sha256": "a32495c36fa076ded05b84dd04a93c2739e9be922ad77221beb519e3fb3d733a", + "size": 5594, + "executable": false + }, + { + "path": "distribution/definitions/rubber-duck.agent.yaml", + "sha256": "eb624f30ef69f8cfd18e7f3f14bcca63e13d1a0d3e05af006372b8d537bbd15d", + "size": 4820, + "executable": false + }, + { + "path": "distribution/definitions/security-review.agent.yaml", + "sha256": "7b6769eb585913a0ad59dadb3bf2e42cd9cfadb1540b341f23a28e5728be2d53", + "size": 14222, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/cloud-session-search.yaml", + "sha256": "0eaa7fe8f8cecb923c87c9598e22c4e338bfdc069cc9c78325eb6b589fa94bcd", + "size": 1809, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/github-context-memory.yaml", + "sha256": "22b699a6dd5b2d7964f8b2a913618ae478cf6910df2fc3ba336cf38b1be3b2ef", + "size": 4114, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/github-context.yaml", + "sha256": "9b7692e969e9ab36d7053fb2ef99ba9dd2ae78f6df75e645f54eb3cdb3fc6252", + "size": 2200, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/session-search.yaml", + "sha256": "8b6f48742fe847002e5ba6e00b05abc5339a5891387cd306790eb3b8c7dd8c11", + "size": 1889, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/subconscious-agent.yaml", + "sha256": "9fb4cb881677adc08c69818ecdde127276b1afe006817802c381a3ff5fb5887f", + "size": 2616, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-context-changed.yaml", + "sha256": "117edd3750a403c6cabf7964f217ae8129b16f9d0a6d2503e09ebe79d64f9d50", + "size": 1029, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-persistent.yaml", + "sha256": "a5ab1c16dd5f9a030010a375f7f26bca56aa196b212ea52ac2c5736cd80f3989", + "size": 861, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-restart.yaml", + "sha256": "21e44442b8380a758793d637a60fe94c226a6547d23c5bea3a14546f7f3e60ec", + "size": 831, + "executable": false + }, + { + "path": "distribution/definitions/sidekick/test-sidekick-trigger-once.yaml", + "sha256": "14a7cedf6ead133828280744b293e90402a2d47568c98a67273c1d7c3acdd53e", + "size": 828, + "executable": false + }, + { + "path": "distribution/definitions/task.agent.yaml", + "sha256": "1780e87f638e5c195b66173438565fd1af97eb0b9e2b1f2ebbd9d4e5b875f3af", + "size": 2112, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/README.md", + "sha256": "ff79555febdbf1ea12b10bef4f6bdcb08812205ba2efe568b048b0ecdd127bf4", + "size": 12375, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/deps_versions.json", + "sha256": "43ccda9b18ba2577033d7bf24580785fd1227828d9ab06b8011ddfeee57af8e9", + "size": 370, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/catalog.d.ts", + "sha256": "aa0db4dee8e54431e2e01d14fd7977d28cd3032f9c4940cc5b926b77d5469dd5", + "size": 3613, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/catalog.js", + "sha256": "c2cb5abd5e01162186cb43e67a0a6e49d4d9a556a7382b7dc1840d6c5c2a6b6b", + "size": 11418, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/configuration.d.ts", + "sha256": "900ba0e99ac907a92d07c08b179ba7d4379326fb71de2d338eb8434860338973", + "size": 2067, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/configuration.js", + "sha256": "7b01b6d5e18b7a8a2e465f0d75214a0b9884ffe57df95af1b2708a5a4c4d7bc5", + "size": 1645, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/coreInterop.d.ts", + "sha256": "80bb839431d2ee4c935d2def128faa4993acbe8d75fa403df5b50a61c2f92ff8", + "size": 980, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/coreInterop.js", + "sha256": "466e3287e579f2b4180fafcb33479549dd2bd683b687456e1b664cd84da524d1", + "size": 5557, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/model.d.ts", + "sha256": "8f219d0af8f00e9a0119ea8d69eb3d92bc2b5e25ce9e17ac218fcb4a8f88355c", + "size": 5311, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/model.js", + "sha256": "8f553a823eb0dda46eacdd53835449abd5f6e030d66d684d9a4e9c88396317ec", + "size": 6663, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelLoadManager.d.ts", + "sha256": "1a2e8b44486f02548f6d30a942f074d052175d7d14d6f5cd7918151cc1ff2552", + "size": 1045, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelLoadManager.js", + "sha256": "1a7888fe6fb620acc9b1d7ff8d17c5563139b0bd32834bdaabc4d36cc54343c2", + "size": 3288, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelVariant.d.ts", + "sha256": "a7af8a0359d36ed90322738bde975c534b9a3b69dcb0e0393cf6f88da7e1b486", + "size": 4649, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/detail/modelVariant.js", + "sha256": "d62acc4da20f11fb40102f79ded6f45e3cf2f684db1f97c7888c26e84f86eef7", + "size": 7217, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/foundryLocalManager.d.ts", + "sha256": "812e9e2eec43997439392d139b995793aa3839f573aea38d115e90573f986cf5", + "size": 7846, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/foundryLocalManager.js", + "sha256": "bfbca8d6235ba998ce3e82ddc9970900a7e2511653341e1315786c6851c1f380", + "size": 9451, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/imodel.d.ts", + "sha256": "26244ca6cdfb41849ca8e8edef23c5a6681ed18a4acfb2b714c7d2beb0f592fb", + "size": 2156, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/imodel.js", + "sha256": "6ab1971ca21097ea33a2b7b423aee408c093fec343292bcbbc59971d3e253713", + "size": 45, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/index.d.ts", + "sha256": "977e345d9eb116eaf3d4db1f972222480fd0fc6e59d3ac273aef1801dbead4f0", + "size": 1109, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/index.js", + "sha256": "e030b97bdb052ed99f00df928c2fc42f7ac165021d86df792290225c30c5376f", + "size": 929, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/audioClient.d.ts", + "sha256": "7a0d9fe37f7e6f253be258a7938667334deadeb930c2b8872f22e517d1963762", + "size": 2253, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/audioClient.js", + "sha256": "04264c41f3567811940ef28b48d97684b333ccbac9829cce8b3d0db8e763b51f", + "size": 9895, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/chatClient.d.ts", + "sha256": "a9da4a98dc50b1425e2ace9ffb044a3aa872e678947bf035339c9b8703dd6d1e", + "size": 3283, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/chatClient.js", + "sha256": "c59a093318d05a3e38a2f68693f3b64a24603472c73517743302d5a507331063", + "size": 13835, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/embeddingClient.d.ts", + "sha256": "a1875a29cd2825fa23c6fe7bf7f4e04c4e2e574e142e0e5fb0c0291b2206528d", + "size": 1470, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/embeddingClient.js", + "sha256": "bd55fbceb7fea625d30144a462d1c18dd857e6a2539a4179734d3b3237656428", + "size": 2554, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioSession.d.ts", + "sha256": "f2ed7df5665add334b18ad793866c814333863345f8077b8c925cb9b0aed66e4", + "size": 3441, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioSession.js", + "sha256": "889d7adcb9a64858113cd881a77f56962fa85266d2a7ac58c319ecc45be745bf", + "size": 12816, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioTypes.d.ts", + "sha256": "831434a52d2643186dd76bbb657c3acf2f0635cafaa94763a0ff2f728242146f", + "size": 2387, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/liveAudioTypes.js", + "sha256": "f78e54d6ed3b9b0fba913e7ceec41b36364279e1d93d8b006b62af55806d027c", + "size": 1840, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/responsesClient.d.ts", + "sha256": "3126a17df66edb893fb5d26bd6ea80a05f5bb516e47da661e927c0936e86d0d2", + "size": 5737, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/openai/responsesClient.js", + "sha256": "76b477538feb61c41bc0ff7cf8f23b7dbe75bc2fda4720e29c70ff3cac9afc7f", + "size": 15246, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/types.d.ts", + "sha256": "cca44657b82f9b3c0cd7528e6dbddf5f949e42f5d34afe60c0d566f28e3fc0b0", + "size": 9844, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/dist/types.js", + "sha256": "ca23f484beade559e7ed612347ac8f615c2c4cc97c2e1b0a74b9c882db7f6f38", + "size": 297, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/LICENSE", + "sha256": "6bb5b2d4c07d793ca928daa63a8899c6914fafb5ac3aa04ec10cae07f3d57dca", + "size": 1106, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/README.md", + "sha256": "9d05e156889b1475cdee97a13ed84a4abee0d752b2aaa28ded5359fcf45df901", + "size": 2894, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/adm-zip.js", + "sha256": "f4feddb21980597cd0248c8790922534abfa90c17a483b00ece15e392074ee27", + "size": 40794, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/entryHeader.js", + "sha256": "6ed1ec82e6124d71dbfb4ee6e5dd4c975657854354cf74741427a1862010a664", + "size": 12717, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/index.js", + "sha256": "536e4b5bf009a3d9f6eccfbbc4157cb6de663d889e0826ea5f6e5fa17aaeb8bf", + "size": 94, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/headers/mainHeader.js", + "sha256": "ed5a2b3fc7c8a6937e07447eb3e55e08a0655d2f24fe04039e7008845d221a25", + "size": 7141, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/deflater.js", + "sha256": "6dc41b2460594cfa5136b797653c166b2f7403820a40f2fca17cca35a5de1b5f", + "size": 1021, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/index.js", + "sha256": "d67714f1a04be942f90be77069af3ff4214aa8ee84b26edeff3a87eb0d8e2dc0", + "size": 128, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/inflater.js", + "sha256": "7b62c190669eeefd6b2810a3339d9109ecb219f845fcd12ae93518beec35dc49", + "size": 1146, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/methods/zipcrypto.js", + "sha256": "23365c7eda0ea098385dc7ec649517fc110ce2764d2e707c37bf6b528604e25a", + "size": 5806, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/package.json", + "sha256": "76a6fc3e9bf811c9ada1173b7a7c0621d36329c29c221428f46f75762d3aeb5a", + "size": 1387, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/types.d.ts", + "sha256": "98fae6ae2e5d026a91e0316209813ce304069ab85d0471f35152d5db500d23c1", + "size": 9641, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/constants.js", + "sha256": "208e943a2e5faad056047f3c7991cce3cde637d8e272a564f2546210ebdf2069", + "size": 6374, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/decoder.js", + "sha256": "73a0ebb00c4dce2124f07acf0b34374cb03a4384cccd1cd6f58aee27c35953d9", + "size": 130, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/errors.js", + "sha256": "d2d243647737c795c2db8aeba2e1f3841d5f76370b521d436cf465322dd4aab7", + "size": 2868, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/fattr.js", + "sha256": "31c93eb386a2bfbf19ad92a6bf20d510a8f1e7e90cc71d33dd888f89da12362d", + "size": 1892, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/index.js", + "sha256": "dc5b230ed853947ea55c0bf69f0e525fbeffefff09aa3da296d541bb8898314e", + "size": 226, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/util/utils.js", + "sha256": "f50d229943cce6067a7f09f5e97b4a69157ec11759777382e5ca0ba03671d1e9", + "size": 13011, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/zipEntry.js", + "sha256": "874e9510d362ba7d2cd85e2dcc7a0e7a6caa4de8ddb37fb4d5e68a018cd87a39", + "size": 15173, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/node_modules/adm-zip/zipFile.js", + "sha256": "a709d4f3ee218f457b7e71dd6b22dcf58858cc5410eece4dc4ecd7c4e9a2c027", + "size": 16516, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/package.json", + "sha256": "07d0242f84fdcfc8f4a0e601dc35f01fa153535c67f95262e0bd750ba7cfa418", + "size": 1296, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/prebuilds/linux-x64/foundry_local_napi.node", + "sha256": "3944ed8fc4bd2cee874f3c440236b277204f9e5a0c9b17b0c8180d8c6a85be33", + "size": 31456, + "executable": false + }, + { + "path": "distribution/foundry-local-sdk/node_modules/foundry-local-sdk/script/install-utils.cjs", + "sha256": "0831c932b10389283e805f88a204b0f6a5a8053f2ee520e56a0f0adf1352aa8b", + "size": 10443, + "executable": false + }, + { + "path": "distribution/index.js", + "sha256": "24ec5cfb8f9bcb6559d8c187fbaaae3a9e964a570bf212fa646cff825bc47b78", + "size": 41547, + "executable": true + }, + { + "path": "distribution/npm-loader.js", + "sha256": "0ea824a86be5757533fdb092eff7050871bd7a711a46babde0ffe0e44ac5ad88", + "size": 1185, + "executable": true + }, + { + "path": "distribution/package.json", + "sha256": "d4a592ea2cd06fc3be13dd46cf4d41b2277516efa3dd3f24e438bde3b4cd523a", + "size": 2188, + "executable": false + }, + { + "path": "distribution/prebuilds/linux-x64/cli-native.node", + "sha256": "e17dda2eb0b2d4d2ca056c8fdc4f474e49f16de73c60b7b09fb00a93106f3a23", + "size": 6859576, + "executable": true + }, + { + "path": "distribution/prebuilds/linux-x64/copilot-runtime", + "sha256": "84da09a390f9b1bb0898bdc8483c368ec69cc80a3ec83fb23069e3a82e215ae1", + "size": 422392, + "executable": true + }, + { + "path": "distribution/prebuilds/linux-x64/runtime.node", + "sha256": "95ee3b30d821fb27d1cbbabc6424bfe726fecedbd25e7a90413eca1a54040234", + "size": 101936656, + "executable": true + }, + { + "path": "distribution/preloads/extension_bootstrap.mjs", + "sha256": "ebf4edc7cc2195fc1b7c5c26863e08137137ec075cc73ff335331534115a1be6", + "size": 2811, + "executable": false + }, + { + "path": "distribution/preloads/extension_sdk_resolver.mjs", + "sha256": "e57d71730c8f937315a72b66187c56cc4fa78ecbd13590795136996e2851bf19", + "size": 1331, + "executable": false + }, + { + "path": "distribution/pvrecorder/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/README.md", + "sha256": "7eb46bc4bdba8002da1165107fc729ee816f58063a8e8d6d0083a3fb81898683", + "size": 1675, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/errors.js", + "sha256": "14b373ef00a0bf0fceb5abe86a6628f914a466b2403ba376f78b025cff05e92b", + "size": 2528, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/index.js", + "sha256": "a99551364022401340991df2d74e1c949290e920aa9247d22c262e26b768a0f0", + "size": 748, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/platforms.js", + "sha256": "9eb9c4c2af151391c4966bdaf15364d15362996915e8f5fa685daca1c3f81359", + "size": 6404, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/pv_recorder.js", + "sha256": "3323ebdbad3d524dc4330d25cb226cc17a0f7f365b738f471faf883d71de658d", + "size": 6154, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/pv_recorder_status_t.js", + "sha256": "c7c94cd144fb0fcf5f844e76ff83d754dc6b835e45893c2cc81de5f0c3ddccbb", + "size": 1519, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/errors.d.ts", + "sha256": "5250616ee995097ea36daa3285d957e3adcdd9c76a6dd13ea3d10174c38a8668", + "size": 236, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/index.d.ts", + "sha256": "6b27f29a807ef113c326fcc2139ddde257e74585cb58591ad9753dcbfd00c03f", + "size": 98, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/platforms.d.ts", + "sha256": "a11acc208b8d403db8882b97e0101f549a5dd7a1ca656ff46d9378277a100064", + "size": 142, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/pv_recorder.d.ts", + "sha256": "6b06821393cd5fbab4874a11c46634e7bb1ff91848bbf34a1e5008ccea6929bf", + "size": 2741, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/dist/types/pv_recorder_status_t.d.ts", + "sha256": "b35985d2b520dc2570f90f23e6db7e6ce37369db6e35c27bc532de349421175e", + "size": 337, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/lib/linux/x86_64/pv_recorder.node", + "sha256": "f42752754975a0266c2b5ea0668b5c9508590273cd7a3baf38a678d5056918cc", + "size": 889128, + "executable": false + }, + { + "path": "distribution/pvrecorder/node_modules/@picovoice/pvrecorder-node/package.json", + "sha256": "e44a9dde00f1344251f22bce37520ea1bbbde5af2965cab7e612758854ee3a99", + "size": 1197, + "executable": false + }, + { + "path": "distribution/queries/bash-highlights.scm", + "sha256": "77860b9f127ca82681e3691bba003e62d1d8dbced05e4fe96041d26fd8944656", + "size": 5776, + "executable": false + }, + { + "path": "distribution/queries/c-highlights.scm", + "sha256": "a99c4c0bf9d9b9f7de0124c529a4229fddada96d65bf5fb0c9d487266e15d483", + "size": 6332, + "executable": false + }, + { + "path": "distribution/queries/cpp-highlights.scm", + "sha256": "69d1dbed8d49c498e93a75db6afedfb8769fa2fed455a64d588dc347281d642f", + "size": 4904, + "executable": false + }, + { + "path": "distribution/queries/csharp-highlights.scm", + "sha256": "4b79059a52926e8cd08a1d80af70ba02acefb86bc8ed82315897fb104ed1a338", + "size": 8302, + "executable": false + }, + { + "path": "distribution/queries/css-highlights.scm", + "sha256": "6641fed120530bfbce5682ab88b2a8d14d99bfcd971ac9f78cd91738b4b0578a", + "size": 1177, + "executable": false + }, + { + "path": "distribution/queries/go-highlights.scm", + "sha256": "c58a7336d4834ebfbb038002c6ab033f792cd1b8d3e7e1682bdaa982bee7f6b1", + "size": 3934, + "executable": false + }, + { + "path": "distribution/queries/html-highlights.scm", + "sha256": "1ebb3811a8cdc054385b847a3aac6fbf7079faefd5b3dfab5bbad256cd5afdcf", + "size": 197, + "executable": false + }, + { + "path": "distribution/queries/java-highlights.scm", + "sha256": "c68c0e7e9ae36d94c9e5ab06556b34c5e8568d1ce1867043d92bbe4b3b4dcf2f", + "size": 4425, + "executable": false + }, + { + "path": "distribution/queries/javascript-highlights.scm", + "sha256": "d3630ae6dc9b2b27b230b5f8bb92b05cd491fb12bff353dae62a0a6d780461ee", + "size": 2739, + "executable": false + }, + { + "path": "distribution/queries/json-highlights.scm", + "sha256": "6ab09656820f0e8dac860c96dced341221284544c05308ee337727f5db937101", + "size": 412, + "executable": false + }, + { + "path": "distribution/queries/php-highlights.scm", + "sha256": "a2a7367659cff3b4be09961c8117d69a9b8703bfc266f8092e089b1760f197e9", + "size": 3207, + "executable": false + }, + { + "path": "distribution/queries/python-highlights.scm", + "sha256": "a6708f209381618e2b398972c8f1ccd892f0c064eab35a2a3f911c3e22e79a7e", + "size": 1957, + "executable": false + }, + { + "path": "distribution/queries/ruby-highlights.scm", + "sha256": "1cc8fc205e4cce4fe9e0d63bb1be903e49ea0f2eda77ae9368898a31ff98bc18", + "size": 4068, + "executable": false + }, + { + "path": "distribution/queries/rust-highlights.scm", + "sha256": "c649a4785322ab72af84874b9dc03c7a4f5d9a05de45a85919a60770d7d5457d", + "size": 8610, + "executable": false + }, + { + "path": "distribution/queries/scala-highlights.scm", + "sha256": "e9e89654a7df214f4c79c28af7bc50bdb5c6cc5abb9690e3e1ce19c35b797991", + "size": 4858, + "executable": false + }, + { + "path": "distribution/queries/typescript-highlights.scm", + "sha256": "e0c35adb819127bfd4f853fac5419e7d8ba44760246201d04a4a5ce0228a10c5", + "size": 515, + "executable": false + }, + { + "path": "distribution/ripgrep/bin/linux-x64/rg", + "sha256": "f2ee496a39139b8d9ad9408081217a55f1382cef4950100565d147d247364856", + "size": 5728000, + "executable": true + }, + { + "path": "distribution/schemas/api.schema.json", + "sha256": "032a7784171eb2c4eb8260f0c0b67f05e9b1c76d0e74fe507871ba6a36d9a3ab", + "size": 1914299, + "executable": false + }, + { + "path": "distribution/schemas/session-events.schema.json", + "sha256": "d8cb713c05d5278a68dde5c5d4482574f836e922ae13aa06f82474c209a7c6e9", + "size": 895984, + "executable": false + }, + { + "path": "distribution/sdk/index.js", + "sha256": "a0718e6376a1146da09b4cf6ef2a332d447cd22f7dfffdedc000c7b622309ef4", + "size": 1165545, + "executable": false + }, + { + "path": "distribution/sea-loader.js", + "sha256": "ae11b2967060bb6239cac956beed3b9f566cb09afb912b021605c486b3bfc7c3", + "size": 122234, + "executable": false + }, + { + "path": "distribution/tgrep/bin/linux-x64/tgrep", + "sha256": "3a88e5824920b56f089a9cd980e95e54a23d9abd6f7492f0a906b5b4706ddd9c", + "size": 7914368, + "executable": true + }, + { + "path": "distribution/tree-sitter-bash.wasm", + "sha256": "8292919c88a0f7d3fb31d0cd0253ca5a9531bc1ede82b0537f2c63dd8abe6a7a", + "size": 1358224, + "executable": false + }, + { + "path": "distribution/tree-sitter-c.wasm", + "sha256": "c852c2a85ebf2beb636aa3b0ef7f7e70458684d74f6741b20dcb296885bed9f9", + "size": 625918, + "executable": false + }, + { + "path": "distribution/tree-sitter-c_sharp.wasm", + "sha256": "6f69e1cae44e1c32c1eccc170dc5a9778fb94ff716f71113fe1f8c4299aa2f40", + "size": 5350581, + "executable": false + }, + { + "path": "distribution/tree-sitter-cpp.wasm", + "sha256": "174eb0deb75b2ec7881bcacda9f995648d8e683956e5c2267e69ab6dc503fcbf", + "size": 3434931, + "executable": false + }, + { + "path": "distribution/tree-sitter-css.wasm", + "sha256": "8a23977fe271357cce6f254ef88c9bebf3854602d8046605aef6a45c02135c59", + "size": 128668, + "executable": false + }, + { + "path": "distribution/tree-sitter-go.wasm", + "sha256": "9504573f352b20be7f2f1911754d710622aedc15afff16d5ed8fb5645681aee7", + "size": 217182, + "executable": false + }, + { + "path": "distribution/tree-sitter-html.wasm", + "sha256": "c48fcd82c7ea8bf943180088ba7f28c48b2bb5287874179168bf9d31e394cf85", + "size": 18385, + "executable": false + }, + { + "path": "distribution/tree-sitter-java.wasm", + "sha256": "4fdeac4ca6ca089f06c6f7e562abcac1733cd465728cc7031ebb73c2019122c4", + "size": 414641, + "executable": false + }, + { + "path": "distribution/tree-sitter-javascript.wasm", + "sha256": "5fb488d0cabb4775a594bab85682de5ad6ce83c0d6ac997a9f82dd084d571240", + "size": 411770, + "executable": false + }, + { + "path": "distribution/tree-sitter-json.wasm", + "sha256": "d2119fb98d5912719b13f9458574f8608d2d29dfbe45f6be1f860ea1fe2a2405", + "size": 5596, + "executable": false + }, + { + "path": "distribution/tree-sitter-php.wasm", + "sha256": "d4df6a6ff08c87c3ec4f9cbb785fe09998a0cb570e03f57d7b19b3acfb146aa7", + "size": 1058041, + "executable": false + }, + { + "path": "distribution/tree-sitter-python.wasm", + "sha256": "16108b50df4ee9a30168794252ab55e7c93bfc5765d7fa0aa3e335752c515f47", + "size": 457883, + "executable": false + }, + { + "path": "distribution/tree-sitter-ruby.wasm", + "sha256": "09a96427d7c72f0613ed470cd9812223fc4a91d6a9c025c0235cc6bd59ff96f4", + "size": 2106352, + "executable": false + }, + { + "path": "distribution/tree-sitter-rust.wasm", + "sha256": "f65f354215611fd94ad34134b3427eb3d58cbb745df7b6509ba722184db73d57", + "size": 1102547, + "executable": false + }, + { + "path": "distribution/tree-sitter-scala.wasm", + "sha256": "b7ec2bb29c19827abcefd18ed5cb5a43596009f96a5d53c5b9d1f9676d7521c3", + "size": 3786700, + "executable": false + }, + { + "path": "distribution/tree-sitter-tsx.wasm", + "sha256": "79e5da75ea62855a0cd67177685f0164eac87d5f630b3cbe1e0a099751ad30f8", + "size": 1445638, + "executable": false + }, + { + "path": "distribution/tree-sitter-typescript.wasm", + "sha256": "778025db5a8be0e70f8ccc3671e486dfeddd048c25d9e8a70c26de2e1bf6f97d", + "size": 1413849, + "executable": false + }, + { + "path": "distribution/tree-sitter.wasm", + "sha256": "f38dcc4b43b818f9a0785bc1c6d5611a75ac4cdd428ff3f02757c34ca4e46d7f", + "size": 205488, + "executable": true + }, + { + "path": "distribution/voice-engine.worker.js", + "sha256": "bf601f1092d1b8734a582d8182c223855c7f5cfb17f8fc529f1791dfd470d5d2", + "size": 112985, + "executable": false + }, + { + "path": "distribution/voice-installer.worker.js", + "sha256": "4a843b379310d44f422271e75d3246ada24f567587850c3ef501f565c905b493", + "size": 11639, + "executable": false + }, + { + "path": "distribution/voice-server.js", + "sha256": "c6b2c140027ef6c7b283ebbb4cbde1dc32f12f97901ff45dfdfd536269bac1d2", + "size": 130952, + "executable": false + }, + { + "path": "distribution/webview/index.js", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-linux-x64-gnu/README.md", + "sha256": "720b4d8d3ee81dea88395f2d3152321f8578be41ba662cce54a8b1cca6af1c85", + "size": 111, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-linux-x64-gnu/package.json", + "sha256": "478445fe04773da57a814e62ec912630591841b10120ff9038b0e8883e21eb20", + "size": 667, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview-linux-x64-gnu/webview.linux-x64-gnu.node", + "sha256": "dd47ad9f7c402298889991226eaad6ee6d1e2239f23a69d52fbe556fd5227b92", + "size": 7942704, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/LICENSE", + "sha256": "9cac72e0573f14d3f9bcb8f70922d589ba07dd1051a5c12229f111e154923f0a", + "size": 1065, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/README.md", + "sha256": "69c2e106db45187b981358c938e41b8bfb67fb3a451930f102a63b471c0da675", + "size": 16477, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/build.mjs", + "sha256": "bcb69c0cc8356b78d07c53719ccaa372f1d6b6c381973b76508681573a7b1db4", + "size": 3640, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/index.mjs", + "sha256": "3d6d86fc751897b67e1b94f0b1d0a667d79e6256b278a7e801467bfd67ad2689", + "size": 3837, + "executable": true + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/cli/utils.mjs", + "sha256": "fb7a2fc54e53c777de835f50c7d4483bee60d1aece0c1deddbe2f232f571d8f1", + "size": 193, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/CNAME", + "sha256": "405d5f5a242885480dc01db6227484ed4b6d723fd7c3b2547f9f36e46decac15", + "size": 14, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/README.md", + "sha256": "65578b7f73cc6c145a9d962ca6f2ee56b06153a8277eea8c3c3cadc7d602f155", + "size": 15717, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/application.md", + "sha256": "44227367accc4a9f5b8468e19680ffd1229891d59c0652e8f45370c0872160bd", + "size": 6074, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/browser-window.md", + "sha256": "597920a56d3ff8c87b6990473e5150a454f109bd7e639125c8a82d039ade7487", + "size": 11107, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/menu.md", + "sha256": "838a998eba786e959bec89e148fae7d547df1b13e490b55c890d58100fd77b98", + "size": 3828, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/notification.md", + "sha256": "8e90225a0f40ebcd2a69bc9e0b8d51c645810faa4fcaae3651101bcaa686d101", + "size": 5622, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/tray.md", + "sha256": "f071ca7d3a5cc7cb271ceb9e2712db9c6c80d5c8ab33b4a8fc3b168e5d9dd3aa", + "size": 1628, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/types.md", + "sha256": "e006fb8428d0e58943468f62c77ec78bcba1bb7b0bc41ce3e2258417fe51320c", + "size": 5711, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/web-context.md", + "sha256": "92b9ed94a8868a1ff8b0f3e4ef92be2a0b2564f7beb1fc8b6107b7f1bca96429", + "size": 1774, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/api/webview.md", + "sha256": "78ade1e288c7a6169678d4bb64f9e450ed4e855324c310354686c11f127bd494", + "size": 6279, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/event-loop.md", + "sha256": "28b1f847d6ff3c71c5b3fbf9f46d531b6fbd9ba585365cfbe9a2292e698171bb", + "size": 2511, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/installation.md", + "sha256": "06acf9a3d61c0d84f0dcc65153f43d288e1bb139a0e16df69253fae68c2c6c76", + "size": 1173, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/getting-started/quick-start.md", + "sha256": "55cdf7992b7ad793b204ac80268c66600975ee20c474c2ca029cdd6db58073b6", + "size": 1995, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/building-executables.md", + "sha256": "18a1d5d2596e0e0b05758643981d7ffe5b9d9f32bf0884c84978c647bf2419e9", + "size": 5026, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/cookies-and-storage.md", + "sha256": "0eb9abdd246e5a2a0d4a8578c6b565781fdb9d7b99463b50c05a6851c3eed365", + "size": 1783, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/custom-protocols.md", + "sha256": "1c72110266d09b04f544c0d20aa7fe1f4b6ad84ffa9e171b7f6e55832d39c4c1", + "size": 3148, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/ipc-messaging.md", + "sha256": "d03b307d46cafe92ad0297cad0a0ee1b3af410143281be4be5ab2dbc31e2e74e", + "size": 1790, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/menus.md", + "sha256": "88b791fa71cbb51351cd3a220184ea54a53426cca00eab0c185acd139053fe6d", + "size": 2764, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/guides/multiple-windows.md", + "sha256": "6d05fa117efc95207d4707bbf2728ae31f482bc6e9f29a8a436389fdaae003cd", + "size": 1835, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/android.md", + "sha256": "646dc6f1ac30a4f2bd25faec682a2d6f0262ea59193ac44490d5914cf1294cd0", + "size": 326, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/ios.md", + "sha256": "b8b1c2bc59611d633971556fb94720bdb411e2de89771fb372217d1a17b53cb7", + "size": 420, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/linux.md", + "sha256": "cbd1df68b7c899f826aff9e0fd2d13e9f9e19892ad16fd47a6fea5c625dc0e8b", + "size": 1986, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/macos.md", + "sha256": "9b5b1d9c4065d7904c02e3dc79517b6df4f8a44dd95902e30502d130cdef5d71", + "size": 2260, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/docs/platform/windows.md", + "sha256": "5a84a310a20d42e0b24492472efd5512aae11c68668620f071a3d85c3583512a", + "size": 2136, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/index.d.ts", + "sha256": "49610270556eaff855f8efc8026a595c354309ea325d846d33a617ad54039a39", + "size": 13502, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/index.js", + "sha256": "9bce1f609372abd7da4973967276ba591c43f9b933bcd52030edb1ad6ee3b8db", + "size": 22727, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/js-bindings.d.ts", + "sha256": "f2f384ab7913a7e38369155a5abda97c0af3b72db6021df341e8da2288ceceaf", + "size": 20365, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/js-bindings.js", + "sha256": "e6a970a01839ed97c9a9636a01bf1c77c298a8270b3f1708c4927191856a3bcc", + "size": 28518, + "executable": false + }, + { + "path": "distribution/webview/node_modules/@webviewjs/webview/package.json", + "sha256": "0a71bc7c23521db3890aadb13763297422e1988e9a68f95ec8b8832c07d33b89", + "size": 3442, + "executable": false + } + ] + } + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-native-instruction-delivery-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-native-instruction-delivery-2026-09-29.json new file mode 100644 index 0000000000..63f054191f --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-native-instruction-delivery-2026-09-29.json @@ -0,0 +1,72 @@ +{ + "schema": "paperclip.copilot-native-instruction-delivery.v1", + "date": "2026-09-29", + "providerVersion": "1.0.88", + "providerBinarySha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "runtimeSourceRevision": "3d0c45920c326821c2f5ff48dc985ef8158b46e4", + "fixtureMechanism": "Credential-free env -i / COPILOT_OFFLINE=true with synthetic credential and loopback OpenAI fixture. Actual native binary and AcpxRuntimeHost. Test-only offline model metadata injection retained. Second explicit requested turn forces native session/load. File-mechanism probe writes the proposed instruction file in prepareSandbox; it is not a final built-production proof.", + "documentation": "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions", + "attempts": [ + { + "mechanism": "generic ACP metadata only", + "rawPrivateEvidenceSha256": "c2a0944f325cbbd73e3238d3aa93b5d1305ec924686b96e5933c2a1b41dc166e", + "probeScriptSha256": "ba346a6ce5652f189ca95b993902570c121142d9c69a85cb2ad88427bdf47360", + "loopbackModelResponses": 2, + "paidProviderCalls": 0, + "nativeLoadObserved": true, + "continuationResult": { + "status": "completed", + "stopReason": "end_turn" + }, + "modelRequestProjection": [ + { + "request": 1, + "paperclipPromptInSystem": false, + "oldRegisteredRootInSystem": false, + "currentRegisteredRootInSystem": false, + "originalCustomEntryInSystem": false, + "updatedCustomEntryInSystem": false + }, + { + "request": 2, + "paperclipPromptInSystem": false, + "oldRegisteredRootInSystem": false, + "currentRegisteredRootInSystem": false, + "originalCustomEntryInSystem": false, + "updatedCustomEntryInSystem": false + } + ] + }, + { + "mechanism": "isolated COPILOT_HOME/copilot-instructions.md refreshed before admission", + "rawPrivateEvidenceSha256": "abe3317c36f178c7b4f512070b290ed5fc2c066ca19b52152a5bebb4ff8ba195", + "probeScriptSha256": "85752c52ff53f99e4a7a8d6fe40b41fbea1d55b07efbcd4c4062674946089850", + "loopbackModelResponses": 2, + "paidProviderCalls": 0, + "nativeLoadObserved": true, + "continuationResult": { + "status": "completed", + "stopReason": "end_turn" + }, + "modelRequestProjection": [ + { + "request": 1, + "paperclipPromptInSystem": true, + "oldRegisteredRootInSystem": true, + "currentRegisteredRootInSystem": false, + "originalCustomEntryInSystem": true, + "updatedCustomEntryInSystem": false + }, + { + "request": 2, + "paperclipPromptInSystem": true, + "oldRegisteredRootInSystem": false, + "currentRegisteredRootInSystem": true, + "originalCustomEntryInSystem": false, + "updatedCustomEntryInSystem": true + } + ] + } + ], + "conclusion": "Pinned Copilot ignores generic systemPrompt metadata. Native personal instruction file supplies fresh composed text to actual model system message after load, replacing old root/custom-entry text. Both attempts retained; final production build and authenticated qualification remain required." +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json new file mode 100644 index 0000000000..5cda9be429 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json @@ -0,0 +1,257 @@ +{ + "schema": "paperclip.copilot-policy-conformance.v1", + "providerVersion": "1.0.88", + "platform": "darwin-arm64", + "executableSha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "sourceBaseRevision": "9f4476feb87df5574edf121b86a1093efb006ac3", + "authentication": "none; explicit fresh environment contains no credential values", + "modelTransport": "COPILOT_OFFLINE=true; COPILOT_PROVIDER_TYPE=openai; COPILOT_PROVIDER_BASE_URL points only to ephemeral 127.0.0.1 Python HTTPServer; fixture model gpt-4.1 is synthetic", + "paidProviderCalls": 0, + "modelSpendUsd": 0, + "qualification": "offline protocol evidence only; no GitHub model, final pack, or Daytona qualification", + "attempts": [ + { + "evidenceFile": "agent-inputs.json", + "sha256": "3def3bcf95db103611722d5e04f803bed555c7c8274a1833c661ef9d87a4b757", + "scenario": "discover-inputs", + "mode": "agent", + "resumed": false, + "loopbackModelRequests": 1, + "nativeInputToolsAdvertised": [], + "forcedToolRejectedAsUnavailable": false, + "permissionRequestCount": 0, + "permissionRequestIds": [], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" + }, + { + "evidenceFile": "plan-inputs.json", + "sha256": "446ee3a2b9e5e4ac3249a8ae18a09f1c33fadc75f94a98d8c6f5fb50a8a73b61", + "scenario": "discover-inputs", + "mode": "plan", + "resumed": false, + "loopbackModelRequests": 1, + "nativeInputToolsAdvertised": [], + "forcedToolRejectedAsUnavailable": false, + "permissionRequestCount": 0, + "permissionRequestIds": [], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" + }, + { + "evidenceFile": "native-question.json", + "sha256": "1cdff42aa31e27a796ecceb4d4fea89c271737a0094982956bb228f03752e21d", + "scenario": "native-question", + "mode": "agent", + "resumed": false, + "loopbackModelRequests": 2, + "nativeInputToolsAdvertised": [], + "forcedToolRejectedAsUnavailable": true, + "permissionRequestCount": 0, + "permissionRequestIds": [], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" + }, + { + "evidenceFile": "native-plan.json", + "sha256": "fecfca013ea072e4e880c77b244b42843f25486206c2e8f64411216ad5c738ea", + "scenario": "native-plan", + "mode": "plan", + "resumed": false, + "loopbackModelRequests": 2, + "nativeInputToolsAdvertised": [], + "forcedToolRejectedAsUnavailable": true, + "permissionRequestCount": 0, + "permissionRequestIds": [], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" + }, + { + "evidenceFile": "deny-read.json", + "sha256": "88d1ad8865b3252963ce12178d6a3a3819f50a2c20ddf95ab557f1cacd127975", + "scenario": "deny-read", + "mode": "agent", + "resumed": false, + "loopbackModelRequests": 1, + "nativeInputToolsAdvertised": [], + "forcedToolRejectedAsUnavailable": false, + "permissionRequestCount": 1, + "permissionRequestIds": [ + 0 + ], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" + }, + { + "evidenceFile": "resumed-deny-shell.json", + "sha256": "3826ca1247b99c3000bff1e8e0e53d0244c0c22f8ec3089b6ca83bfba80ae3bb", + "scenario": "deny-shell", + "mode": "agent", + "resumed": true, + "loopbackModelRequests": 0, + "nativeInputToolsAdvertised": [], + "forcedToolRejectedAsUnavailable": false, + "permissionRequestCount": 0, + "permissionRequestIds": [], + "markerPresent": false, + "rpcErrorCodes": [ + -32002 + ], + "disposition": "probe_setup_failure" + }, + { + "evidenceFile": "resumed-deny-shell-attempt-2.json", + "sha256": "14bdd262303d9273c3612d502fefb4b6f887ce903af590a58bbaea3e00297ff6", + "scenario": "deny-shell", + "mode": "agent", + "resumed": true, + "loopbackModelRequests": 2, + "nativeInputToolsAdvertised": [], + "forcedToolRejectedAsUnavailable": false, + "permissionRequestCount": 1, + "permissionRequestIds": [ + 0 + ], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" + }, + { + "evidenceFile": "restart-deny-shell.json", + "sha256": "c9a22087f343d8d399044bd05d159e457531d3c735a765df2c6bdce85878b2be", + "scenario": "deny-shell", + "mode": "agent", + "resumed": true, + "providerKilledAndReplaced": true, + "replayMutationCount": 1, + "loopbackModelRequests": 3, + "nativeInputToolsAdvertised": [], + "permissionRequestCount": 2, + "permissionDecisions": [ + "allow_once", + "reject_once" + ], + "permissionRequestIds": [ + 0, + 0 + ], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" + }, + { + "evidenceFile": "detached-shell.json", + "sha256": "8b99a6e010e085aad37d39419e3159743f74bb013b731e7856d572f70cce9c89", + "scenario": "detached-shell", + "mode": "agent", + "loopbackModelRequests": 2, + "permissionRequestCount": 1, + "markerAtPromptResult": false, + "markerAfterObservation": false, + "postTerminalObservationMs": 300, + "disposition": "provider_behavior_failure", + "failedAssertion": "Detached shell must finish before prompt terminal result" + }, + { + "evidenceFile": "detached-shell-observe-late.json", + "sha256": "095b0aed787575bbc22f5e80ea8eff74152a696dccdf850201f3aba629ebc978", + "scenario": "detached-shell", + "mode": "agent", + "loopbackModelRequests": 2, + "permissionRequestCount": 1, + "markerAtPromptResult": false, + "markerAfterObservation": true, + "postTerminalObservationMs": 3000, + "disposition": "provider_behavior_failure", + "failedAssertion": "Detached shell must finish before prompt terminal result" + }, + { + "evidenceFile": "detached-shell-policy-denied.json", + "sha256": "a941e02818297025866b76f541a23d078515dcfd4a43bf26421ffd46025ed720", + "providerVersion": "1.0.88", + "scenario": "detached-shell", + "loopbackModelRequests": 1, + "permissionResponseCount": 0, + "markerAtPromptResult": false, + "markerAfterObservation": false, + "policyRejection": "COPILOT_DETACHED_WORK_UNSUPPORTED", + "disposition": "policy_denied_before_effect", + "scope": "exact repository tool-update policy source, not full production admission" + }, + { + "evidenceFile": "detached-shell-policy-denied-final.json", + "sha256": "4a2e91b561300ec6e2bad7ebb9d599ca502692883ffaa3a30d24d606f3c0284b", + "providerVersion": "1.0.88", + "scenario": "detached-shell", + "loopbackModelRequests": 1, + "permissionResponseCount": 0, + "markerAtPromptResult": false, + "markerAfterObservation": false, + "policyRejection": "COPILOT_DETACHED_WORK_UNSUPPORTED", + "disposition": "policy_denied_before_effect", + "scope": "exact repository tool-update policy source, not full production admission" + }, + { + "evidenceFile": "comparison-1.0.89-attached.json", + "sha256": "95c87df86e91483ac67d7f02ffd76cf2c968e4be5c0f79679c0a1e8f289d6b20", + "providerVersion": "1.0.89", + "scenario": "attached-shell", + "loopbackModelRequests": 3, + "permissionResponseCount": 1, + "markerAtPromptResult": true, + "markerAfterObservation": true, + "policyRejection": null, + "disposition": "passed", + "scope": "unguarded native protocol" + }, + { + "evidenceFile": "comparison-1.0.89-detached.json", + "sha256": "0dd99fa9fe72c831f09bebfa99d9effae16fe895964f9ef726462b92ca1cf0f3", + "providerVersion": "1.0.89", + "scenario": "detached-shell", + "loopbackModelRequests": 2, + "permissionResponseCount": 1, + "markerAtPromptResult": false, + "markerAfterObservation": true, + "policyRejection": null, + "disposition": "unsupported_native_lifetime", + "scope": "unguarded native protocol" + } + ], + "emptySessionLoadDiagnosis": "Copilot does not retain an empty conversation for load. Initial resource-not-found retained; second attempt seeds one loopback-only text turn before close/load. It then requests shell permission and rejects the write.", + "providerDeathDiagnosis": "One allowed seed mutation appends X. The exact provider is SIGKILLed and replaced against the same private state, then session/load succeeds. X occurs once after load and the next denied write remains absent. Request ID 0 is reused by the new process and requires fresh permission authority.", + "detachedSettlementDiagnosis": "Explicit native detach returns end_turn before command completion in both 1.0.88 and isolated 1.0.89. Original failed settlement assertions are retained. This intentional native lifetime is unsupported by the runner, not proof that attached-shell upstream issue #4743 remains open. The production guard denies rawInput.detach:true before permission dispatch; native no-side-effect probe passes. Native Rust schemas/platform/feature variants and shell escape lifetimes remain outside comprehensive qualification.", + "comparisonRelease": { + "package": "@github/copilot-darwin-arm64", + "version": "1.0.89", + "tarball": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.89.tgz", + "integrity": "sha512-CaBNRZ1gRgatEdlgL2l/Ykw+79sOOYhBWFh3ohifYL7XgPBLf4xnXf59WlOTRT3Ojm4RSbbu/qVwBAa/elIdPQ==", + "tarballSha256": "7292cd2b3f0275e90bed9306bee23c5b7b1e22d604d8ea121f67c24f7192ad49", + "executableSha256": "97c12874d9adb9738374a9fb8a52cd724b81132ff815140f7e017bac706feba7" + }, + "toolSchemaEvidence": { + "evidenceFile": "native-tool-schemas.json", + "sha256": "68463a6afc5cf6b247998d76d4094fdc44789909197b2cd7a380b1649c8b4062", + "detachParameterTools": [ + "bash" + ], + "noDetachParameterTools": [ + "read_bash", + "stop_bash", + "task" + ], + "notAdvertised": [ + "write_bash", + "powershell", + "write_powershell", + "read_powershell" + ], + "comprehensiveAcrossPlatformsAndFlags": false + }, + "detachedPolicySourceSha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3" +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-product-live-proof-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-product-live-proof-2026-09-28.json new file mode 100644 index 0000000000..2c36acffee --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-product-live-proof-2026-09-28.json @@ -0,0 +1,93 @@ +{ + "schema": "paperclip.copilot-product-live-proof/v1", + "providerVersion": "1.0.88", + "model": "gpt-5.6-luna", + "modelProvider": "github", + "qualificationStatus": "pending", + "sourceRevision": "bcc9c638a25b91b84065f12633f083bd4f7a689f", + "runnerdSourceRevision": "f77ae83aeb6d802c73f1c955760017ae272eba29", + "runnerdSha256": "sha256:986060810ba7377c6ddd64a5d89e322d0a434e1a9c80400e6d4acf5934bfc421", + "executionMode": "source-mode Product E2E with build-owned verified native assets", + "caseId": "extended-harnesses.runner-acpx-copilot.local.hello-complete", + "environment": "local", + "startedAt": "2026-09-28T19:41:44.170Z", + "finishedAt": "2026-09-28T19:42:22.606Z", + "durationMs": 38436, + "attempt": 1, + "automaticRetries": 0, + "promptRequestsSent": 1, + "upstreamModelRequestCount": null, + "activeTurnDeadlineSeconds": 120, + "outerDeadlineSeconds": 600, + "reservationUsd": 2, + "behaviorStatus": "passed", + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "cleanup": "passed", + "visualReview": "Exact single completion marker visible; task Done; Copilot agent attribution and tool activity visible.", + "originalResultSha256": "sha256:92c8aff3960e443be0c009c891d49d285476c3d6b67999fe97bb323076c4dc8b", + "finalScreenshotSha256": "sha256:d5715c79b9c00ee1a87872999ab24f38e7d642c10e52f31ba183d3af2cdc74b9", + "sourceProvenanceNote": "Original Product result source.sha is null; exact committed source is retained in the independent private launcher manifest.", + "tokens": { + "inputTokens": 31332, + "cachedInputTokens": 15451, + "outputTokens": 337 + }, + "accounting": { + "providerReportedCostUsd": null, + "costCoverage": "unknown", + "originalResultUnchanged": true, + "originalErroneousFields": { + "usage.biller": "openai", + "usage.provider": "openai", + "usage.costUsd": 0, + "usage.costStatus": "reported", + "billing.complete": true + }, + "defect": "The shared server path classified the model family as its biller and converted absent cost to a reported zero. Behavior passes do not qualify accounting. Shared fix and verification are required." + }, + "externalBilling": { + "source": "parent verified GitHub billing dashboard after Product hello", + "displayedIncludedAiCreditsUsed": 1, + "displayedIncludedAiCreditsTotal": 1500, + "baselineDisplayedIncludedAiCreditsUsed": 0, + "combinedDisplayedCreditDelta": 1, + "exactPerRunCreditDelta": null, + "additionalUsageEnabled": false, + "additionalCashBudgetUsd": 0, + "additionalCashChargesUsd": 0, + "note": "Combined attribution covers the successful canonical protocol and Product hello turns; per-run and finer-grained credit usage remain unknown." + }, + "pendingQualification": [ + "Product local remaining semantic/edit/interactive cases", + "Product Daytona cases", + "restrictive permission denial before side effects", + "native background command settlement", + "warm continuation and restart recovery", + "post-fix accounting provenance and exact per-run credit reconciliation" + ] +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-product-merged-live-proof-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-product-merged-live-proof-2026-09-28.json new file mode 100644 index 0000000000..3a28eeab5d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-product-merged-live-proof-2026-09-28.json @@ -0,0 +1,149 @@ +{ + "schema": "paperclip.copilot-product-merged-live-proof/v1", + "providerVersion": "1.0.88", + "model": "gpt-5.6-luna", + "modelProvider": "github", + "qualificationStatus": "pending", + "environment": "local", + "activeTurnDeadlineSeconds": 120, + "outerDeadlineSeconds": 300, + "providerPackSourceRevision": "ee9536001fbe733b2386dd3379730a4e0be59488", + "providerPackDigest": "sha256:809ea6bef2fc1122ef214840d119f37854598007ff7449189027294b0802a681", + "productionLockSha256": "9c163adf40fe1c6a65abda6207bd9f5acfc8196bc94f81348b8bf1b2769e050e", + "runnerdBuildSourceRevision": "527ac0e31f45a613cba1f65c25b01547f176736d", + "runnerdRustTree": "8a7155529bf3d4c26947fdc4642badd6fbe96232", + "runnerdSha256": "sha256:dadafb10a6a02f2046798ae4104edc481d205e65724e0526a7f34ba50eb746d0", + "executionMode": "source-mode Product E2E with build-owned verified native assets; complete compiled pack retained separately", + "cases": [ + { + "caseId": "file-edit-validate", + "status": "passed", + "failureClass": null, + "durationMs": 59696, + "cleanup": "passed", + "startedAt": "2026-09-28T20:03:36.094Z", + "finishedAt": "2026-09-28T20:04:35.790Z", + "sourceRevision": "ee9536001fbe733b2386dd3379730a4e0be59488", + "attempt": 1, + "automaticRetries": 0, + "originalResultSha256": "sha256:33c29e5fa267c346bec408b22fa3bc84f9fe2069da25d3ba376683f7ca39d796", + "originalApiSnapshotSha256": "sha256:d91977a5578f4cff83b4204f315ed9d408f4dd5c4783a920c0dc04261e197afc", + "behaviorMatchersPassed": 7, + "behaviorMatchersTotal": 7, + "providerTurns": 1, + "providerReportedCostUsd": null, + "biller": "github", + "costStatus": "unpriced", + "costUsdFieldPresent": false, + "billingComplete": false, + "tokens": { + "inputTokens": 108583, + "cachedInputTokens": 91553, + "outputTokens": 2131 + }, + "validation": { + "fileBytes": 24, + "fileSha256": "sha256:df22c73c1e878f5b497e64921edac53ea77c4f81c64ba347ca436f8550e30183", + "shellComparisonExitCodeInNativeOutput": 0, + "normalizedTypedExitCode": null, + "note": "The native shell output proves the cmp validation completed with exit code0; typed exitCode remains absent, a meaningful field gap." + }, + "visualReview": "Downloadable file artifact, task Done, exact marker and Copilot activity are visible.", + "finalScreenshotSha256": "sha256:d3b0130c973860cb31ee2abbd3d33502218453d02a704c4b5eb8a086e81fa561", + "displayedIncludedCreditBaseline": 1, + "displayedIncludedCreditsAfter": 2 + }, + { + "caseId": "question-resume-complete", + "status": "failed", + "failureClass": "candidate_failure", + "durationMs": 102939, + "cleanup": "passed", + "startedAt": "2026-09-28T20:06:25.416Z", + "finishedAt": "2026-09-28T20:08:08.355Z", + "sourceRevision": "ee9536001fbe733b2386dd3379730a4e0be59488", + "attempt": 1, + "automaticRetries": 0, + "originalResultSha256": "sha256:0f88be06103195bd45f1d10a9e2a7fef270b77be8cafa1cbc352527c6aac7dcd", + "originalApiSnapshotSha256": "sha256:eb35620b00f1c5bdff3274520255cdfcda231a97a9531dd1da671263e09a2e60", + "behaviorMatchersPassed": 4, + "behaviorMatchersTotal": 6, + "providerTurns": 2, + "failureReason": "Provider emitted the literal placeholder [terminal marker] instead of the required exact terminal marker.", + "observedFinalText": "[terminal marker]", + "questionDelivered": true, + "answerOptionId": "cobalt", + "sameProviderSessionReused": true, + "continuation": { + "biller": "github", + "costStatus": "unpriced", + "costUsdFieldPresent": false, + "providerReportedCostUsd": null, + "tokens": { + "inputTokens": 47652, + "cachedInputTokens": 42912, + "outputTokens": 293 + } + }, + "pausedRunAccountingDefect": { + "biller": "github", + "costStatus": "reported", + "costUsdFieldPresent": false, + "allNormalizedTokenCountersZero": true, + "fixedSeparatelyBySource": "c276496e48e34be870a78fddb35bcf4f9b4e85f3" + }, + "originalEvidenceUnchanged": true, + "originalLogEvidence": [ + "run.result.proposed.summary", + "item.completed.text", + "run.result.accepted.result.summary" + ], + "visualReview": "Question options Cobalt/Amber visible; Cobalt answer and same-session continuation visible; final placeholder visible while task is Done.", + "questionScreenshotSha256": "sha256:73f119b3a6a5a51206b7157415084444b00248b02a3598ebb5e6f6a685270d97", + "finalScreenshotSha256": "sha256:9a6fc52c0b71003a0291a4a77796cf15b1e2c97060fe94d1a93876d3a0d98e29", + "displayedIncludedCreditBaseline": 2, + "displayedIncludedCreditsAfter": 3 + }, + { + "caseId": "plan-approve-complete", + "status": "failed", + "failureClass": "transient_infrastructure", + "durationMs": 9531, + "cleanup": "not_started", + "startedAt": "2026-09-28T20:13:18.966Z", + "finishedAt": "2026-09-28T20:13:28.497Z", + "sourceRevision": "c276496e48e34be870a78fddb35bcf4f9b4e85f3", + "attempt": 1, + "automaticRetries": 0, + "originalResultSha256": "sha256:e43690769d695e0a66dab205c17bb55054cf719191b4efe0ca51f07cc42884f8", + "providerTurns": 0, + "providerReportedCostUsd": null, + "failureReason": "Embedded PostgreSQL bootstrap exited1 before provider startup; concurrent host diagnostics confirmed macOS semaphore exhaustion.", + "qualificationEvidence": false, + "modelSpendUsd": 0 + } + ], + "restartCase": { + "status": "not_executed", + "reason": "Paused before launch because host semaphore capacity was exhausted." + }, + "externalBilling": { + "source": "parent verified GitHub billing dashboard after each executed case", + "displayedIncludedAiCreditsBefore": 1, + "displayedIncludedAiCreditsAfter": 3, + "displayedIncludedAiCreditsTotal": 1500, + "exactCreditConsumption": null, + "additionalUsageEnabled": false, + "additionalCashBudgetUsd": 0, + "additionalCashChargesUsd": 0, + "providerReportedCostUsd": null + }, + "remainingQualification": [ + "Exact question terminal behavior failed; no retry or grader weakening.", + "Plan behavior unverified after pre-provider infrastructure failure.", + "Controller restart case unexecuted.", + "Product Daytona cases.", + "Restrictive permissions and background settlement with actual GitHub inference.", + "Full isolation/governance matrix." + ] +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-product-plan-live-proof-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-product-plan-live-proof-2026-09-28.json new file mode 100644 index 0000000000..2bcb933e63 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-product-plan-live-proof-2026-09-28.json @@ -0,0 +1,103 @@ +{ + "schema": "paperclip.copilot-product-plan-live-proof/v1", + "qualificationStatus": "pending", + "sourceRevision": "c06fc5fccc88f5816450434493451b9d2d339125", + "providerPackSourceRevision": "8aa867b64d5fc2fd62cff110bd000addf5dc54de", + "providerPackDigest": "sha256:627992ea80e8be7154d07cbc9925b781519199e25690b02d4a044f44342e6bd8", + "runnerdBuildSourceRevision": "78360fa55adff11da9f200b228467eca782b81e1", + "runnerdSha256": "sha256:e6a9fb5170b76a49b8411834b3706e8edf8f1a1ae85ad13b55368158aa7f67a0", + "providerVersion": "1.0.88", + "model": "gpt-5.6-luna", + "environment": "local", + "caseId": "extended-harnesses.runner-acpx-copilot.local.plan-approve-complete", + "status": "passed", + "durationMs": 45625, + "activeTurnDeadlineSeconds": 120, + "outerDeadlineSeconds": 300, + "reservationUsd": 2, + "providerTurns": 2, + "automaticRetries": 0, + "priorAttempt": "Original pre-provider PostgreSQL bootstrap failure retained separately; this is one newly authorized manual attempt.", + "matcherResults": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "planApproval": { + "displayedRevisionNumber": 1, + "targetMatchesDisplayedRevision": true, + "status": "accepted", + "surface": "Paperclip semantic Plan document and request_confirmation card", + "nativeCopilotPlanCallback": false + }, + "continuation": { + "freshSession": true, + "sessionReused": false, + "reason": "Adapter config changed and forceFreshSession was requested; do not treat as warm-session evidence." + }, + "usage": [ + { + "biller": "github", + "costStatus": "unpriced", + "costUsdFieldPresent": false, + "inputTokens": 0, + "cachedInputTokens": 0, + "outputTokens": 0 + }, + { + "biller": "github", + "costStatus": "unpriced", + "costUsdFieldPresent": false, + "inputTokens": 49444, + "cachedInputTokens": 32654, + "outputTokens": 742 + } + ], + "providerReportedCostUsd": null, + "billingComplete": false, + "cleanup": "passed", + "retainedFixtureProcessesAfterCleanup": 0, + "visualReview": "Full Plan revision1, confirmation card, approved-plan message and exact terminal marker verified in browser screenshots.", + "originalResultSha256": "sha256:b6508a88e9f100e6d654a52cc755d691b0fcdfa49718cbddf7e57a1eb3edadb6", + "originalApiSnapshotSha256": "sha256:bf7f0f293f062dc7ec7c00daf64322b9ea785912d49513d63e1a7d81cfb5a75d", + "planScreenshotSha256": "sha256:3f5dd877e82b62f1e59836a4ad3b699ab9c567acf1b896a6499047aa31669c77", + "finalScreenshotSha256": "sha256:32940594fce3ba7c3f11c0d41f03c2f43595fa3e03f5ffb2e72f7e36ee4e690e", + "externalBilling": { + "baselineDisplayedIncludedAiCreditsUsed": 3, + "displayedIncludedAiCreditsTotal": 1500, + "postCaseReconciliation": "parent refreshed GitHub billing dashboard", + "exactCreditConsumption": null, + "additionalUsageEnabled": false, + "additionalCashBudgetUsd": 0, + "postCaseDisplayedIncludedAiCreditsUsed": 5, + "aggregateDisplayedCreditDelta": 2, + "aggregateCoverage": [ + "denied-write probe", + "detached-shell probe", + "two-turn Product plan" + ], + "additionalCashChargesUsd": 0, + "note": "The display was3 before these probes and the plan; delayed aggregate+2 cannot be allocated exactly among these calls." + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-product-restart-live-proof-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-product-restart-live-proof-2026-09-28.json new file mode 100644 index 0000000000..57f1b64ade --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-product-restart-live-proof-2026-09-28.json @@ -0,0 +1,119 @@ +{ + "schema": "paperclip.copilot-product-restart-live-proof/v1", + "qualificationStatus": "pending", + "sourceRevision": "19ca0f558d3aebddedc6ff14836ff3e71498e68e", + "providerPackSourceRevision": "8aa867b64d5fc2fd62cff110bd000addf5dc54de", + "providerPackDigest": "sha256:627992ea80e8be7154d07cbc9925b781519199e25690b02d4a044f44342e6bd8", + "runnerdBuildSourceRevision": "78360fa55adff11da9f200b228467eca782b81e1", + "runnerdSha256": "sha256:e6a9fb5170b76a49b8411834b3706e8edf8f1a1ae85ad13b55368158aa7f67a0", + "providerVersion": "1.0.88", + "model": "gpt-5.6-luna", + "environment": "local", + "caseId": "extended-harnesses.runner-acpx-copilot.local.structured-question-restart-resume", + "status": "passed", + "durationMs": 50875, + "activeTurnDeadlineSeconds": 120, + "outerDeadlineSeconds": 300, + "reservationUsd": 2, + "providerTurns": 2, + "automaticRetries": 0, + "matcherResults": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "question": { + "surface": "Paperclip semantic structured question", + "nativeCopilotAskUserCallback": false, + "optionIds": [ + "cobalt", + "amber" + ], + "answerOptionIds": [ + "cobalt" + ], + "status": "answered", + "serverRestartedBeforeAnswer": true, + "sameInteractionPreserved": true + }, + "continuation": { + "samePersistedProviderSession": true, + "freshSession": false, + "sessionReused": true, + "taskSessionReused": true, + "providerDeathRecoveryVerified": false + }, + "usage": [ + { + "biller": "github", + "costStatus": "unpriced", + "costUsdFieldPresent": false, + "inputTokens": 0, + "cachedInputTokens": 0, + "outputTokens": 0 + }, + { + "biller": "github", + "costStatus": "unpriced", + "costUsdFieldPresent": false, + "inputTokens": 49436, + "cachedInputTokens": 44075, + "outputTokens": 418 + } + ], + "providerReportedCostUsd": null, + "billingComplete": false, + "cleanup": "passed", + "retainedFixtureProcessesAfterCleanup": 0, + "visualReview": "The same pending Cobalt/Amber question remains visible after controller restart; board answer Cobalt and the exact final marker appear once with task Done.", + "scope": "Controller restart with preserved provider session, not provider-death reconstruction. This separate case does not erase the earlier question-resume-complete exact-marker failure.", + "externalBilling": { + "baselineDisplayedIncludedAiCreditsUsed": 5, + "displayedIncludedAiCreditsTotal": 1500, + "postCaseDisplayedIncludedAiCreditsUsed": 5, + "exactCreditConsumption": null, + "additionalUsageEnabled": false, + "additionalCashBudgetUsd": 0, + "postCaseReconciliation": "parent refreshed GitHub billing dashboard", + "displayedCreditDelta": 0, + "additionalCashChargesUsd": 0, + "note": "The unchanged display cannot establish zero included-credit consumption because of billing delay and precision. Provider USD remains unknown." + }, + "originalResultSha256": "sha256:b5e4df160fa06f38c8b5a3352434d56f082b14415ded018c8bf98a50a8a9e471", + "originalApiSnapshotSha256": "sha256:b1bce4e79886c42a24181f69ca665024a6931f521cafce6786417f78e0573e8a", + "screenshots": [ + { + "id": "question-pending", + "sha256": "sha256:5373069675865fb6fd3f4ba25bc1b095427d5ae5d22b5f0fdcee87744b0d17e2" + }, + { + "id": "question-pending-after-server-restart", + "sha256": "sha256:f66116764bed2e78d7d0e0fcb41c5f7be8b422876323710ae12e78f2a13a28e5" + }, + { + "id": "final-state", + "sha256": "sha256:7d33eb7320ccc98d5d96d435982932f0b9238cdba497fd2f35cb41674d3d4db7" + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-product-v3-question-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-product-v3-question-2026-09-29.json new file mode 100644 index 0000000000..7687a9510b --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-product-v3-question-2026-09-29.json @@ -0,0 +1,78 @@ +{ + "schema": "paperclip.copilot-product-v3-question.v1", + "centralReservationId": "copilot-v3-local-question-20260929-01", + "attempt": "copilot-final-question-20260929t151802z", + "model": "gpt-5.6-luna", + "providerVersion": "1.0.88", + "profileVersion": 3, + "build": { + "sourceRevision": "3d0c45920c326821c2f5ff48dc985ef8158b46e4", + "sourceRoot": "/private/tmp/paperclip-copilot-frozen-3d0c45920/source", + "sourceArchiveSha256": "7f802e5fed2b5dede8b6bfc23affafb7f2c542e98dc09b5e3d111ede0d2e33c7", + "originalLockSha256": "e0c928a494f90ddad3c00791e83f09315ee8c82df2a0418a809dcf93649a8ab3", + "resolvedLockSha256": "60ae61439844519b104cdf014f0f7b7972804cd1c04a10a8a8777cc8da7dc2f2", + "actualControllerPackageRoot": "/private/tmp/paperclip-copilot-frozen-3d0c45920/source/packages/paperclip-runner", + "providerPackDigest": "sha256:977d464241d8a8f36bc879fd1cdddf1e675f72fd6f1f2d3fe2f8e273fafc160e", + "nodeBinary": "/private/tmp/paperclip-pi-v5-node-20260928/darwin-arm64/node", + "nodeSha256": "e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b", + "runnerBinary": "/private/tmp/paperclip-copilot-frozen-3d0c45920/paperclip-runnerd", + "runnerSha256": "2b51c48198c3724255756241cddc1b5efea87dab1c49a6ef7eb897513c7c043b", + "sidecarSha256": "7cfee6ca612b3d7bcc266fdd36f2e77d7bb027169c56e0c9c36b9676232f33a4", + "acpxPatchSha256": "a64405199b381688ffd72fdbb88396e423394cb407813d7b6f1db43bae9c3933", + "frozenBuildRecordSha256": "0a756acca0cc44534465757c4dd01b7039fbe9320a613c7df0e373f96bad7644" + }, + "status": "failed", + "failureClass": "candidate_failure", + "failureStage": "native session recovery after answered question", + "diagnosis": "Product retained an unclassified session.open rejection. Credential-free exact-native recovery reproduces ENOENT when the persisted descriptor references the collected prior-run instruction directory, and succeeds with the current registered directory. This is the supported cause, not directly retained Product wire evidence. No paid retry performed.", + "durationMs": 72086, + "outerDeadlineSeconds": 300, + "activeProviderDeadlineSeconds": 120, + "automaticRetries": 0, + "cleanup": "passed", + "runs": [ + { + "status": "failed", + "providerUsage": null, + "errorCode": "native_session_interrupted" + }, + { + "status": "succeeded", + "providerUsage": { + "provider": "github", + "model": "gpt-5.6-luna", + "costStatus": "unpriced", + "inputTokens": 28545, + "outputTokens": 525, + "cachedInputTokens": 13818, + "sessionReused": false + }, + "errorCode": null + } + ], + "interactionStatuses": [ + { + "kind": "ask_user_questions", + "status": "answered" + } + ], + "providerCostUsd": null, + "upstreamModelRequestCount": null, + "githubReconciliation": { + "baselineIncludedCredits": 5, + "afterIncludedCredits": 6, + "accountCreditDelta": 1, + "includedCreditTotal": 1500, + "additionalUsageEnabled": false, + "additionalCashBudgetUsd": 0, + "accountCashChargesUsd": 0, + "snapshotSource": "Lead observed refreshed GitHub billing UI after attempt, approximately 2026-09-29 15:24 UTC", + "recordedAt": "2026-09-29T15:26:24.763193+00:00", + "perRunUsdAllocation": null + }, + "evidence": { + "result.json": "e67192baff115744fdb122f275e3e1f9d0405898327d984986ed774545ebfef4", + "snapshots/api-state.json": "044f6d79d1c99a4fb4da89e80b82f6ac2de0c7f536148a5f6d701b149f76d6a3", + "evidence-manifest.json": "6da9b164991c6c4b0005f9e1ad94c790018254c5c78be6049111c0a4ff7d695d" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v10-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v10-identity.json new file mode 100644 index 0000000000..a4fb257201 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v10-identity.json @@ -0,0 +1,33 @@ +{ + "commandDigest": "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 10, + "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.v1", + "semanticReceiptSourceSha256": "3d3d0fe5ce0e4dbfc78cf95b1bfcff4d4b3760c548e74c6c12db5bcacdd46ae2", + "semanticBridgeSourceSha256": "ab8f872344b2beb50e3f9970e0631c451a5493e66d259582ecc9a214624269b5", + "toolEvidenceSourceSha256": "f0a93aab9940051972028d6f2610b4018a5a0d6b2326b7c61b767b38442629ca" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json new file mode 100644 index 0000000000..12ddfd88ca --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json @@ -0,0 +1,42 @@ +{ + "commandDigest": "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 11, + "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": "a261497560840da66613a5f4a3dafeba05e53d3a8647c180c9604b3cf983dc23", + "semanticDirectDriverSourceSha256": "31e36917ee3592fa6d8f4b86632129fdffae20c45aed2e0e367ccf523e064f88", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2", + "semanticSidecarProtocolSourceSha256": "165cad76f224d60f0dc7928736d47a16787eaa8ab9e4c2222488da5fc8ad632e" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json new file mode 100644 index 0000000000..82b2ad0f4d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 12, + "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": "d58076d59ec23fe2261cba3aa0dca3e9f189105f0a5fa7ca3eeaac05cb338f31", + "semanticDirectDriverSourceSha256": "31e36917ee3592fa6d8f4b86632129fdffae20c45aed2e0e367ccf523e064f88", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2", + "semanticSidecarProtocolSourceSha256": "165cad76f224d60f0dc7928736d47a16787eaa8ab9e4c2222488da5fc8ad632e", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} 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/copilot-profile-v17-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v17-identity.json new file mode 100644 index 0000000000..a35bae2055 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v17-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 17, + "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": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fe82ac2cbf194d31ac801de6a3cacfb165cb34146c5c26557f283b8c87a5f01e", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json new file mode 100644 index 0000000000..d5e125b240 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json @@ -0,0 +1,15 @@ +{ + "commandDigest": "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 3, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "a64405199b381688ffd72fdbb88396e423394cb407813d7b6f1db43bae9c3933", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", + "agentFilesBinding": "registered-runtime-context-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v4-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v4-identity.json new file mode 100644 index 0000000000..41415e3bac --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v4-identity.json @@ -0,0 +1,16 @@ +{ + "commandDigest": "sha256:a119e436b4ad13ee70e1f58bedad4a0f20761b5fd982b752981f0cb1bc65f797", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 4, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "64180c194ab841d6f4bba7e6eca5ead621c6222c9a8057e4bb2bb6f007d8bb3c", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v5-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v5-identity.json new file mode 100644 index 0000000000..3fea7be092 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v5-identity.json @@ -0,0 +1,16 @@ +{ + "commandDigest": "sha256:ece77e40876631a69a828b91813722001d71b6fc81be47ecd4b3dced84ff9473", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 5, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v6-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v6-identity.json new file mode 100644 index 0000000000..840c089fa4 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v6-identity.json @@ -0,0 +1,17 @@ +{ + "commandDigest": "sha256:0fe49c2f8a2b144344d0de745fed1e4568df305de3a26c3a121dec21c8d4b751", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 6, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v7-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v7-identity.json new file mode 100644 index 0000000000..9385e88e31 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v7-identity.json @@ -0,0 +1,22 @@ +{ + "commandDigest": "sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 7, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "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" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v8-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v8-identity.json new file mode 100644 index 0000000000..b1bc246208 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v8-identity.json @@ -0,0 +1,22 @@ +{ + "commandDigest": "sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 8, + "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" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v9-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v9-identity.json new file mode 100644 index 0000000000..88aea29da2 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v9-identity.json @@ -0,0 +1,28 @@ +{ + "commandDigest": "sha256:98936d763497bd6f0e5605f52831a344f456357de58c457e440e69a1a468a2f4", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 9, + "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": "2bc34ac7899ac2729909459321e5a8a3668342cc61921e8c95e672719c6d91f2", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-provider-pack-darwin-arm64-1.0.88.json b/packages/paperclip-runner/test/fixtures/copilot-provider-pack-darwin-arm64-1.0.88.json new file mode 100644 index 0000000000..e8f0fba3a4 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-provider-pack-darwin-arm64-1.0.88.json @@ -0,0 +1,67 @@ +{ + "schema": "paperclip.copilot-provider-pack-smoke/v1", + "sourceRevision": "5272fc6398d42344d1888a3f97ca6909684eefbf", + "providerPackDigest": "sha256:4ba17b2455b0ab92cfe6ee223f77708379fe219792ccb66dbdef70060e6e22e1", + "platform": "darwin", + "architecture": "arm64", + "candidate": { + "version": "1.0.88", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "closureDigest": "sha256:fb3b367a45cd76122fe931521fa2a18adf234ba944fc302db9e10e005e57037e", + "qualification": "pending", + "path": "provider-assets/copilot/darwin-arm64", + "sha256": "sha256:49ad871973843e2bfc3ee1d1886f6a1537e887748cc6f3d5148b3ec7f3c7298f" + }, + "executableSha256": "sha256:a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "registryLaunch": "verifyAcpxProfileInstallation/openCommand/spawn", + "initializeRequestId": 0, + "initialize": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": true + }, + "sessionCapabilities": { + "close": {}, + "list": {} + } + }, + "agentInfo": { + "name": "Copilot", + "title": "Copilot", + "version": "1.0.88" + }, + "authMethods": [ + { + "id": "copilot-login", + "name": "Log in with Copilot CLI", + "description": "Run `copilot login` in the terminal", + "_meta": { + "terminal-auth": { + "command": "/fixture/verified/copilot", + "args": [ + "login" + ], + "label": "Copilot Login" + } + } + } + ] + }, + "missingCredentialPreflight": "COPILOT_AUTH_REQUIRED", + "cleanExit": true, + "inheritedCredentials": false, + "promptSent": false, + "networkMode": "COPILOT_OFFLINE=true; loopback metadata-only provider", + "fixtureRequests": [], + "protocolFixtureModel": "gpt-4.1 (not a qualified GitHub model)", + "costUsd": 0, + "qualification": "pending: no credential, entitlement, model execution or Daytona" +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-provider-pack-final-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-provider-pack-final-2026-09-28.json new file mode 100644 index 0000000000..0da980bf16 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-provider-pack-final-2026-09-28.json @@ -0,0 +1,86 @@ +{ + "schema": "paperclip.copilot-provider-pack-smoke/v1", + "sourceRevision": "8aa867b64d5fc2fd62cff110bd000addf5dc54de", + "providerPackDigest": "sha256:627992ea80e8be7154d07cbc9925b781519199e25690b02d4a044f44342e6bd8", + "platform": "darwin", + "architecture": "arm64", + "candidate": { + "version": "1.0.88", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "closureDigest": "sha256:fb3b367a45cd76122fe931521fa2a18adf234ba944fc302db9e10e005e57037e", + "qualification": "pending", + "path": "provider-assets/copilot/darwin-arm64", + "sha256": "sha256:49ad871973843e2bfc3ee1d1886f6a1537e887748cc6f3d5148b3ec7f3c7298f" + }, + "executableSha256": "sha256:a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "registryLaunch": "verifyAcpxProfileInstallation/openCommand/spawn", + "initializeRequestId": 0, + "initialize": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": true + }, + "sessionCapabilities": { + "close": {}, + "list": {} + } + }, + "agentInfo": { + "name": "Copilot", + "title": "Copilot", + "version": "1.0.88" + }, + "authMethods": [ + { + "id": "copilot-login", + "name": "Log in with Copilot CLI", + "description": "Run `copilot login` in the terminal", + "_meta": { + "terminal-auth": { + "command": "/fixture/verified/copilot", + "args": [ + "login" + ], + "label": "Copilot Login" + } + } + } + ] + }, + "missingCredentialPreflight": "COPILOT_AUTH_REQUIRED", + "cleanExit": true, + "inheritedCredentials": false, + "promptSent": false, + "networkMode": "COPILOT_OFFLINE=true; loopback metadata-only provider", + "fixtureRequests": [], + "protocolFixtureModel": "gpt-4.1 (not a qualified GitHub model)", + "costUsd": 0, + "qualification": "pending: no credential, entitlement, model execution or Daytona", + "dependencyResolution": { + "source": "committed workspace manifests and tracked lock", + "independentCleanResolutions": 2, + "identical": true, + "resolvedLockSha256": "sha256:aa97f89ba8a7c63573114dda54895523d316df67956c65eeccbc89d3166bb1b4", + "frozenFilteredInstallPassed": true + }, + "runnerd": { + "buildSourceRevision": "78360fa55adff11da9f200b228467eca782b81e1", + "rustTree": "17d327094be5759d80ef5bd02e15c59e78548ea2", + "sha256": "sha256:e6a9fb5170b76a49b8411834b3706e8edf8f1a1ae85ad13b55368158aa7f67a0", + "sourceTreeMatchesPackRevision": true, + "liveTurnAtThisRevision": false + }, + "verification": { + "runnerTypeScriptAndVerifiedSidecarBuild": "passed", + "providerAndContractTestsPassed": 68, + "builderAndScriptTestsPassed": 11 + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-provider-pack-linux-x64-2026-09-28.json b/packages/paperclip-runner/test/fixtures/copilot-provider-pack-linux-x64-2026-09-28.json new file mode 100644 index 0000000000..6916b197fb --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-provider-pack-linux-x64-2026-09-28.json @@ -0,0 +1,88 @@ +{ + "schema": "paperclip.copilot-provider-pack-smoke/v1", + "sourceRevision": "8aa867b64d5fc2fd62cff110bd000addf5dc54de", + "providerPackDigest": "sha256:b11984fe02bd5d5a36b01ed559d2f4c765663df09a46117d3092b1183be08ba4", + "platform": "linux", + "architecture": "x64", + "candidate": { + "version": "1.0.88", + "profileDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "closureDigest": "sha256:1a675c5b54ae4d94f08718a318451e0499708ded388b4cfd98acec6b4311ccbd", + "qualification": "pending", + "path": "provider-assets/copilot/linux-x64", + "sha256": "sha256:e9413d46e5feeec5e7708b3092af02399319342531345fdfdccb4f0853cd87cc" + }, + "executableSha256": "sha256:0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748", + "registryLaunch": "verifyAcpxProfileInstallation/openCommand/spawn", + "initializeRequestId": 0, + "initialize": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "image": true, + "audio": false, + "embeddedContext": true + }, + "sessionCapabilities": { + "close": {}, + "list": {} + } + }, + "agentInfo": { + "name": "Copilot", + "title": "Copilot", + "version": "1.0.88" + }, + "authMethods": [ + { + "id": "copilot-login", + "name": "Log in with Copilot CLI", + "description": "Run `copilot login` in the terminal", + "_meta": { + "terminal-auth": { + "command": "/fixture/verified/copilot", + "args": [ + "login" + ], + "label": "Copilot Login" + } + } + } + ] + }, + "missingCredentialPreflight": "COPILOT_AUTH_REQUIRED", + "cleanExit": true, + "inheritedCredentials": false, + "promptSent": false, + "networkMode": "COPILOT_OFFLINE=true; loopback metadata-only provider", + "fixtureRequests": [], + "protocolFixtureModel": "gpt-4.1 (not a qualified GitHub model)", + "costUsd": 0, + "qualification": "pending: no credential, entitlement, model execution or Daytona", + "executionBoundary": { + "kind": "Linux image initialize-only build proof", + "image": "ghcr.io/paperclipai/paperclip-daytona-runner@sha256:5457769683fd310223d3b0d4f1ed9a6cf341bdb16514746b3aaeabca2e888fee", + "network": "none", + "rootFilesystem": "read-only", + "writableState": "private tmpfs", + "providerCredentials": false, + "probeScript": "maintained copilot-provider-pack-smoke.mjs mounted read-only", + "actualDaytonaServiceExecution": false + }, + "originalOutputSha256": "sha256:46cfc84ce0d180fdaf5b75730683dffc65af4506bfaf8fdd07dd496186a7bec8", + "retainedOperatorSetupFailures": [ + { + "reason": "wrong image repository", + "providerLaunched": false + }, + { + "reason": "wrong Node path", + "providerLaunched": false + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-runtime-context-recovery-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-runtime-context-recovery-2026-09-29.json new file mode 100644 index 0000000000..2e88c783d0 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-runtime-context-recovery-2026-09-29.json @@ -0,0 +1,248 @@ +{ + "schema": "paperclip.copilot-runtime-context-recovery.v1", + "recordedAt": "2026-09-29T15:32:50.799156+00:00", + "sourceRevision": "3d0c45920c326821c2f5ff48dc985ef8158b46e4", + "providerVersion": "1.0.88", + "providerBinarySha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "paidProviderCalls": 0, + "loopbackModelResponses": 2, + "fixtureMechanism": "env -i; synthetic token; COPILOT_OFFLINE=true; loopback OpenAI fixture. Exact native binary and actual AcpxRuntimeHost; second explicit turn forces lazy native session/load. Test-only gpt-4.1 model option injected into guard metadata because offline native initialization omits model options; actual native mode/allow_all remain unchanged. Not authenticated model qualification.", + "modelMetadataFixtureInjected": true, + "probeScriptSha256": "e2cf49568c42d8cb0dc3404cd263adfebac5507a6e694faa8f6db948c85ba4e1", + "rawPrivateWireSha256": "7d19d4f7dc0c2ad4265a6bd17bb8f25f572b31e409c7745279529b6fc4d9840d", + "steps": [ + "Open native provider with old registered agent-files directory", + "Run one deterministic loopback response", + "Close native host, delete old directory", + "Reopen with old context: ENOENT at bindAcpxAgentFiles lstat", + "Reopen with current registered directory: passes with original native session ID", + "Start a second explicit fixture turn: native session/load observed, then session/prompt completes end_turn", + "Close host; exactly two requested fixture turns and two loopback model responses" + ], + "staleContextError": { + "code": "ENOENT", + "phase": "reopen", + "operation": "lstat", + "path": "/agent-copy-old" + }, + "restoredSessionIdentityUnchanged": true, + "restoredPhase": "reopen-current-copy", + "wireProjection": [ + { + "direction": "outbound", + "id": 0, + "method": "initialize" + }, + { + "direction": "inbound", + "id": 0 + }, + { + "direction": "outbound", + "id": 1, + "method": "session/new" + }, + { + "direction": "inbound", + "id": 1, + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "available_commands_update" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "available_commands_update" + }, + { + "direction": "outbound", + "id": 2, + "method": "session/prompt" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "config_option_update" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "session_info_update" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "usage_update" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "agent_message_chunk" + }, + { + "direction": "inbound", + "id": 2 + }, + { + "direction": "outbound", + "id": 0, + "method": "initialize" + }, + { + "direction": "inbound", + "id": 0 + }, + { + "direction": "outbound", + "id": 1, + "method": "session/load" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "user_message_chunk" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "agent_message_chunk" + }, + { + "direction": "inbound", + "id": 1, + "configOptions": [ + { + "type": "select", + "id": "mode", + "name": "Mode", + "currentValue": "https://agentclientprotocol.com/protocol/session-modes#agent", + "options": [ + { + "value": "https://agentclientprotocol.com/protocol/session-modes#agent", + "name": "Agent", + "description": "Default agent mode for conversational interactions" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#plan", + "name": "Plan", + "description": "Plan mode for creating and executing multi-step plans" + }, + { + "value": "https://agentclientprotocol.com/protocol/session-modes#autopilot", + "name": "Autopilot", + "description": "Autonomous mode that enables allow-all and runs until task completion without user interaction (experimental)" + } + ], + "category": "mode", + "description": "Controls how Copilot responds: a conversational agent, planning multi-step work, or autonomous autopilot." + }, + { + "type": "select", + "id": "allow_all", + "name": "Allow All", + "currentValue": "off", + "options": [ + { + "value": "on", + "name": "On", + "description": "Automatically approve all tool, path, and URL requests" + }, + { + "value": "off", + "name": "Off", + "description": "Require approval for tool, path, and URL requests" + } + ], + "category": "permissions", + "description": "Controls whether Copilot prompts for approval before using tools, accessing paths, or fetching URLs." + } + ] + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "available_commands_update" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "available_commands_update" + }, + { + "direction": "outbound", + "id": 2, + "method": "session/prompt" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "usage_update" + }, + { + "direction": "inbound", + "method": "session/update", + "sessionUpdate": "agent_message_chunk" + }, + { + "direction": "inbound", + "id": 2 + } + ], + "limits": [ + "This reproduces the suspected Product recovery cause; failed Product cleanup did not retain raw session.open cause.", + "Live provider context replacement and root retention until active work settles are distinct from closed-provider restoration." + ], + "continuationResult": { + "status": "completed", + "stopReason": "end_turn" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-v4-native-instruction-delivery-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-v4-native-instruction-delivery-2026-09-29.json new file mode 100644 index 0000000000..934a63c35c --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-v4-native-instruction-delivery-2026-09-29.json @@ -0,0 +1,61 @@ +{ + "schema": "paperclip.copilot-v4-native-instruction-delivery.v1", + "date": "2026-09-29", + "candidateBaseRevision": "751f59f26ce401b0737f141167ffd895180e07fa", + "profileIdentity": { + "commandDigest": "sha256:e7f8416b1119895c8c79307b95fc0e46312cc4821abee2a691f26c3d67f282af", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 4, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "a64405199b381688ffd72fdbb88396e423394cb407813d7b6f1db43bae9c3933", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-before-admission:v1" + } + }, + "sourceSha256": { + "packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts": "897669e02875b7b53cabcbb95f86fbf7be3277c325e8a9bf5a7b4d0bcdffd389", + "packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts": "0ff47a0394b2c72bdc1bdd318b684b16efc3f2fbf648002da139d6fb9c2bf992", + "packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts": "38a91b69773e1faab4cbe060e1ae991f8fd1233a7e241bb149063a2bd0dfb5da", + "packages/paperclip-runner/src/drivers/acpx/runtime-host.ts": "bffd96aa6467f9b7e80de7e0699b935f488f203530c6217045ef44d094ddd61c" + }, + "nativeVersion": "1.0.88", + "nativeBinarySha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "actualProductionSandboxWriter": true, + "providerMode": "agent", + "allowAll": "off", + "fixtureMechanism": "env -i; synthetic credential; COPILOT_OFFLINE=true; exactly two requested turns to loopback OpenAI fixture. Imports actual candidate TypeScript runtime host/sandbox/adapter via tsx, with already verified patched ACPX dependency from frozen 3d0 archive. No manual instruction file write. Test-only offline model metadata injection remains; not authenticated model qualification or final frozen-pack proof.", + "loopbackModelResponses": 2, + "paidProviderCalls": 0, + "nativeLoadObserved": true, + "staleDirectoryFailurePreserved": "ENOENT", + "continuationResult": { + "status": "completed", + "stopReason": "end_turn" + }, + "modelRequestProjection": [ + { + "request": 1, + "paperclipPromptInSystem": true, + "oldRegisteredRootInSystem": true, + "currentRegisteredRootInSystem": false, + "originalCustomEntryInSystem": true, + "updatedCustomEntryInSystem": false + }, + { + "request": 2, + "paperclipPromptInSystem": true, + "oldRegisteredRootInSystem": false, + "currentRegisteredRootInSystem": true, + "originalCustomEntryInSystem": false, + "updatedCustomEntryInSystem": true + } + ], + "rawPrivateEvidenceSha256": "02163cfff37ac96ec1775ae716122f32f1da960d0ba36be2dd94051615d1e4b4", + "probeScriptSha256": "b8434b98e0659efeab82dd5885740e2c85c6e1669edb09c74c3a9d41bb25bc13" +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-v4-owned-instruction-delivery-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-v4-owned-instruction-delivery-2026-09-29.json new file mode 100644 index 0000000000..cea4eed442 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-v4-owned-instruction-delivery-2026-09-29.json @@ -0,0 +1,69 @@ +{ + "schema": "paperclip.copilot-v4-owned-instruction-delivery.v1", + "date": "2026-09-29", + "candidateBaseRevision": "0f33a9080ab05f92b909261a20d79ca487889346", + "profileIdentity": { + "commandDigest": "sha256:ce58142849d196faee8898f34c7149b4fbeb7b09966bb523b9cdf39c2ff66a6f", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 4, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "a64405199b381688ffd72fdbb88396e423394cb407813d7b6f1db43bae9c3933", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1" + } + }, + "sourceSha256": { + "packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts": "d78b859d170b931b8fc310577e6d1d01a1c20f168ad48c5de84bca2ea325aad9", + "packages/paperclip-runner/src/drivers/acpx/copilot-profile.ts": "b74133bd72f92d6bd0652c1772a45227f6a549232554528fef2761b17b8f02e7", + "packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts": "d1f788e475b420cb247484709ea09416261676e611ce9cb439e989e804e716f9", + "packages/paperclip-runner/src/drivers/acpx/runtime-host.ts": "229dd5cbbc3ea22dcda8bb10dc7b6096324b713e33ebd52bd6f691e35ceff24b" + }, + "nativeVersion": "1.0.88", + "nativeBinarySha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "actualProductionSandboxWriter": true, + "providerMode": "agent", + "allowAll": "off", + "fixtureMechanism": "env -i; synthetic credential; COPILOT_OFFLINE=true; exactly two requested turns to loopback OpenAI fixture. Imports actual candidate TypeScript runtime host/sandbox/adapter via tsx, with already verified patched ACPX dependency from frozen 3d0 archive. No manual instruction file write. Test-only offline model metadata injection remains; not authenticated model qualification or final frozen-pack proof.", + "loopbackModelResponses": 2, + "paidProviderCalls": 0, + "nativeLoadObserved": true, + "staleDirectoryFailurePreserved": "ENOENT", + "continuationResult": { + "status": "completed", + "stopReason": "end_turn" + }, + "modelRequestProjection": [ + { + "request": 1, + "paperclipPromptInSystem": true, + "oldRegisteredRootInSystem": true, + "currentRegisteredRootInSystem": false, + "originalCustomEntryInSystem": true, + "updatedCustomEntryInSystem": false + }, + { + "request": 2, + "paperclipPromptInSystem": true, + "oldRegisteredRootInSystem": false, + "currentRegisteredRootInSystem": true, + "originalCustomEntryInSystem": false, + "updatedCustomEntryInSystem": true + } + ], + "rawPrivateEvidenceSha256": "880c9dc05686c47c3afcbb351b3cb8d3c9b94db8165e873af06a07e97605d58e", + "probeScriptSha256": "ae433c1415514a97157e80e7196d99550294e49ffba8149176cb3307d5b303a8", + "instructionRefreshUnderLifetimeLease": true, + "priorAdmissionFailure": { + "stage": "provider lifetime lease acquisition", + "reason": "kernel-port quorum admission rejected before model call", + "loopbackModelResponses": 0, + "paidProviderCalls": 0, + "rawPrivateEvidenceSha256": "d20d6d412e7ffe441670fd0df937a33e4f35fe8e3fb9c0e9a9b91fc265b5d814" + } +} 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 new file mode 100644 index 0000000000..6d1dce690a --- /dev/null +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -0,0 +1,374 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { createRequire, stripTypeScriptTypes } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +// Optional test-only package location permits the isolated Pi branch to test +// before the integration owner updates package.json and the shared lockfile. +const packageJson = process.env.PAPERCLIP_TEST_PI_ACP_PACKAGE + ?? createRequire(import.meta.url).resolve("pi-acp/package.json"); +const packageRoot = dirname(packageJson); +const packageMetadata = JSON.parse(await readFile(packageJson, "utf8")); +const packageSource = await readFile(join(packageRoot, "dist/index.js"), "utf8"); +const helperSource = await readFile(join(packageRoot, "dist/paperclip-runtime.js"), "utf8"); +const localHelper = await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"); +const normalize = (source) => source.split("\n").map((line) => line.trimEnd()).join("\n"); + +test("Pi patch embeds the reviewed helper and pins exact upstream version", () => { + assert.equal(packageMetadata.version, "0.0.33"); + assert.equal(normalize(helperSource), normalize(stripTypeScriptTypes(localHelper))); + assert.match(packageSource, /createPiLaunchSpec\(params, process\.env\)/); + assert.match(packageSource, /shell: false/); + assert.match(packageSource, /snapshotPiWorkspaceFile\(this\.cwd, abs\)/); +}); + +async function fixture(t, extra = {}) { + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-acp-contract-"))); + await mkdir(join(root, "workspace")); await mkdir(join(root, "agent")); + await writeFile(join(root, "extension.js"), "export default function() {}\n"); + const fakePi = fileURLToPath(new URL("../test-fixtures/pi-acp/fake-pi.mjs", import.meta.url)); + const child = spawn(process.execPath, [join(packageRoot, "dist/index.js")], { + cwd: join(root, "workspace"), stdio: "pipe", + env: { + PATH: process.env.PATH, HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"), + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0", + PAPERCLIP_PI_NODE_EXECUTABLE: await realpath(process.execPath), + PAPERCLIP_PI_ENTRYPOINT: fakePi, PAPERCLIP_PI_EXTENSION_PATH: join(root, "extension.js"), + ...extra, + }, + }); + let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; }); + const pending = new Map(); const notifications = []; const requests = []; + let id = 0; let answer = async (request) => request.method === "session/request_permission" + ? { outcome: { outcome: "selected", optionId: "allow_once" } } + : { action: "accept", content: { answer: "Paperclip" } }; + const send = (value) => child.stdin.write(JSON.stringify(value) + "\n"); + const receive = (line) => { + const message = JSON.parse(line); + if (message.method && Object.hasOwn(message, "id")) { + requests.push(message); + void answer(message).then((result) => send({ jsonrpc: "2.0", id: message.id, result })); + } else if (Object.hasOwn(message, "id")) { + const waiter = pending.get(message.id); pending.delete(message.id); + if (message.error) waiter?.reject(new Error(JSON.stringify(message.error))); else waiter?.resolve(message.result); + } else notifications.push(message); + }; + const decoder = new StringDecoder("utf8"); let buffered = ""; + child.stdout.on("data", (chunk) => { + buffered += decoder.write(chunk); + for (;;) { const at = buffered.indexOf("\n"); if (at < 0) break; const line = buffered.slice(0, at); buffered = buffered.slice(at + 1); if (line.trim()) receive(line); } + }); + child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`wrapper exited: ${stderr}`)); pending.clear(); }); + const call = (method, params) => new Promise((resolve_, reject) => { + const requestId = ++id; + const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`timeout: ${method}: ${stderr}`)); }, 5000); + pending.set(requestId, { resolve: (value) => { clearTimeout(timer); resolve_(value); }, reject: (error) => { clearTimeout(timer); reject(error); } }); + send({ jsonrpc: "2.0", id: requestId, method, params }); + }); + t.after(async () => { + child.stdin.end(); + if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await once(child, "exit"); clearTimeout(timer); } + await rm(root, { recursive: true, force: true }); + }); + const initialized = await call("initialize", { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } }); + return { call, notify(method, params) { send({ jsonrpc: "2.0", method, params }); }, initialized, root, notifications, requests, setAnswer(value) { answer = value; } }; +} + +test("actual patched ACP process streams thinking and waits for settlement with usage", async (t) => { + const f = await fixture(t); + assert.equal(f.initialized.agentCapabilities._meta.paperclipPi.steering, true); + assert.deepEqual(f.initialized.authMethods, []); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }); + assert.equal(result.stopReason, "end_turn"); assert.equal(result.usage.inputTokens, 11); assert.equal(result.usage.totalTokens, 16); + assert.ok(f.notifications.some((event) => event.params?.update?.sessionUpdate === "agent_thought_chunk")); + assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "hello🌒\u2028world")); +}); + +for (const scenario of ["narration-final", "tool-only", "empty-final"]) test(`actual patched wrapper preserves native message provenance for ${scenario}`, async t => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + for (let warm = 0; warm < 2; warm++) { + const from = f.notifications.length; + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: scenario }] }); + const updates = f.notifications.slice(from).map(event => event.params?.update).filter(update => update?.sessionUpdate === "agent_message_chunk"); + assert.ok(updates.every(update => /^pi-message-[a-f0-9]{64}$/.test(update.messageId))); + assert.equal(updates[0]._meta.kind, "start"); assert.equal(updates[0].content.text, ""); + assert.equal(updates[1].content.text, "Calling finish."); + assert.equal(updates[2]._meta.kind, "end:toolUse"); + if (scenario === "tool-only") assert.equal(updates.length, 3); + else { + assert.equal(updates[3]._meta.kind, "start"); assert.notEqual(updates[3].messageId, updates[0].messageId); + assert.equal(updates[4].content.text, scenario === "empty-final" ? "" : "EXACT_MARKER"); + assert.equal(updates[5]._meta.kind, "end:stop"); + } + } +}); + +for (const scenario of ["missing-message-start", "duplicate-message-start", "mismatched-message-end", "missing-message-end"]) test(`actual wrapper rejects malformed native boundaries: ${scenario}`, async t => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: scenario }] }), /exited|Internal error/); + const failure = f.notifications.find(event => event.method === "paperclip/pi_notice" && event.params.category === "runtime_failure"); + assert.ok(failure, "native failure notice is flushed before prompt rejection"); + assert.ok(["rpc_delta_boundary_invalid", "assistant_boundary_invalid"].includes(failure.params.details.reason)); + assert.equal(failure.params.severity, "error"); +}); + +test("actual patched ACP process scopes recycled native IDs across iterations and warm prompts", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + for (let turn = 0; turn < 2; turn++) await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "reused-tool-ids" }] }); + const updates = f.notifications.map(event => event.params?.update).filter(update => update?.toolCallId); + const starts = updates.filter(update => update.sessionUpdate === "tool_call"); + assert.equal(starts.length, 6); + assert.equal(new Set(starts.map(update => update.toolCallId)).size, 6, "each model iteration has an independent tool identity"); + for (const start of starts) { + assert.match(start.toolCallId, /^pi-[a-f0-9]{64}$/); + const lifecycle = updates.filter(update => update.toolCallId === start.toolCallId); + assert.ok(lifecycle.some(update => update.status === "in_progress")); + assert.equal(lifecycle.filter(update => ["failed", "completed"].includes(update.status)).length, 1); + assert.ok(lifecycle.every(update => update._meta.paperclipPi.nativeToolCallId === "call_0")); + } + assert.deepEqual(starts.map(update => update._meta.paperclipPi.modelIteration), [1, 2, 3, 5, 6, 7]); +}); + +test("cancellation closes the iteration before a warm prompt reuses native IDs", async (t) => { + const f = await fixture(t); 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" }] }); + // The acknowledged native control is a FIFO barrier proving prompt admission. + await f.call("pi/steer", { sessionId: session.sessionId, message: "fixture admission fence" }); + f.notify("session/cancel", { sessionId: session.sessionId }); await active; + const warm = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "reused-tool-ids" }] }); + assert.equal(warm.stopReason, "end_turn"); + const starts = f.notifications.map(event => event.params?.update).filter(update => update?.sessionUpdate === "tool_call"); + assert.deepEqual(starts.map(update => update._meta.paperclipPi.modelIteration), [2, 3, 4]); + 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: [] }); + const loads = []; + for (let load = 0; load < 2; load++) { + f.notifications.length = 0; + await f.call("session/load", { sessionId: session.sessionId, cwd: join(f.root, "workspace"), mcpServers: [] }); + const starts = f.notifications.map(event => event.params?.update).filter(update => update?.sessionUpdate === "tool_call"); + assert.equal(starts.length, 2); + const historicalMessage = f.notifications.map(event => event.params?.update).find(update => update?.content?.text === "Historical reply"); + assert.match(historicalMessage.messageId, /^pi-history-message-[a-f0-9]{64}$/); + assert.deepEqual(historicalMessage._meta, { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" }); + assert.notEqual(starts[0].toolCallId, starts[1].toolCallId); + for (const start of starts) { + assert.match(start.toolCallId, /^pi-history-[a-f0-9]{64}$/); + assert.equal(start._meta.paperclipPi.nativeToolCallId, "call_0"); + assert.equal(start._meta.paperclipPi.identityScope, "history-display-only"); + } + loads.push(starts.map(update => update.toolCallId)); + } + assert.deepEqual(loads[0], loads[1]); + f.notifications.length = 0; + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "reused-tool-ids" }] }); + assert.ok(f.notifications.map(event => event.params?.update).filter(update => update?.toolCallId).every(update => /^pi-[a-f0-9]{64}$/.test(update.toolCallId))); +}); + +test("actual patched ACP process keeps text questions separate from native permissions", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "question" }] }); + assert.equal(f.requests.length, 1); assert.notEqual(f.requests[0].method, "session/request_permission"); + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "permission" }] }); + assert.equal(f.requests[1].method, "session/request_permission"); + assert.equal(f.requests[1].params.options[1].kind, "allow_always"); +}); + +for (const [method, answer, expected] of [["select", "Blue", { value: "Blue" }], ["confirm", false, { confirmed: false }], ["input", "Ada", { value: "Ada" }], ["editor", "New\ntext", { value: "New\ntext" }]]) { + test(`actual patched ACP ${method} question round-trips through form elicitation`, async t => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + f.setAnswer(async () => ({ action: "accept", content: { answer } })); + await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: `native-question-${method}` }] }); + assert.equal(f.requests.length, 1); assert.notEqual(f.requests[0].method, "session/request_permission"); + assert.equal(f.requests[0].params._meta.paperclipPi.method, method); + const response = f.notifications.map(event => event.params?.update?.content?.text).find(text => text?.includes('"extension_ui_response"')); + assert.deepEqual(JSON.parse(response), { type: "extension_ui_response", id: `native-${method}`, ...expected }); + }); +} + +test("actual patched ACP reports unsupported external UI as an error and stops the session", async t => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "oversized-question" }] }), /exited/); + assert.equal(f.requests.length, 0); + assert.ok(f.notifications.some(event => event.method === "paperclip/pi_notice" && event.params?.summary === "Pi structured question is unsupported or invalid; the session was stopped")); +}); + +test("native steering is explicit and does not replace the active ACP turn", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const prompt = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] }); + assert.equal((await f.call("pi/steer", { sessionId: session.sessionId, message: "focus" })).disposition, "queued"); + assert.equal((await f.call("pi/follow_up", { sessionId: session.sessionId, message: "then finish" })).disposition, "queued"); + assert.equal((await prompt).stopReason, "end_turn"); + await assert.rejects(f.call("pi/steer", { sessionId: session.sessionId, message: "too late" }), /active turn/); +}); + +for (const disposition of ["handled", "started", "missing", "malformed"]) for (const method of ["pi/steer", "pi/follow_up"]) { + test(`${method} never claims delivery for Pi 1 disposition ${disposition}`, async (t) => { + const f = await fixture(t, { PI_FIXTURE_INPUT_DISPOSITION: disposition }); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const prompt = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] }); + await assert.rejects(f.call(method, { sessionId: session.sessionId, message: "not queued" }), /not queued by the native runtime/); + f.notify("session/cancel", { sessionId: session.sessionId }); + await prompt; + }); +} + +test("provider exit fails promptly and missing owned extension fails admission", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "die" }] }), /exited/); + const missing = await fixture(t, { PI_FIXTURE_EXTENSION_FAIL: "1" }); + await assert.rejects(missing.call("session/new", { cwd: join(missing.root, "workspace"), mcpServers: [] }), /failed admission/); +}); + +test("provider failure is typed and unadmitted slash commands cannot bypass controls", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "failure" }] }); + assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error"); + await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "/export /outside/report.html" }] }), /not admitted/); +}); + +for (const [outcome, expected] of [ + ["success", "Retry finished, resuming."], + ["failure", "Retry failed; the provider request did not recover."], + ["unknown", "Retry finished; the provider did not report an outcome."], +]) { + test(`actual patched ACP retry ${outcome} reports only its stated outcome`, async (t) => { + const f = await fixture(t); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: `retry-${outcome}` }] }); + const messages = f.notifications.filter(event => event.method === "paperclip/pi_notice").map(event => event.params.summary); + assert.ok(f.notifications.filter(event => event.params?.update?.sessionUpdate === "agent_message_chunk").every(event => !event.params.update.content.text.includes("Retry"))); + assert.ok(messages.includes(expected)); + if (outcome !== "success") assert.ok(messages.every((text) => !text.includes("resuming"))); + if (outcome === "failure") assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error"); + else assert.equal(result._meta?.jetbrains?.air?.sessionFailure, undefined); + }); +} + +test("manual and automatic compaction retain native progress and usage", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const prompt = (text) => f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text }] }); + const manual = await prompt("/compact"); + assert.equal(manual.usage.inputTokens, 5); assert.equal(manual.usage.totalTokens, 8); + assert.equal(manual.usage._meta.paperclipPi.provenance, "assistant_message_and_compaction_receipts"); + const automatic = await prompt("auto-compact"); + assert.equal(automatic.usage.inputTokens, 16); assert.equal(automatic.usage.totalTokens, 24); + assert.equal(automatic.usage._meta.paperclipPi.costUsd, 0.03); + assert.ok(f.notifications.some((event) => event.method === "paperclip/pi_notice" && event.params?.summary === "Context compaction finished.")); + const retried = await prompt("retry-compact"); + assert.equal(retried.usage.inputTokens, undefined); + assert.equal(retried.usage._meta.paperclipPi.costUsd, undefined); + assert.ok(f.notifications.some((event) => event.method === "paperclip/pi_notice" && event.params?.summary === "Context summarization is retrying a provider request.")); + const active = prompt("long"); await new Promise((resolve) => setTimeout(resolve, 25)); + await assert.rejects(prompt("/compact"), /requires an idle session/); + f.notify("session/cancel", { sessionId: session.sessionId }); await active; + assert.match(packageSource, /this\.request\(\{ type: "compact", customInstructions \}, 120000\)/); +}); + +for (const outcome of ["success", "failure", "oversized"]) { + test(`actual patched ACP Bash ${outcome} retains standard structured tool data`, async (t) => { + const f = await fixture(t); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: `bash-${outcome}` }] }); + assert.equal(result.stopReason, "end_turn"); + const updates = f.notifications.map((event) => event.params?.update).filter((update) => update?._meta?.paperclipPi?.nativeToolCallId === "bash-fixture"); + assert.deepEqual(updates[0].rawInput, { command: outcome === "failure" ? "printf failure >&2; exit 7" : "printf done", timeout: 3 }); + assert.deepEqual(updates[1].rawOutput, { content: [{ type: "text", text: "partial" }] }); + const terminal = updates.at(-1); + assert.equal(terminal.status, outcome === "failure" ? "failed" : "completed"); + if (outcome === "oversized") { + assert.equal(terminal.rawOutput._meta.paperclipPi.omitted, "tool value exceeds 65536 bytes"); + assert.ok(terminal.rawOutput._meta.paperclipPi.originalBytes > 65536); + assert.ok(Buffer.byteLength(JSON.stringify(terminal.rawOutput)) < 256); + } else { + assert.deepEqual(terminal.rawOutput, { content: [{ type: "text", text: outcome === "failure" ? "failure\n" : "done\n" }], details: { exitCode: outcome === "failure" ? 7 : 0, truncated: false } }); + } + // Preserve existing terminal consumers without inferring a canonical exit code. + 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 new file mode 100644 index 0000000000..378e166acf --- /dev/null +++ b/packages/paperclip-runner/test/pi-closed-startup.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; + +// Explicit package-root and daemon opt-in: this exercises built artifacts, never +// downloads a provider, submits a prompt, or inherits ambient credentials. +if (process.argv[2] === "--pi-no-key-probe") { + const [packageRoot, daemon, root] = process.argv.slice(3); + const { createCapabilityRunnerdCodexTransport } = await import(pathToFileURL(join(packageRoot, "dist/live/runnerd-codex-transport.js"))); + const workspace = join(root, "workspace"); await mkdir(workspace); + const evidence = []; const started = performance.now(); + const bundle = createCapabilityRunnerdCodexTransport({ + 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 closeError = null; let settledMs; + try { + await bundle.transport.request("initialize", { clientInfo: { name: "pi-closed-startup-regression", 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 (failure) { error = String(failure); } + 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: 90_000, + }, async () => { + const root = await mkdtemp(join(tmpdir(), "pi-closed-startup-")); + await mkdir(join(root, "home")); + try { + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "--pi-no-key-probe", packageRoot, daemon, root], { + 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"), 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 < 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")); + assert.equal(state.descriptor.agent, "pi"); assert.equal(state.descriptor.agentRuntimeVersion, "1.0.0"); + 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 })); + } 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 new file mode 100644 index 0000000000..fa74a09b6f --- /dev/null +++ b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs @@ -0,0 +1,676 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { stripTypeScriptTypes } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; + +// Tests run real pinned Pi RPC without live provider credentials. The final +// stream regressions use synthetic loopback responses and a socket deny guard. +const packageRoot = process.env.PAPERCLIP_TEST_PI_RUNTIME_ROOT + ?? dirname(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")))); +const metadata = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); +const extensionSource = await readFile(new URL("../src/drivers/acpx/pi-runtime-extension.ts", import.meta.url), "utf8"); + +test("Pi 1 offline catalog retains the exact qualification model", async () => { + const { ModelRuntime } = await import(pathToFileURL(join(packageRoot, "dist/core/model-runtime.js")).href); + const { AuthStorage } = await import(pathToFileURL(join(packageRoot, "dist/core/auth-storage.js")).href); + const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({}), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false }); + const model = runtime.getModel("openrouter", "deepseek/deepseek-v4-flash-0731"); + assert.equal(model?.provider, "openrouter"); + assert.equal(model?.id, "deepseek/deepseek-v4-flash-0731"); +}); + +test("owned gate authorizes the actual pinned SDK path expansions and read fallbacks", async () => { + assert.equal(metadata.version, "1.0.0"); + const { resolveToCwd, resolveReadPathAsync } = await import(pathToFileURL(join(packageRoot, "dist/core/tools/path-utils.js")).href); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-native-paths-"))); + try { + const workspace = join(root, "workspace"); const privateRoot = join(root, "private"); + await mkdir(workspace); await mkdir(privateRoot); + const secret = join(privateRoot, "sentinel"); await writeFile(secret, "private fixture"); + await writeFile(join(root, "extension.mjs"), stripTypeScriptTypes(extensionSource)); + await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + const { piNativeToolPaths, checkPiNativeTool } = await import(pathToFileURL(join(root, "extension.mjs")).href); + const config = { workspace, readOnly: false, readRoots: [], protectedRoots: [privateRoot] }; + const context = { cwd: workspace }; + for (const path of [`@${secret}`, `file://${secret}`, "~", "~/sentinel", "@~/sentinel", "~//sentinel", "space\u00a0name/sentinel", "space\u202fname/sentinel", "@safe", "ordinary"]) { + assert.equal(piNativeToolPaths(path, workspace, false)[0], resolveToCwd(path, workspace), path); + } + for (const path of [`@${secret}`, `file://${secret}`, "~", "~/sentinel", "@~/sentinel"]) { + assert.notEqual(await checkPiNativeTool({ toolName: "read", input: { path } }, context, config), null, path); + } + for (const [path, alternate] of [["capture 1 PM.png", "capture 1\u202fPM.png"], ["a'b", "a\u2019b"], ["caf\u00e9'b", "cafe\u0301\u2019b"]]) { + await symlink(secret, join(workspace, alternate)); + const actual = await resolveReadPathAsync(path, workspace); + assert.equal(await realpath(actual), secret); + assert.ok(piNativeToolPaths(path, workspace, true).includes(actual)); + assert.notEqual(await checkPiNativeTool({ toolName: "read", input: { path } }, context, config), null, path); + } + await mkdir(join(workspace, "space name")); await symlink(secret, join(workspace, "space name/sentinel")); + assert.equal(await realpath(resolveToCwd("space\u00a0name/sentinel", workspace)), secret); + assert.notEqual(await checkPiNativeTool({ toolName: "read", input: { path: "space\u00a0name/sentinel" } }, context, config), null); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +for (const brokenCatalog of [false, true]) { + test(`real pinned Pi ${brokenCatalog ? "withholds" : "registers"} readiness after MCP initialization`, { timeout: 20_000 }, async (t) => { + assert.equal(metadata.version, "1.0.0"); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-native-contract-"))); + await mkdir(join(root, "workspace")); await mkdir(join(root, "agent")); + const extension = join(root, "extension.mjs"); + await writeFile(extension, stripTypeScriptTypes(extensionSource)); + await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + const calls = []; + const server = createServer(async (request, response) => { + assert.equal(request.headers.authorization, "Bearer 0123456789abcdef0123456789abcdef"); + let body = ""; for await (const chunk of request) body += chunk; + const rpc = JSON.parse(body); calls.push(rpc.method); + response.setHeader("Content-Type", "application/json"); + const result = rpc.method === "initialize" + ? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } + : { tools: [{ name: brokenCatalog ? "invalid/name" : "connection:search", description: "Report progress", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] }; + response.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result })); + }); + server.listen(0, "127.0.0.1"); await once(server, "listening"); + const port = server.address().port; + const child = spawn(await realpath(process.execPath), [join(packageRoot, "dist/cli.js"), "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension], { + cwd: join(root, "workspace"), stdio: "pipe", + env: { + PATH: process.env.PATH, HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"), PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", + PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify({ + invocationNamespace: "00000000-0000-4000-8000-000000000000", + workspace: join(root, "workspace"), readOnly: true, readRoots: [], protectedRoots: [join(root, "agent")], instructions: "Use the assigned tools", + servers: [{ type: "http", name: "paperclip", url: `http://127.0.0.1:${port}/mcp`, headers: [{ name: "Authorization", value: "Bearer 0123456789abcdef0123456789abcdef" }] }], + }), + }, + }); + let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; }); + t.after(async () => { + child.stdin.end(); + if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 2000); await once(child, "exit"); clearTimeout(timer); } + server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); + await rm(root, { recursive: true, force: true }); + }); + const admission = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Pi RPC admission timed out: ${stderr}`)), 15_000); + const decoder = new StringDecoder("utf8"); let buffered = ""; + child.stdout.on("data", (chunk) => { + buffered += decoder.write(chunk); + for (;;) { + const at = buffered.indexOf("\n"); if (at < 0) break; + const line = buffered.slice(0, at); buffered = buffered.slice(at + 1); if (!line.trim()) continue; + const value = JSON.parse(line); + if (value.type === "response" && value.id === "admission") { clearTimeout(timer); resolve(value); } + } + }); + child.once("error", (error) => { clearTimeout(timer); reject(error); }); + child.once("exit", () => { clearTimeout(timer); reject(new Error(`Pi RPC exited before admission: ${stderr}`)); }); + child.stdin.write(JSON.stringify({ type: "get_commands", id: "admission" }) + "\n"); + }); + if (brokenCatalog) { + await assert.rejects(admission, /Pi RPC exited before admission: Error: Failed to load extension/); + assert.deepEqual(calls, ["initialize", "tools/list"]); + return; + } + const result = await admission; + assert.equal(result.success, true); + assert.deepEqual(calls, ["initialize", "tools/list"]); + const readiness = result.data.commands.find((command) => command.name === "paperclip-runtime-ready-v1"); + assert.equal(readiness?.description, "Paperclip runtime gate v1"); + }); +} + +test("real pinned Pi model iterations align owned identities across warm prompts", { timeout: 20_000 }, async () => { + assert.equal(metadata.version, "1.0.0"); + const load = (name) => import(pathToFileURL(join(packageRoot, `dist/core/${name}.js`)).href); + const [{ createAgentSession }, { DefaultResourceLoader }, { ModelRuntime }, { SessionManager }, { SettingsManager }, { AuthStorage }] = await Promise.all( + ["sdk", "resource-loader", "model-runtime", "session-manager", "settings-manager", "auth-storage"].map(load), + ); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-native-order-"))); + let session; + try { + await writeFile(join(root, "helper.mjs"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + const { PiToolIdentities, PiAssistantMessages, piAssistantChunk } = await import(pathToFileURL(join(root, "helper.mjs")).href); + const namespace = "00000000-0000-4000-8000-000000000000"; + const extensionIds = new PiToolIdentities(namespace); const wrapperIds = new PiToolIdentities(namespace); + const assistantIds = new PiAssistantMessages(namespace); const boundaries = []; + const events = []; const delivered = []; const displayed = []; let streams = 0; + const settings = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }); + const modelRuntime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({}), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false }); + const model = { id: "fixture", name: "Fixture", api: "openai-completions", provider: "fixture", baseUrl: "http://127.0.0.1:1", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 64 }; + const resourceLoader = new DefaultResourceLoader({ cwd: root, agentDir: root, settingsManager: settings, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, extensionFactories: [(pi) => { + pi.on("turn_start", (event) => { extensionIds.begin(); events.push({ surface: "extension", type: event.type, turnIndex: event.turnIndex }); }); + pi.on("turn_end", () => extensionIds.end()); + pi.on("tool_call", (event) => { extensionIds.bind(event.toolCallId, event.toolName, event.input, true); }); + pi.registerTool({ name: "fixture_echo", label: "Fixture echo", description: "No side effects", parameters: { type: "object", properties: {}, additionalProperties: false }, execute: async (nativeId, args) => { + delivered.push(extensionIds.bind(nativeId, "fixture_echo", args)); + return { content: [{ type: "text", text: "fixture result" }] }; + } }); + }] }); + await resourceLoader.reload(); + const manager = SessionManager.create(root, join(root, "sessions")); + ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: manager, resourceLoader, noTools: "builtin", thinkingLevel: "off" })); + const extensionErrors = []; + await session.bindExtensions({ onError: (error) => extensionErrors.push(error.event) }); + session.subscribe((event) => { + const normalized = wrapperIds.normalize(assistantIds.normalize(event)); + if (normalized.paperclipAssistantMessage && event.type !== "message_update") boundaries.push(piAssistantChunk(normalized.paperclipAssistantMessage)); + if (event.type === "message_update" && ["text_delta", "thinking_delta"].includes(event.assistantMessageEvent.type)) boundaries.push(piAssistantChunk(normalized.paperclipAssistantMessage, event.assistantMessageEvent.delta, event.assistantMessageEvent.type === "thinking_delta")); + if (event.type === "turn_start") events.push({ surface: "session", type: event.type }); + if (event.type === "tool_execution_start") displayed.push(normalized.toolCallId); + }); + // Only the model stream is replaced. These are actual pinned Agent loop, + // AgentSession dispatch, extension hooks, and tool execution paths. No key, + // network model lookup, provider request, or inference is involved. + session.agent.streamFunction = () => { + const index = ++streams; const isTool = index % 3 !== 0; + const message = { role: "assistant", content: isTool ? [{ type: "text", text: "Calling fixture tool." }, { type: "toolCall", id: "call_0", name: "fixture_echo", arguments: {} }] : [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: isTool ? "toolUse" : "stop", timestamp: index }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "text_delta", contentIndex: 0, delta: isTool ? "Calling fixture tool." : "done", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message }; + }; + await session.agent.prompt("fixture first"); await session.agent.waitForIdle(); + await session.agent.prompt("fixture warm"); await session.agent.waitForIdle(); + assert.deepEqual(extensionErrors, []); + assert.equal(streams, 6); assert.equal(delivered.length, 4); + assert.deepEqual(displayed, delivered); assert.equal(new Set(delivered).size, 4); + assert.equal(boundaries.filter(chunk => chunk._meta.kind === "start").length, 6); + assert.equal(new Set(boundaries.map(chunk => chunk.messageId)).size, 6); + assert.deepEqual(boundaries.filter(chunk => chunk._meta.kind.startsWith("end:")).map(chunk => chunk._meta.kind), ["end:toolUse", "end:toolUse", "end:stop", "end:toolUse", "end:toolUse", "end:stop"]); + assert.deepEqual(events.filter((event) => event.surface === "extension").map((event) => event.turnIndex), [0, 1, 2, 0, 1, 2]); + for (let index = 0; index < events.length; index++) if (events[index].surface === "session") assert.equal(events[index - 1]?.surface, "extension"); + const lastId = boundaries.at(-1).messageId; + assert.equal(boundaries.filter(chunk => chunk.messageId === lastId).map(chunk => chunk.content.text).join(""), "done"); + assert.ok(boundaries.some(chunk => chunk.content.text === "Calling fixture tool." && chunk.messageId !== lastId)); + const persistedFile = manager.getSessionFile(); assert.ok(persistedFile); + session.dispose(); + const loadedMessages = new PiAssistantMessages("00000000-0000-4000-8000-000000000001"); const loaded = []; + ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.open(persistedFile), resourceLoader, noTools: "builtin", thinkingLevel: "off" })); + await session.bindExtensions({ onError: error => extensionErrors.push(error.event) }); + assert.ok(session.agent.state.messages.some(message => message.role === "assistant")); + session.subscribe(event => { const normalized = loadedMessages.normalize(event); if (normalized.paperclipAssistantMessage && event.type !== "message_update") loaded.push(piAssistantChunk(normalized.paperclipAssistantMessage)); }); + session.agent.streamFunction = () => { + const message = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: 100 }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: "stop", message }; }, result: async () => message }; + }; + await session.agent.prompt("loaded empty final"); await session.agent.waitForIdle(); + assert.deepEqual(loaded.map(chunk => [chunk._meta.kind, chunk.content.text]), [["start", ""], ["end:stop", ""]]); + assert.ok(loaded.every(chunk => !boundaries.some(prior => prior.messageId === chunk.messageId))); + + } finally { + session?.dispose(); await rm(root, { recursive: true, force: true }); + } +}); + +test("real pinned Pi executes all four owned native question methods without permission authority", { timeout: 20_000 }, async () => { + const load = (name) => import(pathToFileURL(join(packageRoot, `dist/core/${name}.js`)).href); + const [{ createAgentSession }, { DefaultResourceLoader }, { ModelRuntime }, { SessionManager }, { SettingsManager }, { AuthStorage }] = await Promise.all( + ["sdk", "resource-loader", "model-runtime", "session-manager", "settings-manager", "auth-storage"].map(load), + ); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-native-questions-"))); + let session; + try { + await writeFile(join(root, "extension.mjs"), stripTypeScriptTypes(extensionSource)); + await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + const { installPiRuntimeExtension, PI_NATIVE_QUESTION_TOOL } = await import(pathToFileURL(join(root, "extension.mjs")).href); + const settings = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }); + const modelRuntime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({}), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false }); + const model = { id: "fixture", name: "Fixture", api: "openai-completions", provider: "fixture", baseUrl: "http://127.0.0.1:1", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 64 }; + const resourceLoader = new DefaultResourceLoader({ cwd: root, agentDir: root, settingsManager: settings, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, + extensionFactories: [pi => installPiRuntimeExtension(pi, { invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace: root, readOnly: true, readRoots: [], protectedRoots: [], instructions: "", servers: [] })] }); + await resourceLoader.reload(); + ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.inMemory(root), resourceLoader, noTools: "builtin", thinkingLevel: "off" })); + const dialogs = []; const results = []; const errors = []; + const uiContext = { + select: async (title, options) => { dialogs.push({ method: "select", title, options }); return "Blue"; }, + confirm: async (title, message) => { dialogs.push({ method: "confirm", title, message }); return false; }, + input: async (title, placeholder) => { dialogs.push({ method: "input", title, placeholder }); return "Ada"; }, + editor: async (title, prefill) => { dialogs.push({ method: "editor", title, prefill }); return "New\ntext"; }, + notify() {}, setStatus() {}, setWidget() {}, setTitle() {}, setEditorText() {}, getEditorText: () => "", setWorkingMessage() {}, + }; + await session.bindExtensions({ uiContext, onError: error => errors.push(error.event) }); + // Exercise the actual Pi 1 extension dispatch used immediately before a + // paid cache refresh; native economics cannot override Runner's policy. + assert.equal(await session.extensionRunner.emitCacheWarmingDecision({ type: "cache_warming_decision", action: "warm", warmCost: 0.01, missCost: 10, continuationProbability: 1 }), "stop"); + + session.subscribe(event => { if (event.type === "tool_execution_end") results.push(event); }); + const requests = [ + { method: "select", title: "Color", options: [{ id: "blue", label: "Blue" }, { id: "red", label: "Red" }] }, + { method: "confirm", title: "Continue", message: "Continue editing?" }, + { method: "input", title: "Name", placeholder: "Your name" }, + { method: "editor", title: "Draft", prefill: "Old\ntext" }, + ]; + let streams = 0; + session.agent.streamFunction = () => { + const args = requests[streams++]; + const message = { role: "assistant", content: args ? [{ type: "toolCall", id: "call_0", name: PI_NATIVE_QUESTION_TOOL, arguments: args }] : [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: args ? "toolUse" : "stop", timestamp: streams }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message }; + }; + await session.agent.prompt("fixture native questions"); await session.agent.waitForIdle(); + assert.deepEqual(errors, []); assert.equal(results.length, 4); + assert.ok(results.every(event => !event.isError)); + assert.deepEqual(dialogs.map(dialog => dialog.method), ["select", "confirm", "input", "editor"]); + assert.ok(dialogs.every(dialog => !dialog.title.startsWith("paperclip.pi.permission.v1:"))); + assert.deepEqual(results.map(event => event.result.details), [{ status: "answered", optionId: "blue" }, { status: "negative_or_cancelled", confirmed: false }, { status: "answered", value: "Ada" }, { status: "answered", value: "New\ntext" }]); + } finally { session?.dispose(); await rm(root, { recursive: true, force: true }); } +}); + +test("real pinned Pi dispatches aliased MCP tools with their exact original names", { timeout: 20_000 }, async () => { + const { getCurrentTools } = await import(pathToFileURL(join(packageRoot, "node_modules/@earendil-works/pi-ai/dist/index.js")).href); + const load = (name) => import(pathToFileURL(join(packageRoot, `dist/core/${name}.js`)).href); + const [{ createAgentSession }, { DefaultResourceLoader }, { ModelRuntime }, { SessionManager }, { SettingsManager }, { AuthStorage }] = await Promise.all( + ["sdk", "resource-loader", "model-runtime", "session-manager", "settings-manager", "auth-storage"].map(load), + ); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-mcp-alias-"))); + let session; + try { + await writeFile(join(root, "extension.mjs"), stripTypeScriptTypes(extensionSource)); + await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + const { installPiRuntimeExtension } = await import(pathToFileURL(join(root, "extension.mjs")).href); + const names = ["connection:search", "connection_search", "connection.search", "x".repeat(128)]; + const calls = []; + const request = async (_server, method, params) => method === "tools/list" + ? { tools: names.map(name => ({ name, description: name, inputSchema: { type: "object", properties: { index: { type: "number" } } } })) } + : method === "tools/call" ? (calls.push(params), { content: [{ type: "text", text: "recorded" }] }) : {}; + const settings = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }); + const modelRuntime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({}), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false }); + const model = { id: "fixture", name: "Fixture", api: "openai-completions", provider: "fixture", baseUrl: "http://127.0.0.1:1", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 64 }; + const resourceLoader = new DefaultResourceLoader({ cwd: root, agentDir: root, settingsManager: settings, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, + extensionFactories: [pi => installPiRuntimeExtension(pi, { invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace: root, readOnly: true, readRoots: [], protectedRoots: [], instructions: "", servers: [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1", headers: [] }] }, request)] }); + await resourceLoader.reload(); + ({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.inMemory(root), resourceLoader, noTools: "builtin", thinkingLevel: "off" })); + const errors = []; const results = []; + await session.bindExtensions({ onError: error => errors.push(error.event) }); + session.subscribe(event => { if (event.type === "tool_execution_end") results.push(event); }); + let streams = 0; + session.agent.streamFunction = (_model, context) => { + const index = streams++; const original = names[index]; + const exposed = getCurrentTools(context.messages).filter(tool => names.includes(tool.description)); + assert.equal(exposed.length, names.length); + assert.equal(new Set(exposed.map(tool => tool.name)).size, names.length); + assert.ok(exposed.every(tool => /^[A-Za-z0-9_-]{1,64}$/.test(tool.name))); + const name = exposed.find(tool => tool.description === original)?.name; + const message = { role: "assistant", content: name ? [{ type: "toolCall", id: "call_0", name, arguments: { index } }] : [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: name ? "toolUse" : "stop", timestamp: streams }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message }; + }; + await session.agent.prompt("fixture aliases"); await session.agent.waitForIdle(); + assert.deepEqual(errors, []); assert.equal(results.length, names.length); assert.ok(results.every(event => !event.isError)); + assert.deepEqual(calls, names.map((name, index) => ({ name, arguments: { index } }))); + } finally { session?.dispose(); await rm(root, { recursive: true, force: true }); } +}); + +test("real pinned Pi tool dispatch admits registered agent files and rejects unassigned roots", { timeout: 20_000 }, async () => { + const load = (name) => import(pathToFileURL(join(packageRoot, `dist/core/${name}.js`)).href); + const [{ createAgentSession }, { DefaultResourceLoader }, { ModelRuntime }, { SessionManager }, { SettingsManager }, { AuthStorage }] = await Promise.all( + ["sdk", "resource-loader", "model-runtime", "session-manager", "settings-manager", "auth-storage"].map(load), + ); + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-native-agent-files-"))); + let session; + try { + const workspace = join(root, "workspace"); const agentHome = join(root, "agent-files"); const privateRoot = join(root, "private"); const outside = join(root, "outside"); + for (const directory of [workspace, agentHome, privateRoot, outside]) await mkdir(directory); + await symlink(outside, join(agentHome, "escape")); + await writeFile(join(root, "extension.mjs"), stripTypeScriptTypes(extensionSource)); + await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + const { installPiRuntimeExtension } = await import(pathToFileURL(join(root, "extension.mjs")).href); + const settings = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }); + const modelRuntime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({}), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false }); + const model = { id: "fixture", name: "Fixture", api: "openai-completions", provider: "fixture", baseUrl: "http://127.0.0.1:1", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 64 }; + const resourceLoader = new DefaultResourceLoader({ cwd: workspace, agentDir: privateRoot, settingsManager: settings, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, + extensionFactories: [pi => installPiRuntimeExtension(pi, { invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace, agentHome, readOnly: false, readRoots: [], protectedRoots: [privateRoot], instructions: "", servers: [] })] }); + await resourceLoader.reload(); + ({ session } = await createAgentSession({ cwd: workspace, agentDir: privateRoot, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.inMemory(workspace), resourceLoader, thinkingLevel: "off" })); + const dialogs = []; const results = []; const errors = []; + const uiContext = { select: async (title) => { dialogs.push(title); return "Allow once"; }, notify() {}, setStatus() {}, setWidget() {}, setTitle() {}, setEditorText() {}, getEditorText: () => "", setWorkingMessage() {} }; + await session.bindExtensions({ uiContext, onError: error => errors.push(error.event) }); + // Exercise the actual Pi 1 extension dispatch used immediately before a + // paid cache refresh; native economics cannot override Runner's policy. + assert.equal(await session.extensionRunner.emitCacheWarmingDecision({ type: "cache_warming_decision", action: "warm", warmCost: 0.01, missCost: 10, continuationProbability: 1 }), "stop"); + + session.subscribe(event => { if (event.type === "tool_execution_end") results.push(event); }); + const memory = join(agentHome, "memory.txt"); + const requests = [ + { name: "write", arguments: { path: memory, content: "private memory nonce\n" } }, + { name: "read", arguments: { path: memory } }, + { name: "write", arguments: { path: join(outside, "denied.txt"), content: "forbidden" } }, + { name: "write", arguments: { path: join(agentHome, "escape/denied.txt"), content: "forbidden" } }, + { name: "write", arguments: { path: join(privateRoot, "denied.txt"), content: "forbidden" } }, + ]; + let streams = 0; + session.agent.streamFunction = () => { + const request = requests[streams++]; + const message = { role: "assistant", content: request ? [{ type: "toolCall", id: "call_0", ...request }] : [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: request ? "toolUse" : "stop", timestamp: streams }; + return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message }; + }; + await session.agent.prompt("fixture native agent files"); await session.agent.waitForIdle(); + assert.deepEqual(errors, []); assert.equal(results.length, 5); + assert.deepEqual(results.map(event => Boolean(event.isError)), [false, false, true, true, true]); + assert.equal(await readFile(memory, "utf8"), "private memory nonce\n"); + assert.match(JSON.stringify(results[1].result), /private memory nonce/); + for (const path of [join(outside, "denied.txt"), join(privateRoot, "denied.txt")]) await assert.rejects(readFile(path), { code: "ENOENT" }); + assert.equal(dialogs.length, 2); assert.ok(dialogs.every(title => title.startsWith("paperclip.pi.permission.v1:"))); + } finally { session?.dispose(); await rm(root, { recursive: true, force: true }); } +}); + +// 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", "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) { + // 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) { + 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 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); 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; + 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) { + chunks.push({ choices: [{ index: 0, delta: { tool_calls: names.map((name, index) => ({ index, id: `call_${n}_${index}`, type: "function", function: { name: `mcp__paperclip__${name}`, arguments: '{"fixture":' } })) } }] }); + // Reverse delivery order proves index correlation, not FIFO guessing. + for (const index of names.map((_, index) => index).reverse()) chunks.push({ choices: [{ index: 0, delta: { tool_calls: [{ index, function: { arguments: `${index}}` } }] } }] }); + } else chunks.push({ choices: [{ index: 0, delta: { reasoning: "Fixture thought", content: "HELLO_COMPLETE" } }] }); + chunks.push({ choices: [{ index: 0, delta: {}, finish_reason: toolTurn ? "tool_calls" : "stop" }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(chunks.map(chunk => `data: ${JSON.stringify({ id: "offline", ...chunk })}\n\n`).join("") + "data: [DONE]\n\n"); return; + } + 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 } + : { content: [{ type: "text", text: JSON.stringify({ accepted: true }) }] }; + response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ jsonrpc: "2.0", id: value.id, result })); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + await writeFile(join(root, "agent/models.json"), JSON.stringify({ providers: { openrouter: { baseUrl: `http://127.0.0.1:${port}/v1`, apiKey: "offline-fixture-not-a-key" } } })); + const bootstrap = join(root, "bootstrap.mjs"); + await writeFile(bootstrap, ` +import net from "node:net"; +import { writeFileSync } from "node:fs"; +const original = net.Socket.prototype.connect; let admitted = 0; +net.Socket.prototype.connect = function(...args) { + const a = Array.isArray(args[0]) ? args[0] : args; + const options = typeof a[0] === "object" ? a[0] : { port: a[0], host: a[1] }; + if (options.path || options.host !== "127.0.0.1" || Number(options.port) !== ${port}) throw new Error("OFFLINE_NETWORK_DENIED_BEFORE_CONNECT"); + admitted++; return original.apply(this, args); +}; +try { new net.Socket().connect({ host: "203.0.113.1", port: 443 }); throw new Error("guard failed"); } +catch (error) { if (error.message !== "OFFLINE_NETWORK_DENIED_BEFORE_CONNECT" || admitted !== 0) throw error; } +writeFileSync(${JSON.stringify(join(root, "network-denial.json"))}, JSON.stringify({ deniedBeforeConnect: true, underlyingConnections: admitted })); +await import(${JSON.stringify(pathToFileURL(join(packageRoot, "dist/cli.js")).href)}); +`); + const extension = join(root, "extension.mjs"); + await writeFile(extension, stripTypeScriptTypes(extensionSource)); + await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8"))); + // The fixture owns each real Pi ChildProcess handle even if the wrapper + // elects to exit early. No numeric PID/group selector is used for cleanup. + const owner = join(root, "wrapper-owner.mjs"); + await writeFile(owner, ` +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { writeFileSync } from "node:fs"; +const spawn = childProcess.spawn; const children = []; +childProcess.spawn = (...args) => { + const child = spawn(...args); const owned = { child, closed: false, spawnError: false, completion: null }; + child.on("error", () => { owned.spawnError = true; }); + owned.completion = new Promise(resolve => child.once("close", () => { owned.closed = true; resolve(); })); + children.push(owned); return child; +}; +syncBuiltinESMExports(); +const exit = process.exit.bind(process); let retiring; +process.exit = code => { + retiring ??= (async () => { + const retirements = await Promise.allSettled(children.map(async owned => { + const child = owned.child; + if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 1500); + try { await owned.completion; } finally { clearTimeout(timer); } + })); + writeFileSync(${JSON.stringify(join(root, "owned-retirement.json"))}, JSON.stringify({ allOwnedChildrenClosed: children.every(owned => owned.closed) && retirements.every(result => result.status === "fulfilled"), spawnErrors: children.filter(owned => owned.spawnError).length, count: children.length })); + })().finally(() => 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)}); +`); + 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); } }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + 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"; + 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")); + assert.doesNotMatch(JSON.stringify({ result, notifications, stderr }), /sensitive-canary/); + if (scenario.startsWith("provider-")) { + assert.equal(modelRequests.length, 1); assert.equal(calls.filter(call => call.method === "tools/call").length, 0); + assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error"); + const notice = notifications.find(event => event.method === "paperclip/pi_notice" && event.params.category === "runtime_failure"); + assert.equal(notice?.params.details.reason, scenario === "provider-error" ? "provider_http_401" : "unknown_native_failure"); return; + } + assert.equal(result.stopReason, "end_turn"); assert.equal(result._meta?.jetbrains, undefined); + assert.deepEqual(calls.filter(call => call.method === "tools/call").map(call => call.name), ["paperclip_get_context", "paperclip_finish"]); + assert.equal(result.usage.totalTokens, (scenario === "hello" ? 3 : 2) * 13); + const updates = notifications.map(event => event.params?.update).filter(Boolean); + assert.equal(updates.filter(update => update.content?.text === "HELLO_COMPLETE").length, 1); + assert.ok(updates.some(update => update.sessionUpdate === "agent_thought_chunk" && update.content.text === "Fixture thought")); + const starts = updates.filter(update => update.sessionUpdate === "tool_call"); + assert.equal(starts.length, 2); assert.notEqual(starts[0].toolCallId, starts[1].toolCallId); + for (const start of starts) { + assert.equal(updates.filter(update => update.toolCallId === start.toolCallId && update.status === "completed").length, 1); + assert.ok(updates.some(update => update.toolCallId === start.toolCallId && update.status === "in_progress")); + } + }); +} diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 7e0673f378..31a9468a3f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1242,6 +1242,8 @@ export interface PaperclipQuestionSetQuestion { helpText?: string; required: boolean; answerMode: "single_select" | "multi_select" | "text"; + /** Editable starting text, never an implicit or submitted answer. Text mode only. */ + initialText?: string; options?: PaperclipQuestionSetOption[]; customAnswer?: { enabled: true; diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 27f688d3cc..ab71435911 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js"; import { addIssueCommentSchema, + paperclipQuestionSetPayloadSchema, issueCommentMetadataSchema, createIssueSchema, issueBlockedInboxAttentionSchema, @@ -160,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({ @@ -654,3 +666,27 @@ describe("issue validators", () => { expect(parsed.success).toBe(false); }); }); + + +describe("persisted canonical question initial text", () => { + const question = { id: "draft", prompt: "Edit", required: true, answerMode: "text" }; + const input = (patch: Record) => ({ schema: "paperclip.question_set.v1", questions: [{ ...question, ...patch }] }); + it("retains exact editable content through the server payload validator", () => { + for (const initialText of ["", " Draft\n漢字\n", "x".repeat(100_000)]) { + expect(paperclipQuestionSetPayloadSchema.parse(input({ initialText }))).toEqual(input({ initialText })); + } + }); + it("counts mixed BMP and astral draft text like JSON Schema", () => { + for (const codePoints of [100_000, 100_001]) { + const initialText = "a".repeat(codePoints - 1) + "😀"; + expect(Buffer.byteLength(JSON.stringify(input({ initialText })))).toBeLessThan(196 * 1024); + expect(paperclipQuestionSetPayloadSchema.safeParse(input({ initialText })).success).toBe(codePoints === 100_000); + } + }); + it("rejects non-text modes and unbounded or nonstring defaults", () => { + for (const initialText of [null, 1, "x".repeat(100_001), "a".repeat(100_000) + "😀"]) { + expect(paperclipQuestionSetPayloadSchema.safeParse(input({ initialText })).success).toBe(false); + } + expect(paperclipQuestionSetPayloadSchema.safeParse(input({ answerMode: "single_select", options: [{ id: "a", label: "A" }], initialText: "a" })).success).toBe(false); + }); +}); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index bbb44441d6..f383d5bb92 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1286,6 +1286,7 @@ const paperclipQuestionSchema = z helpText: z.string().max(4000).optional(), required: z.boolean(), answerMode: z.enum(["single_select", "multi_select", "text"]), + initialText: z.string().refine(value => value.length <= 200000 && Array.from(value).length <= 100000, "initial text exceeds 100000 Unicode code points").optional(), options: z.array(paperclipQuestionOptionSchema).max(128).optional(), customAnswer: z .object({ @@ -1306,6 +1307,9 @@ const paperclipQuestionSchema = z .optional(), }) .superRefine((value, ctx) => { + if (value.initialText !== undefined && value.answerMode !== "text") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "only text questions can define initial text", path: ["initialText"] }); + } if ( value.answerMode === "text" && value.options && @@ -1456,8 +1460,9 @@ export const askUserQuestionsPayloadSchema = z export const askUserQuestionsAnswerSchema = z.object({ questionId: z.string().trim().min(1).max(160), optionIds: z.array(z.string().trim().min(1).max(160)).max(129), - otherText: multilineTextSchema - .pipe(z.string().trim().max(100000)) + // The persisted question determines whether this is exact editor text or a + // legacy custom answer; normalize only after that trusted context is known. + otherText: z.string().max(100000) .nullable() .optional(), }); 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/brace-expansion@5.0.9.patch b/patches/brace-expansion@5.0.9.patch new file mode 100644 index 0000000000..bb868b662a --- /dev/null +++ b/patches/brace-expansion@5.0.9.patch @@ -0,0 +1,421 @@ +--- a/dist/commonjs/index.d.ts ++++ b/dist/commonjs/index.d.ts +@@ -1,8 +1,12 @@ + export declare const EXPANSION_MAX = 100000; + export declare const EXPANSION_MAX_LENGTH = 4000000; ++export declare const EXPANSION_MAX_DEPTH = 1000; ++export declare const EXPANSION_MAX_REWRITES = 1000; + export type BraceExpansionOptions = { + max?: number; + maxLength?: number; ++ maxDepth?: number; ++ maxRewrites?: number; + }; + export declare function expand(str: string, options?: BraceExpansionOptions): string[]; + //# sourceMappingURL=index.d.ts.map +\ No newline at end of file +--- a/dist/commonjs/index.d.ts.map ++++ b/dist/commonjs/index.d.ts.map +@@ -1 +1 @@ +-{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,aAAa,SAAU,CAAA;AAYpC,eAAO,MAAM,oBAAoB,UAAY,CAAA;AAwD7C,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,YAkBtE"} +\ No newline at end of file ++{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,aAAa,SAAU,CAAA;AAYpC,eAAO,MAAM,oBAAoB,UAAY,CAAA;AAU7C,eAAO,MAAM,mBAAmB,OAAQ,CAAA;AAUxC,eAAO,MAAM,sBAAsB,OAAQ,CAAA;AAyE3C,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,YA+BtE"} +\ No newline at end of file +--- a/dist/commonjs/index.js ++++ b/dist/commonjs/index.js +@@ -1,6 +1,6 @@ + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); +-exports.EXPANSION_MAX_LENGTH = exports.EXPANSION_MAX = void 0; ++exports.EXPANSION_MAX_REWRITES = exports.EXPANSION_MAX_DEPTH = exports.EXPANSION_MAX_LENGTH = exports.EXPANSION_MAX = void 0; + exports.expand = expand; + const balanced_match_1 = require("balanced-match"); + const escSlash = '\0SLASH' + Math.random() + '\0'; +@@ -30,6 +30,24 @@ + // realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M + // characters) so legitimate input is unaffected. + exports.EXPANSION_MAX_LENGTH = 4_000_000; ++// `expand_` recurses once per level of brace *nesting* - both when expanding a ++// set's comma members and when re-wrapping a set whose body is a single part. ++// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one ++// level per chained group), which left nesting depth unbounded: about 3,100 ++// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack ++// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser ++// will follow nesting. It sits far above any realistic pattern and well below ++// the depth at which the stack runs out. ++exports.EXPANSION_MAX_DEPTH = 1_000; ++// Bash keeps a quirk where a brace group followed by a comma set still expands ++// (`{a},b}`). The parser implements it by rewriting the string and restarting ++// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n` ++// full passes over a string that itself grows by one `escClose` sentinel each ++// time - quadratic in `n`, with a ~26x constant from the sentinel's length. ++// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27 ++// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many ++// times the scan may restart. Real `{a},b}` input needs a handful. ++exports.EXPANSION_MAX_REWRITES = 1_000; + function numeric(str) { + return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0); + } +@@ -49,37 +67,52 @@ + .replace(escCommaPattern, ',') + .replace(escPeriodPattern, '.'); + } ++// Like `target.push(...items)` but doesn't overflow the stack ++function pushAll(target, items) { ++ for (let i = 0; i < items.length; i++) { ++ target.push(items[i]); ++ } ++} + /** + * Basically just str.split(","), but handling cases + * where we have nested braced sections, which should be + * treated as individual members, like {a,{b,c},d} + */ + function parseCommaParts(str) { +- if (!str) { +- return ['']; +- } + const parts = []; +- const m = (0, balanced_match_1.balanced)('{', '}', str); +- if (!m) { +- return str.split(','); +- } +- const { pre, body, post } = m; +- const p = pre.split(','); +- p[p.length - 1] += '{' + body + '}'; +- const postParts = parseCommaParts(post); +- if (post.length) { +- ; +- p[p.length - 1] += postParts.shift(); +- p.push.apply(p, postParts); ++ // Walk the brace groups iteratively. Recursing on `post` once per group let a ++ // chain of them exhaust the stack - the parsing-side counterpart to ++ // the `expand_` overflow fixed for CVE-2026-14257, and not something `max` or ++ // `maxLength` can bound, since it happens before expansion. ++ // ++ // The part the next chunk continues ++ let carry = ''; ++ for (;;) { ++ const m = (0, balanced_match_1.balanced)('{', '}', str); ++ if (!m) { ++ const tail = str.split(','); ++ tail[0] = carry + tail[0]; ++ pushAll(parts, tail); ++ return parts; ++ } ++ const { pre, body, post } = m; ++ const p = pre.split(','); ++ p[0] = carry + p[0]; ++ p[p.length - 1] += '{' + body + '}'; ++ if (!post.length) { ++ pushAll(parts, p); ++ return parts; ++ } ++ carry = p.pop(); ++ pushAll(parts, p); ++ str = post; + } +- parts.push.apply(parts, p); +- return parts; + } + function expand(str, options = {}) { + if (!str) { + return []; + } +- const { max = exports.EXPANSION_MAX, maxLength = exports.EXPANSION_MAX_LENGTH } = options; ++ const { max = exports.EXPANSION_MAX, maxLength = exports.EXPANSION_MAX_LENGTH, maxDepth = exports.EXPANSION_MAX_DEPTH, maxRewrites = exports.EXPANSION_MAX_REWRITES, } = options; + // I don't know why Bash 4.3 does this, but it does. + // Anything starting with {} will have the first two bytes preserved + // but *only* at the top level, so {},a}b will not expand to anything, +@@ -89,7 +122,7 @@ + if (str.slice(0, 2) === '{}') { + str = '\\{\\}' + str.slice(2); + } +- return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces); ++ return expand_(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces); + } + function embrace(str) { + return '{' + str + '}'; +@@ -184,7 +217,13 @@ + } + return N; + } +-function expand_(str, max, maxLength, isTop) { ++function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { ++ // Too deeply nested to keep following: treat the rest as literal, the same ++ // way a group that cannot expand is already handled. Truncating rather than ++ // throwing keeps `expand` total, matching `max` and `maxLength`. ++ if (depth > maxDepth) { ++ return [str]; ++ } + // Consume the string's top-level brace groups left to right, threading a + // running set of combined prefixes (`acc`). Expanding the tail iteratively - + // rather than recursing on `m.post` once per group - keeps the native stack +@@ -196,6 +235,9 @@ + // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop + // is on the final strings, so it is applied to whichever `combine` produces + // them (the one with no brace set left in the tail). ++ // How many times the `{a},b}` rewrite below has restarted the scan. Each pass ++ // re-reads the whole string, so leaving this unbounded is quadratic. ++ let rewrites = 0; + let dropEmpties = false; + let firstGroup = true; + for (;;) { +@@ -220,7 +262,8 @@ + const isOptions = m.body.indexOf(',') >= 0; + if (!isSequence && !isOptions) { + // {a},b} +- if (m.post.match(/,(?!,).*\}/)) { ++ if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) { ++ rewrites++; + str = m.pre + '{' + m.body + escClose + m.post; + isTop = true; + continue; +@@ -240,7 +283,7 @@ + let n = parseCommaParts(m.body); + if (n.length === 1 && n[0] !== undefined) { + // x{{a,b}}y ==> x{a}y x{b}y +- n = expand_(n[0], max, maxLength, false).map(embrace); ++ n = expand_(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace); + //XXX is this necessary? Can't seem to hit it in tests. + /* c8 ignore start */ + if (n.length === 1) { +@@ -266,12 +309,13 @@ + values = []; + let valuesLength = 0; + outer: for (let j = 0; j < n.length; j++) { +- const expanded = expand_(n[j], max, maxLength, false); ++ const expanded = expand_(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false); + for (let k = 0; k < expanded.length; k++) { + const v = expanded[k]; + if (dropsEmpties && !v) + continue; +- if (values.length >= max || valuesLength + v.length > maxLength) { ++ if (values.length >= max || ++ valuesLength + v.length > maxLength) { + break outer; + } + values.push(v); +--- a/dist/commonjs/index.js.map ++++ b/dist/commonjs/index.js.map +@@ -1 +1 @@ +-{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":";;;AA2FA,wBAkBC;AA7GD,mDAAyC;AAEzC,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AAC/C,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,SAAS,GAAG,UAAU,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACnD,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;AAC/C,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,gBAAgB,GAAG,IAAI,MAAM,CAAC,SAAS,EAAE,GAAG,CAAC,CAAA;AACnD,MAAM,YAAY,GAAG,OAAO,CAAA;AAC5B,MAAM,WAAW,GAAG,MAAM,CAAA;AAC1B,MAAM,YAAY,GAAG,MAAM,CAAA;AAC3B,MAAM,YAAY,GAAG,MAAM,CAAA;AAC3B,MAAM,aAAa,GAAG,OAAO,CAAA;AAEhB,QAAA,aAAa,GAAG,OAAO,CAAA;AAEpC,4EAA4E;AAC5E,2EAA2E;AAC3E,yEAAyE;AACzE,0EAA0E;AAC1E,6EAA6E;AAC7E,sEAAsE;AACtE,4EAA4E;AAC5E,0EAA0E;AAC1E,wEAAwE;AACxE,iDAAiD;AACpC,QAAA,oBAAoB,GAAG,SAAS,CAAA;AAE7C,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,CAAC,KAAK,CAAC,GAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;AACnE,CAAC;AAED,SAAS,YAAY,CAAC,GAAW;IAC/B,OAAO,GAAG;SACP,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC;SAC7B,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,aAAa,EAAE,SAAS,CAAC,CAAA;AACtC,CAAC;AAED,SAAS,cAAc,CAAC,GAAW;IACjC,OAAO,GAAG;SACP,OAAO,CAAC,eAAe,EAAE,IAAI,CAAC;SAC9B,OAAO,CAAC,cAAc,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAA;AACnC,CAAC;AAED;;;;GAIG;AACH,SAAS,eAAe,CAAC,GAAW;IAClC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,CAAC,EAAE,CAAC,CAAA;IACb,CAAC;IAED,MAAM,KAAK,GAAa,EAAE,CAAA;IAC1B,MAAM,CAAC,GAAG,IAAA,yBAAQ,EAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;IAEjC,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,OAAO,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IACvB,CAAC;IAED,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;IAC7B,MAAM,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IAExB,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,IAAI,GAAG,GAAG,CAAA;IACnC,MAAM,SAAS,GAAG,eAAe,CAAC,IAAI,CAAC,CAAA;IACvC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAChB,CAAC;QAAC,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAY,IAAI,SAAS,CAAC,KAAK,EAAE,CAAA;QACjD,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAA;IAC5B,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;IAE1B,OAAO,KAAK,CAAA;AACd,CAAC;AAOD,SAAgB,MAAM,CAAC,GAAW,EAAE,UAAiC,EAAE;IACrE,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,EAAE,GAAG,GAAG,qBAAa,EAAE,SAAS,GAAG,4BAAoB,EAAE,GAAG,OAAO,CAAA;IAEzE,oDAAoD;IACpD,oEAAoE;IACpE,sEAAsE;IACtE,6CAA6C;IAC7C,oEAAoE;IACpE,+DAA+D;IAC/D,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;QAC7B,GAAG,GAAG,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IAC/B,CAAC;IAED,OAAO,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,CAAA;AAC7E,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;AACxB,CAAC;AAED,SAAS,QAAQ,CAAC,EAAU;IAC1B,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AAC1B,CAAC;AAED,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED,0EAA0E;AAC1E,gFAAgF;AAChF,2EAA2E;AAC3E,gFAAgF;AAChF,iCAAiC;AACjC,SAAS,OAAO,CACd,GAAa,EACb,GAAW,EACX,MAAgB,EAChB,GAAW,EACX,SAAiB,EACjB,WAAoB;IAEpB,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACpC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG;gBAAE,OAAO,GAAG,CAAA;YACjC,MAAM,SAAS,GAAI,GAAG,CAAC,CAAC,CAAY,GAAG,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;YACtD,yEAAyE;YACzE,+DAA+D;YAC/D,IAAI,WAAW,IAAI,CAAC,SAAS;gBAAE,SAAQ;YACvC,IAAI,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,SAAS;gBAAE,OAAO,GAAG,CAAA;YACrD,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;YACnB,MAAM,IAAI,SAAS,CAAC,MAAM,CAAA;QAC5B,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,8EAA8E;AAC9E,iBAAiB;AACjB,SAAS,cAAc,CACrB,IAAY,EACZ,eAAwB,EACxB,GAAW,EACX,SAAiB;IAEjB,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;IAC5B,MAAM,CAAC,GAAa,EAAE,CAAA;IACtB,0EAA0E;IAC1E,mBAAmB;IACnB,qBAAqB;IACrB,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;QAC7C,OAAO,CAAC,CAAA;IACV,CAAC;IACD,oBAAoB;IACpB,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvB,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;IAChD,IAAI,IAAI,GACN,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;QACpC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtC,CAAC,CAAC,CAAC,CAAA;IACL,IAAI,IAAI,GAAG,GAAG,CAAA;IACd,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,CAAA;IACrB,IAAI,OAAO,EAAE,CAAC;QACZ,IAAI,IAAI,CAAC,CAAC,CAAA;QACV,IAAI,GAAG,GAAG,CAAA;IACZ,CAAC;IACD,MAAM,GAAG,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAE5B,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC;QACxD,IAAI,CAAC,CAAA;QACL,IAAI,eAAe,EAAE,CAAC;YACpB,CAAC,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC1B,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;gBACf,CAAC,GAAG,EAAE,CAAA;YACR,CAAC;QACH,CAAC;aAAM,CAAC;YACN,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;YACb,IAAI,GAAG,EAAE,CAAC;gBACR,MAAM,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC,MAAM,CAAA;gBAC7B,IAAI,IAAI,GAAG,CAAC,EAAE,CAAC;oBACb,MAAM,CAAC,GAAG,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;oBACvC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;wBACV,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;oBAC1B,CAAC;yBAAM,CAAC;wBACN,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;oBACX,CAAC;gBACH,CAAC;YACH,CAAC;QACH,CAAC;QACD,IAAI,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS;YAAE,MAAK;QACxC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACT,MAAM,IAAI,CAAC,CAAC,MAAM,CAAA;IACpB,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC;AAED,SAAS,OAAO,CACd,GAAW,EACX,GAAW,EACX,SAAiB,EACjB,KAAc;IAEd,yEAAyE;IACzE,6EAA6E;IAC7E,4EAA4E;IAC5E,0EAA0E;IAC1E,wEAAwE;IACxE,gDAAgD;IAChD,IAAI,GAAG,GAAa,CAAC,EAAE,CAAC,CAAA;IAExB,2EAA2E;IAC3E,2EAA2E;IAC3E,4EAA4E;IAC5E,qDAAqD;IACrD,IAAI,WAAW,GAAG,KAAK,CAAA;IACvB,IAAI,UAAU,GAAG,IAAI,CAAA;IAErB,SAAS,CAAC;QACR,MAAM,CAAC,GAAG,IAAA,yBAAQ,EAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;QAEjC,wDAAwD;QACxD,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,OAAO,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,WAAW,CAAC,CAAA;QAC7D,CAAC;QAED,yEAAyE;QACzE,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAA;QAEjB,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACpB,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,EACxB,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;YACD,UAAU,GAAG,KAAK,CAAA;YAClB,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;gBAAE,MAAK;YACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;YACZ,SAAQ;QACV,CAAC;QAED,MAAM,iBAAiB,GAAG,gCAAgC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;QACvE,MAAM,eAAe,GAAG,sCAAsC,CAAC,IAAI,CACjE,CAAC,CAAC,IAAI,CACP,CAAA;QACD,MAAM,UAAU,GAAG,iBAAiB,IAAI,eAAe,CAAA;QACvD,MAAM,SAAS,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC1C,IAAI,CAAC,UAAU,IAAI,CAAC,SAAS,EAAE,CAAC;YAC9B,SAAS;YACT,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,EAAE,CAAC;gBAC/B,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,QAAQ,GAAG,CAAC,CAAC,IAAI,CAAA;gBAC9C,KAAK,GAAG,IAAI,CAAA;gBACZ,SAAQ;YACV,CAAC;YACD,kEAAkE;YAClE,OAAO,OAAO,CACZ,GAAG,EACH,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,EACjC,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,CACZ,CAAA;QACH,CAAC;QAED,IAAI,UAAU,EAAE,CAAC;YACf,WAAW,GAAG,KAAK,IAAI,CAAC,UAAU,CAAA;YAClC,UAAU,GAAG,KAAK,CAAA;QACpB,CAAC;QAED,IAAI,MAAgB,CAAA;QACpB,IAAI,UAAU,EAAE,CAAC;YACf,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,IAAI,EAAE,eAAe,EAAE,GAAG,EAAE,SAAS,CAAC,CAAA;QAClE,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;YAC/B,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;gBACzC,4BAA4B;gBAC5B,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;gBACrD,uDAAuD;gBACvD,qBAAqB;gBACrB,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBACnB,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EACV,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;oBACD,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;wBAAE,MAAK;oBACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;oBACZ,SAAQ;gBACV,CAAC;gBACD,oBAAoB;YACtB,CAAC;YAED,wEAAwE;YACxE,uEAAuE;YACvE,0EAA0E;YAC1E,sEAAsE;YACtE,kBAAkB;YAClB,IAAI,YAAY,GAAG,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,GAAG,CAAA;YACxD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,YAAY,IAAI,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACpD,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;oBACX,YAAY,GAAG,KAAK,CAAA;gBACtB,CAAC;YACH,CAAC;YAED,MAAM,GAAG,EAAE,CAAA;YACX,IAAI,YAAY,GAAG,CAAC,CAAA;YACpB,KAAK,EAAE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAW,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CAAA;gBAC/D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;oBACzC,MAAM,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAW,CAAA;oBAC/B,IAAI,YAAY,IAAI,CAAC,CAAC;wBAAE,SAAQ;oBAChC,IAAI,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,YAAY,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;wBAChE,MAAM,KAAK,CAAA;oBACb,CAAC;oBACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;oBACd,YAAY,IAAI,CAAC,CAAC,MAAM,CAAA;gBAC1B,CAAC;YACH,CAAC;QACH,CAAC;QAED,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAC9E,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;YAAE,MAAK;QACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAA;AACZ,CAAC","sourcesContent":["import { balanced } from 'balanced-match'\n\nconst escSlash = '\\0SLASH' + Math.random() + '\\0'\nconst escOpen = '\\0OPEN' + Math.random() + '\\0'\nconst escClose = '\\0CLOSE' + Math.random() + '\\0'\nconst escComma = '\\0COMMA' + Math.random() + '\\0'\nconst escPeriod = '\\0PERIOD' + Math.random() + '\\0'\nconst escSlashPattern = new RegExp(escSlash, 'g')\nconst escOpenPattern = new RegExp(escOpen, 'g')\nconst escClosePattern = new RegExp(escClose, 'g')\nconst escCommaPattern = new RegExp(escComma, 'g')\nconst escPeriodPattern = new RegExp(escPeriod, 'g')\nconst slashPattern = /\\\\\\\\/g\nconst openPattern = /\\\\{/g\nconst closePattern = /\\\\}/g\nconst commaPattern = /\\\\,/g\nconst periodPattern = /\\\\\\./g\n\nexport const EXPANSION_MAX = 100_000\n\n// `EXPANSION_MAX` caps the *number* of expansions, but not their length. An\n// input like `'{a,b}'.repeat(1500)` stays under that count - its output is\n// truncated to 100k results - while making every result ~1500 characters\n// long. The result set, and the intermediate arrays built while combining\n// brace sets, then grow large enough to exhaust memory and crash the process\n// (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of\n// characters the accumulator may hold at any point, so memory stays flat no\n// matter how many brace groups are chained. The limit sits well above any\n// realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M\n// characters) so legitimate input is unaffected.\nexport const EXPANSION_MAX_LENGTH = 4_000_000\n\nfunction numeric(str: string) {\n return !isNaN(str as any) ? parseInt(str, 10) : str.charCodeAt(0)\n}\n\nfunction escapeBraces(str: string) {\n return str\n .replace(slashPattern, escSlash)\n .replace(openPattern, escOpen)\n .replace(closePattern, escClose)\n .replace(commaPattern, escComma)\n .replace(periodPattern, escPeriod)\n}\n\nfunction unescapeBraces(str: string) {\n return str\n .replace(escSlashPattern, '\\\\')\n .replace(escOpenPattern, '{')\n .replace(escClosePattern, '}')\n .replace(escCommaPattern, ',')\n .replace(escPeriodPattern, '.')\n}\n\n/**\n * Basically just str.split(\",\"), but handling cases\n * where we have nested braced sections, which should be\n * treated as individual members, like {a,{b,c},d}\n */\nfunction parseCommaParts(str: string) {\n if (!str) {\n return ['']\n }\n\n const parts: string[] = []\n const m = balanced('{', '}', str)\n\n if (!m) {\n return str.split(',')\n }\n\n const { pre, body, post } = m\n const p = pre.split(',')\n\n p[p.length - 1] += '{' + body + '}'\n const postParts = parseCommaParts(post)\n if (post.length) {\n ;(p[p.length - 1] as string) += postParts.shift()\n p.push.apply(p, postParts)\n }\n\n parts.push.apply(parts, p)\n\n return parts\n}\n\nexport type BraceExpansionOptions = {\n max?: number\n maxLength?: number\n}\n\nexport function expand(str: string, options: BraceExpansionOptions = {}) {\n if (!str) {\n return []\n }\n\n const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH } = options\n\n // I don't know why Bash 4.3 does this, but it does.\n // Anything starting with {} will have the first two bytes preserved\n // but *only* at the top level, so {},a}b will not expand to anything,\n // but a{},b}c will be expanded to [a}c,abc].\n // One could argue that this is a bug in Bash, but since the goal of\n // this module is to match Bash's rules, we escape a leading {}\n if (str.slice(0, 2) === '{}') {\n str = '\\\\{\\\\}' + str.slice(2)\n }\n\n return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces)\n}\n\nfunction embrace(str: string) {\n return '{' + str + '}'\n}\n\nfunction isPadded(el: string) {\n return /^-?0\\d/.test(el)\n}\n\nfunction lte(i: number, y: number) {\n return i <= y\n}\n\nfunction gte(i: number, y: number) {\n return i >= y\n}\n\n// Build `{ acc[a] + pre + values[v] }` for every combination, capping the\n// number of results at `max` and the total number of characters at `maxLength`.\n// This is the one place output grows, so bounding it here keeps the single\n// accumulator - and therefore memory - flat regardless of how many brace groups\n// are combined (CVE-2026-14257).\nfunction combine(\n acc: string[],\n pre: string,\n values: string[],\n max: number,\n maxLength: number,\n dropEmpties: boolean,\n): string[] {\n const out: string[] = []\n let length = 0\n for (let a = 0; a < acc.length; a++) {\n for (let v = 0; v < values.length; v++) {\n if (out.length >= max) return out\n const expansion = (acc[a] as string) + pre + values[v]\n // Bash drops empty results at the top level. Skip them before they count\n // against `max`, so `max` bounds the number of *kept* results.\n if (dropEmpties && !expansion) continue\n if (length + expansion.length > maxLength) return out\n out.push(expansion)\n length += expansion.length\n }\n }\n return out\n}\n\n// The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`)\n// sequence body.\nfunction expandSequence(\n body: string,\n isAlphaSequence: boolean,\n max: number,\n maxLength: number,\n): string[] {\n const n = body.split(/\\.\\./)\n const N: string[] = []\n // A sequence body always splits into two or three parts, but the compiler\n // can't know that.\n /* c8 ignore start */\n if (n[0] === undefined || n[1] === undefined) {\n return N\n }\n /* c8 ignore stop */\n const x = numeric(n[0])\n const y = numeric(n[1])\n const width = Math.max(n[0].length, n[1].length)\n let incr =\n n.length === 3 && n[2] !== undefined ?\n Math.max(Math.abs(numeric(n[2])), 1)\n : 1\n let test = lte\n const reverse = y < x\n if (reverse) {\n incr *= -1\n test = gte\n }\n const pad = n.some(isPadded)\n\n let length = 0\n for (let i = x; test(i, y) && N.length < max; i += incr) {\n let c\n if (isAlphaSequence) {\n c = String.fromCharCode(i)\n if (c === '\\\\') {\n c = ''\n }\n } else {\n c = String(i)\n if (pad) {\n const need = width - c.length\n if (need > 0) {\n const z = new Array(need + 1).join('0')\n if (i < 0) {\n c = '-' + z + c.slice(1)\n } else {\n c = z + c\n }\n }\n }\n }\n if (length + c.length > maxLength) break\n N.push(c)\n length += c.length\n }\n return N\n}\n\nfunction expand_(\n str: string,\n max: number,\n maxLength: number,\n isTop: boolean,\n): string[] {\n // Consume the string's top-level brace groups left to right, threading a\n // running set of combined prefixes (`acc`). Expanding the tail iteratively -\n // rather than recursing on `m.post` once per group - keeps the native stack\n // depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no\n // longer overflow the stack, and leaves a single accumulator whose size\n // `maxLength` bounds directly (CVE-2026-14257).\n let acc: string[] = ['']\n\n // Bash drops empty results, but only when the *first* top-level group is a\n // comma set - a sequence like `{a..\\}` may legitimately yield ''. The drop\n // is on the final strings, so it is applied to whichever `combine` produces\n // them (the one with no brace set left in the tail).\n let dropEmpties = false\n let firstGroup = true\n\n for (;;) {\n const m = balanced('{', '}', str)\n\n // No brace set left: the rest of the string is literal.\n if (!m) {\n return combine(acc, str, [''], max, maxLength, dropEmpties)\n }\n\n // no need to expand pre, since it is guaranteed to be free of brace-sets\n const pre = m.pre\n\n if (/\\$$/.test(pre)) {\n acc = combine(\n acc,\n pre + '{' + m.body + '}',\n [''],\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n firstGroup = false\n if (!m.post.length) break\n str = m.post\n continue\n }\n\n const isNumericSequence = /^-?\\d+\\.\\.-?\\d+(?:\\.\\.-?\\d+)?$/.test(m.body)\n const isAlphaSequence = /^[a-zA-Z]\\.\\.[a-zA-Z](?:\\.\\.-?\\d+)?$/.test(\n m.body,\n )\n const isSequence = isNumericSequence || isAlphaSequence\n const isOptions = m.body.indexOf(',') >= 0\n if (!isSequence && !isOptions) {\n // {a},b}\n if (m.post.match(/,(?!,).*\\}/)) {\n str = m.pre + '{' + m.body + escClose + m.post\n isTop = true\n continue\n }\n // Nothing here expands, so the whole remaining string is literal.\n return combine(\n acc,\n pre + '{' + m.body + '}' + m.post,\n [''],\n max,\n maxLength,\n dropEmpties,\n )\n }\n\n if (firstGroup) {\n dropEmpties = isTop && !isSequence\n firstGroup = false\n }\n\n let values: string[]\n if (isSequence) {\n values = expandSequence(m.body, isAlphaSequence, max, maxLength)\n } else {\n let n = parseCommaParts(m.body)\n if (n.length === 1 && n[0] !== undefined) {\n // x{{a,b}}y ==> x{a}y x{b}y\n n = expand_(n[0], max, maxLength, false).map(embrace)\n //XXX is this necessary? Can't seem to hit it in tests.\n /* c8 ignore start */\n if (n.length === 1) {\n acc = combine(\n acc,\n pre + n[0],\n [''],\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n if (!m.post.length) break\n str = m.post\n continue\n }\n /* c8 ignore stop */\n }\n\n // Values that `combine` is going to drop as empty produce no result, so\n // they must not count against `max` - otherwise `{a,,b}` with `max: 2`\n // would stop at `['a', '']` and yield one result instead of two. Skipping\n // them outright keeps `values` bounded while leaving `max` a bound on\n // *kept* results.\n let dropsEmpties = dropEmpties && !m.post.length && !pre\n for (let d = 0; dropsEmpties && d < acc.length; d++) {\n if (acc[d]) {\n dropsEmpties = false\n }\n }\n\n values = []\n let valuesLength = 0\n outer: for (let j = 0; j < n.length; j++) {\n const expanded = expand_(n[j] as string, max, maxLength, false)\n for (let k = 0; k < expanded.length; k++) {\n const v = expanded[k] as string\n if (dropsEmpties && !v) continue\n if (values.length >= max || valuesLength + v.length > maxLength) {\n break outer\n }\n values.push(v)\n valuesLength += v.length\n }\n }\n }\n\n acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length)\n if (!m.post.length) break\n str = m.post\n }\n\n return acc\n}\n"]} +\ No newline at end of file ++{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":";;;AAkIA,wBA+BC;AAjKD,mDAAyC;AAEzC,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AAC/C,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,SAAS,GAAG,UAAU,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACnD,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;AAC/C,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,gBAAgB,GAAG,IAAI,MAAM,CAAC,SAAS,EAAE,GAAG,CAAC,CAAA;AACnD,MAAM,YAAY,GAAG,OAAO,CAAA;AAC5B,MAAM,WAAW,GAAG,MAAM,CAAA;AAC1B,MAAM,YAAY,GAAG,MAAM,CAAA;AAC3B,MAAM,YAAY,GAAG,MAAM,CAAA;AAC3B,MAAM,aAAa,GAAG,OAAO,CAAA;AAEhB,QAAA,aAAa,GAAG,OAAO,CAAA;AAEpC,4EAA4E;AAC5E,2EAA2E;AAC3E,yEAAyE;AACzE,0EAA0E;AAC1E,6EAA6E;AAC7E,sEAAsE;AACtE,4EAA4E;AAC5E,0EAA0E;AAC1E,wEAAwE;AACxE,iDAAiD;AACpC,QAAA,oBAAoB,GAAG,SAAS,CAAA;AAE7C,+EAA+E;AAC/E,8EAA8E;AAC9E,+EAA+E;AAC/E,4EAA4E;AAC5E,gFAAgF;AAChF,4EAA4E;AAC5E,8EAA8E;AAC9E,yCAAyC;AAC5B,QAAA,mBAAmB,GAAG,KAAK,CAAA;AAExC,+EAA+E;AAC/E,8EAA8E;AAC9E,+EAA+E;AAC/E,8EAA8E;AAC9E,4EAA4E;AAC5E,yEAAyE;AACzE,2EAA2E;AAC3E,mEAAmE;AACtD,QAAA,sBAAsB,GAAG,KAAK,CAAA;AAE3C,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,CAAC,KAAK,CAAC,GAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;AACnE,CAAC;AAED,SAAS,YAAY,CAAC,GAAW;IAC/B,OAAO,GAAG;SACP,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC;SAC7B,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,aAAa,EAAE,SAAS,CAAC,CAAA;AACtC,CAAC;AAED,SAAS,cAAc,CAAC,GAAW;IACjC,OAAO,GAAG;SACP,OAAO,CAAC,eAAe,EAAE,IAAI,CAAC;SAC9B,OAAO,CAAC,cAAc,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAA;AACnC,CAAC;AAED,8DAA8D;AAC9D,SAAS,OAAO,CAAC,MAAgB,EAAE,KAAe;IAChD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAW,CAAC,CAAA;IACjC,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,SAAS,eAAe,CAAC,GAAW;IAClC,MAAM,KAAK,GAAa,EAAE,CAAA;IAE1B,8EAA8E;IAC9E,oEAAoE;IACpE,8EAA8E;IAC9E,4DAA4D;IAC5D,EAAE;IACF,oCAAoC;IACpC,IAAI,KAAK,GAAG,EAAE,CAAA;IAEd,SAAS,CAAC;QACR,MAAM,CAAC,GAAG,IAAA,yBAAQ,EAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;QAEjC,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YAC3B,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,GAAI,IAAI,CAAC,CAAC,CAAY,CAAA;YACrC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;YACpB,OAAO,KAAK,CAAA;QACd,CAAC;QAED,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;QAC7B,MAAM,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QACxB,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,GAAI,CAAC,CAAC,CAAC,CAAY,CAAA;QAC/B,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,IAAI,GAAG,GAAG,CAAA;QAEnC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YACjB,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;YACjB,OAAO,KAAK,CAAA;QACd,CAAC;QAED,KAAK,GAAG,CAAC,CAAC,GAAG,EAAY,CAAA;QACzB,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;QACjB,GAAG,GAAG,IAAI,CAAA;IACZ,CAAC;AACH,CAAC;AASD,SAAgB,MAAM,CAAC,GAAW,EAAE,UAAiC,EAAE;IACrE,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,EACJ,GAAG,GAAG,qBAAa,EACnB,SAAS,GAAG,4BAAoB,EAChC,QAAQ,GAAG,2BAAmB,EAC9B,WAAW,GAAG,8BAAsB,GACrC,GAAG,OAAO,CAAA;IAEX,oDAAoD;IACpD,oEAAoE;IACpE,sEAAsE;IACtE,6CAA6C;IAC7C,oEAAoE;IACpE,+DAA+D;IAC/D,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;QAC7B,GAAG,GAAG,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IAC/B,CAAC;IAED,OAAO,OAAO,CACZ,YAAY,CAAC,GAAG,CAAC,EACjB,GAAG,EACH,SAAS,EACT,QAAQ,EACR,CAAC,EACD,WAAW,EACX,IAAI,CACL,CAAC,GAAG,CAAC,cAAc,CAAC,CAAA;AACvB,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;AACxB,CAAC;AAED,SAAS,QAAQ,CAAC,EAAU;IAC1B,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AAC1B,CAAC;AAED,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED,0EAA0E;AAC1E,gFAAgF;AAChF,2EAA2E;AAC3E,gFAAgF;AAChF,iCAAiC;AACjC,SAAS,OAAO,CACd,GAAa,EACb,GAAW,EACX,MAAgB,EAChB,GAAW,EACX,SAAiB,EACjB,WAAoB;IAEpB,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACpC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG;gBAAE,OAAO,GAAG,CAAA;YACjC,MAAM,SAAS,GAAI,GAAG,CAAC,CAAC,CAAY,GAAG,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;YACtD,yEAAyE;YACzE,+DAA+D;YAC/D,IAAI,WAAW,IAAI,CAAC,SAAS;gBAAE,SAAQ;YACvC,IAAI,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,SAAS;gBAAE,OAAO,GAAG,CAAA;YACrD,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;YACnB,MAAM,IAAI,SAAS,CAAC,MAAM,CAAA;QAC5B,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,8EAA8E;AAC9E,iBAAiB;AACjB,SAAS,cAAc,CACrB,IAAY,EACZ,eAAwB,EACxB,GAAW,EACX,SAAiB;IAEjB,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;IAC5B,MAAM,CAAC,GAAa,EAAE,CAAA;IACtB,0EAA0E;IAC1E,mBAAmB;IACnB,qBAAqB;IACrB,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;QAC7C,OAAO,CAAC,CAAA;IACV,CAAC;IACD,oBAAoB;IACpB,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvB,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;IAChD,IAAI,IAAI,GACN,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;QACpC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtC,CAAC,CAAC,CAAC,CAAA;IACL,IAAI,IAAI,GAAG,GAAG,CAAA;IACd,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,CAAA;IACrB,IAAI,OAAO,EAAE,CAAC;QACZ,IAAI,IAAI,CAAC,CAAC,CAAA;QACV,IAAI,GAAG,GAAG,CAAA;IACZ,CAAC;IACD,MAAM,GAAG,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAE5B,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC;QACxD,IAAI,CAAC,CAAA;QACL,IAAI,eAAe,EAAE,CAAC;YACpB,CAAC,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC1B,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;gBACf,CAAC,GAAG,EAAE,CAAA;YACR,CAAC;QACH,CAAC;aAAM,CAAC;YACN,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;YACb,IAAI,GAAG,EAAE,CAAC;gBACR,MAAM,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC,MAAM,CAAA;gBAC7B,IAAI,IAAI,GAAG,CAAC,EAAE,CAAC;oBACb,MAAM,CAAC,GAAG,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;oBACvC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;wBACV,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;oBAC1B,CAAC;yBAAM,CAAC;wBACN,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;oBACX,CAAC;gBACH,CAAC;YACH,CAAC;QACH,CAAC;QACD,IAAI,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS;YAAE,MAAK;QACxC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACT,MAAM,IAAI,CAAC,CAAC,MAAM,CAAA;IACpB,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC;AAED,SAAS,OAAO,CACd,GAAW,EACX,GAAW,EACX,SAAiB,EACjB,QAAgB,EAChB,KAAa,EACb,WAAmB,EACnB,KAAc;IAEd,2EAA2E;IAC3E,4EAA4E;IAC5E,iEAAiE;IACjE,IAAI,KAAK,GAAG,QAAQ,EAAE,CAAC;QACrB,OAAO,CAAC,GAAG,CAAC,CAAA;IACd,CAAC;IAED,yEAAyE;IACzE,6EAA6E;IAC7E,4EAA4E;IAC5E,0EAA0E;IAC1E,wEAAwE;IACxE,gDAAgD;IAChD,IAAI,GAAG,GAAa,CAAC,EAAE,CAAC,CAAA;IAExB,2EAA2E;IAC3E,2EAA2E;IAC3E,4EAA4E;IAC5E,qDAAqD;IACrD,8EAA8E;IAC9E,qEAAqE;IACrE,IAAI,QAAQ,GAAG,CAAC,CAAA;IAChB,IAAI,WAAW,GAAG,KAAK,CAAA;IACvB,IAAI,UAAU,GAAG,IAAI,CAAA;IAErB,SAAS,CAAC;QACR,MAAM,CAAC,GAAG,IAAA,yBAAQ,EAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;QAEjC,wDAAwD;QACxD,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,OAAO,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,WAAW,CAAC,CAAA;QAC7D,CAAC;QAED,yEAAyE;QACzE,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAA;QAEjB,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACpB,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,EACxB,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;YACD,UAAU,GAAG,KAAK,CAAA;YAClB,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;gBAAE,MAAK;YACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;YACZ,SAAQ;QACV,CAAC;QAED,MAAM,iBAAiB,GAAG,gCAAgC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;QACvE,MAAM,eAAe,GAAG,sCAAsC,CAAC,IAAI,CACjE,CAAC,CAAC,IAAI,CACP,CAAA;QACD,MAAM,UAAU,GAAG,iBAAiB,IAAI,eAAe,CAAA;QACvD,MAAM,SAAS,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC1C,IAAI,CAAC,UAAU,IAAI,CAAC,SAAS,EAAE,CAAC;YAC9B,SAAS;YACT,IAAI,QAAQ,GAAG,WAAW,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,EAAE,CAAC;gBACzD,QAAQ,EAAE,CAAA;gBACV,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,QAAQ,GAAG,CAAC,CAAC,IAAI,CAAA;gBAC9C,KAAK,GAAG,IAAI,CAAA;gBACZ,SAAQ;YACV,CAAC;YACD,kEAAkE;YAClE,OAAO,OAAO,CACZ,GAAG,EACH,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,EACjC,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,CACZ,CAAA;QACH,CAAC;QAED,IAAI,UAAU,EAAE,CAAC;YACf,WAAW,GAAG,KAAK,IAAI,CAAC,UAAU,CAAA;YAClC,UAAU,GAAG,KAAK,CAAA;QACpB,CAAC;QAED,IAAI,MAAgB,CAAA;QACpB,IAAI,UAAU,EAAE,CAAC;YACf,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,IAAI,EAAE,eAAe,EAAE,GAAG,EAAE,SAAS,CAAC,CAAA;QAClE,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;YAC/B,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;gBACzC,4BAA4B;gBAC5B,CAAC,GAAG,OAAO,CACT,CAAC,CAAC,CAAC,CAAC,EACJ,GAAG,EACH,SAAS,EACT,QAAQ,EACR,KAAK,GAAG,CAAC,EACT,WAAW,EACX,KAAK,CACN,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;gBACd,uDAAuD;gBACvD,qBAAqB;gBACrB,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBACnB,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EACV,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;oBACD,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;wBAAE,MAAK;oBACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;oBACZ,SAAQ;gBACV,CAAC;gBACD,oBAAoB;YACtB,CAAC;YAED,wEAAwE;YACxE,uEAAuE;YACvE,0EAA0E;YAC1E,sEAAsE;YACtE,kBAAkB;YAClB,IAAI,YAAY,GAAG,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,GAAG,CAAA;YACxD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,YAAY,IAAI,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACpD,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;oBACX,YAAY,GAAG,KAAK,CAAA;gBACtB,CAAC;YACH,CAAC;YAED,MAAM,GAAG,EAAE,CAAA;YACX,IAAI,YAAY,GAAG,CAAC,CAAA;YACpB,KAAK,EAAE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACzC,MAAM,QAAQ,GAAG,OAAO,CACtB,CAAC,CAAC,CAAC,CAAW,EACd,GAAG,EACH,SAAS,EACT,QAAQ,EACR,KAAK,GAAG,CAAC,EACT,WAAW,EACX,KAAK,CACN,CAAA;gBACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;oBACzC,MAAM,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAW,CAAA;oBAC/B,IAAI,YAAY,IAAI,CAAC,CAAC;wBAAE,SAAQ;oBAChC,IACE,MAAM,CAAC,MAAM,IAAI,GAAG;wBACpB,YAAY,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS,EACnC,CAAC;wBACD,MAAM,KAAK,CAAA;oBACb,CAAC;oBACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;oBACd,YAAY,IAAI,CAAC,CAAC,MAAM,CAAA;gBAC1B,CAAC;YACH,CAAC;QACH,CAAC;QAED,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,EACH,MAAM,EACN,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;QACD,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;YAAE,MAAK;QACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAA;AACZ,CAAC","sourcesContent":["import { balanced } from 'balanced-match'\n\nconst escSlash = '\\0SLASH' + Math.random() + '\\0'\nconst escOpen = '\\0OPEN' + Math.random() + '\\0'\nconst escClose = '\\0CLOSE' + Math.random() + '\\0'\nconst escComma = '\\0COMMA' + Math.random() + '\\0'\nconst escPeriod = '\\0PERIOD' + Math.random() + '\\0'\nconst escSlashPattern = new RegExp(escSlash, 'g')\nconst escOpenPattern = new RegExp(escOpen, 'g')\nconst escClosePattern = new RegExp(escClose, 'g')\nconst escCommaPattern = new RegExp(escComma, 'g')\nconst escPeriodPattern = new RegExp(escPeriod, 'g')\nconst slashPattern = /\\\\\\\\/g\nconst openPattern = /\\\\{/g\nconst closePattern = /\\\\}/g\nconst commaPattern = /\\\\,/g\nconst periodPattern = /\\\\\\./g\n\nexport const EXPANSION_MAX = 100_000\n\n// `EXPANSION_MAX` caps the *number* of expansions, but not their length. An\n// input like `'{a,b}'.repeat(1500)` stays under that count - its output is\n// truncated to 100k results - while making every result ~1500 characters\n// long. The result set, and the intermediate arrays built while combining\n// brace sets, then grow large enough to exhaust memory and crash the process\n// (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of\n// characters the accumulator may hold at any point, so memory stays flat no\n// matter how many brace groups are chained. The limit sits well above any\n// realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M\n// characters) so legitimate input is unaffected.\nexport const EXPANSION_MAX_LENGTH = 4_000_000\n\n// `expand_` recurses once per level of brace *nesting* - both when expanding a\n// set's comma members and when re-wrapping a set whose body is a single part.\n// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one\n// level per chained group), which left nesting depth unbounded: about 3,100\n// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack\n// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser\n// will follow nesting. It sits far above any realistic pattern and well below\n// the depth at which the stack runs out.\nexport const EXPANSION_MAX_DEPTH = 1_000\n\n// Bash keeps a quirk where a brace group followed by a comma set still expands\n// (`{a},b}`). The parser implements it by rewriting the string and restarting\n// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`\n// full passes over a string that itself grows by one `escClose` sentinel each\n// time - quadratic in `n`, with a ~26x constant from the sentinel's length.\n// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27\n// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many\n// times the scan may restart. Real `{a},b}` input needs a handful.\nexport const EXPANSION_MAX_REWRITES = 1_000\n\nfunction numeric(str: string) {\n return !isNaN(str as any) ? parseInt(str, 10) : str.charCodeAt(0)\n}\n\nfunction escapeBraces(str: string) {\n return str\n .replace(slashPattern, escSlash)\n .replace(openPattern, escOpen)\n .replace(closePattern, escClose)\n .replace(commaPattern, escComma)\n .replace(periodPattern, escPeriod)\n}\n\nfunction unescapeBraces(str: string) {\n return str\n .replace(escSlashPattern, '\\\\')\n .replace(escOpenPattern, '{')\n .replace(escClosePattern, '}')\n .replace(escCommaPattern, ',')\n .replace(escPeriodPattern, '.')\n}\n\n// Like `target.push(...items)` but doesn't overflow the stack\nfunction pushAll(target: string[], items: string[]) {\n for (let i = 0; i < items.length; i++) {\n target.push(items[i] as string)\n }\n}\n\n/**\n * Basically just str.split(\",\"), but handling cases\n * where we have nested braced sections, which should be\n * treated as individual members, like {a,{b,c},d}\n */\nfunction parseCommaParts(str: string) {\n const parts: string[] = []\n\n // Walk the brace groups iteratively. Recursing on `post` once per group let a\n // chain of them exhaust the stack - the parsing-side counterpart to\n // the `expand_` overflow fixed for CVE-2026-14257, and not something `max` or\n // `maxLength` can bound, since it happens before expansion.\n //\n // The part the next chunk continues\n let carry = ''\n\n for (;;) {\n const m = balanced('{', '}', str)\n\n if (!m) {\n const tail = str.split(',')\n tail[0] = carry + (tail[0] as string)\n pushAll(parts, tail)\n return parts\n }\n\n const { pre, body, post } = m\n const p = pre.split(',')\n p[0] = carry + (p[0] as string)\n p[p.length - 1] += '{' + body + '}'\n\n if (!post.length) {\n pushAll(parts, p)\n return parts\n }\n\n carry = p.pop() as string\n pushAll(parts, p)\n str = post\n }\n}\n\nexport type BraceExpansionOptions = {\n max?: number\n maxLength?: number\n maxDepth?: number\n maxRewrites?: number\n}\n\nexport function expand(str: string, options: BraceExpansionOptions = {}) {\n if (!str) {\n return []\n }\n\n const {\n max = EXPANSION_MAX,\n maxLength = EXPANSION_MAX_LENGTH,\n maxDepth = EXPANSION_MAX_DEPTH,\n maxRewrites = EXPANSION_MAX_REWRITES,\n } = options\n\n // I don't know why Bash 4.3 does this, but it does.\n // Anything starting with {} will have the first two bytes preserved\n // but *only* at the top level, so {},a}b will not expand to anything,\n // but a{},b}c will be expanded to [a}c,abc].\n // One could argue that this is a bug in Bash, but since the goal of\n // this module is to match Bash's rules, we escape a leading {}\n if (str.slice(0, 2) === '{}') {\n str = '\\\\{\\\\}' + str.slice(2)\n }\n\n return expand_(\n escapeBraces(str),\n max,\n maxLength,\n maxDepth,\n 0,\n maxRewrites,\n true,\n ).map(unescapeBraces)\n}\n\nfunction embrace(str: string) {\n return '{' + str + '}'\n}\n\nfunction isPadded(el: string) {\n return /^-?0\\d/.test(el)\n}\n\nfunction lte(i: number, y: number) {\n return i <= y\n}\n\nfunction gte(i: number, y: number) {\n return i >= y\n}\n\n// Build `{ acc[a] + pre + values[v] }` for every combination, capping the\n// number of results at `max` and the total number of characters at `maxLength`.\n// This is the one place output grows, so bounding it here keeps the single\n// accumulator - and therefore memory - flat regardless of how many brace groups\n// are combined (CVE-2026-14257).\nfunction combine(\n acc: string[],\n pre: string,\n values: string[],\n max: number,\n maxLength: number,\n dropEmpties: boolean,\n): string[] {\n const out: string[] = []\n let length = 0\n for (let a = 0; a < acc.length; a++) {\n for (let v = 0; v < values.length; v++) {\n if (out.length >= max) return out\n const expansion = (acc[a] as string) + pre + values[v]\n // Bash drops empty results at the top level. Skip them before they count\n // against `max`, so `max` bounds the number of *kept* results.\n if (dropEmpties && !expansion) continue\n if (length + expansion.length > maxLength) return out\n out.push(expansion)\n length += expansion.length\n }\n }\n return out\n}\n\n// The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`)\n// sequence body.\nfunction expandSequence(\n body: string,\n isAlphaSequence: boolean,\n max: number,\n maxLength: number,\n): string[] {\n const n = body.split(/\\.\\./)\n const N: string[] = []\n // A sequence body always splits into two or three parts, but the compiler\n // can't know that.\n /* c8 ignore start */\n if (n[0] === undefined || n[1] === undefined) {\n return N\n }\n /* c8 ignore stop */\n const x = numeric(n[0])\n const y = numeric(n[1])\n const width = Math.max(n[0].length, n[1].length)\n let incr =\n n.length === 3 && n[2] !== undefined ?\n Math.max(Math.abs(numeric(n[2])), 1)\n : 1\n let test = lte\n const reverse = y < x\n if (reverse) {\n incr *= -1\n test = gte\n }\n const pad = n.some(isPadded)\n\n let length = 0\n for (let i = x; test(i, y) && N.length < max; i += incr) {\n let c\n if (isAlphaSequence) {\n c = String.fromCharCode(i)\n if (c === '\\\\') {\n c = ''\n }\n } else {\n c = String(i)\n if (pad) {\n const need = width - c.length\n if (need > 0) {\n const z = new Array(need + 1).join('0')\n if (i < 0) {\n c = '-' + z + c.slice(1)\n } else {\n c = z + c\n }\n }\n }\n }\n if (length + c.length > maxLength) break\n N.push(c)\n length += c.length\n }\n return N\n}\n\nfunction expand_(\n str: string,\n max: number,\n maxLength: number,\n maxDepth: number,\n depth: number,\n maxRewrites: number,\n isTop: boolean,\n): string[] {\n // Too deeply nested to keep following: treat the rest as literal, the same\n // way a group that cannot expand is already handled. Truncating rather than\n // throwing keeps `expand` total, matching `max` and `maxLength`.\n if (depth > maxDepth) {\n return [str]\n }\n\n // Consume the string's top-level brace groups left to right, threading a\n // running set of combined prefixes (`acc`). Expanding the tail iteratively -\n // rather than recursing on `m.post` once per group - keeps the native stack\n // depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no\n // longer overflow the stack, and leaves a single accumulator whose size\n // `maxLength` bounds directly (CVE-2026-14257).\n let acc: string[] = ['']\n\n // Bash drops empty results, but only when the *first* top-level group is a\n // comma set - a sequence like `{a..\\}` may legitimately yield ''. The drop\n // is on the final strings, so it is applied to whichever `combine` produces\n // them (the one with no brace set left in the tail).\n // How many times the `{a},b}` rewrite below has restarted the scan. Each pass\n // re-reads the whole string, so leaving this unbounded is quadratic.\n let rewrites = 0\n let dropEmpties = false\n let firstGroup = true\n\n for (;;) {\n const m = balanced('{', '}', str)\n\n // No brace set left: the rest of the string is literal.\n if (!m) {\n return combine(acc, str, [''], max, maxLength, dropEmpties)\n }\n\n // no need to expand pre, since it is guaranteed to be free of brace-sets\n const pre = m.pre\n\n if (/\\$$/.test(pre)) {\n acc = combine(\n acc,\n pre + '{' + m.body + '}',\n [''],\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n firstGroup = false\n if (!m.post.length) break\n str = m.post\n continue\n }\n\n const isNumericSequence = /^-?\\d+\\.\\.-?\\d+(?:\\.\\.-?\\d+)?$/.test(m.body)\n const isAlphaSequence = /^[a-zA-Z]\\.\\.[a-zA-Z](?:\\.\\.-?\\d+)?$/.test(\n m.body,\n )\n const isSequence = isNumericSequence || isAlphaSequence\n const isOptions = m.body.indexOf(',') >= 0\n if (!isSequence && !isOptions) {\n // {a},b}\n if (rewrites < maxRewrites && m.post.match(/,(?!,).*\\}/)) {\n rewrites++\n str = m.pre + '{' + m.body + escClose + m.post\n isTop = true\n continue\n }\n // Nothing here expands, so the whole remaining string is literal.\n return combine(\n acc,\n pre + '{' + m.body + '}' + m.post,\n [''],\n max,\n maxLength,\n dropEmpties,\n )\n }\n\n if (firstGroup) {\n dropEmpties = isTop && !isSequence\n firstGroup = false\n }\n\n let values: string[]\n if (isSequence) {\n values = expandSequence(m.body, isAlphaSequence, max, maxLength)\n } else {\n let n = parseCommaParts(m.body)\n if (n.length === 1 && n[0] !== undefined) {\n // x{{a,b}}y ==> x{a}y x{b}y\n n = expand_(\n n[0],\n max,\n maxLength,\n maxDepth,\n depth + 1,\n maxRewrites,\n false,\n ).map(embrace)\n //XXX is this necessary? Can't seem to hit it in tests.\n /* c8 ignore start */\n if (n.length === 1) {\n acc = combine(\n acc,\n pre + n[0],\n [''],\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n if (!m.post.length) break\n str = m.post\n continue\n }\n /* c8 ignore stop */\n }\n\n // Values that `combine` is going to drop as empty produce no result, so\n // they must not count against `max` - otherwise `{a,,b}` with `max: 2`\n // would stop at `['a', '']` and yield one result instead of two. Skipping\n // them outright keeps `values` bounded while leaving `max` a bound on\n // *kept* results.\n let dropsEmpties = dropEmpties && !m.post.length && !pre\n for (let d = 0; dropsEmpties && d < acc.length; d++) {\n if (acc[d]) {\n dropsEmpties = false\n }\n }\n\n values = []\n let valuesLength = 0\n outer: for (let j = 0; j < n.length; j++) {\n const expanded = expand_(\n n[j] as string,\n max,\n maxLength,\n maxDepth,\n depth + 1,\n maxRewrites,\n false,\n )\n for (let k = 0; k < expanded.length; k++) {\n const v = expanded[k] as string\n if (dropsEmpties && !v) continue\n if (\n values.length >= max ||\n valuesLength + v.length > maxLength\n ) {\n break outer\n }\n values.push(v)\n valuesLength += v.length\n }\n }\n }\n\n acc = combine(\n acc,\n pre,\n values,\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n if (!m.post.length) break\n str = m.post\n }\n\n return acc\n}\n"]} +\ No newline at end of file +--- a/dist/esm/index.d.ts ++++ b/dist/esm/index.d.ts +@@ -1,8 +1,12 @@ + export declare const EXPANSION_MAX = 100000; + export declare const EXPANSION_MAX_LENGTH = 4000000; ++export declare const EXPANSION_MAX_DEPTH = 1000; ++export declare const EXPANSION_MAX_REWRITES = 1000; + export type BraceExpansionOptions = { + max?: number; + maxLength?: number; ++ maxDepth?: number; ++ maxRewrites?: number; + }; + export declare function expand(str: string, options?: BraceExpansionOptions): string[]; + //# sourceMappingURL=index.d.ts.map +\ No newline at end of file +--- a/dist/esm/index.d.ts.map ++++ b/dist/esm/index.d.ts.map +@@ -1 +1 @@ +-{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,aAAa,SAAU,CAAA;AAYpC,eAAO,MAAM,oBAAoB,UAAY,CAAA;AAwD7C,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,YAkBtE"} +\ No newline at end of file ++{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,aAAa,SAAU,CAAA;AAYpC,eAAO,MAAM,oBAAoB,UAAY,CAAA;AAU7C,eAAO,MAAM,mBAAmB,OAAQ,CAAA;AAUxC,eAAO,MAAM,sBAAsB,OAAQ,CAAA;AAyE3C,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,YA+BtE"} +\ No newline at end of file +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -26,6 +26,24 @@ + // realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M + // characters) so legitimate input is unaffected. + export const EXPANSION_MAX_LENGTH = 4_000_000; ++// `expand_` recurses once per level of brace *nesting* - both when expanding a ++// set's comma members and when re-wrapping a set whose body is a single part. ++// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one ++// level per chained group), which left nesting depth unbounded: about 3,100 ++// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack ++// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser ++// will follow nesting. It sits far above any realistic pattern and well below ++// the depth at which the stack runs out. ++export const EXPANSION_MAX_DEPTH = 1_000; ++// Bash keeps a quirk where a brace group followed by a comma set still expands ++// (`{a},b}`). The parser implements it by rewriting the string and restarting ++// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n` ++// full passes over a string that itself grows by one `escClose` sentinel each ++// time - quadratic in `n`, with a ~26x constant from the sentinel's length. ++// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27 ++// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many ++// times the scan may restart. Real `{a},b}` input needs a handful. ++export const EXPANSION_MAX_REWRITES = 1_000; + function numeric(str) { + return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0); + } +@@ -45,37 +63,52 @@ + .replace(escCommaPattern, ',') + .replace(escPeriodPattern, '.'); + } ++// Like `target.push(...items)` but doesn't overflow the stack ++function pushAll(target, items) { ++ for (let i = 0; i < items.length; i++) { ++ target.push(items[i]); ++ } ++} + /** + * Basically just str.split(","), but handling cases + * where we have nested braced sections, which should be + * treated as individual members, like {a,{b,c},d} + */ + function parseCommaParts(str) { +- if (!str) { +- return ['']; +- } + const parts = []; +- const m = balanced('{', '}', str); +- if (!m) { +- return str.split(','); +- } +- const { pre, body, post } = m; +- const p = pre.split(','); +- p[p.length - 1] += '{' + body + '}'; +- const postParts = parseCommaParts(post); +- if (post.length) { +- ; +- p[p.length - 1] += postParts.shift(); +- p.push.apply(p, postParts); ++ // Walk the brace groups iteratively. Recursing on `post` once per group let a ++ // chain of them exhaust the stack - the parsing-side counterpart to ++ // the `expand_` overflow fixed for CVE-2026-14257, and not something `max` or ++ // `maxLength` can bound, since it happens before expansion. ++ // ++ // The part the next chunk continues ++ let carry = ''; ++ for (;;) { ++ const m = balanced('{', '}', str); ++ if (!m) { ++ const tail = str.split(','); ++ tail[0] = carry + tail[0]; ++ pushAll(parts, tail); ++ return parts; ++ } ++ const { pre, body, post } = m; ++ const p = pre.split(','); ++ p[0] = carry + p[0]; ++ p[p.length - 1] += '{' + body + '}'; ++ if (!post.length) { ++ pushAll(parts, p); ++ return parts; ++ } ++ carry = p.pop(); ++ pushAll(parts, p); ++ str = post; + } +- parts.push.apply(parts, p); +- return parts; + } + export function expand(str, options = {}) { + if (!str) { + return []; + } +- const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH } = options; ++ const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH, maxDepth = EXPANSION_MAX_DEPTH, maxRewrites = EXPANSION_MAX_REWRITES, } = options; + // I don't know why Bash 4.3 does this, but it does. + // Anything starting with {} will have the first two bytes preserved + // but *only* at the top level, so {},a}b will not expand to anything, +@@ -85,7 +118,7 @@ + if (str.slice(0, 2) === '{}') { + str = '\\{\\}' + str.slice(2); + } +- return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces); ++ return expand_(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces); + } + function embrace(str) { + return '{' + str + '}'; +@@ -180,7 +213,13 @@ + } + return N; + } +-function expand_(str, max, maxLength, isTop) { ++function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { ++ // Too deeply nested to keep following: treat the rest as literal, the same ++ // way a group that cannot expand is already handled. Truncating rather than ++ // throwing keeps `expand` total, matching `max` and `maxLength`. ++ if (depth > maxDepth) { ++ return [str]; ++ } + // Consume the string's top-level brace groups left to right, threading a + // running set of combined prefixes (`acc`). Expanding the tail iteratively - + // rather than recursing on `m.post` once per group - keeps the native stack +@@ -192,6 +231,9 @@ + // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop + // is on the final strings, so it is applied to whichever `combine` produces + // them (the one with no brace set left in the tail). ++ // How many times the `{a},b}` rewrite below has restarted the scan. Each pass ++ // re-reads the whole string, so leaving this unbounded is quadratic. ++ let rewrites = 0; + let dropEmpties = false; + let firstGroup = true; + for (;;) { +@@ -216,7 +258,8 @@ + const isOptions = m.body.indexOf(',') >= 0; + if (!isSequence && !isOptions) { + // {a},b} +- if (m.post.match(/,(?!,).*\}/)) { ++ if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) { ++ rewrites++; + str = m.pre + '{' + m.body + escClose + m.post; + isTop = true; + continue; +@@ -236,7 +279,7 @@ + let n = parseCommaParts(m.body); + if (n.length === 1 && n[0] !== undefined) { + // x{{a,b}}y ==> x{a}y x{b}y +- n = expand_(n[0], max, maxLength, false).map(embrace); ++ n = expand_(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace); + //XXX is this necessary? Can't seem to hit it in tests. + /* c8 ignore start */ + if (n.length === 1) { +@@ -262,12 +305,13 @@ + values = []; + let valuesLength = 0; + outer: for (let j = 0; j < n.length; j++) { +- const expanded = expand_(n[j], max, maxLength, false); ++ const expanded = expand_(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false); + for (let k = 0; k < expanded.length; k++) { + const v = expanded[k]; + if (dropsEmpties && !v) + continue; +- if (values.length >= max || valuesLength + v.length > maxLength) { ++ if (values.length >= max || ++ valuesLength + v.length > maxLength) { + break outer; + } + values.push(v); +--- a/dist/esm/index.js.map ++++ b/dist/esm/index.js.map +@@ -1 +1 @@ +-{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAEzC,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AAC/C,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,SAAS,GAAG,UAAU,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACnD,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;AAC/C,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,gBAAgB,GAAG,IAAI,MAAM,CAAC,SAAS,EAAE,GAAG,CAAC,CAAA;AACnD,MAAM,YAAY,GAAG,OAAO,CAAA;AAC5B,MAAM,WAAW,GAAG,MAAM,CAAA;AAC1B,MAAM,YAAY,GAAG,MAAM,CAAA;AAC3B,MAAM,YAAY,GAAG,MAAM,CAAA;AAC3B,MAAM,aAAa,GAAG,OAAO,CAAA;AAE7B,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAA;AAEpC,4EAA4E;AAC5E,2EAA2E;AAC3E,yEAAyE;AACzE,0EAA0E;AAC1E,6EAA6E;AAC7E,sEAAsE;AACtE,4EAA4E;AAC5E,0EAA0E;AAC1E,wEAAwE;AACxE,iDAAiD;AACjD,MAAM,CAAC,MAAM,oBAAoB,GAAG,SAAS,CAAA;AAE7C,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,CAAC,KAAK,CAAC,GAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;AACnE,CAAC;AAED,SAAS,YAAY,CAAC,GAAW;IAC/B,OAAO,GAAG;SACP,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC;SAC7B,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,aAAa,EAAE,SAAS,CAAC,CAAA;AACtC,CAAC;AAED,SAAS,cAAc,CAAC,GAAW;IACjC,OAAO,GAAG;SACP,OAAO,CAAC,eAAe,EAAE,IAAI,CAAC;SAC9B,OAAO,CAAC,cAAc,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAA;AACnC,CAAC;AAED;;;;GAIG;AACH,SAAS,eAAe,CAAC,GAAW;IAClC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,CAAC,EAAE,CAAC,CAAA;IACb,CAAC;IAED,MAAM,KAAK,GAAa,EAAE,CAAA;IAC1B,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;IAEjC,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,OAAO,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IACvB,CAAC;IAED,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;IAC7B,MAAM,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IAExB,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,IAAI,GAAG,GAAG,CAAA;IACnC,MAAM,SAAS,GAAG,eAAe,CAAC,IAAI,CAAC,CAAA;IACvC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAChB,CAAC;QAAC,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAY,IAAI,SAAS,CAAC,KAAK,EAAE,CAAA;QACjD,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAA;IAC5B,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;IAE1B,OAAO,KAAK,CAAA;AACd,CAAC;AAOD,MAAM,UAAU,MAAM,CAAC,GAAW,EAAE,UAAiC,EAAE;IACrE,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,EAAE,GAAG,GAAG,aAAa,EAAE,SAAS,GAAG,oBAAoB,EAAE,GAAG,OAAO,CAAA;IAEzE,oDAAoD;IACpD,oEAAoE;IACpE,sEAAsE;IACtE,6CAA6C;IAC7C,oEAAoE;IACpE,+DAA+D;IAC/D,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;QAC7B,GAAG,GAAG,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IAC/B,CAAC;IAED,OAAO,OAAO,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,CAAA;AAC7E,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;AACxB,CAAC;AAED,SAAS,QAAQ,CAAC,EAAU;IAC1B,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AAC1B,CAAC;AAED,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED,0EAA0E;AAC1E,gFAAgF;AAChF,2EAA2E;AAC3E,gFAAgF;AAChF,iCAAiC;AACjC,SAAS,OAAO,CACd,GAAa,EACb,GAAW,EACX,MAAgB,EAChB,GAAW,EACX,SAAiB,EACjB,WAAoB;IAEpB,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACpC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG;gBAAE,OAAO,GAAG,CAAA;YACjC,MAAM,SAAS,GAAI,GAAG,CAAC,CAAC,CAAY,GAAG,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;YACtD,yEAAyE;YACzE,+DAA+D;YAC/D,IAAI,WAAW,IAAI,CAAC,SAAS;gBAAE,SAAQ;YACvC,IAAI,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,SAAS;gBAAE,OAAO,GAAG,CAAA;YACrD,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;YACnB,MAAM,IAAI,SAAS,CAAC,MAAM,CAAA;QAC5B,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,8EAA8E;AAC9E,iBAAiB;AACjB,SAAS,cAAc,CACrB,IAAY,EACZ,eAAwB,EACxB,GAAW,EACX,SAAiB;IAEjB,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;IAC5B,MAAM,CAAC,GAAa,EAAE,CAAA;IACtB,0EAA0E;IAC1E,mBAAmB;IACnB,qBAAqB;IACrB,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;QAC7C,OAAO,CAAC,CAAA;IACV,CAAC;IACD,oBAAoB;IACpB,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvB,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;IAChD,IAAI,IAAI,GACN,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;QACpC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtC,CAAC,CAAC,CAAC,CAAA;IACL,IAAI,IAAI,GAAG,GAAG,CAAA;IACd,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,CAAA;IACrB,IAAI,OAAO,EAAE,CAAC;QACZ,IAAI,IAAI,CAAC,CAAC,CAAA;QACV,IAAI,GAAG,GAAG,CAAA;IACZ,CAAC;IACD,MAAM,GAAG,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAE5B,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC;QACxD,IAAI,CAAC,CAAA;QACL,IAAI,eAAe,EAAE,CAAC;YACpB,CAAC,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC1B,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;gBACf,CAAC,GAAG,EAAE,CAAA;YACR,CAAC;QACH,CAAC;aAAM,CAAC;YACN,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;YACb,IAAI,GAAG,EAAE,CAAC;gBACR,MAAM,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC,MAAM,CAAA;gBAC7B,IAAI,IAAI,GAAG,CAAC,EAAE,CAAC;oBACb,MAAM,CAAC,GAAG,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;oBACvC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;wBACV,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;oBAC1B,CAAC;yBAAM,CAAC;wBACN,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;oBACX,CAAC;gBACH,CAAC;YACH,CAAC;QACH,CAAC;QACD,IAAI,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS;YAAE,MAAK;QACxC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACT,MAAM,IAAI,CAAC,CAAC,MAAM,CAAA;IACpB,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC;AAED,SAAS,OAAO,CACd,GAAW,EACX,GAAW,EACX,SAAiB,EACjB,KAAc;IAEd,yEAAyE;IACzE,6EAA6E;IAC7E,4EAA4E;IAC5E,0EAA0E;IAC1E,wEAAwE;IACxE,gDAAgD;IAChD,IAAI,GAAG,GAAa,CAAC,EAAE,CAAC,CAAA;IAExB,2EAA2E;IAC3E,2EAA2E;IAC3E,4EAA4E;IAC5E,qDAAqD;IACrD,IAAI,WAAW,GAAG,KAAK,CAAA;IACvB,IAAI,UAAU,GAAG,IAAI,CAAA;IAErB,SAAS,CAAC;QACR,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;QAEjC,wDAAwD;QACxD,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,OAAO,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,WAAW,CAAC,CAAA;QAC7D,CAAC;QAED,yEAAyE;QACzE,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAA;QAEjB,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACpB,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,EACxB,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;YACD,UAAU,GAAG,KAAK,CAAA;YAClB,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;gBAAE,MAAK;YACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;YACZ,SAAQ;QACV,CAAC;QAED,MAAM,iBAAiB,GAAG,gCAAgC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;QACvE,MAAM,eAAe,GAAG,sCAAsC,CAAC,IAAI,CACjE,CAAC,CAAC,IAAI,CACP,CAAA;QACD,MAAM,UAAU,GAAG,iBAAiB,IAAI,eAAe,CAAA;QACvD,MAAM,SAAS,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC1C,IAAI,CAAC,UAAU,IAAI,CAAC,SAAS,EAAE,CAAC;YAC9B,SAAS;YACT,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,EAAE,CAAC;gBAC/B,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,QAAQ,GAAG,CAAC,CAAC,IAAI,CAAA;gBAC9C,KAAK,GAAG,IAAI,CAAA;gBACZ,SAAQ;YACV,CAAC;YACD,kEAAkE;YAClE,OAAO,OAAO,CACZ,GAAG,EACH,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,EACjC,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,CACZ,CAAA;QACH,CAAC;QAED,IAAI,UAAU,EAAE,CAAC;YACf,WAAW,GAAG,KAAK,IAAI,CAAC,UAAU,CAAA;YAClC,UAAU,GAAG,KAAK,CAAA;QACpB,CAAC;QAED,IAAI,MAAgB,CAAA;QACpB,IAAI,UAAU,EAAE,CAAC;YACf,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,IAAI,EAAE,eAAe,EAAE,GAAG,EAAE,SAAS,CAAC,CAAA;QAClE,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;YAC/B,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;gBACzC,4BAA4B;gBAC5B,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;gBACrD,uDAAuD;gBACvD,qBAAqB;gBACrB,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBACnB,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EACV,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;oBACD,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;wBAAE,MAAK;oBACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;oBACZ,SAAQ;gBACV,CAAC;gBACD,oBAAoB;YACtB,CAAC;YAED,wEAAwE;YACxE,uEAAuE;YACvE,0EAA0E;YAC1E,sEAAsE;YACtE,kBAAkB;YAClB,IAAI,YAAY,GAAG,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,GAAG,CAAA;YACxD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,YAAY,IAAI,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACpD,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;oBACX,YAAY,GAAG,KAAK,CAAA;gBACtB,CAAC;YACH,CAAC;YAED,MAAM,GAAG,EAAE,CAAA;YACX,IAAI,YAAY,GAAG,CAAC,CAAA;YACpB,KAAK,EAAE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAW,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CAAA;gBAC/D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;oBACzC,MAAM,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAW,CAAA;oBAC/B,IAAI,YAAY,IAAI,CAAC,CAAC;wBAAE,SAAQ;oBAChC,IAAI,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,YAAY,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;wBAChE,MAAM,KAAK,CAAA;oBACb,CAAC;oBACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;oBACd,YAAY,IAAI,CAAC,CAAC,MAAM,CAAA;gBAC1B,CAAC;YACH,CAAC;QACH,CAAC;QAED,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAC9E,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;YAAE,MAAK;QACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAA;AACZ,CAAC","sourcesContent":["import { balanced } from 'balanced-match'\n\nconst escSlash = '\\0SLASH' + Math.random() + '\\0'\nconst escOpen = '\\0OPEN' + Math.random() + '\\0'\nconst escClose = '\\0CLOSE' + Math.random() + '\\0'\nconst escComma = '\\0COMMA' + Math.random() + '\\0'\nconst escPeriod = '\\0PERIOD' + Math.random() + '\\0'\nconst escSlashPattern = new RegExp(escSlash, 'g')\nconst escOpenPattern = new RegExp(escOpen, 'g')\nconst escClosePattern = new RegExp(escClose, 'g')\nconst escCommaPattern = new RegExp(escComma, 'g')\nconst escPeriodPattern = new RegExp(escPeriod, 'g')\nconst slashPattern = /\\\\\\\\/g\nconst openPattern = /\\\\{/g\nconst closePattern = /\\\\}/g\nconst commaPattern = /\\\\,/g\nconst periodPattern = /\\\\\\./g\n\nexport const EXPANSION_MAX = 100_000\n\n// `EXPANSION_MAX` caps the *number* of expansions, but not their length. An\n// input like `'{a,b}'.repeat(1500)` stays under that count - its output is\n// truncated to 100k results - while making every result ~1500 characters\n// long. The result set, and the intermediate arrays built while combining\n// brace sets, then grow large enough to exhaust memory and crash the process\n// (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of\n// characters the accumulator may hold at any point, so memory stays flat no\n// matter how many brace groups are chained. The limit sits well above any\n// realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M\n// characters) so legitimate input is unaffected.\nexport const EXPANSION_MAX_LENGTH = 4_000_000\n\nfunction numeric(str: string) {\n return !isNaN(str as any) ? parseInt(str, 10) : str.charCodeAt(0)\n}\n\nfunction escapeBraces(str: string) {\n return str\n .replace(slashPattern, escSlash)\n .replace(openPattern, escOpen)\n .replace(closePattern, escClose)\n .replace(commaPattern, escComma)\n .replace(periodPattern, escPeriod)\n}\n\nfunction unescapeBraces(str: string) {\n return str\n .replace(escSlashPattern, '\\\\')\n .replace(escOpenPattern, '{')\n .replace(escClosePattern, '}')\n .replace(escCommaPattern, ',')\n .replace(escPeriodPattern, '.')\n}\n\n/**\n * Basically just str.split(\",\"), but handling cases\n * where we have nested braced sections, which should be\n * treated as individual members, like {a,{b,c},d}\n */\nfunction parseCommaParts(str: string) {\n if (!str) {\n return ['']\n }\n\n const parts: string[] = []\n const m = balanced('{', '}', str)\n\n if (!m) {\n return str.split(',')\n }\n\n const { pre, body, post } = m\n const p = pre.split(',')\n\n p[p.length - 1] += '{' + body + '}'\n const postParts = parseCommaParts(post)\n if (post.length) {\n ;(p[p.length - 1] as string) += postParts.shift()\n p.push.apply(p, postParts)\n }\n\n parts.push.apply(parts, p)\n\n return parts\n}\n\nexport type BraceExpansionOptions = {\n max?: number\n maxLength?: number\n}\n\nexport function expand(str: string, options: BraceExpansionOptions = {}) {\n if (!str) {\n return []\n }\n\n const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH } = options\n\n // I don't know why Bash 4.3 does this, but it does.\n // Anything starting with {} will have the first two bytes preserved\n // but *only* at the top level, so {},a}b will not expand to anything,\n // but a{},b}c will be expanded to [a}c,abc].\n // One could argue that this is a bug in Bash, but since the goal of\n // this module is to match Bash's rules, we escape a leading {}\n if (str.slice(0, 2) === '{}') {\n str = '\\\\{\\\\}' + str.slice(2)\n }\n\n return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces)\n}\n\nfunction embrace(str: string) {\n return '{' + str + '}'\n}\n\nfunction isPadded(el: string) {\n return /^-?0\\d/.test(el)\n}\n\nfunction lte(i: number, y: number) {\n return i <= y\n}\n\nfunction gte(i: number, y: number) {\n return i >= y\n}\n\n// Build `{ acc[a] + pre + values[v] }` for every combination, capping the\n// number of results at `max` and the total number of characters at `maxLength`.\n// This is the one place output grows, so bounding it here keeps the single\n// accumulator - and therefore memory - flat regardless of how many brace groups\n// are combined (CVE-2026-14257).\nfunction combine(\n acc: string[],\n pre: string,\n values: string[],\n max: number,\n maxLength: number,\n dropEmpties: boolean,\n): string[] {\n const out: string[] = []\n let length = 0\n for (let a = 0; a < acc.length; a++) {\n for (let v = 0; v < values.length; v++) {\n if (out.length >= max) return out\n const expansion = (acc[a] as string) + pre + values[v]\n // Bash drops empty results at the top level. Skip them before they count\n // against `max`, so `max` bounds the number of *kept* results.\n if (dropEmpties && !expansion) continue\n if (length + expansion.length > maxLength) return out\n out.push(expansion)\n length += expansion.length\n }\n }\n return out\n}\n\n// The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`)\n// sequence body.\nfunction expandSequence(\n body: string,\n isAlphaSequence: boolean,\n max: number,\n maxLength: number,\n): string[] {\n const n = body.split(/\\.\\./)\n const N: string[] = []\n // A sequence body always splits into two or three parts, but the compiler\n // can't know that.\n /* c8 ignore start */\n if (n[0] === undefined || n[1] === undefined) {\n return N\n }\n /* c8 ignore stop */\n const x = numeric(n[0])\n const y = numeric(n[1])\n const width = Math.max(n[0].length, n[1].length)\n let incr =\n n.length === 3 && n[2] !== undefined ?\n Math.max(Math.abs(numeric(n[2])), 1)\n : 1\n let test = lte\n const reverse = y < x\n if (reverse) {\n incr *= -1\n test = gte\n }\n const pad = n.some(isPadded)\n\n let length = 0\n for (let i = x; test(i, y) && N.length < max; i += incr) {\n let c\n if (isAlphaSequence) {\n c = String.fromCharCode(i)\n if (c === '\\\\') {\n c = ''\n }\n } else {\n c = String(i)\n if (pad) {\n const need = width - c.length\n if (need > 0) {\n const z = new Array(need + 1).join('0')\n if (i < 0) {\n c = '-' + z + c.slice(1)\n } else {\n c = z + c\n }\n }\n }\n }\n if (length + c.length > maxLength) break\n N.push(c)\n length += c.length\n }\n return N\n}\n\nfunction expand_(\n str: string,\n max: number,\n maxLength: number,\n isTop: boolean,\n): string[] {\n // Consume the string's top-level brace groups left to right, threading a\n // running set of combined prefixes (`acc`). Expanding the tail iteratively -\n // rather than recursing on `m.post` once per group - keeps the native stack\n // depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no\n // longer overflow the stack, and leaves a single accumulator whose size\n // `maxLength` bounds directly (CVE-2026-14257).\n let acc: string[] = ['']\n\n // Bash drops empty results, but only when the *first* top-level group is a\n // comma set - a sequence like `{a..\\}` may legitimately yield ''. The drop\n // is on the final strings, so it is applied to whichever `combine` produces\n // them (the one with no brace set left in the tail).\n let dropEmpties = false\n let firstGroup = true\n\n for (;;) {\n const m = balanced('{', '}', str)\n\n // No brace set left: the rest of the string is literal.\n if (!m) {\n return combine(acc, str, [''], max, maxLength, dropEmpties)\n }\n\n // no need to expand pre, since it is guaranteed to be free of brace-sets\n const pre = m.pre\n\n if (/\\$$/.test(pre)) {\n acc = combine(\n acc,\n pre + '{' + m.body + '}',\n [''],\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n firstGroup = false\n if (!m.post.length) break\n str = m.post\n continue\n }\n\n const isNumericSequence = /^-?\\d+\\.\\.-?\\d+(?:\\.\\.-?\\d+)?$/.test(m.body)\n const isAlphaSequence = /^[a-zA-Z]\\.\\.[a-zA-Z](?:\\.\\.-?\\d+)?$/.test(\n m.body,\n )\n const isSequence = isNumericSequence || isAlphaSequence\n const isOptions = m.body.indexOf(',') >= 0\n if (!isSequence && !isOptions) {\n // {a},b}\n if (m.post.match(/,(?!,).*\\}/)) {\n str = m.pre + '{' + m.body + escClose + m.post\n isTop = true\n continue\n }\n // Nothing here expands, so the whole remaining string is literal.\n return combine(\n acc,\n pre + '{' + m.body + '}' + m.post,\n [''],\n max,\n maxLength,\n dropEmpties,\n )\n }\n\n if (firstGroup) {\n dropEmpties = isTop && !isSequence\n firstGroup = false\n }\n\n let values: string[]\n if (isSequence) {\n values = expandSequence(m.body, isAlphaSequence, max, maxLength)\n } else {\n let n = parseCommaParts(m.body)\n if (n.length === 1 && n[0] !== undefined) {\n // x{{a,b}}y ==> x{a}y x{b}y\n n = expand_(n[0], max, maxLength, false).map(embrace)\n //XXX is this necessary? Can't seem to hit it in tests.\n /* c8 ignore start */\n if (n.length === 1) {\n acc = combine(\n acc,\n pre + n[0],\n [''],\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n if (!m.post.length) break\n str = m.post\n continue\n }\n /* c8 ignore stop */\n }\n\n // Values that `combine` is going to drop as empty produce no result, so\n // they must not count against `max` - otherwise `{a,,b}` with `max: 2`\n // would stop at `['a', '']` and yield one result instead of two. Skipping\n // them outright keeps `values` bounded while leaving `max` a bound on\n // *kept* results.\n let dropsEmpties = dropEmpties && !m.post.length && !pre\n for (let d = 0; dropsEmpties && d < acc.length; d++) {\n if (acc[d]) {\n dropsEmpties = false\n }\n }\n\n values = []\n let valuesLength = 0\n outer: for (let j = 0; j < n.length; j++) {\n const expanded = expand_(n[j] as string, max, maxLength, false)\n for (let k = 0; k < expanded.length; k++) {\n const v = expanded[k] as string\n if (dropsEmpties && !v) continue\n if (values.length >= max || valuesLength + v.length > maxLength) {\n break outer\n }\n values.push(v)\n valuesLength += v.length\n }\n }\n }\n\n acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length)\n if (!m.post.length) break\n str = m.post\n }\n\n return acc\n}\n"]} +\ No newline at end of file ++{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAEzC,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AAC/C,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACjD,MAAM,SAAS,GAAG,UAAU,GAAG,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAA;AACnD,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;AAC/C,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,eAAe,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AACjD,MAAM,gBAAgB,GAAG,IAAI,MAAM,CAAC,SAAS,EAAE,GAAG,CAAC,CAAA;AACnD,MAAM,YAAY,GAAG,OAAO,CAAA;AAC5B,MAAM,WAAW,GAAG,MAAM,CAAA;AAC1B,MAAM,YAAY,GAAG,MAAM,CAAA;AAC3B,MAAM,YAAY,GAAG,MAAM,CAAA;AAC3B,MAAM,aAAa,GAAG,OAAO,CAAA;AAE7B,MAAM,CAAC,MAAM,aAAa,GAAG,OAAO,CAAA;AAEpC,4EAA4E;AAC5E,2EAA2E;AAC3E,yEAAyE;AACzE,0EAA0E;AAC1E,6EAA6E;AAC7E,sEAAsE;AACtE,4EAA4E;AAC5E,0EAA0E;AAC1E,wEAAwE;AACxE,iDAAiD;AACjD,MAAM,CAAC,MAAM,oBAAoB,GAAG,SAAS,CAAA;AAE7C,+EAA+E;AAC/E,8EAA8E;AAC9E,+EAA+E;AAC/E,4EAA4E;AAC5E,gFAAgF;AAChF,4EAA4E;AAC5E,8EAA8E;AAC9E,yCAAyC;AACzC,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,CAAA;AAExC,+EAA+E;AAC/E,8EAA8E;AAC9E,+EAA+E;AAC/E,8EAA8E;AAC9E,4EAA4E;AAC5E,yEAAyE;AACzE,2EAA2E;AAC3E,mEAAmE;AACnE,MAAM,CAAC,MAAM,sBAAsB,GAAG,KAAK,CAAA;AAE3C,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,CAAC,KAAK,CAAC,GAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;AACnE,CAAC;AAED,SAAS,YAAY,CAAC,GAAW;IAC/B,OAAO,GAAG;SACP,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC;SAC7B,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,YAAY,EAAE,QAAQ,CAAC;SAC/B,OAAO,CAAC,aAAa,EAAE,SAAS,CAAC,CAAA;AACtC,CAAC;AAED,SAAS,cAAc,CAAC,GAAW;IACjC,OAAO,GAAG;SACP,OAAO,CAAC,eAAe,EAAE,IAAI,CAAC;SAC9B,OAAO,CAAC,cAAc,EAAE,GAAG,CAAC;SAC5B,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC;SAC7B,OAAO,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAA;AACnC,CAAC;AAED,8DAA8D;AAC9D,SAAS,OAAO,CAAC,MAAgB,EAAE,KAAe;IAChD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAW,CAAC,CAAA;IACjC,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,SAAS,eAAe,CAAC,GAAW;IAClC,MAAM,KAAK,GAAa,EAAE,CAAA;IAE1B,8EAA8E;IAC9E,oEAAoE;IACpE,8EAA8E;IAC9E,4DAA4D;IAC5D,EAAE;IACF,oCAAoC;IACpC,IAAI,KAAK,GAAG,EAAE,CAAA;IAEd,SAAS,CAAC;QACR,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;QAEjC,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YAC3B,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,GAAI,IAAI,CAAC,CAAC,CAAY,CAAA;YACrC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;YACpB,OAAO,KAAK,CAAA;QACd,CAAC;QAED,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;QAC7B,MAAM,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QACxB,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,GAAI,CAAC,CAAC,CAAC,CAAY,CAAA;QAC/B,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,IAAI,GAAG,GAAG,CAAA;QAEnC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YACjB,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;YACjB,OAAO,KAAK,CAAA;QACd,CAAC;QAED,KAAK,GAAG,CAAC,CAAC,GAAG,EAAY,CAAA;QACzB,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAA;QACjB,GAAG,GAAG,IAAI,CAAA;IACZ,CAAC;AACH,CAAC;AASD,MAAM,UAAU,MAAM,CAAC,GAAW,EAAE,UAAiC,EAAE;IACrE,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,EACJ,GAAG,GAAG,aAAa,EACnB,SAAS,GAAG,oBAAoB,EAChC,QAAQ,GAAG,mBAAmB,EAC9B,WAAW,GAAG,sBAAsB,GACrC,GAAG,OAAO,CAAA;IAEX,oDAAoD;IACpD,oEAAoE;IACpE,sEAAsE;IACtE,6CAA6C;IAC7C,oEAAoE;IACpE,+DAA+D;IAC/D,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;QAC7B,GAAG,GAAG,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IAC/B,CAAC;IAED,OAAO,OAAO,CACZ,YAAY,CAAC,GAAG,CAAC,EACjB,GAAG,EACH,SAAS,EACT,QAAQ,EACR,CAAC,EACD,WAAW,EACX,IAAI,CACL,CAAC,GAAG,CAAC,cAAc,CAAC,CAAA;AACvB,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;AACxB,CAAC;AAED,SAAS,QAAQ,CAAC,EAAU;IAC1B,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AAC1B,CAAC;AAED,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED,SAAS,GAAG,CAAC,CAAS,EAAE,CAAS;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED,0EAA0E;AAC1E,gFAAgF;AAChF,2EAA2E;AAC3E,gFAAgF;AAChF,iCAAiC;AACjC,SAAS,OAAO,CACd,GAAa,EACb,GAAW,EACX,MAAgB,EAChB,GAAW,EACX,SAAiB,EACjB,WAAoB;IAEpB,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACpC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG;gBAAE,OAAO,GAAG,CAAA;YACjC,MAAM,SAAS,GAAI,GAAG,CAAC,CAAC,CAAY,GAAG,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;YACtD,yEAAyE;YACzE,+DAA+D;YAC/D,IAAI,WAAW,IAAI,CAAC,SAAS;gBAAE,SAAQ;YACvC,IAAI,MAAM,GAAG,SAAS,CAAC,MAAM,GAAG,SAAS;gBAAE,OAAO,GAAG,CAAA;YACrD,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;YACnB,MAAM,IAAI,SAAS,CAAC,MAAM,CAAA;QAC5B,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,8EAA8E;AAC9E,iBAAiB;AACjB,SAAS,cAAc,CACrB,IAAY,EACZ,eAAwB,EACxB,GAAW,EACX,SAAiB;IAEjB,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;IAC5B,MAAM,CAAC,GAAa,EAAE,CAAA;IACtB,0EAA0E;IAC1E,mBAAmB;IACnB,qBAAqB;IACrB,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;QAC7C,OAAO,CAAC,CAAA;IACV,CAAC;IACD,oBAAoB;IACpB,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvB,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAA;IAChD,IAAI,IAAI,GACN,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;QACpC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtC,CAAC,CAAC,CAAC,CAAA;IACL,IAAI,IAAI,GAAG,GAAG,CAAA;IACd,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,CAAA;IACrB,IAAI,OAAO,EAAE,CAAC;QACZ,IAAI,IAAI,CAAC,CAAC,CAAA;QACV,IAAI,GAAG,GAAG,CAAA;IACZ,CAAC;IACD,MAAM,GAAG,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IAE5B,IAAI,MAAM,GAAG,CAAC,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC;QACxD,IAAI,CAAC,CAAA;QACL,IAAI,eAAe,EAAE,CAAC;YACpB,CAAC,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;YAC1B,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;gBACf,CAAC,GAAG,EAAE,CAAA;YACR,CAAC;QACH,CAAC;aAAM,CAAC;YACN,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;YACb,IAAI,GAAG,EAAE,CAAC;gBACR,MAAM,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC,MAAM,CAAA;gBAC7B,IAAI,IAAI,GAAG,CAAC,EAAE,CAAC;oBACb,MAAM,CAAC,GAAG,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;oBACvC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;wBACV,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;oBAC1B,CAAC;yBAAM,CAAC;wBACN,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;oBACX,CAAC;gBACH,CAAC;YACH,CAAC;QACH,CAAC;QACD,IAAI,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS;YAAE,MAAK;QACxC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACT,MAAM,IAAI,CAAC,CAAC,MAAM,CAAA;IACpB,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC;AAED,SAAS,OAAO,CACd,GAAW,EACX,GAAW,EACX,SAAiB,EACjB,QAAgB,EAChB,KAAa,EACb,WAAmB,EACnB,KAAc;IAEd,2EAA2E;IAC3E,4EAA4E;IAC5E,iEAAiE;IACjE,IAAI,KAAK,GAAG,QAAQ,EAAE,CAAC;QACrB,OAAO,CAAC,GAAG,CAAC,CAAA;IACd,CAAC;IAED,yEAAyE;IACzE,6EAA6E;IAC7E,4EAA4E;IAC5E,0EAA0E;IAC1E,wEAAwE;IACxE,gDAAgD;IAChD,IAAI,GAAG,GAAa,CAAC,EAAE,CAAC,CAAA;IAExB,2EAA2E;IAC3E,2EAA2E;IAC3E,4EAA4E;IAC5E,qDAAqD;IACrD,8EAA8E;IAC9E,qEAAqE;IACrE,IAAI,QAAQ,GAAG,CAAC,CAAA;IAChB,IAAI,WAAW,GAAG,KAAK,CAAA;IACvB,IAAI,UAAU,GAAG,IAAI,CAAA;IAErB,SAAS,CAAC;QACR,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;QAEjC,wDAAwD;QACxD,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,OAAO,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,WAAW,CAAC,CAAA;QAC7D,CAAC;QAED,yEAAyE;QACzE,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAA;QAEjB,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACpB,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,EACxB,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;YACD,UAAU,GAAG,KAAK,CAAA;YAClB,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;gBAAE,MAAK;YACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;YACZ,SAAQ;QACV,CAAC;QAED,MAAM,iBAAiB,GAAG,gCAAgC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;QACvE,MAAM,eAAe,GAAG,sCAAsC,CAAC,IAAI,CACjE,CAAC,CAAC,IAAI,CACP,CAAA;QACD,MAAM,UAAU,GAAG,iBAAiB,IAAI,eAAe,CAAA;QACvD,MAAM,SAAS,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC1C,IAAI,CAAC,UAAU,IAAI,CAAC,SAAS,EAAE,CAAC;YAC9B,SAAS;YACT,IAAI,QAAQ,GAAG,WAAW,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,EAAE,CAAC;gBACzD,QAAQ,EAAE,CAAA;gBACV,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,QAAQ,GAAG,CAAC,CAAC,IAAI,CAAA;gBAC9C,KAAK,GAAG,IAAI,CAAA;gBACZ,SAAQ;YACV,CAAC;YACD,kEAAkE;YAClE,OAAO,OAAO,CACZ,GAAG,EACH,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,GAAG,GAAG,GAAG,CAAC,CAAC,IAAI,EACjC,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,CACZ,CAAA;QACH,CAAC;QAED,IAAI,UAAU,EAAE,CAAC;YACf,WAAW,GAAG,KAAK,IAAI,CAAC,UAAU,CAAA;YAClC,UAAU,GAAG,KAAK,CAAA;QACpB,CAAC;QAED,IAAI,MAAgB,CAAA;QACpB,IAAI,UAAU,EAAE,CAAC;YACf,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,IAAI,EAAE,eAAe,EAAE,GAAG,EAAE,SAAS,CAAC,CAAA;QAClE,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;YAC/B,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;gBACzC,4BAA4B;gBAC5B,CAAC,GAAG,OAAO,CACT,CAAC,CAAC,CAAC,CAAC,EACJ,GAAG,EACH,SAAS,EACT,QAAQ,EACR,KAAK,GAAG,CAAC,EACT,WAAW,EACX,KAAK,CACN,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;gBACd,uDAAuD;gBACvD,qBAAqB;gBACrB,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;oBACnB,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EACV,CAAC,EAAE,CAAC,EACJ,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;oBACD,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;wBAAE,MAAK;oBACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;oBACZ,SAAQ;gBACV,CAAC;gBACD,oBAAoB;YACtB,CAAC;YAED,wEAAwE;YACxE,uEAAuE;YACvE,0EAA0E;YAC1E,sEAAsE;YACtE,kBAAkB;YAClB,IAAI,YAAY,GAAG,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,GAAG,CAAA;YACxD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,YAAY,IAAI,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACpD,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;oBACX,YAAY,GAAG,KAAK,CAAA;gBACtB,CAAC;YACH,CAAC;YAED,MAAM,GAAG,EAAE,CAAA;YACX,IAAI,YAAY,GAAG,CAAC,CAAA;YACpB,KAAK,EAAE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBACzC,MAAM,QAAQ,GAAG,OAAO,CACtB,CAAC,CAAC,CAAC,CAAW,EACd,GAAG,EACH,SAAS,EACT,QAAQ,EACR,KAAK,GAAG,CAAC,EACT,WAAW,EACX,KAAK,CACN,CAAA;gBACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;oBACzC,MAAM,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAW,CAAA;oBAC/B,IAAI,YAAY,IAAI,CAAC,CAAC;wBAAE,SAAQ;oBAChC,IACE,MAAM,CAAC,MAAM,IAAI,GAAG;wBACpB,YAAY,GAAG,CAAC,CAAC,MAAM,GAAG,SAAS,EACnC,CAAC;wBACD,MAAM,KAAK,CAAA;oBACb,CAAC;oBACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;oBACd,YAAY,IAAI,CAAC,CAAC,MAAM,CAAA;gBAC1B,CAAC;YACH,CAAC;QACH,CAAC;QAED,GAAG,GAAG,OAAO,CACX,GAAG,EACH,GAAG,EACH,MAAM,EACN,GAAG,EACH,SAAS,EACT,WAAW,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAA;QACD,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM;YAAE,MAAK;QACzB,GAAG,GAAG,CAAC,CAAC,IAAI,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAA;AACZ,CAAC","sourcesContent":["import { balanced } from 'balanced-match'\n\nconst escSlash = '\\0SLASH' + Math.random() + '\\0'\nconst escOpen = '\\0OPEN' + Math.random() + '\\0'\nconst escClose = '\\0CLOSE' + Math.random() + '\\0'\nconst escComma = '\\0COMMA' + Math.random() + '\\0'\nconst escPeriod = '\\0PERIOD' + Math.random() + '\\0'\nconst escSlashPattern = new RegExp(escSlash, 'g')\nconst escOpenPattern = new RegExp(escOpen, 'g')\nconst escClosePattern = new RegExp(escClose, 'g')\nconst escCommaPattern = new RegExp(escComma, 'g')\nconst escPeriodPattern = new RegExp(escPeriod, 'g')\nconst slashPattern = /\\\\\\\\/g\nconst openPattern = /\\\\{/g\nconst closePattern = /\\\\}/g\nconst commaPattern = /\\\\,/g\nconst periodPattern = /\\\\\\./g\n\nexport const EXPANSION_MAX = 100_000\n\n// `EXPANSION_MAX` caps the *number* of expansions, but not their length. An\n// input like `'{a,b}'.repeat(1500)` stays under that count - its output is\n// truncated to 100k results - while making every result ~1500 characters\n// long. The result set, and the intermediate arrays built while combining\n// brace sets, then grow large enough to exhaust memory and crash the process\n// (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of\n// characters the accumulator may hold at any point, so memory stays flat no\n// matter how many brace groups are chained. The limit sits well above any\n// realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M\n// characters) so legitimate input is unaffected.\nexport const EXPANSION_MAX_LENGTH = 4_000_000\n\n// `expand_` recurses once per level of brace *nesting* - both when expanding a\n// set's comma members and when re-wrapping a set whose body is a single part.\n// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one\n// level per chained group), which left nesting depth unbounded: about 3,100\n// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack\n// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser\n// will follow nesting. It sits far above any realistic pattern and well below\n// the depth at which the stack runs out.\nexport const EXPANSION_MAX_DEPTH = 1_000\n\n// Bash keeps a quirk where a brace group followed by a comma set still expands\n// (`{a},b}`). The parser implements it by rewriting the string and restarting\n// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`\n// full passes over a string that itself grows by one `escClose` sentinel each\n// time - quadratic in `n`, with a ~26x constant from the sentinel's length.\n// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27\n// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many\n// times the scan may restart. Real `{a},b}` input needs a handful.\nexport const EXPANSION_MAX_REWRITES = 1_000\n\nfunction numeric(str: string) {\n return !isNaN(str as any) ? parseInt(str, 10) : str.charCodeAt(0)\n}\n\nfunction escapeBraces(str: string) {\n return str\n .replace(slashPattern, escSlash)\n .replace(openPattern, escOpen)\n .replace(closePattern, escClose)\n .replace(commaPattern, escComma)\n .replace(periodPattern, escPeriod)\n}\n\nfunction unescapeBraces(str: string) {\n return str\n .replace(escSlashPattern, '\\\\')\n .replace(escOpenPattern, '{')\n .replace(escClosePattern, '}')\n .replace(escCommaPattern, ',')\n .replace(escPeriodPattern, '.')\n}\n\n// Like `target.push(...items)` but doesn't overflow the stack\nfunction pushAll(target: string[], items: string[]) {\n for (let i = 0; i < items.length; i++) {\n target.push(items[i] as string)\n }\n}\n\n/**\n * Basically just str.split(\",\"), but handling cases\n * where we have nested braced sections, which should be\n * treated as individual members, like {a,{b,c},d}\n */\nfunction parseCommaParts(str: string) {\n const parts: string[] = []\n\n // Walk the brace groups iteratively. Recursing on `post` once per group let a\n // chain of them exhaust the stack - the parsing-side counterpart to\n // the `expand_` overflow fixed for CVE-2026-14257, and not something `max` or\n // `maxLength` can bound, since it happens before expansion.\n //\n // The part the next chunk continues\n let carry = ''\n\n for (;;) {\n const m = balanced('{', '}', str)\n\n if (!m) {\n const tail = str.split(',')\n tail[0] = carry + (tail[0] as string)\n pushAll(parts, tail)\n return parts\n }\n\n const { pre, body, post } = m\n const p = pre.split(',')\n p[0] = carry + (p[0] as string)\n p[p.length - 1] += '{' + body + '}'\n\n if (!post.length) {\n pushAll(parts, p)\n return parts\n }\n\n carry = p.pop() as string\n pushAll(parts, p)\n str = post\n }\n}\n\nexport type BraceExpansionOptions = {\n max?: number\n maxLength?: number\n maxDepth?: number\n maxRewrites?: number\n}\n\nexport function expand(str: string, options: BraceExpansionOptions = {}) {\n if (!str) {\n return []\n }\n\n const {\n max = EXPANSION_MAX,\n maxLength = EXPANSION_MAX_LENGTH,\n maxDepth = EXPANSION_MAX_DEPTH,\n maxRewrites = EXPANSION_MAX_REWRITES,\n } = options\n\n // I don't know why Bash 4.3 does this, but it does.\n // Anything starting with {} will have the first two bytes preserved\n // but *only* at the top level, so {},a}b will not expand to anything,\n // but a{},b}c will be expanded to [a}c,abc].\n // One could argue that this is a bug in Bash, but since the goal of\n // this module is to match Bash's rules, we escape a leading {}\n if (str.slice(0, 2) === '{}') {\n str = '\\\\{\\\\}' + str.slice(2)\n }\n\n return expand_(\n escapeBraces(str),\n max,\n maxLength,\n maxDepth,\n 0,\n maxRewrites,\n true,\n ).map(unescapeBraces)\n}\n\nfunction embrace(str: string) {\n return '{' + str + '}'\n}\n\nfunction isPadded(el: string) {\n return /^-?0\\d/.test(el)\n}\n\nfunction lte(i: number, y: number) {\n return i <= y\n}\n\nfunction gte(i: number, y: number) {\n return i >= y\n}\n\n// Build `{ acc[a] + pre + values[v] }` for every combination, capping the\n// number of results at `max` and the total number of characters at `maxLength`.\n// This is the one place output grows, so bounding it here keeps the single\n// accumulator - and therefore memory - flat regardless of how many brace groups\n// are combined (CVE-2026-14257).\nfunction combine(\n acc: string[],\n pre: string,\n values: string[],\n max: number,\n maxLength: number,\n dropEmpties: boolean,\n): string[] {\n const out: string[] = []\n let length = 0\n for (let a = 0; a < acc.length; a++) {\n for (let v = 0; v < values.length; v++) {\n if (out.length >= max) return out\n const expansion = (acc[a] as string) + pre + values[v]\n // Bash drops empty results at the top level. Skip them before they count\n // against `max`, so `max` bounds the number of *kept* results.\n if (dropEmpties && !expansion) continue\n if (length + expansion.length > maxLength) return out\n out.push(expansion)\n length += expansion.length\n }\n }\n return out\n}\n\n// The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`)\n// sequence body.\nfunction expandSequence(\n body: string,\n isAlphaSequence: boolean,\n max: number,\n maxLength: number,\n): string[] {\n const n = body.split(/\\.\\./)\n const N: string[] = []\n // A sequence body always splits into two or three parts, but the compiler\n // can't know that.\n /* c8 ignore start */\n if (n[0] === undefined || n[1] === undefined) {\n return N\n }\n /* c8 ignore stop */\n const x = numeric(n[0])\n const y = numeric(n[1])\n const width = Math.max(n[0].length, n[1].length)\n let incr =\n n.length === 3 && n[2] !== undefined ?\n Math.max(Math.abs(numeric(n[2])), 1)\n : 1\n let test = lte\n const reverse = y < x\n if (reverse) {\n incr *= -1\n test = gte\n }\n const pad = n.some(isPadded)\n\n let length = 0\n for (let i = x; test(i, y) && N.length < max; i += incr) {\n let c\n if (isAlphaSequence) {\n c = String.fromCharCode(i)\n if (c === '\\\\') {\n c = ''\n }\n } else {\n c = String(i)\n if (pad) {\n const need = width - c.length\n if (need > 0) {\n const z = new Array(need + 1).join('0')\n if (i < 0) {\n c = '-' + z + c.slice(1)\n } else {\n c = z + c\n }\n }\n }\n }\n if (length + c.length > maxLength) break\n N.push(c)\n length += c.length\n }\n return N\n}\n\nfunction expand_(\n str: string,\n max: number,\n maxLength: number,\n maxDepth: number,\n depth: number,\n maxRewrites: number,\n isTop: boolean,\n): string[] {\n // Too deeply nested to keep following: treat the rest as literal, the same\n // way a group that cannot expand is already handled. Truncating rather than\n // throwing keeps `expand` total, matching `max` and `maxLength`.\n if (depth > maxDepth) {\n return [str]\n }\n\n // Consume the string's top-level brace groups left to right, threading a\n // running set of combined prefixes (`acc`). Expanding the tail iteratively -\n // rather than recursing on `m.post` once per group - keeps the native stack\n // depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no\n // longer overflow the stack, and leaves a single accumulator whose size\n // `maxLength` bounds directly (CVE-2026-14257).\n let acc: string[] = ['']\n\n // Bash drops empty results, but only when the *first* top-level group is a\n // comma set - a sequence like `{a..\\}` may legitimately yield ''. The drop\n // is on the final strings, so it is applied to whichever `combine` produces\n // them (the one with no brace set left in the tail).\n // How many times the `{a},b}` rewrite below has restarted the scan. Each pass\n // re-reads the whole string, so leaving this unbounded is quadratic.\n let rewrites = 0\n let dropEmpties = false\n let firstGroup = true\n\n for (;;) {\n const m = balanced('{', '}', str)\n\n // No brace set left: the rest of the string is literal.\n if (!m) {\n return combine(acc, str, [''], max, maxLength, dropEmpties)\n }\n\n // no need to expand pre, since it is guaranteed to be free of brace-sets\n const pre = m.pre\n\n if (/\\$$/.test(pre)) {\n acc = combine(\n acc,\n pre + '{' + m.body + '}',\n [''],\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n firstGroup = false\n if (!m.post.length) break\n str = m.post\n continue\n }\n\n const isNumericSequence = /^-?\\d+\\.\\.-?\\d+(?:\\.\\.-?\\d+)?$/.test(m.body)\n const isAlphaSequence = /^[a-zA-Z]\\.\\.[a-zA-Z](?:\\.\\.-?\\d+)?$/.test(\n m.body,\n )\n const isSequence = isNumericSequence || isAlphaSequence\n const isOptions = m.body.indexOf(',') >= 0\n if (!isSequence && !isOptions) {\n // {a},b}\n if (rewrites < maxRewrites && m.post.match(/,(?!,).*\\}/)) {\n rewrites++\n str = m.pre + '{' + m.body + escClose + m.post\n isTop = true\n continue\n }\n // Nothing here expands, so the whole remaining string is literal.\n return combine(\n acc,\n pre + '{' + m.body + '}' + m.post,\n [''],\n max,\n maxLength,\n dropEmpties,\n )\n }\n\n if (firstGroup) {\n dropEmpties = isTop && !isSequence\n firstGroup = false\n }\n\n let values: string[]\n if (isSequence) {\n values = expandSequence(m.body, isAlphaSequence, max, maxLength)\n } else {\n let n = parseCommaParts(m.body)\n if (n.length === 1 && n[0] !== undefined) {\n // x{{a,b}}y ==> x{a}y x{b}y\n n = expand_(\n n[0],\n max,\n maxLength,\n maxDepth,\n depth + 1,\n maxRewrites,\n false,\n ).map(embrace)\n //XXX is this necessary? Can't seem to hit it in tests.\n /* c8 ignore start */\n if (n.length === 1) {\n acc = combine(\n acc,\n pre + n[0],\n [''],\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n if (!m.post.length) break\n str = m.post\n continue\n }\n /* c8 ignore stop */\n }\n\n // Values that `combine` is going to drop as empty produce no result, so\n // they must not count against `max` - otherwise `{a,,b}` with `max: 2`\n // would stop at `['a', '']` and yield one result instead of two. Skipping\n // them outright keeps `values` bounded while leaving `max` a bound on\n // *kept* results.\n let dropsEmpties = dropEmpties && !m.post.length && !pre\n for (let d = 0; dropsEmpties && d < acc.length; d++) {\n if (acc[d]) {\n dropsEmpties = false\n }\n }\n\n values = []\n let valuesLength = 0\n outer: for (let j = 0; j < n.length; j++) {\n const expanded = expand_(\n n[j] as string,\n max,\n maxLength,\n maxDepth,\n depth + 1,\n maxRewrites,\n false,\n )\n for (let k = 0; k < expanded.length; k++) {\n const v = expanded[k] as string\n if (dropsEmpties && !v) continue\n if (\n values.length >= max ||\n valuesLength + v.length > maxLength\n ) {\n break outer\n }\n values.push(v)\n valuesLength += v.length\n }\n }\n }\n\n acc = combine(\n acc,\n pre,\n values,\n max,\n maxLength,\n dropEmpties && !m.post.length,\n )\n if (!m.post.length) break\n str = m.post\n }\n\n return acc\n}\n"]} +\ No newline at end of file +--- a/package.json ++++ b/package.json +@@ -1,7 +1,7 @@ + { + "name": "brace-expansion", + "description": "Brace expansion as known from sh/bash", +- "version": "5.0.9", ++ "version": "5.0.12", + "files": [ + "dist" + ], +@@ -29,6 +29,7 @@ + "test": "tap", + "snap": "tap", + "format": "prettier --write .", ++ "format:check": "prettier --check .", + "benchmark": "node benchmark/index.js", + "typedoc": "typedoc --tsconfig .tshy/esm.json ./src/*.ts" + }, diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch new file mode 100644 index 0000000000..074863bf50 --- /dev/null +++ b/patches/pi-acp@0.0.33.patch @@ -0,0 +1,1472 @@ +--- a/dist/index.js ++++ b/dist/index.js +@@ -11,6 +11,7 @@ + // src/acp/auth.ts + var PI_SETUP_METHOD_ID = "pi_terminal_login"; + function getAuthMethods(opts) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return []; + const supportsTerminalAuthMeta = opts?.supportsTerminalAuthMeta ?? true; + const method = { + id: PI_SETUP_METHOD_ID, +@@ -53,7 +54,7 @@ + + // src/pi-rpc/process.ts + import { spawn } from "child_process"; +-import * as readline from "readline"; ++import { PI_ACP_FEATURES, PiRpcMessageDeltas, piNativeFailure, PiAssistantMessages, piAssistantChunk, piHistoricalAssistantChunk, PiToolIdentities, piHistoricalToolIdentity, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js"; + + // src/pi-rpc/command.ts + import { platform } from "os"; +@@ -94,51 +95,44 @@ + pending = /* @__PURE__ */ new Map(); + eventHandlers = []; + preludeLines = []; +- constructor(child) { ++ constructor(child, invocationNamespace) { ++ this.toolIdentities = new PiToolIdentities(invocationNamespace); ++ this.assistantMessages = new PiAssistantMessages(invocationNamespace); ++ this.rpcMessages = new PiRpcMessageDeltas(); + this.child = child; +- const rl = readline.createInterface({ input: child.stdout }); +- rl.on("line", (line) => { +- if (!line.trim()) return; +- let msg; +- try { +- msg = JSON.parse(line); +- } catch { +- const cleaned = stripAnsi(String(line)).trimEnd(); +- if (cleaned) this.preludeLines.push(cleaned); ++ this.exited = null; ++ const fail = (error) => { ++ if (this.exited) return; ++ this.exited = error; ++ for (const [, pending] of this.pending) pending.reject(error); ++ this.pending.clear(); ++ for (const handler of this.eventHandlers) handler({ type: "paperclip_process_exit", failure: piNativeFailure(error) }); ++ }; ++ const frames = new PiRpcFrames((msg) => { ++ if (msg.type === "response") { ++ if (typeof msg.id === "string") this.pending.get(msg.id)?.resolve(msg); + return; + } +- if (msg?.type === "response") { +- const id = typeof msg.id === "string" ? msg.id : void 0; +- if (id) { +- const pending = this.pending.get(id); +- if (pending) { +- this.pending.delete(id); +- pending.resolve(msg); +- return; +- } +- } +- } +- for (const h of this.eventHandlers) h(msg); ++ const event = this.toolIdentities.normalize(this.assistantMessages.normalize(this.rpcMessages.normalize(msg))); ++ for (const handler of this.eventHandlers) handler(event); + }); +- child.on("exit", (code, signal) => { +- const err = new Error(`pi process exited (code=${code}, signal=${signal})`); +- for (const [, p] of this.pending) p.reject(err); +- this.pending.clear(); ++ child.stdout.on("data", (chunk) => { ++ try { frames.write(chunk); } catch (error) { fail(error); this.dispose("SIGKILL"); } + }); +- child.on("error", (err) => { +- for (const [, p] of this.pending) p.reject(err); +- this.pending.clear(); ++ child.stdout.on("end", () => { ++ try { frames.end(); } catch (error) { fail(error); } + }); ++ child.on("exit", () => fail(new Error("Pi process exited before its next request"))); ++ child.on("error", fail); + } + static async spawn(params) { +- const cmd = getPiCommand(params.piCommand); +- const args = ["--mode", "rpc", "--no-themes"]; +- if (params.sessionPath) args.push("--session", params.sessionPath); +- const child = spawn(cmd, args, { ++ const launch = createPiLaunchSpec(params, process.env); ++ const cmd = launch.command; ++ const child = spawn(cmd, launch.args, { + cwd: params.cwd, + stdio: "pipe", +- env: process.env, +- shell: shouldUseShellForPiCommand(cmd) ++ env: launch.env, ++ shell: false + }); + try { + await new Promise((resolve4, reject) => { +@@ -172,7 +166,7 @@ + } + child.stderr.on("data", () => { + }); +- const proc = new _PiRpcProcess(child); ++ const proc = new _PiRpcProcess(child, launch.invocationNamespace); + try { + const state = await proc.getState(); + const sessionFile = typeof state?.sessionFile === "string" ? state.sessionFile : null; +@@ -181,21 +175,30 @@ + const { dirname: dirname3 } = await import("path"); + mkdirSync2(dirname3(sessionFile), { recursive: true }); + } +- } catch { ++ const commands = await proc.getCommands(); ++ if (!Array.isArray(commands?.commands) || !commands.commands.some((command) => command.name === "paperclip-runtime-ready-v1" && command.description === "Paperclip runtime gate v1")) throw new Error("Pi owned runtime extension did not initialize"); ++ } catch (error) { ++ proc.dispose("SIGKILL"); ++ throw new PiRpcSpawnError("Pi owned runtime extension failed admission", { cause: error }); + } + return proc; + } + onEvent(handler) { + this.eventHandlers.push(handler); ++ if (this.exited) queueMicrotask(() => handler({ type: "paperclip_process_exit", failure: piNativeFailure(this.exited) })); + return () => { + this.eventHandlers = this.eventHandlers.filter((h) => h !== handler); + }; + } + dispose(signal = "SIGTERM") { +- if (this.child.killed) return; +- try { +- this.child.kill(signal); +- } catch { ++ if (this.child.exitCode !== null || this.child.signalCode !== null) return; ++ try { this.child.stdin.end(); this.child.kill(signal); } catch {} ++ if (signal !== "SIGKILL") { ++ const timer = setTimeout(() => { ++ if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGKILL"); ++ }, 2000); ++ timer.unref(); ++ this.child.once("exit", () => clearTimeout(timer)); + } + } + /** +@@ -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) { +- const res = await this.request({ type: "compact", customInstructions }); ++ const res = await this.request({ type: "compact", customInstructions }, 120000); + if (!res.success) throw new Error(`pi compact failed: ${res.error ?? JSON.stringify(res.data)}`); + return res.data; + } +@@ -283,17 +291,23 @@ + await this.writeLine(`${JSON.stringify({ type: "extension_ui_response", ...response })} + `); + } +- request(cmd) { ++ request(cmd, timeoutMs = 30000) { ++ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120000) throw new Error("Invalid Pi RPC deadline"); + const id = crypto.randomUUID(); + const withId = { ...cmd, id }; + const line = `${JSON.stringify(withId)} + `; ++ if (this.exited) return Promise.reject(this.exited); + return new Promise((resolve4, reject) => { +- this.pending.set(id, { resolve: resolve4, reject }); +- void this.writeLine(line).catch((error) => { +- this.pending.delete(id); +- reject(error); +- }); ++ const timer = setTimeout(() => { finish(new Error("Pi RPC request timed out")); this.dispose("SIGKILL"); }, timeoutMs); ++ timer.unref(); ++ const finish = (error, value) => { ++ if (!this.pending.delete(id)) return; ++ clearTimeout(timer); ++ if (error) reject(error); else resolve4(value); ++ }; ++ this.pending.set(id, { resolve: (value) => finish(null, value), reject: (error) => finish(error) }); ++ void this.writeLine(line).catch((error) => finish(error)); + }); + } + writeLine(line) { +@@ -337,7 +351,7 @@ + { + authMethods: getAuthMethods() + }, +- "Configure an API key or log in with an OAuth provider." ++ "Bind the configured OpenRouter API credential to this Paperclip runtime." + ); + } + +@@ -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; ++} ++// Standard ACP data survives clients that do not consume terminal metadata. ++// Keep each structured native value whole or explicitly omit it at a byte bound. ++function boundedBashValue(value) { ++ const encoded = JSON.stringify(value); ++ if (encoded === void 0) return void 0; ++ const bytes = Buffer.byteLength(encoded); ++ return bytes <= 65536 ? value : { _meta: { paperclipPi: { omitted: "tool value exceeds 65536 bytes", originalBytes: bytes } } }; + } + function bashResultText(result) { + const record = result; +@@ -714,6 +736,7 @@ + try { + proc = await PiRpcProcess.spawn({ + cwd: params.cwd, ++ mcpServers: params.mcpServers, + piCommand: params.piCommand + }); + } catch (e) { +@@ -808,6 +831,9 @@ + this.proc = opts.proc; + this.conn = opts.conn; + this.fileCommands = opts.fileCommands ?? []; ++ this.uiBridge = new PiUiBridge(this.sessionId, this.conn, this.proc); ++ this.turnUsage = new PiTurnUsage(); ++ this.turnEpoch = 0; + this.proc.onEvent((ev) => this.handlePiEvent(ev)); + } + setStartupInfo(text) { +@@ -822,10 +848,7 @@ + sendStartupInfoIfPending() { + if (this.startupInfoSent || !this.startupInfo) return; + this.startupInfoSent = true; +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text: this.startupInfo } +- }); ++ this.emitNotice("startup", this.startupInfo, "info"); + } + async prompt(message, images = []) { + const expandedMessage = expandSlashCommand(message, this.fileCommands); +@@ -852,6 +875,7 @@ + } + async cancel() { + this.cancelRequested = true; ++ this.uiBridge.cancelAll(); + if (this.turnQueue.length) { + const queued = this.turnQueue.splice(0, this.turnQueue.length); + for (const t of queued) t.resolve("cancelled"); +@@ -870,6 +894,8 @@ + return this.cancelRequested; + } + emit(update) { ++ const provenance = typeof update.toolCallId === "string" ? this.proc.toolIdentities.provenance(update.toolCallId) : undefined; ++ if (provenance) update = { ...update, _meta: { ...update._meta, paperclipPi: { ...update._meta?.paperclipPi, ...provenance } } }; + this.lastEmit = this.lastEmit.then( + () => this.conn.sessionUpdate({ + sessionId: this.sessionId, +@@ -877,6 +903,12 @@ + }) + ).catch(() => { + }); ++ } ++ emitNotice(category, summary, severity = "info", details = {}) { ++ this.lastEmit = this.lastEmit.then(() => this.conn.extNotification("paperclip/pi_notice", { ++ sessionId: this.sessionId, category, severity, ++ summary: String(summary).slice(0, 4000), details ++ })).catch(() => {}); + } + async flushEmits() { + await this.lastEmit; +@@ -890,6 +922,7 @@ + kind: "execute", + status: params.status, + locations: params.locations, ++ rawInput: boundedBashValue(params.args), + ...params.includeTerminal ? { content: bashTerminalContent(params.toolCallId) } : {}, + ...params.includeTerminal ? { _meta: bashTerminalInfoMeta(params.toolCallId, this.cwd) } : {} + }); +@@ -903,6 +936,7 @@ + sessionUpdate: "tool_call_update", + toolCallId: params.toolCallId, + status: params.status, ++ rawOutput: boundedBashValue(params.result), + _meta: { + ...delta ? bashTerminalOutputMeta(params.toolCallId, delta) : {}, + ...params.status === "completed" || params.status === "failed" ? bashTerminalExitMeta(params.toolCallId, bashExitCode(params.result, Boolean(params.isError))) : {} +@@ -920,18 +954,24 @@ + this.cancelRequested = false; + this.inAgentLoop = false; + this.pendingTurn = { resolve: t.resolve, reject: t.reject }; ++ const epoch = ++this.turnEpoch; ++ this.turnUsage.reset(); + this.emit({ + sessionUpdate: "session_info_update", + _meta: { piAcp: { queueDepth: this.turnQueue.length, running: true } } + }); + this.proc.prompt(t.message, t.images).catch((err) => { ++ const failure = piNativeFailure(err); ++ if (!this.cancelRequested) this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason }); + void this.flushEmits().finally(() => { ++ if (epoch !== this.turnEpoch || !this.pendingTurn) return; ++ this.uiBridge.cancelAll(); + const authErr = maybeAuthRequiredError(err); + if (authErr) { + this.pendingTurn?.reject(authErr); + } else { +- const reason = this.cancelRequested ? "cancelled" : "error"; +- this.pendingTurn?.resolve(reason); ++ if (this.cancelRequested) this.pendingTurn?.resolve("cancelled"); ++ else this.pendingTurn?.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary)); + } + this.pendingTurn = null; + this.inAgentLoop = false; +@@ -946,20 +986,47 @@ + handlePiEvent(ev) { + const type = String(ev.type ?? ""); + switch (type) { ++ case "paperclip_process_exit": { ++ this.uiBridge.cancelAll(); ++ const failure = piNativeFailure(ev.failure?.summary); ++ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason }); ++ const turns = [...(this.pendingTurn ? [this.pendingTurn] : []), ...this.turnQueue.splice(0)]; ++ this.pendingTurn = null; ++ this.turnEpoch += 1; ++ void this.flushEmits().finally(() => { ++ for (const turn of turns) turn.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary)); ++ }); ++ break; ++ } ++ case "message_start": { ++ if (ev.paperclipAssistantMessage) this.emit(piAssistantChunk(ev.paperclipAssistantMessage)); ++ break; ++ } ++ case "message_end": { ++ if (ev.message?.role === "assistant" && ev.message.stopReason === "error") { ++ const failure = piNativeFailure(ev.message.errorMessage); ++ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason }); ++ } ++ if (ev.paperclipAssistantMessage) this.emit(piAssistantChunk(ev.paperclipAssistantMessage)); ++ try { this.turnUsage.accept(ev.message); } catch { ++ const failure = piNativeFailure("Pi usage receipt is invalid"); ++ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason }); ++ const pending = this.pendingTurn; ++ this.pendingTurn = null; ++ void this.flushEmits().finally(() => pending?.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary))); ++ this.uiBridge.cancelAll(); ++ this.proc.dispose("SIGKILL"); ++ } ++ break; ++ } + case "message_update": { + const ame = ev.assistantMessageEvent; + if (ame?.type === "text_delta" && typeof ame.delta === "string") { +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text: ame.delta } +- }); ++ this.emit(piAssistantChunk(ev.paperclipAssistantMessage, ame.delta)); + break; + } + if (ame?.type === "thinking_delta" && typeof ame.delta === "string") { +- this.emit({ +- sessionUpdate: "agent_thought_chunk", +- content: { type: "text", text: ame.delta } +- }); ++ this.emit(piAssistantChunk(ev.paperclipAssistantMessage, ame.delta, true)); + break; + } + if (ame?.type === "toolcall_start" || ame?.type === "toolcall_delta" || ame?.type === "toolcall_end") { +@@ -1047,7 +1114,7 @@ + if (p) { + try { + const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p); +- snapshotOldText = readFileSync3(abs, "utf8"); ++ snapshotOldText = snapshotPiWorkspaceFile(this.cwd, abs); + this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText }); + if (toolName === "edit") { + for (const needle of getEditOldTexts(args)) { +@@ -1125,7 +1192,7 @@ + if (!isError && snapshot) { + try { + const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path); +- const newText = readFileSync3(abs, "utf8"); ++ const newText = snapshotPiWorkspaceFile(this.cwd, abs); + if (snapshot.oldText === null || newText !== snapshot.oldText) { + hasStructuredDiff = true; + content = [ +@@ -1154,48 +1221,41 @@ + break; + } + case "extension_ui_request": { +- void this.handleExtensionUiRequest(ev).catch(() => { +- const id = stringProp(ev, "id"); +- if (!id) { +- return; +- } +- void this.proc.sendExtensionUiResponse({ id, cancelled: true }).catch(() => { +- }); ++ if (ev.method === "notify") this.emitNotice("extension_notify", typeof ev.message === "string" ? ev.message : "Pi notification", ["warning", "error"].includes(ev.notifyType) ? ev.notifyType : "info"); ++ void this.uiBridge.handle(ev).catch(() => { ++ this.emitNotice("invalid_question", "Pi structured question is unsupported or invalid; the session was stopped", "error"); ++ this.proc.dispose("SIGKILL"); + }); + break; + } + case "auto_retry_start": { +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text: formatAutoRetryMessage(ev) } ++ this.emitNotice("auto_retry_start", formatAutoRetryMessage(ev), "warning", { ++ attempt: ev.attempt, maxAttempts: ev.maxAttempts, delayMs: ev.delayMs, ++ errorMessage: typeof ev.errorMessage === "string" ? ev.errorMessage.slice(0, 4000) : undefined + }); + break; + } + case "auto_retry_end": { +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text: "Retry finished, resuming." } ++ this.emitNotice("auto_retry_end", ev.success === true ? "Retry finished, resuming." ++ : ev.success === false ? "Retry failed; the provider request did not recover." ++ : "Retry finished; the provider did not report an outcome.", ev.success === true ? "info" : "warning", { attempt: ev.attempt, success: ev.success }); ++ break; ++ } ++ case "compaction_start": { ++ this.emitNotice("compaction_start", ev.reason === "manual" ? "Compacting context..." : "Context nearing limit, running automatic compaction...", "info", { reason: ev.reason }); ++ break; ++ } ++ case "compaction_end": { ++ (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.acceptCompaction(ev.result); ++ this.emitNotice("compaction_end", ev.aborted ? "Context compaction cancelled." : ev.errorMessage ? "Context compaction failed." : "Context compaction finished.", ev.errorMessage ? "error" : ev.aborted ? "warning" : "info", { ++ reason: ev.reason, aborted: ev.aborted, willRetry: ev.willRetry, ++ errorMessage: typeof ev.errorMessage === "string" ? ev.errorMessage.slice(0, 4000) : undefined + }); + break; + } +- case "auto_compaction_start": { +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { +- type: "text", +- text: "Context nearing limit, running automatic compaction..." +- } +- }); +- break; +- } +- case "auto_compaction_end": { +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { +- type: "text", +- text: "Automatic compaction finished; context was summarized to continue the session." +- } +- }); ++ case "summarization_retry_scheduled": { ++ (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.markIncomplete(); ++ this.emitNotice("summarization_retry_scheduled", "Context summarization is retrying a provider request.", "warning"); + break; + } + case "agent_start": { +@@ -1210,9 +1270,12 @@ + break; + } + case "agent_settled": { ++ const epoch = this.turnEpoch; + void this.flushEmits().finally(() => { ++ if (epoch !== this.turnEpoch || !this.pendingTurn) return; ++ this.uiBridge.cancelAll(); + const reason = this.cancelRequested ? "cancelled" : "end_turn"; +- this.pendingTurn?.resolve(reason); ++ this.pendingTurn?.resolve({ stopReason: reason, ...this.turnUsage.response(reason) }); + this.pendingTurn = null; + this.inAgentLoop = false; + const next = this.turnQueue.shift(); +@@ -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) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return true; + const merged = getMergedSettings(cwd); + const direct = merged.enableSkillCommands; + if (typeof direct === "boolean") return direct; +@@ -1733,6 +1797,7 @@ + return true; + } + function getQuietStartup(cwd) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return true; + const merged = getMergedSettings(cwd); + const direct = merged.quietStartup; + if (typeof direct === "boolean") return direct; +@@ -1824,6 +1889,7 @@ + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const c of [...a, ...b]) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && !["compact", "session", "autocompact"].includes(c.name)) continue; + if (seen.has(c.name)) continue; + seen.add(c.name); + out.push(c); +@@ -1884,11 +1950,13 @@ + throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`); + } + const cwd = opts?.cwd ?? stored.cwd; ++ if (cwd !== stored.cwd || !opts) throw RequestError3.invalidParams("Pi recovery requires session/load in the original workspace"); + let proc; + try { + proc = await PiRpcProcess.spawn({ + cwd, + sessionPath: stored.sessionFile, ++ mcpServers: opts?.mcpServers ?? [], + piCommand: process.env.PI_ACP_PI_COMMAND + }); + } catch (e) { +@@ -1897,7 +1965,7 @@ + } + throw e; + } +- const fileCommands = loadSlashCommands(cwd); ++ const fileCommands = []; + const session = this.sessions.getOrCreate(sessionId, { + cwd, + mcpServers: opts?.mcpServers ?? [], +@@ -1917,6 +1985,7 @@ + } + } + async initialize(params) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw RequestError3.invalidParams("Pi requires a qualified Paperclip launch"); + const supportedVersion = 1; + const requested = params.protocolVersion; + return { +@@ -1933,7 +2002,8 @@ + }), + agentCapabilities: { + loadSession: true, +- mcpCapabilities: { http: false, sse: false }, ++ mcpCapabilities: { http: true, sse: false }, ++ _meta: { paperclipPi: PI_ACP_FEATURES }, + promptCapabilities: { + image: true, + audio: false, +@@ -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. +- list: {}, +- delete: {} ++ list: {} + } + } + }; +@@ -1953,7 +2022,7 @@ + throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`); + } + this.lastSessionCwd = params.cwd; +- const fileCommands = loadSlashCommands(params.cwd); ++ const fileCommands = []; + const enableSkillCommands = getEnableSkillCommands(params.cwd); + const session = await this.sessions.create({ + cwd: params.cwd, +@@ -1994,14 +2063,14 @@ + this.cleanupFailedNewSession(session.sessionId, state); + throw RequestError3.authRequired( + { authMethods: getAuthMethods() }, +- "Configure an API key or log in with an OAuth provider." ++ "Bind the configured OpenRouter API credential to this Paperclip runtime." + ); + } + if (stateErr && maybeAuthRequiredError(stateErr)) { + this.cleanupFailedNewSession(session.sessionId, state); + throw RequestError3.authRequired( + { authMethods: getAuthMethods() }, +- "Configure an API key or log in with an OAuth provider." ++ "Bind the configured OpenRouter API credential to this Paperclip runtime." + ); + } + const { configOptions, models, modes } = await getSessionConfiguration(session.proc, { +@@ -2064,34 +2133,27 @@ + } + async prompt(params) { + const session = await this.restoreSession(params.sessionId); ++ if (session.manualCompactionUsage) throw RequestError3.invalidParams("Pi context compaction is active"); + const { message, images } = promptToPiMessage(params.prompt); + if (images.length === 0 && message.trimStart().startsWith("/")) { ++ const command = message.trim().split(/\s+/)[0]; ++ if (!["/compact", "/session", "/autocompact"].includes(command)) throw RequestError3.invalidParams("Pi slash command is not admitted by Paperclip Runner"); + const trimmed = message.trim(); + const space = trimmed.indexOf(" "); + const cmd = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space); + const argsString = space === -1 ? "" : trimmed.slice(space + 1); + const args = parseCommandArgs(argsString); + if (cmd === "compact") { ++ if (session.pendingTurn || session.manualCompactionUsage) throw RequestError3.invalidParams("Manual compaction requires an idle session"); + const customInstructions = args.join(" ").trim() || void 0; +- const res = await session.proc.compact(customInstructions); +- const r = res && typeof res === "object" ? res : null; +- const tokensBefore = typeof r?.tokensBefore === "number" ? r.tokensBefore : null; +- const summary = typeof r?.summary === "string" ? r.summary : null; +- const headerLines = [ +- `Compaction completed.${customInstructions ? " (custom instructions applied)" : ""}`, +- tokensBefore !== null ? `Tokens before: ${tokensBefore}` : null +- ].filter(Boolean); +- const text = headerLines.join("\n") + (summary ? ` +- +-${summary}` : ""); +- await this.conn.sessionUpdate({ +- sessionId: session.sessionId, +- update: { +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text } +- } +- }); +- return { stopReason: "end_turn" }; ++ const compactionUsage = new PiTurnUsage(); ++ session.manualCompactionUsage = compactionUsage; ++ try { ++ const res = await session.proc.compact(customInstructions); ++ compactionUsage.acceptCompaction(res); ++ await session.flushEmits(); ++ return { stopReason: "end_turn", ...compactionUsage.response() }; ++ } finally { session.manualCompactionUsage = null; } + } + if (cmd === "session") { + const stats = await session.proc.getSessionStats(); +@@ -2112,13 +2174,8 @@ + } + const text = lines.length ? lines.join("\n") : `Session stats: + ${JSON.stringify(stats, null, 2)}`; +- await this.conn.sessionUpdate({ +- sessionId: session.sessionId, +- update: { +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text } +- } +- }); ++ session.emitNotice("session_stats", text, "info"); ++ await session.flushEmits(); + return { stopReason: "end_turn" }; + } + if (cmd === "name") { +@@ -2418,22 +2475,24 @@ + enabled = !current; + } + await session.proc.setAutoCompaction(enabled); +- await this.conn.sessionUpdate({ +- sessionId: session.sessionId, +- update: { +- sessionUpdate: "agent_message_chunk", +- content: { +- type: "text", +- text: `Auto-compaction ${enabled ? "enabled" : "disabled"}.` +- } +- } +- }); ++ session.emitNotice("auto_compaction_policy", `Auto-compaction ${enabled ? "enabled" : "disabled"}.`, "info", { enabled }); ++ await session.flushEmits(); + return { stopReason: "end_turn" }; + } + } + const result = await session.prompt(message, images); +- const stopReason = result === "error" ? session.wasCancelRequested() ? "cancelled" : "end_turn" : result; +- return { stopReason }; ++ if (result && typeof result === "object") return result; ++ if (result === "error") throw RequestError3.internalError({}, "Pi prompt failed"); ++ return { stopReason: result }; ++ } ++ async extMethod(method, params) { ++ if (method !== "pi/steer" && method !== "pi/follow_up") throw RequestError3.methodNotFound(method); ++ if (typeof params.sessionId !== "string" || typeof params.message !== "string" || !params.message.trim() || Buffer.byteLength(params.message) > 65536) throw RequestError3.invalidParams("Invalid Pi continuation"); ++ const session = this.sessions.maybeGet(params.sessionId); ++ if (!session?.pendingTurn || session.cancelRequested) throw RequestError3.invalidParams("Pi continuation requires an active turn"); ++ const response = await session.proc.request({ type: method === "pi/steer" ? "steer" : "follow_up", message: params.message }); ++ if (!response.success || response.data?.disposition !== "queued") throw RequestError3.internalError({}, "Pi continuation was not queued by the native runtime"); ++ return { accepted: true, sessionId: session.sessionId, kind: method === "pi/steer" ? "steer" : "follow_up", disposition: response.data.disposition }; + } + async cancel(params) { + const session = this.sessions.maybeGet(params.sessionId); +@@ -2473,7 +2532,7 @@ + mcpServers: params.mcpServers + }); + const proc = session.proc; +- const fileCommands = loadSlashCommands(params.cwd); ++ const fileCommands = []; + this.sessions.closeAllExcept?.(session.sessionId); + this.store.upsert({ + sessionId: params.sessionId, +@@ -2482,7 +2541,7 @@ + }); + const data = await proc.getMessages(); + const messages = Array.isArray(data?.messages) ? data.messages : []; +- for (const m of messages) { ++ for (const [messageIndex, m] of messages.entries()) { + const role = String(m?.role ?? ""); + if (role === "user") { + const text = normalizePiMessageText(m?.content); +@@ -2501,16 +2560,14 @@ + if (text) { + await this.conn.sessionUpdate({ + sessionId: session.sessionId, +- update: { +- sessionUpdate: "agent_message_chunk", +- content: { type: "text", text } +- } ++ update: piHistoricalAssistantChunk(params.sessionId, messageIndex, text) + }); + } + } + if (role === "toolResult") { + const toolName = String(m?.toolName ?? "tool"); +- const toolCallId = String(m?.toolCallId ?? crypto.randomUUID()); ++ const historical = piHistoricalToolIdentity(params.sessionId, messageIndex, String(m?.toolCallId ?? "")); ++ const toolCallId = historical.id; + const isError = Boolean(m?.isError); + const isBash = isBashTool(toolName); + if (isBash) { +@@ -2524,7 +2581,7 @@ + kind: "execute", + status: "completed", + content: bashTerminalContent(toolCallId), +- _meta: bashTerminalInfoMeta(toolCallId, params.cwd) ++ _meta: { ...bashTerminalInfoMeta(toolCallId, params.cwd), paperclipPi: historical.provenance } + } + }); + await this.conn.sessionUpdate({ +@@ -2535,7 +2592,8 @@ + status: isError ? "failed" : "completed", + _meta: { + ...text2 ? bashTerminalOutputMeta(toolCallId, text2) : {}, +- ...bashTerminalExitMeta(toolCallId, bashExitCode(m, isError)) ++ ...bashTerminalExitMeta(toolCallId, bashExitCode(m, isError)), ++ paperclipPi: historical.provenance + } + } + }); +@@ -2549,6 +2607,7 @@ + title: toolName, + kind: toolName === "read" ? "read" : toolName === "write" || toolName === "edit" ? "edit" : "other", + status: "completed", ++ _meta: { paperclipPi: historical.provenance }, + rawInput: null, + rawOutput: m + } +@@ -2560,6 +2619,7 @@ + sessionUpdate: "tool_call_update", + toolCallId, + status: isError ? "failed" : "completed", ++ _meta: { paperclipPi: historical.provenance }, + content: text ? [{ type: "content", content: { type: "text", text } }] : null, + rawOutput: m + } +@@ -2607,6 +2667,7 @@ + return response; + } + async deleteSession(params) { ++ throw RequestError3.methodNotFound("session/delete"); + const stored = this.store.get(params.sessionId); + const piSession = findPiSession(params.sessionId); + if (!stored && !piSession) { +@@ -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() { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return null; + try { + const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" }); + const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace( +@@ -2848,6 +2914,7 @@ + } + } + function buildStartupInfo(opts) { ++ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return ""; + void opts.fileCommands; + const md = []; + try { +@@ -2980,6 +3047,7 @@ + + // src/index.ts + if (process.argv.includes("--terminal-login")) { ++ throw new Error("Interactive Pi authentication is not available in Paperclip Runner"); + const { spawnSync: spawnSync2 } = await import("child_process"); + const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND); + const res = spawnSync2(cmd, [], { +@@ -3019,11 +3087,12 @@ + } + }); + var stream = ndJsonStream(input, output); +-var agent = new AgentSideConnection((conn) => new PiAcpAgent(conn), stream); ++var ownedPiAgent; ++var agent = new AgentSideConnection((conn) => (ownedPiAgent = new PiAcpAgent(conn)), stream); + function shutdown() { + try { + ; +- agent?.agent?.dispose?.(); ++ ownedPiAgent?.dispose?.(); + } catch { + } + try { +--- 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. */ ++import { createHash, randomUUID } from "node:crypto"; ++import { StringDecoder } from "node:string_decoder"; ++import { isAbsolute, relative, resolve, sep } from "node:path"; ++import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs"; ++ ++const PERMISSION_PREFIX = "paperclip.pi.permission.v1:"; ++const PERMISSION_CHOICES = [ ++ { optionId: "allow_once", name: "Allow once", kind: "allow_once" }, ++ { optionId: "allow_always", name: "Allow for this session", kind: "allow_always" }, ++ { optionId: "reject_once", name: "Deny", kind: "reject_once" }, ++]; ++ ++function record(value ) { ++ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Pi runtime record"); ++ return value ; ++} ++function text(value , max = 16_384) { ++ if (typeof value !== "string" || Buffer.byteLength(value) > max) throw new Error("Invalid Pi runtime text"); ++ return value; ++} ++ ++export const PI_ACP_FEATURES = Object.freeze({ ++ version: 1, steering: true, queuedFollowUp: true, ++ questions: ["select", "confirm", "input", "editor"], ++ nativePermissions: true, promptUsage: true, nativePlan: false, ++ pendingRequestRecovery: "live-process-only", ++}); ++ ++ ++ ++ ++ ++ ++ ++/** Pi 1 RPC serializes message_update without message/partial. Reconstruct only ++ * the streaming view, anchored to the actual message_start; message_end remains ++ * the native authoritative receipt. Never infer an executable tool from text. */ ++export class PiRpcMessageDeltas { ++ active = null; ++ blocks = new Map (); ++ streamedBytes = 0; ++ failed = false; ++ fail() { this.failed = true; throw new Error("Pi RPC message delta boundary is invalid"); } ++ normalize(event ) { ++ if (this.failed) return this.fail(); ++ if (event.type === "message_start" && event.message && typeof event.message === "object" && (event.message ).role === "assistant") { ++ if (this.active) return this.fail(); ++ this.active = event.message ; this.blocks.clear(); this.streamedBytes = 0; ++ return event; ++ } ++ if (event.type === "message_end" && event.message && typeof event.message === "object" && (event.message ).role === "assistant") { ++ const message = event.message ; ++ if (!this.active || message.timestamp !== this.active.timestamp) return this.fail(); ++ if (!["error", "aborted"].includes(String(message.stopReason)) && [...this.blocks.values()].some(block => !block.ended)) return this.fail(); ++ for (const [index, block] of this.blocks) if (block.ended) { ++ const final = Array.isArray(message.content) ? message.content[index] : undefined; ++ if (!final || typeof final !== "object") return this.fail(); ++ if (block.kind === "toolcall") { ++ if (final.type !== "toolCall" || toolSignature(final.id, final.name, final.arguments) !== block.final) return this.fail(); ++ } else if (final.type !== block.kind || final[block.kind === "text" ? "text" : "thinking"] !== block.json) return this.fail(); ++ } ++ this.active = null; this.blocks.clear(); return event; ++ } ++ if (event.type !== "message_update") return event; ++ if (!this.active || event.message !== undefined || !event.assistantMessageEvent || typeof event.assistantMessageEvent !== "object" || Array.isArray(event.assistantMessageEvent)) return this.fail(); ++ const update = event.assistantMessageEvent ; ++ if (update.partial !== undefined || typeof update.type !== "string") return this.fail(); ++ const match = /^(text|thinking|toolcall)_(start|delta|end)$/.exec(update.type); ++ const index = update.contentIndex; ++ if (!match || !Number.isSafeInteger(index) || (index ) < 0 || (index ) >= 4096) return this.fail(); ++ const [, kind, phase] = match; ++ let block = this.blocks.get(index ); ++ if (phase === "start") { ++ if (block) return this.fail(); ++ block = { kind: kind , ended: false, json: "" }; ++ if (kind === "toolcall") { ++ if (typeof update.id !== "string" || Buffer.byteLength(update.id) > 256 || typeof update.toolName !== "string" || Buffer.byteLength(update.toolName) > 256) return this.fail(); ++ if (update.id && [...this.blocks.values()].some(other => other.id === update.id)) return this.fail(); ++ block.id = update.id; block.name = update.toolName; ++ } ++ this.blocks.set(index , block); ++ } else if (!block || block.ended || block.kind !== kind) return this.fail(); ++ if (phase === "delta") { ++ if (typeof update.delta !== "string" || Buffer.byteLength(update.delta) > 262_144) return this.fail(); ++ this.streamedBytes += Buffer.byteLength(update.delta); ++ if (this.streamedBytes > 4 * 1024 * 1024) return this.fail(); ++ block .json += update.delta; ++ if (kind === "toolcall" && Buffer.byteLength(block .json) > 1_048_576) return this.fail(); ++ } ++ let toolCall ; ++ if (kind === "toolcall") { ++ if (phase === "end") { ++ if (!update.toolCall || typeof update.toolCall !== "object" || Array.isArray(update.toolCall)) return this.fail(); ++ const final = update.toolCall ; ++ if (final.type !== "toolCall" || typeof final.id !== "string" || !final.id || Buffer.byteLength(final.id) > 256 || typeof final.name !== "string" || !final.name || Buffer.byteLength(final.name) > 256 ++ || block .id && block .id !== final.id || block .name && block .name !== final.name ++ || [...this.blocks.entries()].some(([otherIndex, other]) => otherIndex !== index && other.id === final.id) ++ || !final.arguments || typeof final.arguments !== "object" || Array.isArray(final.arguments) || Buffer.byteLength(JSON.stringify(final.arguments)) > 1_048_576) return this.fail(); ++ block .id = final.id; block .name = final.name; block .final = toolSignature(final.id, final.name, final.arguments); toolCall = final; ++ } else toolCall = { type: "toolCall", id: block .id, name: block .name, partialArgs: block .json }; ++ } ++ if (phase === "end") { ++ if (kind !== "toolcall" && (typeof update.content !== "string" || update.content !== block .json)) return this.fail(); ++ block .ended = true; ++ } ++ return { ...event, message: { ...this.active, content: [] }, assistantMessageEvent: { ...update, ...(toolCall ? { toolCall } : {}) } }; ++ } ++} ++ ++function toolSignature(id , name , args ) { ++ const canonical = (value ) => Array.isArray(value) ? value.map(canonical) ++ : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value; ++ return JSON.stringify([id, name, canonical(args)]); ++} ++ ++/** A closed diagnostic vocabulary: raw exception text, paths, provider bodies ++ * and credentials never cross the wrapper boundary. Unknown failures stay ++ * generic rather than masquerading as a known local or provider condition. */ ++export function piNativeFailure(value ) { ++ const message = value instanceof Error ? value.message : typeof value === "string" ? value : ""; ++ const known = { ++ "Pi RPC message delta boundary is invalid": "rpc_delta_boundary_invalid", ++ "Pi native assistant message boundary is invalid": "assistant_boundary_invalid", ++ "Pi native tool invocation identity conflict": "tool_identity_conflict", ++ "Pi model iteration identity is ambiguous": "model_iteration_ambiguous", ++ "Pi model iteration identity is unavailable": "model_iteration_unavailable", ++ "Pi tool invocation has no active model iteration": "tool_iteration_missing", ++ "Pi tool identity is outside its iteration bound": "tool_iteration_bound", ++ "Pi RPC frame exceeds its bound": "rpc_frame_oversized", ++ "Pi RPC stream ended inside a frame": "rpc_frame_incomplete", ++ "Pi usage receipt is invalid": "usage_receipt_invalid", ++ "Pi process exited before its next request": "provider_process_exited", ++ }; ++ if (Object.hasOwn(known, message)) return { reason: known[message] , summary: message }; ++ const status = /^(?:pi prompt failed: )?(401|403|429|500|502|503|504)(?::|\b)/.exec(message.slice(0, 128))?.[1]; ++ if (status) return { reason: `provider_http_${status}`, summary: `Pi provider request failed (HTTP ${status})` }; ++ return { reason: "unknown_native_failure", summary: "Pi native runtime failed; diagnostic details were withheld" }; ++} ++ ++/** IDs are allocated only at actual SDK assistant message_start events. Tool ++ * iterations, retries, notices and ACP prompts cannot create assistant IDs. */ ++export class PiAssistantMessages { ++ ordinal = 0; ++ active = null; ++ poisoned = false; ++ namespace ; ++ constructor(namespace ) { ++ this.namespace = namespace; ++ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi assistant namespace is invalid"); ++ } ++ fail() { this.poisoned = true; throw new Error("Pi native assistant message boundary is invalid"); } ++ normalize(event ) { ++ if (this.poisoned) return this.fail(); ++ if (event.type === "agent_settled") { ++ if (this.active) return this.fail(); ++ return event; ++ } ++ if (!["message_start", "message_update", "message_end"].includes(String(event.type))) return event; ++ const message = event.message; ++ if (!message || typeof message !== "object" || Array.isArray(message)) return this.fail(); ++ const native = message ; ++ if (native.role !== "assistant") { ++ if (event.type === "message_update" || this.active || !["user", "toolResult", "system"].includes(String(native.role))) return this.fail(); ++ // Pi 1 records prompt/tool declaration updates as structural system messages. ++ // They never receive an assistant occurrence or become final-answer content. ++ if (native.role === "system" && (typeof native.content !== "string" || !Number.isSafeInteger(native.timestamp) || (native.timestamp ) < 0)) return this.fail(); ++ return event; ++ } ++ if (!Number.isSafeInteger(native.timestamp) || (native.timestamp ) < 0 || !Array.isArray(native.content)) return this.fail(); ++ let boundary ; ++ if (event.type === "message_start") { ++ if (this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) return this.fail(); ++ const messageId = `pi-message-${createHash("sha256").update(JSON.stringify([this.namespace, ++this.ordinal])).digest("hex")}`; ++ this.active = { messageId, timestamp: native.timestamp }; ++ boundary = { messageId, phase: "start" }; ++ } else { ++ if (!this.active || this.active.timestamp !== native.timestamp) return this.fail(); ++ boundary = { messageId: this.active.messageId, phase: event.type === "message_end" ? "end" : "delta" }; ++ if (event.type === "message_end") { ++ if (!["stop", "length", "toolUse", "error", "aborted"].includes(String(native.stopReason))) return this.fail(); ++ boundary.stopReason = native.stopReason ; ++ this.active = null; ++ } ++ } ++ return { ...event, paperclipAssistantMessage: boundary }; ++ } ++} ++ ++export function piAssistantChunk(boundaryValue , textValue = "", thought = false) { ++ const boundary = record(boundaryValue); ++ const messageId = text(boundary.messageId, 96); ++ if (!/^pi-message-[a-f0-9]{64}$/.test(messageId) || !["start", "delta", "end"].includes(String(boundary.phase))) throw new Error("Pi assistant chunk lacks native provenance"); ++ if (boundary.phase === "end" && !["stop", "length", "toolUse", "error", "aborted"].includes(String(boundary.stopReason))) throw new Error("Pi assistant chunk end is invalid"); ++ const content = text(textValue, 262_144); ++ if (boundary.phase !== "delta" && (content || thought)) throw new Error("Pi assistant boundary cannot carry synthetic content"); ++ return { sessionUpdate: thought ? "agent_thought_chunk" : "agent_message_chunk", messageId, ++ content: { type: "text", text: content }, ++ _meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind: boundary.phase === "end" ? `end:${boundary.stopReason}` : boundary.phase }, ++ }; ++} ++ ++export function piHistoricalAssistantChunk(sessionId , messageIndex , value ) { ++ if (!sessionId || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi historical assistant identity is invalid"); ++ return { sessionUpdate: "agent_message_chunk", ++ messageId: `pi-history-message-${createHash("sha256").update(JSON.stringify([text(sessionId, 1024), messageIndex])).digest("hex")}`, ++ content: { type: "text", text: text(value, 262_144) }, ++ _meta: { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" }, ++ }; ++} ++ ++/** One trusted Pi model iteration, not one ACP prompt. Pi resets its own turnIndex ++ * on warm prompts; our ordinal is monotonic for the lifetime of the child. */ ++export class PiToolIdentities { ++ ordinal = 0; ++ active = false; ++ poisoned = false; ++ entries = new Map (); ++ namespace ; ++ constructor(namespace ) { ++ this.namespace = namespace; ++ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi invocation namespace is invalid"); ++ } ++ fail(message ) { this.poisoned = true; throw new Error(message); } ++ begin() { ++ if (this.poisoned || this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) this.fail("Pi model iteration identity is ambiguous"); ++ this.ordinal++; this.active = true; this.entries.clear(); ++ } ++ end() { ++ if (this.poisoned || !this.active) this.fail("Pi model iteration identity is unavailable"); ++ this.active = false; ++ } ++ identity(nativeId ) { ++ if (this.poisoned || this.ordinal === 0) this.fail("Pi model iteration identity is unavailable"); ++ let native ; ++ try { native = text(nativeId, 256); } catch { return this.fail("Pi native tool identity is invalid"); } ++ if (!native) this.fail("Pi native tool identity is missing"); ++ const prior = this.entries.get(native); ++ if (prior) return prior.id; ++ if (!this.active || this.entries.size >= 4096) this.fail("Pi tool identity is outside its iteration bound"); ++ const id = `pi-${createHash("sha256").update(JSON.stringify([this.namespace, this.ordinal, native])).digest("hex")}`; ++ this.entries.set(native, { id, nativeId: native, claimed: false }); ++ return id; ++ } ++ bind(nativeId , name , args , claim = false) { ++ if (this.poisoned || !this.active) this.fail("Pi tool invocation has no active model iteration"); ++ const id = this.identity(nativeId); const entry = this.entries.get(nativeId) ; ++ const canonical = (value ) => Array.isArray(value) ? value.map(canonical) ++ : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value; ++ let encoded ; ++ try { encoded = JSON.stringify([text(name, 256), canonical(args)]); } ++ catch { return this.fail("Pi tool invocation is invalid"); } ++ if (Buffer.byteLength(encoded) > 1_048_576) this.fail("Pi tool invocation is oversized"); ++ const fingerprint = createHash("sha256").update(encoded).digest("hex"); ++ if ((claim && entry.claimed) || (entry.fingerprint !== undefined && entry.fingerprint !== fingerprint)) this.fail("Pi native tool invocation identity conflict"); ++ entry.fingerprint = fingerprint; if (claim) entry.claimed = true; ++ return id; ++ } ++ provenance(id ) { ++ for (const entry of this.entries.values()) if (entry.id === id) return { nativeToolCallId: entry.nativeId, modelIteration: this.ordinal, identityScope: "native-model-iteration" }; ++ return undefined; ++ } ++ normalize(event ) { ++ if (event.type === "turn_start") { this.begin(); return event; } ++ if (event.type === "turn_end") { this.end(); return event; } ++ if (["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(String(event.type))) { ++ const native = text(event.toolCallId, 256); ++ const id = event.type === "tool_execution_start" ? this.bind(native, text(event.toolName, 256), event.args, true) : this.identity(native); ++ return { ...event, toolCallId: id }; ++ } ++ if (event.type === "message_update" && event.assistantMessageEvent) { ++ const update = record(event.assistantMessageEvent); ++ const normalizeTool = (value , direct = false) => { ++ if (!value || typeof value !== "object") return value; ++ const block = record(value); ++ // Empty IDs during initial streaming do not identify an executable call. ++ return (direct || block.type === "toolCall") && typeof block.id === "string" && block.id ++ ? { ...block, id: direct && update.type === "toolcall_end" ++ ? this.bind(block.id, text(block.name, 256), block.arguments) : this.identity(block.id) } : value; ++ }; ++ const partial = update.partial && typeof update.partial === "object" ? record(update.partial) : undefined; ++ return { ...event, assistantMessageEvent: { ...update, ++ ...(update.toolCall ? { toolCall: normalizeTool(update.toolCall, true) } : {}), ++ ...(partial && Array.isArray(partial.content) ? { partial: { ...partial, content: partial.content.map((block) => normalizeTool(block)) } } : {}), ++ } }; ++ } ++ return event; ++ } ++} ++ ++/** Session history is presentation-only and must never acquire a live delivery ID. */ ++export function piHistoricalToolIdentity(sessionId , messageIndex , nativeId ) { ++ const session = text(sessionId, 1024); const native = text(nativeId, 256); ++ if (!session || !native || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi history tool identity is invalid"); ++ return { id: `pi-history-${createHash("sha256").update(JSON.stringify([session, messageIndex, native])).digest("hex")}`, ++ provenance: { nativeToolCallId: native, historyMessageIndex: messageIndex, identityScope: "history-display-only" } }; ++} ++ ++/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */ ++export class PiRpcFrames { ++ decoder = new StringDecoder("utf8"); ++ pending = ""; ++ receive ; ++ limit ; ++ constructor(receive , limit = 4 * 1024 * 1024) { this.receive = receive; this.limit = limit; } ++ write(chunk ) { ++ this.pending += this.decoder.write(Buffer.from(chunk)); ++ for (;;) { ++ const index = this.pending.indexOf("\n"); ++ if (index < 0) break; ++ const line = this.pending.slice(0, index).replace(/\r$/, ""); ++ this.pending = this.pending.slice(index + 1); ++ if (Buffer.byteLength(line) > this.limit) throw new Error("Pi RPC frame exceeds its bound"); ++ if (line.trim()) this.receive(record(JSON.parse(line))); ++ } ++ if (Buffer.byteLength(this.pending) > this.limit) throw new Error("Pi RPC frame exceeds its bound"); ++ } ++ end() { ++ this.pending += this.decoder.end(); ++ if (this.pending.trim()) throw new Error("Pi RPC stream ended inside a frame"); ++ } ++} ++ ++function requiredFile(environment , name ) { ++ const path = environment[name]; ++ if (!path || !isAbsolute(path) || /[\0\r\n]/.test(path)) throw new Error(`Missing verified Pi launch binding: ${name}`); ++ // This checks the handoff shape. The runner command lease verifies all bytes, ++ // retains their directory identity, and owns the subprocess lifetime. ++ if (!lstatSync(path).isFile()) throw new Error(`Invalid verified Pi launch binding: ${name}`); ++ return path; ++} ++function pathArray(raw ) { ++ const paths = JSON.parse(raw ?? "[]"); ++ if (!Array.isArray(paths) || paths.length > 128 || paths.some((p) => typeof p !== "string" || !isAbsolute(p) || /[\0\r\n]/.test(p))) throw new Error("Invalid Pi runtime paths"); ++ return paths; ++} ++ ++/** Provider tool events are untrusted; diff projection cannot read host files. */ ++export function snapshotPiWorkspaceFile(cwd , path , environment = process.env) { ++ const root = realpathSync(cwd); ++ const logical = resolve(root, path); ++ const physical = realpathSync(logical); ++ const within = (base , target ) => { ++ const suffix = relative(base, target); ++ return !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`); ++ }; ++ if (!within(root, logical) || !within(root, physical)) throw new Error("Pi diff escaped its workspace"); ++ for (const protectedPath of pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS)) { ++ if (within(protectedPath, logical)) throw new Error("Pi diff targets protected state"); ++ let protectedPhysical ; ++ try { protectedPhysical = realpathSync(protectedPath); } catch { continue; } ++ if (within(protectedPhysical, physical)) throw new Error("Pi diff targets protected state"); ++ } ++ const fd = openSync(physical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); ++ try { ++ const before = fstatSync(fd, { bigint: true }); ++ if (!before.isFile() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Pi diff file is unsupported"); ++ const result = readFileSync(fd, "utf8"); ++ const after = fstatSync(fd, { bigint: true }); ++ const named = lstatSync(physical, { bigint: true }); ++ if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.ctimeNs !== after.ctimeNs || named.ino !== before.ino || named.dev !== before.dev || realpathSync(logical) !== physical) throw new Error("Pi diff file changed during capture"); ++ return result; ++ } finally { closeSync(fd); } ++} ++ ++export function createPiLaunchSpec( ++ params , ++ environment , ++) { ++ if (environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw new Error("Pi requires an isolated runner launch"); ++ const command = requiredFile(environment, "PAPERCLIP_PI_NODE_EXECUTABLE"); ++ const entrypoint = requiredFile(environment, "PAPERCLIP_PI_ENTRYPOINT"); ++ const extension = requiredFile(environment, "PAPERCLIP_PI_EXTENSION_PATH"); ++ if (environment.PAPERCLIP_PI_READ_ONLY !== "0" && environment.PAPERCLIP_PI_READ_ONLY !== "1") throw new Error("Pi requires an explicit read-only policy"); ++ const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS); ++ const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS); ++ const workspace = realpathSync(params.cwd); ++ const suppliedAgentHome = environment.PAPERCLIP_PI_AGENT_HOME; ++ let agentHome ; ++ if (suppliedAgentHome !== undefined) { ++ if (!isAbsolute(suppliedAgentHome) || /[\0\r\n]/.test(suppliedAgentHome) || suppliedAgentHome === "/" || !lstatSync(suppliedAgentHome).isDirectory() || realpathSync(suppliedAgentHome) !== suppliedAgentHome) throw new Error("Pi agent files require a canonical registered directory"); ++ agentHome = suppliedAgentHome; ++ } ++ const invocationNamespace = randomUUID(); ++ const configuration = JSON.stringify({ ++ invocationNamespace, workspace, servers: params.mcpServers ?? [], ++ readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1", ++ readRoots, protectedRoots, ++ ...(agentHome ? { agentHome } : {}), ++ instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "", ++ }); ++ if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound"); ++ const guard = environment.PAPERCLIP_PI_MODULE_GUARD_PATH === undefined ? [] ++ : ["--require", requiredFile(environment, "PAPERCLIP_PI_MODULE_GUARD_PATH")]; ++ const args = [...guard, entrypoint, "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension]; ++ for (const root of readRoots) args.push("--skill", root); ++ if (params.sessionPath) { ++ const home = environment.PI_CODING_AGENT_DIR; ++ if (!home) throw new Error("Pi session home is missing"); ++ const sessionRoot = realpathSync(resolve(home, "sessions")); ++ const sessionPath = realpathSync(params.sessionPath); ++ const suffix = relative(sessionRoot, sessionPath); ++ if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || !lstatSync(params.sessionPath).isFile()) throw new Error("Pi session escaped its private home"); ++ args.push("--session", sessionPath); ++ } ++ const env = { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration }; ++ // Ambient AGENT_HOME never grants filesystem authority. Only the runner's ++ // authenticated working-copy binding reaches both the model and tool gate. ++ if (agentHome) env.AGENT_HOME = agentHome; else delete env.AGENT_HOME; ++ return { command, args, invocationNamespace, env }; ++} ++ ++// Same UTF-16 character bound as the canonical question-set title/header/label. ++export const PI_QUESTION_LABEL_MAX_LENGTH = 1_000; ++export function piQuestionLabel(value ) { ++ if (typeof value !== "string" || !value.trim() || value.length > PI_QUESTION_LABEL_MAX_LENGTH) throw new Error("Pi question label is invalid or oversized"); ++ return value; ++} ++ ++ ++ ++ ++ ++ ++ ++/** Live promises never become authority to replay an answer after provider death. */ ++export class PiUiBridge { ++ pending = new Map (); ++ seen = new Set (); ++ sessionId ; ++ connection ; ++ process ; ++ constructor(sessionId , connection , process ) { this.sessionId = sessionId; this.connection = connection; this.process = process; } ++ ++ cancelAll() { for (const value of this.pending.values()) value.cancel(); } ++ ++ async handle(event ) { ++ const id = text(event.id, 256); ++ const method = text(event.method, 64); ++ if (!["select", "confirm", "input", "editor"].includes(method)) return; ++ if (this.seen.has(id)) return; ++ if (this.seen.size >= 4096) throw new Error("Pi UI request history exceeds its bound"); ++ this.seen.add(id); ++ let done = false; ++ let timeout ; ++ const finish = async (response ) => { ++ if (done) return; ++ done = true; ++ if (timeout) clearTimeout(timeout); ++ this.pending.delete(id); ++ await this.process.sendExtensionUiResponse({ id, ...response }); ++ }; ++ this.pending.set(id, { cancel: () => { void finish({ cancelled: true }).catch(() => {}); } }); ++ if (typeof event.timeout === "number" && Number.isFinite(event.timeout)) { ++ timeout = setTimeout(() => { void finish({ cancelled: true }).catch(() => {}); }, Math.max(0, Math.min(event.timeout, 2_147_483_647))); ++ timeout.unref?.(); ++ } ++ try { ++ const title = text(event.title ?? "Additional information needed", 65_536); ++ if (method === "select" && title.startsWith(PERMISSION_PREFIX)) { ++ const permission = record(JSON.parse(title.slice(PERMISSION_PREFIX.length))); ++ const toolCallId = text(permission.toolCallId, 256); ++ const toolName = text(permission.toolName, 128); ++ const input = record(permission.input); ++ const result = record(await this.connection.requestPermission({ ++ sessionId: this.sessionId, ++ toolCall: { toolCallId, _meta: { paperclipPi: { nativeToolCallId: text(permission.nativeToolCallId, 256), modelIteration: permission.modelIteration } }, title: `Pi ${toolName}`, kind: toolName === "bash" ? "execute" : ["write", "edit"].includes(toolName) ? "edit" : "read", status: "pending", rawInput: input }, ++ options: PERMISSION_CHOICES, ++ })); ++ const outcome = record(result.outcome); ++ const selected = outcome.outcome === "selected" ? PERMISSION_CHOICES.find((option) => option.optionId === outcome.optionId) : undefined; ++ await finish(selected ? { value: selected.name } : { cancelled: true }); ++ return; ++ } ++ piQuestionLabel(title); ++ const property = { type: method === "confirm" ? "boolean" : "string", title }; ++ if (method === "select") { ++ if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options"); ++ property.enum = event.options.map((option) => piQuestionLabel(option)); ++ if (new Set(property.enum ).size !== event.options.length) throw new Error("Ambiguous Pi question options"); ++ } ++ if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder); ++ if (method === "editor" && typeof event.prefill === "string") property.default = text(event.prefill); ++ const result = record(await this.connection.unstable_createElicitation({ ++ sessionId: this.sessionId, mode: "form", message: typeof event.message === "string" ? text(event.message) : title, ++ requestedSchema: { type: "object", title, properties: { answer: property }, required: ["answer"] }, ++ _meta: { paperclipPi: { version: 1, requestId: id, method } }, ++ })); ++ if (result.action !== "accept") { await finish({ cancelled: true }); return; } ++ const answer = record(result.content).answer; ++ if (method === "confirm") { ++ if (typeof answer !== "boolean") throw new Error("Invalid Pi confirmation response"); ++ await finish({ confirmed: answer }); ++ } else { ++ const value = text(answer, 65_536); ++ if (method === "select" && !(property.enum ).includes(value)) throw new Error("Invalid Pi question response"); ++ await finish({ value }); ++ } ++ } catch { ++ if (done) return; // An expired request cannot fail a later live session. ++ await finish({ cancelled: true }).catch(() => {}); ++ throw new Error("Pi structured question is unsupported or invalid"); ++ } ++ } ++} ++ ++/** Sum native usage receipts; preserve missing fields and label catalog pricing. */ ++export class PiTurnUsage { ++ seen = new Set (); ++ total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; ++ cost = 0; ++ unknown = new Set (); ++ costObserved = false; ++ costIncomplete = false; ++ compactionObserved = false; ++ failed = false; ++ observed = false; ++ reset() { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.unknown.clear(); this.costObserved = false; this.costIncomplete = false; this.compactionObserved = false; this.failed = false; this.observed = false; } ++ accept(value ) { ++ const message = record(value); ++ if (message.role !== "assistant") return; ++ if (!message.usage || typeof message.usage !== "object") { ++ this.failed = message.stopReason === "error"; ++ return; ++ } ++ const key = JSON.stringify([message.timestamp, message.id, message.usage, message.content]); ++ if (this.seen.has(key)) return; ++ if (this.seen.size >= 8192) throw new Error("Pi usage receipts exceed their bound"); ++ this.seen.add(key); ++ this.failed = message.stopReason === "error"; ++ const usage = record(message.usage); ++ const valid = (value ) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; ++ const fields = ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"] ; ++ const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name]); ++ for (const [index, name] of fields.entries()) { ++ const value = numbers[index]; ++ if (valid(value)) { this.total[name] += value; this.observed = true; } ++ else this.unknown.add(name); ++ } ++ const total = valid(usage.totalTokens) ? usage.totalTokens ++ : numbers.every(valid) ? (numbers ).reduce((sum, value) => sum + value, 0) : undefined; ++ if (valid(total)) this.total.totalTokens += total; ++ else this.unknown.add("totalTokens"); ++ const cost = usage.cost && typeof usage.cost === "object" ? record(usage.cost).total : undefined; ++ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) { ++ this.cost += cost; this.costObserved = true; ++ } else this.costIncomplete = true; ++ } ++ ++ markIncomplete() { ++ for (const name of Object.keys(this.total)) this.unknown.add(name ); ++ this.costIncomplete = true; ++ } ++ ++ acceptCompaction(value ) { ++ this.compactionObserved = true; ++ const result = value && typeof value === "object" && !Array.isArray(value) ? record(value) : {}; ++ if (!result.usage || typeof result.usage !== "object") { ++ // A compaction may consume tokens even if its terminal receipt is absent. ++ // Known assistant counters alone cannot establish the complete turn. ++ this.markIncomplete(); ++ return; ++ } ++ const previousFailure = this.failed; ++ this.accept({ role: "assistant", id: "compaction", timestamp: result.firstKeptEntryId, ++ content: [result.tokensBefore, result.summary], usage: result.usage }); ++ // A successful summary is not proof that an earlier failed model turn recovered. ++ this.failed = previousFailure; ++ } ++ ++ 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 && stopReason !== "cancelled" ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}), ++ }; ++ } ++} diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index d3acce38a3..d084ee9be8 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -41,6 +41,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 c53fc2c190..8ce7663810 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -151,18 +151,23 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi `; } -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..663754fcbc --- /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, 22); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9'); +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: 22, 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 e71085b64a..b3c2db21d2 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, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } 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); @@ -125,6 +127,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, codexPackedPayloadsOmitted: true, codexPackedPackagesChecked: tarballs.length, ...codex, 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__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 10ac1d8e74..341079db14 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,4 +1,4 @@ -import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation } from "@paperclipai/paperclip-runner/live"; +import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/live/index.js"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils"; import type { ServerAdapterModule } from "../adapters/index.js"; @@ -17,9 +17,10 @@ import { setOverridePaused, } from "../adapters/registry.js"; -vi.mock("@paperclipai/paperclip-runner/live", () => ({ +vi.mock("../vendor/paperclip-runner/live/index.js", () => ({ probeAcpxClaudeInstallation: vi.fn(async () => undefined), probeAcpxGrokInstallation: vi.fn(async () => undefined), + probeAcpxPiInstallation: vi.fn(async () => undefined), probeAcpxCursorInstallation: vi.fn(async () => undefined), })); @@ -327,23 +328,10 @@ describe("server adapter registry", () => { }); }); - it.each([true, false])("checks ordinary Cursor runtime readiness (%s)", async (ready) => { - const probe = vi.mocked(probeAcpxCursorInstallation); + it.each([true, false])("probes the exact Pi installation without qualification opt-in (ready=%s)", async (ready) => { + const probe = vi.mocked(probeAcpxPiInstallation); if (ready) probe.mockResolvedValueOnce(undefined); - else probe.mockRejectedValueOnce(new Error("Run paperclipai runtime setup cursor")); - const model = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"; - const result = await requireServerAdapter("paperclip_runner").testEnvironment({ - companyId: "company-1", adapterType: "paperclip_runner", - config: { provider: "acpx", acpxAgent: "cursor", model }, - }); - expect(probe).toHaveBeenLastCalledWith(model); - expect(result).toMatchObject({ - status: ready ? "pass" : "fail", - checks: [expect.objectContaining({ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" })], - }); - }); - - it("keeps the ACPX Pi profile unavailable", async () => { + else probe.mockRejectedValueOnce(new Error("Pi closure unavailable")); const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", @@ -355,9 +343,10 @@ describe("server adapter registry", () => { }); expect(result).toMatchObject({ - status: "fail", - checks: [{ code: "paperclip_runner_acpx_agent_unavailable" }], + status: ready ? "pass" : "fail", + checks: [{ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" }], }); + expect(probe).toHaveBeenLastCalledWith("openrouter/deepseek/deepseek-v4-flash-0731"); }); it("reports qualification-only readiness for an exact host-authorized candidate", async () => { const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION"; diff --git a/server/src/__tests__/agent-directory-probe.test.ts b/server/src/__tests__/agent-directory-probe.test.ts new file mode 100644 index 0000000000..01a7b4b023 --- /dev/null +++ b/server/src/__tests__/agent-directory-probe.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +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", () => { + let root: string; + beforeEach(() => { + root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "agent-directory-probe-"))); + fs.mkdirSync(join(root, "notes")); + fs.writeFileSync(join(root, "AGENTS.md"), "Instructions\n"); + fs.writeFileSync(join(root, "notes", "memory.bin"), Buffer.from([0, 1, 255])); + }); + afterEach(() => { vi.restoreAllMocks(); fs.rmSync(root, { recursive: true, force: true }); }); + function productionPrograms(): { probe: string; cleanup: string } { + // Product/dev starts the server with tsx. Vitest's own transform does not + // reproduce the lexical helpers injected into function.toString() there. + const loader = createRequire(import.meta.url).resolve("tsx"); + const source = new URL("../services/agent-directory-probe.ts", import.meta.url).href; + const program = `import { agentDirectoryProbeProgram, agentDirectoryTransferCleanupProgram } from ${JSON.stringify(source)}; process.stdout.write(JSON.stringify({probe:agentDirectoryProbeProgram,cleanup:agentDirectoryTransferCleanupProgram}));`; + return JSON.parse(execFileSync(process.execPath, ["--import", loader, "--input-type=module", "--eval", program], { + cwd: root, env: { TSX_DISABLE_CACHE: "1", HOME: root, TMPDIR: root }, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, + })); + } + it("runs a self-contained live probe after the production tsx transform", () => { + const { probe } = productionPrograms(); + const observe = () => JSON.parse(execFileSync(process.execPath, ["-e", probe, root], { + env: {}, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, stdio: "pipe", + })); + const before = probeAgentDirectory(root); + expect(observe()).toEqual(before); + fs.writeFileSync(join(root, "AGENTS.md"), "Changed instructions\n"); + expect(observe()).toEqual(probeAgentDirectory(root)); + expect(observe().digest).not.toBe(before.digest); + fs.symlinkSync(join(root, "AGENTS.md"), join(root, "unsafe")); + expect(observe).toThrow(); + }); + it("runs bounded transfer cleanup after the production tsx transform", () => { + const { cleanup } = productionPrograms(); + const scratch = join(root, ".paperclip-runtime", "agent-files"); + const retire = () => execFileSync(process.execPath, ["-e", cleanup, root], { + env: {}, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, stdio: "pipe", + }); + fs.mkdirSync(scratch, { recursive: true }); + fs.writeFileSync(join(scratch, "unexpected"), "retain"); + expect(retire).toThrow(); + expect(fs.readFileSync(join(scratch, "unexpected"), "utf8")).toBe("retain"); + fs.unlinkSync(join(scratch, "unexpected")); + retire(); + expect(fs.existsSync(join(root, ".paperclip-runtime"))).toBe(false); + expect(fs.readFileSync(join(root, "AGENTS.md"), "utf8")).toBe("Instructions\n"); + expect(fs.readFileSync(join(root, "notes", "memory.bin"))).toEqual(Buffer.from([0, 1, 255])); + }); + it("matches the complete materialized baseline and the actual remote Node program", async () => { + const baseline = await captureDirectorySnapshot(root); + const observed = probeAgentDirectory(root); + expect(observed.digest).toBe(agentDirectoryBaselineDigest(baseline)); + const remote = JSON.parse(execFileSync(process.execPath, ["-e", agentDirectoryProbeProgram, root], { encoding: "utf8", env: {}, timeout: 10_000 })); + expect(remote).toEqual(observed); + }); + it.runIf(process.platform === "darwin")("accepts only the observing Darwin host's fixed system aliases", () => { + const alias = root.replace(/^\/private(?=\/(tmp|var|etc)\/)/, ""); + expect(alias).not.toBe(root); + const observed = probeAgentDirectory(root); + expect(probeAgentDirectory(alias)).toEqual(observed); + const remote = JSON.parse(execFileSync(process.execPath, ["-e", agentDirectoryProbeProgram, alias], { encoding: "utf8", env: {}, timeout: 10_000 })); + expect(remote).toEqual(observed); + // A Linux remote cannot inherit the controller's Darwin exception. + expect(() => execFileSync(process.execPath, ["-e", agentDirectoryProbeProgram.replace("platform:process.platform", "platform:'linux'"), alias], + { encoding: "utf8", env: {}, timeout: 10_000, stdio: "pipe" })).toThrow(); + fs.symlinkSync(root, join(root, "redirect")); + expect(() => probeAgentDirectory(`${alias}/redirect`)).toThrow(); + }); + it("observes the complete agent directory after the actual remote transfer", async () => { + const remote = `${root}-remote`; + fs.mkdirSync(remote); + const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: remote, + 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: "probe-transfer", adapterKey: "agent-files", + workspaceLocalDir: root, workspaceRemoteDir: remote, syncWorkspace: true, workspaceBaseline: baseline, + workspaceGitSnapshot: null, workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] }); + expect(fs.readdirSync(`${remote}/.paperclip-runtime`)).toEqual(["agent-files"]); + expect(fs.readdirSync(`${remote}/.paperclip-runtime/agent-files`)).toEqual([]); + execFileSync(process.execPath, ["-e", agentDirectoryTransferCleanupProgram, remote], { env: {}, timeout: 10_000 }); + expect(() => probeAgentDirectory(remote)).not.toThrow(); + 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 }); + const retained = join(parent, "retained.txt"); + if (kind === "file") fs.writeFileSync(join(scratch, "retained.txt"), "keep"); + if (kind === "extra-directory") fs.mkdirSync(join(parent, "unknown")); + if (kind === "symlink") { fs.rmdirSync(scratch); fs.symlinkSync(join(root, "notes"), scratch); } + if (kind === "race") { + const rmdir = fs.rmdirSync; + vi.spyOn(fs, "rmdirSync").mockImplementation(((directory: fs.PathLike) => { + if (directory === scratch) fs.writeFileSync(retained, "concurrent bytes"); + return rmdir(directory); + }) as typeof rmdir); + } + expect(() => retireAgentDirectoryTransferScratch(root)).toThrow(); + expect(fs.existsSync(parent)).toBe(true); + if (kind === "file") expect(fs.readFileSync(join(scratch, "retained.txt"), "utf8")).toBe("keep"); + if (kind === "race") expect(fs.readFileSync(retained, "utf8")).toBe("concurrent bytes"); + expect(fs.existsSync(join(root, "notes", "memory.bin"))).toBe(true); + }); + it("runs the local observation in its bounded credential-free child", async () => { + expect(await observeLocalAgentDirectory(root)).toEqual(probeAgentDirectory(root)); + }); + it.each(["timeout", "oversized", "malformed"])("rejects %s local-child observations", async kind => { + const stub = vi.spyOn(childProcess, "execFile").mockImplementation(((file: string, args: string[], options: object, callback: (error: Error | null, stdout: string, stderr: string) => void) => { + expect(file).toBe(process.execPath); + expect(args).toEqual(["-e", agentDirectoryProbeProgram, root]); + expect(options).toMatchObject({ cwd: root, env: {}, timeout: 10_000, maxBuffer: 1024, killSignal: "SIGKILL" }); + callback(kind === "timeout" ? Object.assign(new Error("timed out"), { killed: true, signal: "SIGKILL" }) : null, + kind === "oversized" ? "x".repeat(1025) : "{}", ""); + return {} as childProcess.ChildProcess; + }) as typeof childProcess.execFile); + await expect(observeLocalAgentDirectory(root)).rejects.toThrow(); + expect(stub).toHaveBeenCalledOnce(); + }); + it.each(["edit", "add", "remove", "mode"])("detects a legitimate %s", change => { + const before = probeAgentDirectory(root); + if (change === "edit") fs.writeFileSync(join(root, "AGENTS.md"), "New instructions\n"); + if (change === "add") fs.writeFileSync(join(root, "new.txt"), "New"); + if (change === "remove") fs.unlinkSync(join(root, "notes", "memory.bin")); + if (change === "mode") fs.chmodSync(join(root, "AGENTS.md"), 0o700); + expect(probeAgentDirectory(root).digest).not.toBe(before.digest); + }); + it.each(["symlink", "hardlink", "reserved"])("rejects %s entries", kind => { + if (kind === "symlink") fs.symlinkSync(join(root, "AGENTS.md"), join(root, "unsafe")); + if (kind === "hardlink") fs.linkSync(join(root, "AGENTS.md"), join(root, "unsafe")); + if (kind === "reserved") fs.mkdirSync(join(root, ".paperclip-runtime")); + expect(() => probeAgentDirectory(root)).toThrow(); + }); + it("detects file mutation during a bounded read", () => { + const read = fs.readSync; + let changed = false; + vi.spyOn(fs, "readSync").mockImplementation(((...args: Parameters) => { + const result = Reflect.apply(read, fs, args); + if (!changed) { changed = true; fs.appendFileSync(join(root, "AGENTS.md"), "Concurrent change"); } + return result; + }) as typeof read); + expect(() => probeAgentDirectory(root)).toThrow(/changed|grew/); + }); + it("fails on unreadable observations instead of reporting unchanged", () => { + vi.spyOn(fs, "openSync").mockImplementation(() => { throw Object.assign(new Error("denied"), { code: "EACCES" }); }); + expect(() => probeAgentDirectory(root)).toThrow("denied"); + }); + it("does not let matching replacement bytes preserve the pinned root identity", () => { + const before = probeAgentDirectory(root); + const retired = `${root}-retired`; + fs.renameSync(root, retired); + try { fs.cpSync(retired, root, { recursive: true }); expect(probeAgentDirectory(root).identity).not.toBe(before.identity); } + finally { fs.rmSync(retired, { recursive: true, force: true }); } + }); + it("rejects excluded/incomplete baselines", async () => { + const baseline = await captureDirectorySnapshot(root); + expect(() => agentDirectoryBaselineDigest({ ...baseline, exclude: ["notes"] })).toThrow("Incomplete"); + }); +}); diff --git a/server/src/__tests__/agent-directory-working-copies.test.ts b/server/src/__tests__/agent-directory-working-copies.test.ts index 32e37089a3..69fbb1c7d0 100644 --- a/server/src/__tests__/agent-directory-working-copies.test.ts +++ b/server/src/__tests__/agent-directory-working-copies.test.ts @@ -74,6 +74,183 @@ describe("persistent agent directories", () => { await fs.writeFile(path.join(root, entryFile), initial); }); + describe("warm directory ownership", () => { + async function nextRun() { + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId }); + return runId; + } + it("adopts one physical root and fences stale cleanup through final save", async () => { + const first = await run(); + expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false); + const secondId = await nextRun(); + const second = await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home }); + expect(second).toMatchObject({ runId: secondId, localRoot: first.localRoot, executionRoot: first.executionRoot, + responsibleUserId: userId, receipt: { materializationRunId: first.runId } }); + expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBe(secondId); + await copies.release(companyId, first.runId); + expect((await copies.collectStopped({ companyId, runId: first.runId }))?.processStoppedAt).toBeNull(); + await copies.reportUnavailable(companyId, first.runId); + expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial); + await copies.release(companyId, secondId); + expect(await copies.hasChanges({ companyId, runId: secondId })).toBe(false); + const thirdId = await nextRun(); + const third = await copies.adopt({ companyId, agentId, runId: thirdId, previousRunId: secondId, cwd: home }); + expect(third?.executionRoot).toBe(first.executionRoot); + await fs.writeFile(path.join(first.localRoot, "warm-memory.txt"), "third turn edit"); + expect(await copies.hasChanges({ companyId, runId: thirdId })).toBe(true); + expect(await copies.collectStopped({ companyId, runId: thirdId })).toMatchObject({ state: "saved", receipt: { cleanupPending: false } }); + expect(await fs.readFile(path.join(root, "warm-memory.txt"), "utf8")).toBe("third turn edit"); + await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await copies.release(companyId, first.runId); + }); + it.each(["company", "agent", "workspace", "environment"])("rejects foreign %s adoption", async mismatch => { + const first = await run(); + expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false); + const secondId = await nextRun(); + expect(await copies.adopt({ companyId: mismatch === "company" ? randomUUID() : companyId, + agentId: mismatch === "agent" ? randomUUID() : agentId, runId: secondId, previousRunId: first.runId, + cwd: mismatch === "workspace" ? `${home}-other` : home, + ...(mismatch === "environment" ? { target: { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), remoteCwd: "/workspace" } } : {}), + })).toBeNull(); + expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBeUndefined(); + await copies.collectStopped({ companyId, runId: first.runId }); + }); + it("allows only one stale concurrent adoption with identical updatedAt timestamps", async () => { + const fixed = new Date("2030-01-01T00:00:00.000Z"); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(fixed); + try { + const first = await run(); + expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false); + const next = await Promise.all([nextRun(), nextRun()]); + let reads = 0; + let releaseReads!: () => void; + const bothRead = new Promise(resolve => { releaseReads = resolve; }); + // Both claimants see the same unclaimed row before either can enter + // the agent-row transaction. Date precision cannot distinguish them. + const staleReader = async () => { + const captured = await copies.get(companyId, first.runId); + reads++; + if (reads >= 3) { if (reads === 4) releaseReads(); await bothRead; } + return captured; + }; + const service = agentDirectoryWorkingCopyService(db, staleReader, async (row, values) => { + const [updated] = await db.update(agentInstructionWorkingCopies).set({ ...values, updatedAt: fixed }) + .where(eq(agentInstructionWorkingCopies.runId, row.runId)).returning(); + return updated; + }); + const settled = await Promise.allSettled(next.map(runId => service.adopt({ companyId, agentId, runId, previousRunId: first.runId, cwd: home }))); + const winners = settled.filter(result => result.status === "fulfilled" && result.value); + expect(winners).toHaveLength(1); + expect(settled.filter(result => result.status === "rejected")).toHaveLength(1); + const prior = await copies.get(companyId, first.runId); + expect(prior?.updatedAt).toEqual(fixed); + const winnerId = prior?.receipt?.retainedByRunId as string; + expect(next).toContain(winnerId); + expect(await copies.get(companyId, next.find(id => id !== winnerId)!)).toBeNull(); + await copies.collectStopped({ companyId, runId: winnerId }); + } finally { vi.useRealTimers(); } + }); + it("fences a stale collector when adoption commits immediately before its stop CAS", async () => { + const first = await run(); + expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false); + const secondId = await nextRun(); + const update = db.update.bind(db); + let injected = false; + const spy = vi.spyOn(db, "update").mockImplementation(table => { + const builder = update(table); + if (table !== agentInstructionWorkingCopies) return builder; + const set = builder.set.bind(builder); + builder.set = (values => { + const query = set(values); + if (!("processStoppedAt" in values) || !values.processStoppedAt || injected) return query; + const returning = query.returning.bind(query); + query.returning = (async () => { + injected = true; + expect(await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home })).not.toBeNull(); + return returning(); + }) as typeof query.returning; + return query; + }) as typeof builder.set; + return builder; + }); + try { + expect(await copies.collectStopped({ companyId, runId: first.runId })).toMatchObject({ + receipt: { retainedByRunId: secondId }, processStoppedAt: null, attempts: 0, + }); + expect(injected).toBe(true); + expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial); + expect((await copies.get(companyId, secondId))?.processStoppedAt).toBeNull(); + } finally { spy.mockRestore(); } + await copies.collectStopped({ companyId, runId: secondId }); + }); + it("requires current-run authorization before transferring collection authority", async () => { + const first = await run(); + expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false); + const secondId = await nextRun(); + await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, secondId)); + await expect(copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home })).rejects.toMatchObject({ status: 403 }); + expect((await copies.get(companyId, first.runId))?.receipt?.retainedByRunId).toBeUndefined(); + await copies.collectStopped({ companyId, runId: first.runId }); + }); + it.each(["owner-close", "restart-proof"])("durably preserves an adopted directory after retirement failure and final cleanup: %s", async recovery => { + const first = await run(); + expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false); + const secondId = await nextRun(); + await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home }); + await db.update(heartbeatRuns).set({ status: "failed", runtimeMode: "native" }).where(eq(heartbeatRuns.id, secondId)); + expect(await copies.reportRetirementUnconfirmed(companyId, secondId)).toMatchObject({ + state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED", processStoppedAt: null, + }); + await fs.writeFile(path.join(first.localRoot, "unsaved-memory.txt"), "Keep failed retirement edits"); + // Mirror heartbeat's final cleanup after preparation throws. Neither the + // current pending owner nor the prior alias may become terminal loss. + await copies.reportUnavailable(companyId, secondId); + await copies.reportUnavailable(companyId, first.runId); + expect(await copies.get(companyId, secondId)).toMatchObject({ state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED", processStoppedAt: null }); + await copies.reportRetirementUnconfirmed(companyId, first.runId); + await copies.release(companyId, first.runId); + await copies.release(companyId, secondId); + await copies.recoverStopped(); // A terminal run with no stop receipt grants no collection authority. + expect((await copies.get(companyId, secondId))?.processStoppedAt).toBeNull(); + expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial); + if (recovery === "owner-close") await copies.collectStopped({ companyId, runId: secondId }); // Explicit test owner retirement. + else { + const { appendHeartbeatRunEvent } = await import("../services/heartbeat-run-events.js"); + const stop = (runId: string) => appendHeartbeatRunEvent(db, { companyId, runId, agentId, + eventType: "native.local_process_stopped", stream: "system", level: "info", message: "Test-owned process retirement proof", payload: {} }); + await stop(first.runId); + copies = agentInstructionWorkingCopyService(db); // No in-memory owner or transport survives. + 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 } }); + expect(await fs.readFile(path.join(root, "unsaved-memory.txt"), "utf8")).toBe("Keep failed retirement edits"); + await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("declines reuse after canonical instruction changes", async () => { + const first = await run(); + expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false); + await fs.writeFile(path.join(root, entryFile), "Changed canonical instructions"); + const secondId = await nextRun(); + expect(await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home })).toBeNull(); + await copies.collectStopped({ companyId, runId: first.runId }); + }); + it("requires stop for matching bytes at a replaced materialized root", async () => { + const first = await run(); + const replaced = `${first.localRoot}-old`; + await fs.rename(first.localRoot, replaced); + await fs.cp(replaced, first.localRoot, { recursive: true }); + expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(true); + await copies.collectStopped({ companyId, runId: first.runId }); + }); + }); + it.each([".paperclip-runtime/state", "notes/.paperclip-runtime/state", "promptTemplate.legacy.md"])("rejects reserved board path %s before mutation", async (reserved) => { await expect(agentFileStore(db).write({ ...target(), path: reserved, bytes: Buffer.from("reserved"), baseHash: null }, board())).rejects.toMatchObject({ status: 422 }); await expect(fs.stat(path.join(root, reserved))).rejects.toMatchObject({ code: "ENOENT" }); @@ -381,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); @@ -389,6 +567,60 @@ describe("persistent agent directories", () => { expect(execute).toHaveBeenCalledTimes(2); }); + it.each(["missing", "stopped", "destroyed"])("recovers a retained remote unchanged turn only with exact stop proof: %s", async stopped => { + const copy = await run(); + const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task"; + const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "retained-sandbox" }; + await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" }); + await db.insert(environmentLeases).values({ ...lease, status: "released", releasedAt: new Date(), cleanupStatus: "success", + metadata: stopped !== "missing" ? { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: stopped }) } : {} }); + await db.update(heartbeatRuns).set({ status: "succeeded", runtimeMode: "native" }).where(eq(heartbeatRuns.id, copy.runId)); + await db.update(agentInstructionWorkingCopies).set({ state: "unchanged_turn", location: `remote:${environmentId}`, + executionRoot: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId), + receipt: { ...copy.receipt, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId)); + const execute = vi.fn(); + copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService }); + expect((await copies.reportUnavailable(companyId, copy.runId))?.state).toBe("unchanged_turn"); + await copies.recoverStopped(); + const recovered = (await copies.get(companyId, copy.runId))!; + if (stopped === "destroyed") { + expect(recovered).toMatchObject({ state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", receipt: { cleanupPending: false } }); + expect(recovered.processStoppedAt).toBeInstanceOf(Date); + await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await copies.recoverStopped(); + expect((await copies.get(companyId, copy.runId))?.updatedAt).toEqual(recovered.updatedAt); + } else { + expect(recovered).toMatchObject({ state: stopped === "stopped" ? "pending_collection" : "unchanged_turn", processStoppedAt: null }); + if (stopped === "stopped") { + expect(recovered.errorCode).toBe("INSTRUCTION_STOPPED_REMOTE_COLLECTION_PENDING"); + expect(recovered.receipt?.baseline).toBeDefined(); + await copies.release(companyId, copy.runId); + await copies.recoverCaptured(); + } + expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial); + } + expect(execute).not.toHaveBeenCalled(); // Never restart a remote provider to recover bytes. + }); + + it.each([0, 2])("preserves a legacy no-ID remote copy with %s matching leases", async count => { + const copy = await run(); + const environmentId = randomUUID(), remoteCwd = "/fixture/task"; + await db.insert(environments).values({ id: environmentId, name: `Legacy remote ${environmentId}`, driver: "sandbox" }); + for (let index = 0; index < count; index++) await db.insert(environmentLeases).values({ + companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: `allocation-${index}` }); + await db.update(agentInstructionWorkingCopies).set({ location: `remote:${environmentId}`, + executionRoot: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId), + receipt: { ...copy.receipt, cleanup: { remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId)); + const execute = vi.fn(); + copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService }); + const pending = await copies.collectStopped({ companyId, runId: copy.runId }); + expect(pending).toMatchObject({ state: "pending_collection", attempts: 0, processStoppedAt: null, errorCode: "INSTRUCTION_REMOTE_LEASE_UNVERIFIED" }); + expect(pending!.receipt?.baseline).toBeDefined(); + await copies.release(companyId, copy.runId); + expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial); + expect(execute).not.toHaveBeenCalled(); + }); + it("finishes remote cleanup from a destruction receipt after the environment is deleted", async () => { const copy = await run(); const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task"; @@ -614,15 +846,22 @@ describe("persistent agent directories", () => { } }, }; - const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), remoteCwd, runner }; + const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), leaseId: "", remoteCwd, runner }; + await db.insert(environments).values({ id: executionTarget.environmentId, name: "Fixture sandbox", driver: "sandbox" }); const prepare = async () => { const runId = randomUUID(); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId }); + executionTarget.leaseId = randomUUID(); + await db.insert(environmentLeases).values({ id: executionTarget.leaseId, companyId, environmentId: executionTarget.environmentId, + heartbeatRunId: runId, provider: "daytona", providerLeaseId: "fixture-allocation" }); return (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget }))!; }; 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])); @@ -632,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 }); @@ -666,10 +906,14 @@ describe("persistent agent directories", () => { } }, }; - const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), remoteCwd, runner }; + const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), leaseId: "", remoteCwd, runner }; + await db.insert(environments).values({ id: executionTarget.environmentId, name: "Warm fixture sandbox", driver: "sandbox" }); const prepare = async (reuseRunId?: string) => { const runId = randomUUID(); await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId }); + executionTarget.leaseId = randomUUID(); + await db.insert(environmentLeases).values({ id: executionTarget.leaseId, companyId, environmentId: executionTarget.environmentId, + heartbeatRunId: runId, provider: "daytona", providerLeaseId: "warm-fixture-allocation" }); return (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget, warm: true, reuseRunId }))!; }; let copy = await prepare(); @@ -687,7 +931,7 @@ describe("persistent agent directories", () => { } await copies.collectStopped({ companyId, runId: original.runId, target: executionTarget }); expect(await fs.stat(copy.executionRoot)).toBeTruthy(); - await copies.collectStopped({ companyId, runId: copy.runId, target: executionTarget }); + expect((await copies.collectStopped({ companyId, runId: copy.runId, target: executionTarget }))?.receipt).toMatchObject({ cleanupPending: false }); await expect(fs.stat(copy.executionRoot)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -706,6 +950,9 @@ describe("persistent agent directories", () => { await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId }); const target = { kind: "remote" as const, transport: "ssh" as const, environmentId: randomUUID(), remoteCwd, spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } }; + await db.insert(environments).values({ id: target.environmentId, name: "Fixture SSH", driver: "ssh" }); + // Legacy no-ID receipt: only the unique run/environment lease authorizes retrieval. + await db.insert(environmentLeases).values({ companyId, environmentId: target.environmentId, heartbeatRunId: runId, provider: "ssh", providerLeaseId: "fixture-host" }); const copy = (await copies.prepare({ companyId, agentId, runId, cwd: home, target }))!; expect(stage).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: copy.executionRoot })); expect(await fs.readFile(path.join(copy.executionRoot, entryFile), "utf8")).toBe(initial); @@ -948,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 9b468844e3..cec993f591 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1084,17 +1084,30 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { .where(and(inArray(chatConversations.companyId, companyIds), inArray(chatConversations.state, ["active", "waiting"]))); } - async function seedCompany() { + const companyPrefixAttempts = 8; + const freshCompanyPrefix = () => `C${randomUUID().replaceAll("-", "").slice(0, 7).toUpperCase()}`; + + async function seedCompany(nextIssuePrefix = freshCompanyPrefix) { const companyId = randomUUID(); - fixtureCompanies.add(companyId); 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) @@ -1149,6 +1162,46 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { return { companyId, assignedAgentId, replacementAgentId }; } + it("retries a colliding company fixture prefix and creates a distinct complete fixture", async () => { + const existing = await seedCompany(); + const [original] = await db.select().from(companies).where(eq(companies.id, existing.companyId)); + const candidates: string[] = []; + const nextPrefix = vi.fn(() => { + const prefix = candidates.length === 0 ? original!.issuePrefix : freshCompanyPrefix(); + candidates.push(prefix); + return prefix; + }); + const fixture = await seedCompany(nextPrefix); + const [allocated] = await db.select().from(companies).where(eq(companies.id, fixture.companyId)); + expect(nextPrefix.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(allocated!.issuePrefix).toBe(candidates.at(-1)); + expect(allocated!.issuePrefix).toMatch(/^C[A-F0-9]{7}$/); + expect(allocated!.issuePrefix).not.toBe(original!.issuePrefix); + expect(fixture.companyId).not.toBe(existing.companyId); + expect(await db.select().from(companies).where(eq(companies.id, existing.companyId))).toEqual([original]); + expect((await db.select().from(agents).where(eq(agents.companyId, fixture.companyId))).map(agent => agent.id).sort()) + .toEqual([fixture.assignedAgentId, fixture.replacementAgentId].sort()); + expect(await db.select().from(companyMemberships).where(eq(companyMemberships.companyId, fixture.companyId))) + .toEqual([expect.objectContaining({ principalId: "owner-user", membershipRole: "operator" })]); + }); + + it("bounds company fixture prefix collision retries without creating a partial fixture", async () => { + const fixture = await seedCompany(); + const [company] = await db.select().from(companies).where(eq(companies.id, fixture.companyId)); + const before = await db.select({ id: companies.id }).from(companies); + const nextPrefix = vi.fn(() => company!.issuePrefix); + await expect(seedCompany(nextPrefix)).rejects.toThrow(`after ${companyPrefixAttempts} attempts`); + expect(nextPrefix).toHaveBeenCalledTimes(companyPrefixAttempts); + expect((await db.select({ id: companies.id }).from(companies)).map(row => row.id).sort()) + .toEqual(before.map(row => row.id).sort()); + }); + + it("does not retry unrelated company fixture database errors", async () => { + const nextPrefix = vi.fn(() => "C\0INVALID"); + await expect(seedCompany(nextPrefix)).rejects.toMatchObject({ cause: { code: "22021" } }); + expect(nextPrefix).toHaveBeenCalledOnce(); + }); + // A truthy return is not a durable scheduler receipt. These transport tests // record the same exact receipt identity; real scheduling/coalescing is // separately exercised by durable-chat-wakeup.test.ts against heartbeat. @@ -1572,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( @@ -28187,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({ @@ -31689,6 +31753,28 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }); it("quarantines Telegram maintenance success when credential-lease ownership is reclaimed", async () => { + // Global sweeps may also reconcile a retired fixture's receipt. Keep that + // work eligible so the fault hook proves it cannot steal another owner. + const retiredFixture = await seedCompany(); + const retired = await configuredSlackEndpoint(retiredFixture); + const retiredThread = makeThread({ channelId: "C-LEASE-SCOPE", id: "slack:C-LEASE-SCOPE:7002.1" }); + await deliverMessage({ + callbacks: retired.callbacks, + endpointId: retired.endpoint.id, + thread: retiredThread.thread, + message: makeMessage({ id: "7002.1", text: "@maya check lease isolation", mentioned: true }), + trigger: "mention", + }); + await retired.service.shutdown(); + const [retiredReceipt] = await db.select().from(chatActions).where(and( + eq(chatActions.endpointId, retired.endpoint.id), + eq(chatActions.kind, "receipt_reaction"), + )); + expect(retiredReceipt).toBeDefined(); + await db.update(chatActions).set({ status: "received" }) + .where(eq(chatActions.id, retiredReceipt!.id)); + await retireFixtureState([retiredFixture.companyId]); + const fixture = await seedCompany(); const botToken = "123456:telegram-lease-reclaim"; let reclaimLease = false; @@ -31741,7 +31827,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const { service } = createService(new FakeChatSdkRuntime(), providerFetch, { credentialMutationLeaseRenewalIntervalMs: 5, renewCredentialMutationLease: async (input) => { - if (!reclaimLease) return true; + // Force the unrelated receipt to reach its ownership check only + // after the Telegram fault is armed, independent of query timing. + if (blockCommands && input.companyId === retiredFixture.companyId) { + await leaseReclaimed; + } + if (input.companyId !== fixture.companyId || !reclaimLease) return true; const reclaimed = await db .update(chatEndpointLeases) .set({ @@ -31799,6 +31890,13 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await service.processPendingDeliveries(); + await expect(db.select({ status: chatActions.status }).from(chatActions) + .where(eq(chatActions.id, retiredReceipt!.id))) + .resolves.toEqual([{ status: "cancelled" }]); + await expect(db.select().from(chatEndpointLeases) + .where(eq(chatEndpointLeases.endpointId, retired.endpoint.id))) + .resolves.toEqual([]); + await expect( db .select({ status: chatActions.status, result: chatActions.result }) @@ -62486,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 } = @@ -62589,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-native-runner-cancellation.test.ts b/server/src/__tests__/heartbeat-native-runner-cancellation.test.ts index 31749f638f..1c2f877ec7 100644 --- a/server/src/__tests__/heartbeat-native-runner-cancellation.test.ts +++ b/server/src/__tests__/heartbeat-native-runner-cancellation.test.ts @@ -41,6 +41,13 @@ describe("native heartbeat cancellation authority", () => { ); }); + it("passes the caller identity into the audited native boundary", async () => { + const cancel = vi.fn(async () => ({ decision: {}, auditId: "audit" })); + const db = {} as Db, cancellationRequestId = "11111111-1111-4111-8111-111111111111"; + await cancelHeartbeatNativeRun({ db, runId: "run", reason: "Stop", runtimeMode: "native", cancellationRequestId, cancel }); + expect(cancel).toHaveBeenCalledWith("run", "Stop", { db, scope: "run", cancellationRequestId }); + }); + it("fails closed when a native cancellation lacks its decision audit", async () => { const cancel = vi.fn(async () => ({ decision: null, diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 6d47fc4795..f02983143e 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,3 +1,7 @@ +import { ensureNativeCompletionContract } from "../services/native-runtime/completion-contracts.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 aiConnectionRuntime from "../services/ai-connection-runtime.js"; import { unprocessable } from "../errors.js"; import * as executionContinuation from "../services/execution-continuation.js"; @@ -23,6 +27,7 @@ import { afterAll, afterEach, beforeAll, + beforeEach, describe, expect, it, @@ -66,6 +71,7 @@ import { heartbeatRunEvents, heartbeatRuns, issueComments, + issueQuestionResponseDeliveries, issueApprovals, issueDocuments, issuePlanDecompositions, @@ -16451,4 +16457,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 775905a8c6..38120e0d08 100644 --- a/server/src/__tests__/invite-test-resolution-route.test.ts +++ b/server/src/__tests__/invite-test-resolution-route.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; function createSelectChain(rows: unknown[]) { const query = { @@ -44,6 +44,13 @@ function createInvite(overrides: Record = {}) { }; } +function loadAppModules() { + return Promise.all([ + import("../routes/access.js"), + import("../middleware/index.js"), + ]); +} + async function createApp( db: Record, network: { @@ -51,10 +58,7 @@ async function createApp( requestHead: ReturnType; }, ) { - const [access, middleware] = await Promise.all([ - import("../routes/access.js"), - import("../middleware/index.js"), - ]); + const [access, middleware] = await loadAppModules(); const app = express(); app.use((req, _res, next) => { (req as any).actor = { type: "anon" }; @@ -74,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__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index abc52ad413..be3f8c36cb 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -173,11 +173,15 @@ type TestActor = runId: string | null; }; -async function createApp(actor?: TestActor) { - const [{ errorHandler }, { issueRoutes }] = await Promise.all([ +function loadAppModules() { + return Promise.all([ import("../middleware/index.js"), import("../routes/issues.js"), ]); +} + +async function createApp(actor?: TestActor) { + const [{ errorHandler }, { issueRoutes }] = await loadAppModules(); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -196,7 +200,7 @@ async function createApp(actor?: TestActor) { } describe("issue execution policy routes", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/issues.js"); @@ -263,6 +267,9 @@ describe("issue execution policy routes", () => { }; }); mockAccessService.hasPermission.mockResolvedValue(false); + // Finish cold imports before a test configures its request-specific mocks. + // A timed-out import must not resume a request against the next test's mocks. + await loadAppModules(); }); it("reauthorizes a terminal verdict against the review policy held under the update lock", async () => { diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index ba5b19453e..1075c05ea1 100644 --- a/server/src/__tests__/redaction.test.ts +++ b/server/src/__tests__/redaction.test.ts @@ -23,21 +23,66 @@ import { sanitizeRecord, } from "../redaction.js"; +import { createCopilotToolEvidence } from "../../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js"; +import { appendSemanticToolReceipt } from "../../../packages/paperclip-runner/src/drivers/semantic-tool-receipt.js"; + +function receiptNotice(version: 1 | 2 = 1): Record { + return { + schema: "paperclip.provider.notice.v1", noticeId: `copilot-evidence-${"a".repeat(24)}-1`, + severity: "info", category: `paperclip_semantic_tool_receipt_v${version}`, scope: "turn", + recoverable: true, userActionable: false, summary: "Paperclip returned a semantic tool result.", + provenance: { method: "paperclip/semantic_tool_result", eventType: "semantic_result", sessionId: "session", turnId: "turn" }, + details: Object.entries({ stage: "semantic_result", schema: `paperclip.semantic_tool_receipt.v${version}`, + operationId: "finish_task", callIdentitySha256: "a".repeat(64), inputSha256: "b".repeat(64), + resultSha256: "c".repeat(64), outcome: "returned", ...(version === 2 ? { normalizedInputSha256: "d".repeat(64) } : {}), + }).map(([name, value]) => ({ name, value })), + }; +} +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/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 5d92575459..7b568806cc 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -681,6 +681,21 @@ describe("routine routes", () => { userId: "board-user", runId: null, }); - expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything()); + try { + expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything()); + } catch (error) { + // Report mock wiring without importing modules again or changing timing. + console.error("Routine creation telemetry mock diagnostic", { + clientGetterCalls: mockGetTelemetryClient.mock.calls.length, + clientGetterResults: mockGetTelemetryClient.mock.results.slice(-4).map(result => ({ + type: result.type, + truthy: Boolean(result.value), + })), + trackingCalls: mockTrackRoutineCreated.mock.calls.length, + createCalls: mockRoutineService.create.mock.calls.length, + activityCalls: mockLogActivity.mock.calls.length, + }); + throw error; + } }); }); diff --git a/server/src/__tests__/runner-project-icon-contract.test.ts b/server/src/__tests__/runner-project-icon-contract.test.ts new file mode 100644 index 0000000000..04d7c20b7e --- /dev/null +++ b/server/src/__tests__/runner-project-icon-contract.test.ts @@ -0,0 +1,25 @@ +import Ajv2020 from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; +import { + capabilitySemanticToolDescriptor, + paperclipSemanticAction, +} from "@paperclipai/paperclip-runner"; +import { PROJECT_ICON_NAMES } from "@paperclipai/shared"; + +// Cross-package parity belongs at the App boundary: the standalone runner +// must not import the App's shared package, even from its own tests. +describe("runner project icon contract", () => { + it("advertises only icons accepted by the project API on both tool surfaces", () => { + const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true }); + for (const schema of [ + capabilitySemanticToolDescriptor("create_project")!.inputSchema, + paperclipSemanticAction("create_project")!.inputSchema, + ]) { + const validate = ajv.compile(schema); + const input = { name: "Onboarding", idempotencyKey: "onboarding" }; + expect(schema).toMatchObject({ properties: { icon: { enum: [...PROJECT_ICON_NAMES, null] } } }); + for (const icon of [...PROJECT_ICON_NAMES, null]) expect(validate({ ...input, icon }), String(icon)).toBe(true); + expect(validate({ ...input, icon: "users" })).toBe(false); + } + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index cabbd2fdd5..112c30243a 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -6678,7 +6678,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => } throw new Error("Timed out waiting for runtime service process marker"); }; - const waitForPersistedStatus = async (status: string) => { + const waitForPersistedStatus = async (status: string, requireProviderRef = false) => { const deadline = Date.now() + 5_000; while (Date.now() < deadline) { const row = await db @@ -6686,7 +6686,9 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => .from(workspaceRuntimeServices) .where(eq(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceId)) .then((rows) => rows[0] ?? null); - if (row?.status === status) return row; + // The provisional starting row is durable before spawn; the child can + // write its marker before the subsequent PID update reaches the DB. + if (row?.status === status && (!requireProviderRef || /^\d+$/.test(row.providerRef ?? ""))) return row; await new Promise((resolve) => setTimeout(resolve, 25)); } throw new Error(`Timed out waiting for persisted runtime service status ${status}`); diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index 572c1c9c47..d86b014421 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -4,13 +4,15 @@ import { listServerAdapters, requireServerAdapter } from "./registry.js"; import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; -const { probeInstallation, probeGrokInstallation } = vi.hoisted(() => ({ +const { probeInstallation, probeGrokInstallation, probePiInstallation } = vi.hoisted(() => ({ probeInstallation: vi.fn(), probeGrokInstallation: vi.fn(), + probePiInstallation: vi.fn(), })); -vi.mock("@paperclipai/paperclip-runner/live", () => ({ +vi.mock("../vendor/paperclip-runner/live/index.js", () => ({ probeAcpxClaudeInstallation: probeInstallation, probeAcpxGrokInstallation: probeGrokInstallation, + probeAcpxPiInstallation: probePiInstallation, probeAcpxCursorInstallation: vi.fn(async () => undefined), })); @@ -96,6 +98,7 @@ describe("native ACPX environment checks", () => { beforeEach(() => { probeInstallation.mockReset().mockResolvedValue(undefined); probeGrokInstallation.mockReset().mockResolvedValue(undefined); + probePiInstallation.mockReset().mockResolvedValue(undefined); }); afterEach(() => vi.restoreAllMocks()); @@ -132,6 +135,19 @@ describe("native ACPX environment checks", () => { expect(probeInstallation).not.toHaveBeenCalled(); }); + it.each([true, false])("checks qualified Pi's own installation readiness (%s)", async (ready) => { + if (!ready) probePiInstallation.mockRejectedValueOnce(new Error("Pi installation integrity mismatch")); + const model = "openrouter/deepseek/deepseek-v4-flash-0731"; + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ + ...context, + config: { provider: "acpx", acpxAgent: "pi", model }, + }); + expect(result.status).toBe(ready ? "pass" : "fail"); + expect(probePiInstallation).toHaveBeenCalledWith(model); + expect(probeInstallation).not.toHaveBeenCalled(); + expect(probeGrokInstallation).not.toHaveBeenCalled(); + }); + it("does not use the host platform to reject a remote environment", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 6727cd60f3..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: [ { @@ -569,6 +569,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = { hint: PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.opencode.description, meta: { visibleWhen: { key: "provider", value: "opencode" } }, }, + { + key: "acpxSessionMode", + label: "Cursor mode", + type: "select" as const, + default: "agent", + options: [ + { value: "agent", label: "Agent" }, + { value: "plan", label: "Plan" }, + { value: "ask", label: "Ask" }, + ], + hint: "Select Cursor's session mode. Permissions and company approval rules still apply.", + meta: { visibleWhen: { key: "acpxAgent", value: "cursor" } }, + }, { key: "acpxPermissionMode", label: "ACPX permission mode", 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-probe.ts b/server/src/services/agent-directory-probe.ts new file mode 100644 index 0000000000..97b3385fe0 --- /dev/null +++ b/server/src/services/agent-directory-probe.ts @@ -0,0 +1,169 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import childProcess from "node:child_process"; +import type { DirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge"; + +/** Only for a live unchanged-turn observation. Never a save or stop receipt. */ +export function agentDirectoryBaselineDigest(snapshot: DirectorySnapshot): string { + if (snapshot.exclude.length || snapshot.ignoredPaths) throw new Error("Incomplete agent directory baseline"); + const entries = [...snapshot.entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0); + return createHash("sha256").update(JSON.stringify(entries)).digest("hex"); +} + +// This self-contained function also runs inside the original remote target. +// Keep all dependencies explicit so remote observation never reads a local mirror. +export function probeAgentDirectory( + root: string, + modules = { fs, path, createHash, platform: process.platform }, +): { digest: string; identity: string } { + const { fs, path, createHash } = modules; + const deadline = Date.now() + 5_000; + const checkTime = () => { if (Date.now() > deadline) throw new Error("Agent directory probe deadline"); }; + const stamp = (s: fs.BigIntStats) => [s.dev, s.ino, s.mode, s.nlink, s.size, s.mtimeNs, s.ctimeNs].join(":"); + const identity = (s: fs.BigIntStats) => [s.dev, s.ino, s.birthtimeNs].join(":"); + if (!path.isAbsolute(root) || path.resolve(root) !== root || root.includes("\0")) throw new Error("Invalid agent directory root"); + // Resolve only the observing host's fixed Darwin aliases. The exact full + // counterpart excludes nested/user symlinks; canonical ancestors remain strict. + const requestedRoot = root; + if (modules.platform === "darwin" && /^\/(tmp|var|etc)\//.test(root)) { + const resolved = fs.realpathSync(root); + if (resolved !== `/private${root}`) throw new Error("Unsafe agent directory alias"); + root = resolved; + } + const ancestors = new Map(); + let ancestor = path.parse(root).root; + for (const segment of root.slice(ancestor.length).split(path.sep)) { + ancestor = path.join(ancestor, segment); + const s = fs.lstatSync(ancestor, { bigint: true }); + if (!s.isDirectory() || s.isSymbolicLink()) throw new Error("Unsafe agent directory ancestor"); + ancestors.set(ancestor, identity(s)); + } + const metadata = new Map(); + const entries: Array<[string, { kind: "dir" } | { kind: "file"; mode: number; hash: string }]> = []; + let total = 0; + function walk(relative: string, verify: boolean) { + checkTime(); + const absolute = path.join(root, relative); + const before = fs.lstatSync(absolute, { bigint: true }); + if (!before.isDirectory() || before.isSymbolicLink()) throw new Error("Unsafe agent directory"); + const names = fs.readdirSync(absolute).sort(); + if (verify) { + if (metadata.get(relative) !== stamp(before)) throw new Error("Agent directory changed during probe"); + } else metadata.set(relative, stamp(before)); + for (const name of names) { + checkTime(); + const next = relative ? `${relative}/${name}` : name; + if (next.length > 512 || name === "." || name === ".." || name.includes("\\") || name.includes("\0") + || name === ".paperclip-runtime" || next === "promptTemplate.legacy.md") throw new Error("Unsafe agent file path"); + const filename = path.join(root, next); + const s = fs.lstatSync(filename, { bigint: true }); + if (s.isDirectory()) { + if (!verify) entries.push([next, { kind: "dir" }]); + walk(next, verify); + } else { + if (!s.isFile() || s.nlink !== 1n || s.size > 256n * 1024n * 1024n) throw new Error("Unsafe or oversized agent file"); + if (verify) { + if (metadata.get(next) !== stamp(s)) throw new Error("Agent file changed during probe"); + } else { + total += Number(s.size); + if (total > 2 * 1024 * 1024 * 1024) throw new Error("Agent directory probe byte limit"); + const fd = fs.openSync(filename, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + if (stamp(fs.fstatSync(fd, { bigint: true })) !== stamp(s)) throw new Error("Agent file replaced during open"); + const hash = createHash("sha256"), buffer = Buffer.alloc(64 * 1024); + let size = 0; + for (;;) { + checkTime(); + const count = fs.readSync(fd, buffer, 0, buffer.length, null); + if (!count) break; + size += count; + if (size > Number(s.size)) throw new Error("Agent file grew during probe"); + hash.update(buffer.subarray(0, count)); + } + if (size !== Number(s.size) || stamp(fs.fstatSync(fd, { bigint: true })) !== stamp(s) + || stamp(fs.lstatSync(filename, { bigint: true })) !== stamp(s)) throw new Error("Agent file changed during read"); + metadata.set(next, stamp(s)); + entries.push([next, { kind: "file", mode: Number(s.mode), hash: hash.digest("hex") }]); + } finally { fs.closeSync(fd); } + } + } + if (entries.length > 100_000) throw new Error("Agent directory probe entry limit"); + } + if (stamp(fs.lstatSync(absolute, { bigint: true })) !== stamp(before) + || JSON.stringify(fs.readdirSync(absolute).sort()) !== JSON.stringify(names)) throw new Error("Agent directory membership changed"); + } + const initial = fs.lstatSync(root, { bigint: true }); + walk("", false); + walk("", true); + if (stamp(fs.lstatSync(root, { bigint: true })) !== stamp(initial)) throw new Error("Agent directory root changed"); + for (const [ancestor, expected] of ancestors) { + const current = fs.lstatSync(ancestor, { bigint: true }); + if (!current.isDirectory() || current.isSymbolicLink() || identity(current) !== expected) throw new Error("Agent directory ancestor changed"); + } + if (requestedRoot !== root && fs.realpathSync(requestedRoot) !== root) throw new Error("Agent directory alias changed"); + entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0); + return { digest: createHash("sha256").update(JSON.stringify(entries)).digest("hex"), identity: identity(initial) }; +} + +// tsx/esbuild keepNames injects this lexical helper into nested functions. +// Include its exact name-property behavior in each plain-Node child program; +// function.toString() alone does not carry the controller module's bindings. +const childNameHelper = `const __name=(target,value)=>Object.defineProperty(target,"name",{value,configurable:true});`; + +export const agentDirectoryProbeProgram = `${childNameHelper} const probe=${probeAgentDirectory.toString()}; process.stdout.write(JSON.stringify(probe(process.argv[1], {fs:require('node:fs'),path:require('node:path'),createHash:require('node:crypto').createHash,platform:process.platform})))`; + +/** Retire only the two empty directories created by initial sandbox transfer. + * Run before provider admission; this is never an exclusion from live probing. */ +export function retireAgentDirectoryTransferScratch(root: string, modules = { fs, path, platform: process.platform }): void { + const { fs, path } = modules; + if (!path.isAbsolute(root) || path.resolve(root) !== root || root.includes("\0")) throw new Error("Invalid agent directory root"); + const requestedRoot = root; + if (modules.platform === "darwin" && /^\/(tmp|var|etc)\//.test(root)) { + const resolved = fs.realpathSync(root); + if (resolved !== `/private${root}`) throw new Error("Unsafe agent directory alias"); + root = resolved; + } + const identities = new Map(); + const identify = (directory: string) => { + const stat = fs.lstatSync(directory, { bigint: true }); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe transfer scratch directory"); + return [stat.dev, stat.ino, stat.birthtimeNs].join(":"); + }; + let ancestor = path.parse(root).root; + for (const segment of root.slice(ancestor.length).split(path.sep)) { + ancestor = path.join(ancestor, segment); identities.set(ancestor, identify(ancestor)); + } + const parent = path.join(root, ".paperclip-runtime"), scratch = path.join(parent, "agent-files"); + identities.set(parent, identify(parent)); identities.set(scratch, identify(scratch)); + const verify = () => { + for (const [directory, expected] of identities) if (identify(directory) !== expected) throw new Error("Transfer scratch identity changed"); + if (requestedRoot !== root && fs.realpathSync(requestedRoot) !== root) throw new Error("Agent directory alias changed"); + }; + const entries = fs.readdirSync(parent); + if (entries.length !== 1 || entries[0] !== "agent-files" || fs.readdirSync(scratch).length) throw new Error("Unexpected transfer scratch contents"); + verify(); + // rmdir atomically refuses nonempty directories. Never recursively delete or + // follow a link; any change stops preparation before the provider can start. + fs.rmdirSync(scratch); identities.delete(scratch); + verify(); + if (fs.readdirSync(parent).length) throw new Error("Transfer scratch changed during retirement"); + fs.rmdirSync(parent); identities.delete(parent); + verify(); +} + +export const agentDirectoryTransferCleanupProgram = `${childNameHelper} const retire=${retireAgentDirectoryTransferScratch.toString()}; retire(process.argv[1], {fs:require('node:fs'),path:require('node:path'),platform:process.platform})`; + +/** Bound child: no shell, inherited credentials, or event-loop hashing. */ +export async function observeLocalAgentDirectory(root: string) { + const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + childProcess.execFile(process.execPath, ["-e", agentDirectoryProbeProgram, root], { + cwd: root, env: {}, timeout: 10_000, maxBuffer: 1024, killSignal: "SIGKILL", encoding: "utf8", + }, (error, stdout, stderr) => { if (error) reject(error); else resolve({ stdout, stderr }); }); + }); + if (result.stdout.length > 1024) throw new Error("Agent directory observation exceeds bound"); + const value = JSON.parse(result.stdout); + if (!value || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest) + || typeof value.identity !== "string" || !/^[0-9]+:[0-9]+:[0-9]+$/.test(value.identity)) throw new Error("Invalid agent directory observation"); + return value as { digest: string; identity: string }; +} diff --git a/server/src/services/agent-directory-working-copies.ts b/server/src/services/agent-directory-working-copies.ts index 869a497e97..8bbead8531 100644 --- a/server/src/services/agent-directory-working-copies.ts +++ b/server/src/services/agent-directory-working-copies.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { agents, environmentLeases, environments, agentInstructionWorkingCopies as copies, type Db } from "@paperclipai/db"; import { syncDirectoryToSsh, restoreWorkspaceFromSshExecution } from "@paperclipai/adapter-utils/ssh"; import { prepareAdapterExecutionTargetRuntime, runAdapterExecutionTargetShellCommand, type AdapterExecutionTarget, type PreparedAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target"; @@ -13,6 +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, 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"; @@ -22,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; } @@ -50,11 +55,10 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string } async function transport(row: Copy, target: AdapterExecutionTarget, recovering: boolean) { if (target.kind !== "remote" || row.location !== `remote:${target.environmentId ?? ""}`) throw new Error("Agent directory environment changed"); - if (recovering && target.transport === "ssh") throw new Error("The original SSH collector is unavailable"); if (target.transport === "ssh") { // Reuse SSH's plain directory transfer without its task-workspace suffix // or Git-history discovery. The registered root is the exact writable root. - await syncDirectoryToSsh({ spec: target.spec, localDir: row.localRoot, remoteDir: row.executionRoot, exclude: [".paperclip-runtime"] }); + if (!recovering) await syncDirectoryToSsh({ spec: target.spec, localDir: row.localRoot, remoteDir: row.executionRoot, exclude: [".paperclip-runtime"] }); return { target, workspaceRemoteDir: row.executionRoot, runtimeRootDir: null, assetDirs: {}, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, restoreWorkspace: () => restoreWorkspaceFromSshExecution({ spec: target.spec, localDir: row.localRoot, @@ -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"); @@ -110,15 +134,18 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string } }, "agent_directory_handoff"); } - const localRoot = path.join(path.dirname(root), "file-sync", "runs", input.runId, "live"); + let row = await get(input.companyId, input.runId); + if (row?.receipt?.retainedByRunId) throw conflict("Agent directory ownership passed to another run"); + const origin = typeof row?.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : input.runId; + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(origin)) throw conflict("Invalid agent directory owner"); + const localRoot = path.join(path.dirname(root), "file-sync", "runs", origin, "live"); // Local copies live outside the task cwd. Remote copies use the reserved, // excluded runtime area inside the provider's confined workspace. They are // synchronized independently; provider HOME is never reinterpreted. const executionRoot = input.target?.kind === "remote" - ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "agent-files", input.agentId, input.runId) + ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "agent-files", input.agentId, origin) : localRoot; const location = input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local"; - let row = await get(input.companyId, input.runId); if (row && (row.localRoot !== localRoot || row.executionRoot !== executionRoot || row.agentId !== input.agentId || row.location !== location)) throw conflict("Agent directory belongs to a different execution environment"); if (row && !serialHeld) { // Restart preparation can replace a completed copy under the same run ID. @@ -135,7 +162,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string // leaves a recoverable preparing receipt instead of an orphan directory. const preparing = { entryFile: deriveBundleState(agent).entryFile, baseRevisionId: null, baseHash: "preparing", localRoot, executionRoot, location, state: "preparing", candidateBase64: null, candidateHash: null, - receipt: { schema: AGENT_FILES_CONTRACT, ...(input.warm ? { warm: true, directoryRunId: input.runId } : {}), ...(input.target?.kind === "remote" + receipt: { schema: AGENT_FILES_CONTRACT, workspaceCwd: input.cwd, materializationRunId: origin, ...(input.warm ? { warm: true, directoryRunId: input.runId } : {}), ...(input.target?.kind === "remote" ? { cleanup: { leaseId: input.target.leaseId ?? null, remoteCwd: input.target.remoteCwd } } : {}) }, processStoppedAt: null, attempts: 0, nextAttemptAt: null, errorCode: null, errorMessage: null }; if (row) row = await patch(row, preparing); @@ -183,9 +210,15 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string `node -e ${quote(instructionGitExcludeProgram)} ${quote(input.target.remoteCwd)}`, { cwd: input.target.remoteCwd, env: {}, timeoutSec: 15 }); if (excluded.exitCode !== 0 || excluded.timedOut) throw new Error("Could not exclude agent files from task Git staging"); - transports.set(key(row), await transport(row, input.target, false)); + const runtime = await transport(row, input.target, false); + transports.set(key(row), runtime); + if (input.target.transport === "sandbox") { + if (runtime.runtimeRootDir !== sandboxTransferRoot(row, input.target.remoteCwd)) throw new Error("Agent directory transfer scratch path changed"); + } } - return await patch(row, { state: "prepared" }); + const observed = await observe(row, input.target).catch(() => null); + return await patch(row, { state: "prepared", receipt: { ...row.receipt, + ...(observed?.digest === agentDirectoryBaselineDigest(snapshot) ? { materializationIdentity: observed.identity } : {}) } }); } catch (error) { // Preparation failed before a provider could start using this copy. row = await patch(row, { state: "unavailable", processStoppedAt: new Date(), errorCode: "AGENT_FILES_PREPARE_FAILED", @@ -207,12 +240,81 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string } return inspectAgentDirectory(row.localRoot); } - async function hasChanges(_row: Copy, _target?: AdapterExecutionTarget | null) { - // A whole-directory collector needs a quiescent provider, including its - // child processes. Checkpoint and close at the turn boundary before reading - // either local or remote files. The provider conversation remains resumable; - // ordinary file edits do not change the session configuration digest. - return true; + async function hasChanges(row: Copy, target?: AdapterExecutionTarget | null) { + try { + if (row.receipt?.retainedByRunId || row.receipt?.retirementRequired || typeof row.receipt?.materializationIdentity !== "string") return true; + const observed = await observe(row, target); + if (observed.identity !== row.receipt.materializationIdentity || observed.digest !== agentDirectoryBaselineDigest(baseline(row))) return true; + const unchanged = await patch(row, { state: "unchanged_turn", nextAttemptAt: null }); + return unchanged.state !== "unchanged_turn" || Boolean(unchanged.receipt?.retainedByRunId) || Boolean(unchanged.processStoppedAt); + } catch { return true; } + } + /** Rebind one physical materialization; earlier runs lose collection authority. */ + async function adopt(input: { companyId: string; agentId: string; runId: string; previousRunId: string; target?: AdapterExecutionTarget | null; cwd: string; allowRetirementHandoff?: boolean }) { + let prior = await get(input.companyId, input.previousRunId); + if (!prior || prior.agentId !== input.agentId || prior.state !== "unchanged_turn" || prior.receipt?.retainedByRunId || prior.processStoppedAt + || prior.receipt?.workspaceCwd !== input.cwd || prior.location !== (input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local")) return null; + const oldRuntime = transports.get(key(prior)); + const remote = input.target?.kind === "remote" ? input.target : null; + const priorCleanup = prior.receipt?.cleanup as { leaseId?: string; remoteCwd?: string } | undefined; + const validLeases = (leases: Array) => { + if (!remote) return true; + const previous = leases.find(lease => lease.id === priorCleanup?.leaseId); + const successor = leases.find(lease => lease.id === remote.leaseId); + return Boolean(previous && successor && previous.id !== successor.id + && previous.companyId === input.companyId && successor.companyId === input.companyId + && previous.heartbeatRunId === input.previousRunId && successor.heartbeatRunId === input.runId + && previous.environmentId === remote.environmentId && successor.environmentId === remote.environmentId + && previous.provider && previous.provider === successor.provider + && previous.providerLeaseId && previous.providerLeaseId === successor.providerLeaseId + && successor.status === "active" && !successor.releasedAt + && !hasRemoteTerminationReceipt(previous) && !hasRemoteTerminationReceipt(successor) + && (remote.transport !== "sandbox" || remote.providerKey === successor.provider) + && (successor.metadata?.remoteCwd === undefined || successor.metadata.remoteCwd === remote.remoteCwd)); + }; + const leaseIds = remote && priorCleanup?.leaseId && remote.leaseId ? [priorCleanup.leaseId, remote.leaseId] : []; + if (remote && (!oldRuntime || oldRuntime.target.kind !== "remote" || oldRuntime.target.transport !== remote.transport + || oldRuntime.target.leaseId !== priorCleanup?.leaseId || oldRuntime.target.remoteCwd !== remote.remoteCwd + || priorCleanup?.remoteCwd !== remote.remoteCwd || leaseIds.length !== 2 + || !validLeases(await db.select().from(environmentLeases).where(and(eq(environmentLeases.companyId, input.companyId), inArray(environmentLeases.id, leaseIds)))))) return null; + const bound = await resolveInstructionActor(db, { type: "agent", companyId: input.companyId, agentId: input.agentId, runId: input.runId }); + const observedChange = await hasChanges(prior, input.target); + if (observedChange && !input.allowRetirementHandoff) return null; + prior = (await get(input.companyId, input.previousRunId))!; + // adopt_remote constructs a collector on the successor target without + // uploading the stale host mirror over the retained remote directory. + const replacement = remote ? await transport({ ...prior, runId: input.runId }, remote, true) : undefined; + let adopted: Copy | null; + try { adopted = await store.locked(input.companyId, input.agentId, bound, false, async (tx, _agent, canonical) => { + if (remote && !validLeases(await tx.select().from(environmentLeases).where(and( + eq(environmentLeases.companyId, input.companyId), inArray(environmentLeases.id, leaseIds))).for("update"))) return null; + const current = await inspectAgentDirectory(canonical); + const canonicalChanged = agentDirectoryBaselineDigest(current.snapshot) !== agentDirectoryBaselineDigest(baseline(prior)); + if (canonicalChanged && !input.allowRetirementHandoff) return null; + const [claimed] = await tx.update(copies).set({ receipt: { ...prior.receipt, retainedByRunId: input.runId }, updatedAt: new Date() }) + .where(and(eq(copies.companyId, input.companyId), eq(copies.runId, prior.runId), eq(copies.agentId, input.agentId), eq(copies.state, "unchanged_turn"), + eq(copies.updatedAt, prior.updatedAt), eq(copies.baseHash, prior.baseHash), eq(copies.localRoot, prior.localRoot), + isNull(copies.processStoppedAt), sql`${copies.receipt}->>'retainedByRunId' IS NULL`)).returning(); + if (!claimed) throw conflict("Agent directory ownership changed"); + const { createdAt: _createdAt, updatedAt: _updatedAt, ...values } = prior; + const [next] = await tx.insert(copies).values({ ...values, runId: input.runId, responsibleUserId: bound.onBehalfOfUserId!, state: "prepared", + receipt: { ...prior.receipt, materializationRunId: prior.receipt?.materializationRunId ?? prior.runId, + ...(remote ? { cleanup: { leaseId: remote.leaseId, remoteCwd: remote.remoteCwd } } : {}), + ...(observedChange || canonicalChanged ? { retirementRequired: true } : {}) }, updatedAt: new Date(), + }).returning(); + return next; + }); } catch (error) { await replacement?.cleanupWorkspaceSnapshot?.(); throw error; } + // Do not move the live transport until the database ownership commit succeeds. + if (adopted) { + if (replacement && oldRuntime) { + const cleanup = replacement.cleanupWorkspaceSnapshot; + replacement.cleanupWorkspaceSnapshot = async () => { + try { await cleanup?.(); } finally { await oldRuntime.cleanupWorkspaceSnapshot?.(); } + }; + transports.delete(key(prior)); transports.set(key(adopted), replacement); + } + } else await replacement?.cleanupWorkspaceSnapshot?.(); + return adopted; } async function checkpoint(row: Copy, target?: AdapterExecutionTarget | null, stopped = false): Promise { if (!await owns(row) || row.state === "superseded") return row; @@ -254,6 +356,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string receipt: { ...row.receipt, storageWarning: storageLimit ? agentStorageWarning(failure instanceof AgentFileLimitError ? failure.message : "Agent folder exceeds a storage limit") : null } }); } async function collectStopped(row: Copy, target?: AdapterExecutionTarget | null) { + if (row.receipt?.retainedByRunId) return row; if (!await owns(row) || row.state === "superseded") return row; if (row.receipt?.warm === true && !completed.has(row.state)) { row = await checkpoint(row, target, true); @@ -261,7 +364,15 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string return (await get(row.companyId, row.runId))!; } if (completed.has(row.state)) { await release(row); return (await get(row.companyId, row.runId))!; } + const remoteLease = row.location !== "local" ? await ownedRemoteLease(row) : null; + if (row.location !== "local" && (!remoteLease + || remoteLease.releasedAt || remoteLease.status !== "active" || hasRemoteTerminationReceipt(remoteLease))) { + return recoverStoppedRemote(row); + } row = await patch(row, { processStoppedAt: row.processStoppedAt ?? new Date() }); + // Adoption may win between the initial read and the stop CAS. The returned + // current row is authoritative; a transferred alias cannot inspect or save. + if (row.receipt?.retainedByRunId || !row.processStoppedAt) return row; // The stopped working copy is the only temporary tree. Retry transient I/O // at this boundary, then clean it up; there are no preserved run snapshots. let inspected: Awaited> | undefined; @@ -303,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 }); } @@ -328,6 +453,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string return; } await patch(row, { receipt: { schema: AGENT_FILES_CONTRACT, state: row.state, appliedCandidateHash: row.candidateHash, storageWarning: row.receipt?.storageWarning ?? null, cleanupPending, + materializationRunId: row.receipt?.materializationRunId ?? row.runId, ...(row.receipt?.directoryRunId ? { directoryRunId: row.receipt.directoryRunId } : {}), ...(cleanupPending ? { cleanup: row.receipt?.cleanup, ...(destroyedOnly ? { cleanupDestroyedOnly: true } : {}) } : {}) }, nextAttemptAt: cleanupPending ? new Date(Date.now() + 30_000) : null }); @@ -345,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) { @@ -363,28 +489,50 @@ 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); - const cleanup = row.receipt?.cleanup as { leaseId?: string; remoteCwd?: string } | undefined; - // Older preview receipts did not record the lease ID. Accept only a unique - // lease still bound to this run and environment in that compatibility case. + 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), - cleanup?.leaseId ? eq(environmentLeases.id, cleanup.leaseId) : and( - eq(environmentLeases.environmentId, row.location.slice("remote:".length)), eq(environmentLeases.heartbeatRunId, row.runId)), + 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 false; + 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; const termination = lease.metadata?.remoteExecutionTermination as { state?: string } | undefined; - if (hasRemoteTerminationReceipt(lease) && termination?.state === "destroyed") return true; + if (hasRemoteTerminationReceipt(lease)) return termination?.state === "destroyed"; + // Never execute a cleanup command against a released/uncertain allocation. + if (lease.releasedAt || lease.status !== "active") return false; if (!environmentRuntime || !lease.environmentId || row.location !== `remote:${lease.environmentId}`) return false; const [environment] = await db.select().from(environments).where(eq(environments.id, lease.environmentId)); 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, 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], + 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 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; } @@ -397,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 c88c053772..6eb2c974b6 100644 --- a/server/src/services/agent-instruction-working-copies.ts +++ b/server/src/services/agent-instruction-working-copies.ts @@ -33,6 +33,19 @@ const MAX_COLLECTION_ATTEMPTS = 3; const liveTargets = new Map(); const targetKey = (companyId: string, runId: string) => `${companyId}:${runId}`; +/** Finish only bounded immediate collection work; deferred ownership remains pending. */ +export async function collectStoppedInstructionCopyWithRetries>(collect: () => Promise): Promise { + let saved: T | null = null; + let previousAttempts = -1; + for (let calls = 0; calls < MAX_COLLECTION_ATTEMPTS; calls++) { + saved = await collect(); + if (!saved || saved.state !== "pending_collection" || saved.nextAttemptAt === null + || saved.attempts >= MAX_COLLECTION_ATTEMPTS || saved.attempts <= previousAttempts) break; + previousAttempts = saved.attempts; + } + return saved; +} + export function instructionWorkingCopyGuidance(copy: Pick) { if (isAgentDirectoryCopy(copy)) return `Your persistent agent directory is ${copy.executionRoot} (AGENT_HOME). Your instruction entry is ${copy.executionRoot}/${copy.entryFile}. Read and write your own files and subfolders there. This directory belongs to this agent across tasks and sessions; task files belong in the task working directory. Paperclip restores this directory before execution and saves validated changes at turn boundaries. A warm native Codex session keeps the same writable directory between turns. Other sessions collect after the provider stops. Regular files, including binary files, persist; symlinks and special files are unsupported. Check the agent-files save receipt before claiming persistence; Only files you change or delete are synchronized. If another run changes the same file, the last completed synchronization wins. Temporary copies are removed when the owning session stops; there is no per-run file history. Storage allows 256 MiB per file, 2 GiB total, and 100,000 entries; the instruction entry must remain UTF-8 and at most 1 MiB. Reaching a storage limit never prevents this or future tasks from running. Remove or shrink files to free space; changes that exceed the limits will not be saved.${typeof copy.receipt?.storageWarning === "string" ? `\n\n${copy.receipt.storageWarning}` : ""}`; return `Your editable agent instruction file is ${copy.executionRoot}/${copy.entryFile}. 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. Use read_agent_instructions, update_agent_instructions, get_agent_instruction_history, and restore_agent_instructions for immediate saves and history. Read first and pin the returned revision. Preserve conflicts; never silently retry against a newer head. Repository instructions, skills, and the loaded prompt are separate and are not collected.`; @@ -63,6 +76,7 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen async function patch(row: Copy, values: Partial) { const [updated] = await db.update(copies).set({ ...values, updatedAt: new Date() }).where(and( scope(row.companyId, row.runId), eq(copies.state, row.state), eq(copies.attempts, row.attempts), eq(copies.baseHash, row.baseHash), + sql`${copies.receipt}->>'retainedByRunId' IS NOT DISTINCT FROM ${typeof row.receipt?.retainedByRunId === "string" ? row.receipt.retainedByRunId : null}`, )).returning(); // A late cleanup must not overwrite an explicit resolution or a newer // baseline. Canonical content has its own independent head CAS. @@ -314,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) { @@ -335,6 +350,7 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen const result = await collectStopped({ companyId: row.companyId, runId: row.runId }); if (result && isAgentDirectoryCopy(result) && completed.has(result.state)) await directories.release(result); } else if (row.location !== "local" && await remoteExecutionHasStopped(db, row.companyId, row.runId)) { + if (row.receipt?.warm === true) { const unavailable = row.receipt?.warm === true ? await patch(row, { state: "unavailable", errorCode: "AGENT_FILES_FINAL_COLLECTION_UNAVAILABLE", errorMessage: "The last completed checkpoint remains saved. Files changed afterwards could not be collected after remote termination.", nextAttemptAt: null }) @@ -342,10 +358,14 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen if (unavailable && isAgentDirectoryCopy(unavailable)) { await directories.release(await patch(unavailable, { processStoppedAt: unavailable.processStoppedAt ?? new Date() })); } + } else { + if (isAgentDirectoryCopy(row)) await directories.recoverStoppedRemote(row); + else await reportUnavailable(row.companyId, row.runId); + } } 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) }); @@ -379,11 +399,28 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen async function reportUnavailable(companyId: string, runId: string) { const row = await get(companyId, runId); if (!row || completed.has(row.state) || ["unchanged_turn", "warm_saved", "superseded"].includes(row.state) || row.candidateBase64 !== null || (isAgentDirectoryCopy(row) && row.candidateHash !== null) || row.state === "conflict") return row; - if (isAgentDirectoryCopy(row) && row.state === "unavailable") return row; + // A failed close or deferred lease observation grants no stop proof. Keep + // that row recoverable through generic heartbeat cleanup. + if (isAgentDirectoryCopy(row) && (row.state === "unavailable" + || row.state === "pending_collection" && !row.processStoppedAt)) return row; return patch(row, { state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", errorMessage: "The registered instruction copy could not be retrieved safely before environment release. No instruction save is claimed.", nextAttemptAt: null }); } async function release(companyId: string, runId: string) { liveTargets.delete(targetKey(companyId, runId)); const row = await get(companyId, runId); if (row && isAgentDirectoryCopy(row)) await directories.release(row); } - return { prepare, get, hasChanges, checkpointWarm, canReuseWarm: directories.canReuse, acknowledgeExplicitSave, collectStopped, recoverCaptured, recoverStopped, list, resolve, reportUnavailable, release }; + async function reportRetirementUnconfirmed(companyId: string, runId: string) { + const row = await get(companyId, runId); + if (!row || !isAgentDirectoryCopy(row) || row.receipt?.retainedByRunId || row.processStoppedAt) return row; + return patch(row, { state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED", + errorMessage: "The retained provider did not confirm retirement. Its registered agent directory is preserved; no save is claimed.", nextAttemptAt: null }); + } + async function adopt(input: Parameters[0]) { + const row = await directories.adopt(input); + if (row) { + liveTargets.delete(targetKey(input.companyId, input.previousRunId)); + if (input.target) liveTargets.set(targetKey(input.companyId, input.runId), input.target); + } + return row; + } + return { prepare, checkpointWarm, canReuseWarm: directories.canReuse, adopt, reportRetirementUnconfirmed, get, hasChanges, acknowledgeExplicitSave, collectStopped, recoverCaptured, recoverStopped, list, resolve, reportUnavailable, release }; } 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-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index 0da343e0a3..558c6bf3c6 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -456,12 +456,12 @@ describe("Paperclip Runner native provider configuration", () => { ).toThrow("provider changed after this run selected its native backend"); }); - it("rejects Pi before a native descriptor is persisted", () => { - expect(() => + it("preserves a caller-selected Pi model in the native descriptor", () => { + expect( resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "pi-model" }, }), - ).toThrow("Pi is awaiting local and Daytona qualification"); + ).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: "pi-model" }); }); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index af9e1605ce..8d84f116e6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -230,7 +230,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"; @@ -350,8 +350,7 @@ import { prepareGitHubExecutionEnvironment, startAdapterExecutionTargetPaperclipBridge, } from "@paperclipai/adapter-utils/execution-target"; - -import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js"; +import { agentInstructionWorkingCopyService, collectStoppedInstructionCopyWithRetries, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js"; import { resolveManagedOpenAiBilling, } from "@paperclipai/adapter-utils"; @@ -463,6 +462,8 @@ import { buildNativeExecutionWithCheckpoint, buildNativeRuntimeContext, cancelNativeSession, + claimWarmNativeInstructionCopy, + nativeSessionWorkspaceScope, closeWarmNativeSessionsForEnvironment, reserveWarmNativeInstructionDirectory, dispatchNativeSessionResumptions, @@ -484,6 +485,7 @@ import { nativeToolContractFingerprintForTarget, prepareNativeSessionBootstrapPersistence, prepareNativeWorkspaceSync, + readNativeWorkspaceSyncReference, recordNativeFinalizationFailure, type NativeRestartRecoveryClaim, rebindNativeSessionCheckpoint, @@ -6656,6 +6658,7 @@ export function heartbeatService( Parameters[0] | null = null; let nativeSessionResumeScheduled = false; let nativeOwnershipHeld = false; + let releaseWarmInstructionPreparation: (() => Promise) | null = null; let nativeInstructionReservation: Awaited> = null; let nativeDispatchStarted = false; let nativeWorkspaceFinalizeScheduled = false; @@ -8907,6 +8910,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, @@ -8919,6 +8931,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({ @@ -9041,6 +9058,13 @@ export function heartbeatService( const executionTarget = realizationResult.executionTarget; let instructionCopy: Awaited> = null; let instructionSave: Record | null = null; + const instructionPreparationKey = createHash("sha256").update(JSON.stringify({ + adapterType: agent.adapterType, adapterConfig: agent.adapterConfig, runtimeConfig: agent.runtimeConfig, sessionConfigMetadata, + workspace: nativeSessionWorkspaceScope({ + binding: { runId: run.id, executionWorkspaceId: persistedExecutionWorkspace?.id ?? run.id }, + workspace: executionWorkspace, + }), cwd: executionWorkspace.cwd, + })).digest("hex"); const recordInstructionSave = async (saved: NonNullable>>) => { const receipt = parseObject(saved.receipt); const storageWarning = readNonEmptyString(receipt.storageWarning); @@ -9057,15 +9081,24 @@ export function heartbeatService( }; const collectStoppedInstructions = async () => { if (!instructionCopy) return; - let saved = await instructionCopies.collectStopped({ companyId: agent.companyId, runId: run.id, target: executionTarget }); - // Capture before disposal. Exhausted bounded collection leaves a durable - // explicit loss report, never a claim that missing bytes were saved. - while (saved?.state === "pending_collection" && saved.attempts < 3) { - saved = await instructionCopies.collectStopped({ companyId: agent.companyId, runId: run.id, target: executionTarget }); - } + const saved = await collectStoppedInstructionCopyWithRetries(() => instructionCopies.collectStopped({ + companyId: agent.companyId, runId: run.id, target: executionTarget, + })); if (!saved) return; if (saved.state !== "superseded") await recordInstructionSave(saved); }; + const nativeInstructionWorkingCopy = () => instructionCopy ? { + ...(isAgentDirectoryCopy(instructionCopy) ? { runId: run.id, preparationKey: instructionPreparationKey } : {}), + 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, + retirementFailed: async () => { await instructionCopies.reportRetirementUnconfirmed(agent.companyId, run.id); }, + } : undefined; if (managedAiRuntime && aiBinding) { try { await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget); } catch { throw new ConfigurationIncompleteFailure("Project authentication conflicts with this agent’s managed AI connection", { configurationIncomplete: { reason: "ai_connection_incompatible", actionUrl: `/agents/${agent.id}/runtime` } }); } @@ -9913,9 +9946,9 @@ export function heartbeatService( try { // Missing contract fields on a restored session mean the deployed // legacy format. New sessions opt into whole-directory persistence. - const priorFileRun = taskSessionForRun?.lastRunId + const priorFileRun = taskSession?.lastRunId ? await db.select({ profile: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and( - eq(heartbeatRuns.id, taskSessionForRun.lastRunId), eq(heartbeatRuns.companyId, agent.companyId), eq(heartbeatRuns.agentId, agent.id))).then(rows => rows[0]) + eq(heartbeatRuns.id, taskSession.lastRunId), eq(heartbeatRuns.companyId, agent.companyId), eq(heartbeatRuns.agentId, agent.id))).then(rows => rows[0]) : null; const savedFileInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput); const priorFileInput = Object.keys(savedFileInput).length ? savedFileInput : parseObject(parseObject(priorFileRun?.profile).nativeExecutionInput); @@ -9931,6 +9964,21 @@ export function heartbeatService( canReuse: () => instructionCopies.canReuseWarm(agent.companyId, agent.id, taskSessionForRun!.lastRunId!), }); } + if (!warmFiles && nativeRuntimeResolution.kind === "native" && priorWorkingCopy.kind === "agent_files" && taskSession) { + releaseWarmInstructionPreparation = await claimWarmNativeInstructionCopy({ + priorExecution: parseNativeExecutionInput(priorFileInput), companyId: agent.companyId, agentId: agent.id, + executionWorkspaceId: persistedExecutionWorkspace?.id ?? run.id, workspace: executionWorkspace, + environmentId: executionTarget?.environmentId ?? null, runId: run.id, preparationKey: instructionPreparationKey, forceRetirement: !taskSessionForRun, + adopt: async previousRunId => { + instructionCopy = await instructionCopies.adopt({ companyId: agent.companyId, agentId: agent.id, + runId: run.id, previousRunId, target: executionTarget, cwd: executionWorkspace.cwd, allowRetirementHandoff: true }); + return nativeInstructionWorkingCopy() ?? null; + }, + }); + // A collection-only handoff was retired. Re-enter normal + // preparation instead of composing a root already collected. + if (!releaseWarmInstructionPreparation) instructionCopy = null; + } const prepareInstructions = (reuseRunId?: string) => instructionCopies.prepare({ companyId: agent.companyId, agentId: agent.id, runId: run.id, target: executionTarget, cwd: executionWorkspace.cwd, @@ -9942,7 +9990,7 @@ export function heartbeatService( }, }); try { - instructionCopy = await prepareInstructions(nativeInstructionReservation?.reuseRunId); + instructionCopy ??= await prepareInstructions(nativeInstructionReservation?.reuseRunId); } catch (error) { if (!(error instanceof AgentDirectoryReuseInvalidatedError)) throw error; // prepare has released its canonical lock. Retirement can now @@ -11157,17 +11205,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, @@ -13113,6 +13151,7 @@ export function heartbeatService( } // A retained or unverified process stays above this release boundary. // If no stopped-copy capture occurred, preserve an explicit loss report. + await releaseWarmInstructionPreparation?.(); const uncapturedInstructions = await instructionCopies.reportUnavailable(run.companyId, run.id); if (uncapturedInstructions?.state === "unavailable") { await appendRunEvent(run, { eventType: "instruction_save", stream: "system", level: "warn", 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 3b4c78bc07..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, @@ -1667,6 +1668,7 @@ function resolveRequestItemVerdictSubmissions(args: { function normalizeQuestionAnswers(args: { questions: AskUserQuestionsInteraction["payload"]["questions"]; + textQuestionIds?: ReadonlySet; answers: RespondIssueThreadInteraction["answers"]; }) { const questionById = new Map( @@ -1701,7 +1703,11 @@ function normalizeQuestionAnswers(args: { ); } - const otherText = answer.otherText?.trim() ?? ""; + // Canonical text fields may contain code or intentional whitespace. The + // legacy custom-answer path keeps its historical newline/trim behavior. + const otherText = args.textQuestionIds?.has(answer.questionId) + ? answer.otherText ?? "" + : normalizeEscapedLineBreaks(answer.otherText ?? "").trim(); answerByQuestionId.set(answer.questionId, { questionId: answer.questionId, optionIds: uniqueOptionIds, @@ -1713,7 +1719,7 @@ function normalizeQuestionAnswers(args: { const answer = answerByQuestionId.get(question.id); if ( question.required && - (!answer || (answer.optionIds.length === 0 && !answer.otherText)) + (!answer || (answer.optionIds.length === 0 && !answer.otherText?.trim())) ) { throw unprocessable(`Question ${question.id} requires an answer`); } @@ -4957,6 +4963,9 @@ export function issueThreadInteractionService( ) as AskUserQuestionsInteraction; const normalizedAnswers = normalizeQuestionAnswers({ questions: interaction.payload.questions, + textQuestionIds: new Set((interaction.payload.questionSet?.questions ?? []) + .filter((question) => question.answerMode === "text") + .map((question) => question.id)), answers: input.answers, }); if (interaction.payload.questionSet) { diff --git a/server/src/services/native-runtime/acpx-qualification.test.ts b/server/src/services/native-runtime/acpx-qualification.test.ts index 5dbe759c4c..71ddf2c513 100644 --- a/server/src/services/native-runtime/acpx-qualification.test.ts +++ b/server/src/services/native-runtime/acpx-qualification.test.ts @@ -8,7 +8,7 @@ const provider = { kind: "acpx", agent: "copilot", model: "exact-model", permiss const authorize = (value: unknown) => ({ [ACPX_QUALIFICATION_ENV]: JSON.stringify(value) }); describe("host ACPX qualification admission", () => { afterEach(() => vi.unstubAllEnvs()); - it.each(["copilot", "pi"])("admits %s through agent validation and native input only for the exact host pair", (agent) => { + it.each(["copilot"])("admits %s through agent validation and native input only for the exact host pair", (agent) => { const config = { provider: "acpx", acpxAgent: agent, model: "exact-model" }; vi.stubEnv(ACPX_QUALIFICATION_ENV, undefined); expect(() => resolvePaperclipRunnerProviderProfile(config)).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" })); @@ -19,6 +19,21 @@ describe("host ACPX qualification admission", () => { expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "other-model" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_qualification_invalid" })); expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" })); }); + it("admits only the exact Pi model without host authorization and preserves permission modes", () => { + const config = { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }; + for (const authorization of [undefined, "malformed unrelated candidate authorization"]) { + vi.stubEnv(ACPX_QUALIFICATION_ENV, authorization); + expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject(config); + for (const acpxPermissionMode of [undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"]) { + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...config, acpxPermissionMode } })) + .toMatchObject({ ...config, acpxPermissionMode: acpxPermissionMode ?? "approve-all" }); + } + for (const model of [undefined, ""]) { + expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model })) + .toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" })); + } + } + }); it("keeps normal candidate execution closed and admits only the exact operator pair", () => { expect(resolveAcpxQualification(provider, {})).toBeUndefined(); expect(resolveAcpxQualification(provider, authorize([{ agent: "copilot", model: "exact-model" }]))).toBe("copilot"); @@ -43,7 +58,7 @@ describe("host ACPX qualification admission", () => { it("does not alter existing qualified providers", () => { expect(resolveAcpxQualification({ ...provider, agent: "codex" } as typeof provider, authorize([]))).toBeUndefined(); }); - it.each(["claude", "codex", "grok", "cursor"])("keeps %s model selection open to native verification", (acpxAgent) => { + it.each(["claude", "codex", "grok", "cursor", "pi"])("keeps %s model selection open to native verification", (acpxAgent) => { const adapterConfig = { provider: "acpx", acpxAgent, model: "explicit-new-model" }; expect(resolvePaperclipRunnerProviderProfile(adapterConfig)) .toMatchObject({ acpxAgent, model: "explicit-new-model" }); @@ -68,3 +83,9 @@ describe("host ACPX qualification admission", () => { expect(source).not.toContain("resolveAcpxQualification(input.execution.provider, effectiveRunnerEnvironment)"); }); }); + +it.each([undefined, "off", "low", "high", "max"] as const)("validates saved Pi thinking %s and projects it explicitly", piThinkingLevel => { + const adapterConfig = { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", piThinkingLevel }; + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig })).toMatchObject({ piThinkingLevel: piThinkingLevel ?? "low" }); + for (const alias of ["medium", "minimal", "xhigh"]) expect(() => resolvePaperclipRunnerProviderProfile({ ...adapterConfig, piThinkingLevel: alias })).toThrow(expect.objectContaining({ code: "paperclip_runner_pi_thinking_invalid" })); +}); 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 4ee46edf7a..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 @@ -3,6 +3,13 @@ import { inheritNativeRunnerAdapterConfig } from "./native-agent-runtime-inherit import { resolvePaperclipRunnerProviderProfile } from "./provider-profile.js"; describe("native hire runtime inheritance", () => { + it("preserves Cursor's selected mode without its live session or credentials", () => { + expect(inheritNativeRunnerAdapterConfig({ + provider: "acpx", acpxAgent: "cursor", acpxSessionMode: "plan", model: "exact-cursor-model", + runtimeSessionId: "parent-session", env: { CURSOR_API_KEY: "parent-secret" }, + })).toEqual({ provider: "acpx", acpxAgent: "cursor", acpxSessionMode: "plan", model: "exact-cursor-model" }); + }); + it.each([ ["claude_managed", "managedProfileId", "managedAgentsRetentionAcknowledged"], ["aws_agentcore", "agentCoreProfileId", "agentCoreRetentionAcknowledged"], @@ -40,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-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 9df5a0f51f..8abc557e83 100644 --- a/server/src/services/native-runtime/native-execution-input.test.ts +++ b/server/src/services/native-runtime/native-execution-input.test.ts @@ -585,3 +585,52 @@ describe("native completion references", () => { expect(input.completionContract.contract).toEqual(args.completionContract.contract); }); }); + + +describe("Cursor mode native execution projection", () => { + function fixture(acpxSessionMode?: "agent" | "plan" | "ask") { + 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: "cursor" as const, + model: "exact-cursor-model", acpxSessionMode, 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, "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", mode: mode ?? "agent", permissionMode: "deny-all" }); + expect(result.executionMode).toBe("default"); + expect(result.task.workMode).toBe("standard"); + }); + it("rejects a Cursor mode attached to another harness", () => { + 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-question-bridge.test.ts b/server/src/services/native-runtime/native-question-bridge.test.ts index 2e1b71f628..d97214db55 100644 --- a/server/src/services/native-runtime/native-question-bridge.test.ts +++ b/server/src/services/native-runtime/native-question-bridge.test.ts @@ -23,6 +23,7 @@ import { nativeRunFinalizations, } from "@paperclipai/db"; import type { PrpEvent } from "@paperclipai/paperclip-runner"; +import { respondIssueThreadInteractionSchema } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, @@ -442,6 +443,54 @@ describeEmbeddedPostgres("native question bridge", () => { release(); }); + it.each(["legacy", "canonical_select"])("keeps %s custom-answer normalization and rejects blank public answers", async (mode) => { + await seed(); + const event = runtimeRequestEvent(); + const input = (event.payload.request as { input: { questions: Record[] } }).input; + input.questions[0]!.customAnswer = { enabled: true }; + const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event }); + if (mode === "legacy") { + const payload = { ...interaction!.payload }; + if ("questionSet" in payload) delete payload.questionSet; + await db.update(issueThreadInteractions).set({ payload }).where(eq(issueThreadInteractions.id, interaction!.id)); + } + const service = issueThreadInteractionService(db); + await expect(service.answerQuestions({ id: issueId, companyId, status: "in_progress" }, interaction!.id, + respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "color", optionIds: [], otherText: " \n " }] }), { userId: "operator-1" }, + )).rejects.toThrow(/requires an answer/); + const answered = await service.answerQuestions({ id: issueId, companyId, status: "in_progress" }, interaction!.id, + respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "color", optionIds: [], otherText: " Extra\\nline " }] }), { userId: "operator-1" }, + ); + expect(answered.result).toMatchObject({ answers: [{ questionId: "color", otherText: "Extra\nline" }] }); + }); + + it("persists initial text without resolving and delivers only an explicit edited answer", async () => { + await seed(); + const initialText = " Editable draft\n漢字\n"; + const event = runtimeRequestEvent(); + const request = event.payload.request as Record; + request.input = { schema: "paperclip.question_set.v1", questions: [{ id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText }] }; + const interaction = await projectNativeRuntimeRequest({ db, binding: binding(), event }); + expect(interaction).toMatchObject({ status: "pending", payload: { questionSet: { questions: [{ initialText }] } } }); + const [stored] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction!.id)); + expect(stored).toMatchObject({ status: "pending", result: null, payload: { questionSet: { questions: [{ initialText }] } } }); + const queueCommand = vi.fn(() => ({ commandId: "edited", controllerSeq: 1 })); + const release = registerNativeQuestionCommandTarget({ binding: { companyId, issueId, runId, agentId }, queueCommand }); + try { + await flushNativeQuestionResponses(db, runId); + expect(queueCommand).not.toHaveBeenCalled(); + const edited = "\n Operator 漢字 edited\n\nLiteral \\n stays literal. \n"; + await issueThreadInteractionService(db).answerQuestions( + { id: issueId, companyId, status: "in_progress" }, interaction!.id, + respondIssueThreadInteractionSchema.parse({ answers: [{ questionId: "draft", optionIds: [], otherText: edited }] }), { userId: "operator-1" }, + ); + await flushNativeQuestionResponses(db, runId); + expect(queueCommand).toHaveBeenCalledExactlyOnceWith("request.resolve", { + requestId: "request-1", response: { schema: "paperclip.question_response.v1", answers: { draft: { text: edited } } }, + }, `question_${interaction!.id}`); + } finally { release(); } + }); + it.each(["succeeded", "failed", "cancelled", "timed_out"])("delivers a historical answer exactly once after a %s native run", async status => { await seed(); await db.update(issues).set({ conversationAgentId: agentId, conversationUserId: "operator-1", conversationState: "active", conversationSessionGeneration: 1, executionRunId: null }).where(eq(issues.id, issueId)); diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index d986baf2c7..19d97de8c2 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -11,7 +11,7 @@ import { conversationNativeDecision, isConversation } from "../agent-conversatio import { issueTreeControlService } from "../issue-tree-control.js"; import { randomUUID } from "node:crypto"; import { preserveNativeWorkspaceExportLease } from "./native-workspace-export-resume.js"; -import { and, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { approvals, @@ -20,6 +20,7 @@ import { heartbeatRuns, heartbeatRunEvents, issueApprovals, + issueComments, issueRecoveryActions, issueThreadInteractions, issues, @@ -62,7 +63,11 @@ import { import { logger } from "../../middleware/logger.js"; import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, + findHeartbeatRunCompletionComment, + isExternalChatPresentationContext, + readCompletedAssistantMessageCandidate, resolveHeartbeatRunResponse, + selectHeartbeatRunFinalAgentMessage, } from "../heartbeat-run-summary.js"; import { resolveChatRunPresentationAuthorizationReason } from "../chat-run-publications.js"; import { @@ -940,13 +945,6 @@ export async function repairCommittedNativeChatResponse( !accepted || !decision || result.schema !== "paperclip.run_result.v1" || - result.reportedWorkDisposition !== "yielded" || - record(result.continuation).kind !== "response_wake" || - !Array.isArray(result.attentionRequests) || - result.attentionRequests.length > 0 || - terminal.runTerminalState !== "succeeded" || - terminal.turnTerminalState !== "completed" || - terminal.reportedWorkDisposition !== "yielded" || !(await acceptedResponseDigestMatches(tx, run, accepted)) ) return false; @@ -954,6 +952,70 @@ export async function repairCommittedNativeChatResponse( // presentation contract, including selected attachments. Do not bypass it. if (record(decision.decisionJson).externalChatReviewPresentation) return false; + // Copyback can be owned by reconciliation before the live heartbeat + // reaches its presentation step. Recover a completed internal task's + // exact final reply from the same accepted turn, without rerunning work + // or granting external-chat publication authority. + if ( + !isConversation(issue) && + run.status === "succeeded" && + record(run.contextSnapshot).skipIssueComment !== true && + !isExternalChatPresentationContext(run.contextSnapshot) && + result.reportedWorkDisposition === "done" && + terminal.runTerminalState === "succeeded" && + terminal.turnTerminalState === "completed" && + terminal.reportedWorkDisposition === "done" && + (await resolveChatRunPresentationAuthorizationReason(tx, input)) === "internal_agent_write" + ) { + const rows = await tx.select({ seq: heartbeatRunEvents.seq, payload: heartbeatRunEvents.payload }) + .from(heartbeatRunEvents).where(and( + eq(heartbeatRunEvents.companyId, input.companyId), + eq(heartbeatRunEvents.runId, run.id), + eq(heartbeatRunEvents.eventType, "item.completed"), + sql`${heartbeatRunEvents.payload}->'prpEvent'->>'turnId' = ${accepted.turnId}`, + sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}' = 'agentMessage'`, + sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`, + )).orderBy(desc(heartbeatRunEvents.seq)).limit(200); + const finalAgentMessage = selectHeartbeatRunFinalAgentMessage({ + candidates: rows.flatMap((row) => { + const candidate = readCompletedAssistantMessageCandidate({ seq: row.seq, prpEvent: record(row.payload).prpEvent }); + return candidate ? [candidate] : []; + }), + }); + if (!finalAgentMessage) return false; + const comments = await tx.select({ id: issueComments.id, body: issueComments.body }) + .from(issueComments).where(and( + eq(issueComments.companyId, input.companyId), + eq(issueComments.issueId, input.issueId), + eq(issueComments.createdByRunId, run.id), + )).orderBy(desc(issueComments.createdAt), desc(issueComments.id)); + const resolved = resolveHeartbeatRunResponse({ + resultJson: { ...record(run.resultJson), nativeResult: result }, + existingComment: findHeartbeatRunCompletionComment(comments, run.resultJson), + finalAgentMessage, + }); + if (!resolved.text || resolved.decision.commentAction !== "create") return false; + const comment = await issueService(db).addComment(input.issueId, resolved.text, + { agentId: run.agentId, runId: run.id }, + { authorizationReason: "internal_agent_write", completionReply: true }, tx); + await tx.update(heartbeatRuns).set({ + resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ + presentationDecision: { ...resolved.decision, commentId: comment.id, + reasonCodes: [...resolved.decision.reasonCodes, "committed_task_response_recovered"] }, + })}::jsonb`, updatedAt: new Date(), + }).where(eq(heartbeatRuns.id, run.id)); + agentId = run.agentId; + return true; + } + if ( + result.reportedWorkDisposition !== "yielded" || + record(result.continuation).kind !== "response_wake" || + !Array.isArray(result.attentionRequests) || + result.attentionRequests.length > 0 || + terminal.runTerminalState !== "succeeded" || + terminal.turnTerminalState !== "completed" || + terminal.reportedWorkDisposition !== "yielded" + ) return false; await authorizeCommittedChatResponse(db, tx, { ...input, agentId: run.agentId, 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 1fabdfc7dd..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 @@ -8,6 +8,8 @@ import { link, mkdir, readFile, + readdir, + rm, symlink, unlink, writeFile, @@ -39,6 +41,7 @@ import { issueService } from "../issues.js"; import { findHeartbeatRunCompletionComment, resolveHeartbeatRunResponse } from "../heartbeat-run-summary.js"; import { renderNativeRunnerStagedAttachmentPrompt, + stageNativeRunnerAttachmentBytes, stageNativeRunnerWakeAttachments, } from "./native-runner-file-handoff.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; @@ -195,6 +198,91 @@ describe("native runner file handoff", () => { }; } + async function stageEmptyWake(root: string, boundCompanyId = companyId) { + await db.update(heartbeatRuns) + .set({ contextSnapshot: { issueId } }) + .where(eq(heartbeatRuns.id, runId)); + return stageNativeRunnerWakeAttachments({ + db, + binding: { + companyId: boundCompanyId, issueId, runId, agentId, + workspaceRoot: root, executionTargetKind: "local", + }, + }); + } + + it("leaves an empty workspace unchanged for an empty wake and permits later explicit staging", async () => { + const root = await mkdtemp(path.join(temporaryRoot, "empty-wake-")); + try { + const wake = await stageEmptyWake(root); + expect(wake.attachments).toEqual([]); + await wake.cleanup(); + await expect(readdir(root)).resolves.toEqual([]); + + const explicit = await stageNativeRunnerAttachmentBytes({ workspaceRoot: root, body: Buffer.from("later authorized bytes") }); + await expect(readFile(path.join(root, explicit.workspaceRelativePath), "utf8")) + .resolves.toBe("later authorized bytes"); + await explicit.cleanup(); + await expect(readFile(path.join(root, explicit.workspaceRelativePath))) + .resolves.toEqual(Buffer.alloc(0)); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("scrubs inactive staging bytes on an empty wake without creating another directory", async () => { + const root = await mkdtemp(path.join(temporaryRoot, "empty-wake-residue-")); + try { + const previous = await stageNativeRunnerAttachmentBytes({ workspaceRoot: root, body: Buffer.from("previous attachment") }); + await previous.cleanup(); + const slot = path.join(root, previous.workspaceRelativePath); + await writeFile(slot, "stale bytes after an interrupted owner"); + const directories = await readdir(path.join(root, ".paperclip-inbound")); + + const wake = await stageEmptyWake(root); + expect(wake.attachments).toEqual([]); + await wake.cleanup(); + await expect(readFile(slot)).resolves.toEqual(Buffer.alloc(0)); + await expect(readdir(path.join(root, ".paperclip-inbound"))).resolves.toEqual(directories); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.each(["root-file", "root-symlink", "entry-symlink"] as const)( + "rejects unsafe %s staging on an empty wake without changing outside bytes", + async (kind) => { + const root = await mkdtemp(path.join(temporaryRoot, "empty-wake-unsafe-")); + const outside = await mkdtemp(path.join(temporaryRoot, "empty-wake-outside-")); + try { + await writeFile(path.join(outside, "keep.txt"), "outside bytes"); + const inbound = path.join(root, ".paperclip-inbound"); + if (kind === "root-file") await writeFile(inbound, "not a directory"); + else if (kind === "root-symlink") await symlink(outside, inbound); + else { + await mkdir(inbound); + await symlink(outside, path.join(inbound, "untrusted-owner")); + } + await expect(stageEmptyWake(root)).rejects.toThrow(/staging_(?:path|residue)_denied/); + await expect(readFile(path.join(outside, "keep.txt"), "utf8")).resolves.toBe("outside bytes"); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }, + ); + + it("still authorizes an empty wake before touching its workspace", async () => { + const root = await mkdtemp(path.join(temporaryRoot, "empty-wake-unauthorized-")); + try { + await expect(stageEmptyWake(root, "00000000-0000-4000-8000-000000009199")) + .rejects.toThrow("paperclip_runner_attachment_staging_not_authorized"); + await expect(readdir(root)).resolves.toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("rejects a workspace-only file completion and invented delivery receipts without asking the user to approve completion", async () => { for (const ref of ["launch-checklist.md", "./out/report.pdf", "/workspace/answer.txt", "file:out/report.csv", "deliverable:00000000-0000-4000-8000-000000000001"]) { await expect(nativeCompletionFeedback(db, runId, doneReport([ref]))) @@ -244,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 da86c15319..e61211cee1 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -5,6 +5,7 @@ import { PgDialect } from "drizzle-orm/pg-core"; import type { SQL } from "drizzle-orm"; import { access, + chmod, cp, lstat, mkdir, @@ -42,12 +43,18 @@ import { validatePrpEvent, parseNativeExecutionInput, type NativeExecutionInputV1, + type NativeExecutionInputV3, type NativeExecutionInput, type PrpEvent, } from "@paperclipai/paperclip-runner"; import { createHash, generateKeyPairSync } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; import { nativeSha256 } from "./canonical.js"; +import { agentDirectoryWorkingCopyService } from "../agent-directory-working-copies.js"; +import { probeAgentDirectory } from "../agent-directory-probe.js"; +import { AGENT_FILES_CONTRACT } from "../agent-file-store.js"; +import { captureDirectorySnapshot, directorySnapshotSha256, serializeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { agentInstructionWorkingCopies } from "@paperclipai/db"; import * as noLaunchProofModule from "./native-maintenance-no-launch.js"; import { NativeSessionCleanupQuarantinedError, @@ -63,6 +70,7 @@ import { } from "./native-harness-backup-stamp.js"; import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js"; import { nativeToolContractFingerprintForTarget } from "./native-session-resume.js"; +import * as nativeSessionResume from "./native-session-resume.js"; import { buildNativeHeartbeatPreparationSpans } from "./native-run-trace.js"; import { NativeRunnerOwnershipUnverifiedError } from "./native-runner-ownership.js"; import type { AdapterRuntimeEvent } from "../../adapters/index.js"; @@ -297,6 +305,8 @@ import { buildNativeHarnessBackupManifest, cancelNativeSession, closeWarmNativeSessionsForEnvironment, + claimWarmNativeInstructionCopy, + nativeSessionWorkspaceScope, reserveWarmNativeInstructionDirectory, closeIdleWarmNativeSessionsForRestart, createGovernedWaitEventObservation, @@ -1252,22 +1262,43 @@ describe("remote provider pack manifest", () => { const candidatePath = "provider-assets/pi/linux-x64"; await mkdir(join(root, candidatePath), { recursive: true }); await writeFile(join(root, candidatePath, "runtime"), "pinned runtime"); - const candidates = { pi: { version: "0.0.33", profileDigest: digest("profile"), - closureDigest: digest("closure"), qualification: "pending", path: candidatePath, + const candidates = { pi: { version: "1.0.0", profileDigest: digest("profile"), + closureDigest: digest("closure"), qualification: "qualified", path: candidatePath, sha256: sha256DirectoryTree(join(root, candidatePath)) } }; Object.assign(payload, { candidateProviders: candidates }); await writeManifest(); - expect(readRemoteProviderPackManifest(root).payload.candidateProviders?.pi?.qualification).toBe("pending"); + 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"); - for (const invalid of [{ path: "../outside" }, { qualification: "qualified" }]) { + candidates.pi.qualification = "pending"; + await writeManifest(); + expect(() => readRemoteProviderPackManifest(root)).toThrow("invalid candidate identity"); + candidates.pi.qualification = "qualified"; + for (const invalid of [{ path: "../outside" }, { qualification: "unknown" }]) { const original = { ...candidates.pi }; Object.assign(candidates.pi, invalid); await writeManifest(); expect(() => readRemoteProviderPackManifest(root)).toThrow("invalid candidate identity"); candidates.pi = original; } + for (const provider of ["cursor", "copilot"]) { + Object.assign(candidates, { [provider]: { ...candidates.pi, path: `provider-assets/${provider}/linux-x64` } }); + await writeManifest(); + 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(); for (const [artifactName, substituteName] of [ ["nodeCommand", "productionLock"], @@ -1537,6 +1568,67 @@ describe("verified native harness backups", () => { ).toBe(false); }); + it("binds Cursor mode across governed and identical recovery identities", () => { + const execution = { + ...backupExecution, + provider: { kind: "acpx", agent: "cursor", model: "explicit-model", mode: "plan" }, + interactionResponses: [{ interactionId: "interaction-1" }], + } as unknown as NativeExecutionInputV1; + const identity = (suffix: string, mode: unknown) => { + const value = acpxIdentity(suffix); + return { ...value, providerSessionIdentity: { ...value.providerSessionIdentity, mode } }; + }; + const previous = identity("previous", "plan"); + const current = identity("current", "plan"); + expect(providerSessionIdentityTransitionIsAllowed({ execution, previous, current })).toBe(true); + expect(providerSessionIdentityTransitionIsAllowed({ execution, previous, current: previous })).toBe(true); + for (const mode of [undefined, null, "agent", "ask", "autopilot"]) { + 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: "cursor", model: "explicit-model" }, + { kind: "acpx", agent: "copilot", model: "explicit-model" }, + ]) { + 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, + })).toBe(false); + } + }); + it("restores a verified continuation into an intentionally fresh non-reusable sandbox", () => { expect( shouldRestoreNativeHarnessBackupIntoSandbox({ @@ -1899,6 +1991,27 @@ 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", 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", 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", 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 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" } }; + 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([ ["codex", "codex_app_server"], ["opencode", "opencode_server"], @@ -4841,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. @@ -5011,9 +5134,13 @@ function cancellationDb(options?: { runId: string; assessmentId: string | null; decisionId?: string | null; + phase?: string; + failureCode?: string | null; } | null; + status?: string; failResultJsonUpdateAt?: number; ownershipHeld?: boolean; + resultJson?: Record; }) { const initialRun = { id: execution.binding.runId, @@ -5021,6 +5148,7 @@ function cancellationDb(options?: { companyId: execution.binding.companyId, nativeIssueId: execution.binding.issueId, runtimeMode: "native", + status: options?.status ?? "running", ...(options?.ownershipHeld ? { status: "running", @@ -5033,6 +5161,7 @@ function cancellationDb(options?: { }; let currentResultJson: Record = { durableReceipt: { operationId: "operation-1" }, + ...options?.resultJson, }; const issue = { status: "in_progress", @@ -5079,6 +5208,7 @@ function cancellationDb(options?: { set: (values: Record) => ({ where: () => { updates.push({ table, values }); + if (table === nativeRunFinalizations && coordinator) Object.assign(coordinator, values); const updatesResultJson = "resultJson" in values; if (updatesResultJson) resultJsonUpdateCount += 1; const shouldFail = @@ -5953,6 +6083,71 @@ describe("native session cancellation", () => { expect(state.publishActivity).toHaveBeenCalledTimes(2); }); + it.each(["terminal_failure", "observed", "committed"])("rejects retry phase %s that changed after caller reservation, before any native effect", async phase => { + const id = "11111111-1111-4111-8111-111111111111"; + const persistence = cancellationDb({ status: "failed", + coordinator: { runId: execution.binding.runId, assessmentId: null, phase, failureCode: "board_recovery" }, + resultJson: { startupCancellation: { cancellationRequestId: id, retryCancellation: true } } }); + await expect(cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db, + scope: "run", cancellationRequestId: id })).rejects.toMatchObject({ status: 409, message: "Native retry is no longer cancellable" }); + expect(persistence.updates).toEqual([]); + expect(state.cancel).not.toHaveBeenCalled(); + expect(state.persistActivity).not.toHaveBeenCalled(); + }); + + it("does not mistake a recovered running attempt for the failed retry that reserved Stop", async () => { + const id = "11111111-1111-4111-8111-111111111111"; + const persistence = cancellationDb({ status: "running", + coordinator: { runId: execution.binding.runId, assessmentId: null, phase: "observed" }, + resultJson: { startupCancellation: { cancellationRequestId: id, retryCancellation: true } } }); + await expect(cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db, + scope: "run", cancellationRequestId: id })).rejects.toMatchObject({ status: 409 }); + expect(persistence.updates).toEqual([]); + expect(state.cancel).not.toHaveBeenCalled(); + }); + + it("fences a failed retry at dispatch and preserves the same audited intent on replay", async () => { + const id = "11111111-1111-4111-8111-111111111111"; + const persistence = cancellationDb({ status: "failed", + coordinator: { runId: execution.binding.runId, assessmentId: null, phase: "retryable_failure" }, + resultJson: { startupCancellation: { cancellationRequestId: id, retryCancellation: true } } }); + const first = await cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db, scope: "run", cancellationRequestId: id }); + expect(persistence.updates).toContainEqual(expect.objectContaining({ table: nativeRunFinalizations, + values: expect.objectContaining({ phase: "terminal_failure", failureCode: "native_retry_cancelled", nextAttemptAt: null }) })); + const writes = persistence.getResultJsonUpdateCount(); + await expect(cancelNativeSession(execution.binding.runId, "retry Stop", { db: persistence.db, + scope: "run", cancellationRequestId: id })).resolves.toMatchObject({ auditId: first.auditId }); + expect(persistence.getResultJsonUpdateCount()).toBe(writes); + }); + + it("uses the reserved caller UUID and permits same-intent and default retries", async () => { + const id = "11111111-1111-4111-8111-111111111111"; + const persistence = cancellationDb({ resultJson: { startupCancellation: { cancellationRequestId: id } } }); + await cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db, scope: "run", cancellationRequestId: id }); + expect(persistence.getResultJson().nativeCancellation).toMatchObject({ intentId: `native-cancellation:${id}` }); + const writes = persistence.getResultJsonUpdateCount(); + await cancelNativeSession(execution.binding.runId, "same request", { db: persistence.db, scope: "run", cancellationRequestId: id }); + await cancelNativeSession(execution.binding.runId, "default retry", { db: persistence.db, scope: "run" }); + expect(persistence.getResultJsonUpdateCount()).toBe(writes); + await expect(cancelNativeSession(execution.binding.runId, "foreign caller", { + db: persistence.db, scope: "run", cancellationRequestId: "22222222-2222-4222-8222-222222222222", + })).rejects.toMatchObject({ status: 409 }); + expect(persistence.getResultJsonUpdateCount()).toBe(writes); + }); + + it.each([{}, { startupCancellation: { requestedAt: "prior" } }, + { startupCancellation: { cancellationRequestId: "other" } }, + { startupCancellation: { cancellationRequestId: "11111111-1111-4111-8111-111111111111" }, nativeCancellation: { intentId: "foreign" } }, + ])("rejects missing or raced caller ownership under the intent lock: %j", async resultJson => { + const persistence = cancellationDb({ resultJson }); + await expect(cancelNativeSession(execution.binding.runId, "operator Stop", { + db: persistence.db, scope: "run", cancellationRequestId: "11111111-1111-4111-8111-111111111111", + })).rejects.toMatchObject({ status: 409 }); + expect(persistence.getForUpdateCount()).toBe(1); + expect(persistence.updates).toEqual([]); + expect(state.cancel).not.toHaveBeenCalled(); + }); + it("recovers a post-dispatch persistence failure without cancelling the provider twice", async () => { const persistence = cancellationDb({ failResultJsonUpdateAt: 2 }); const running = executePaperclipNativeSession({ @@ -6418,7 +6613,499 @@ describe("native session same-turn steering", () => { }); }); +describe("bounded stopped instruction collection", () => { + it.each([ + { scenario: "deferred", attempts: [0], nextAttemptAt: null, expectedCalls: 1 }, + { scenario: "no progress", attempts: [0, 0], nextAttemptAt: new Date(0), expectedCalls: 2 }, + { scenario: "regressed counter", attempts: [1, 0], nextAttemptAt: new Date(0), expectedCalls: 2 }, + { scenario: "progressing counter", attempts: [0, 1, 2], nextAttemptAt: new Date(0), expectedCalls: 3 }, + { scenario: "exhausted", attempts: [3], nextAttemptAt: new Date(0), expectedCalls: 1 }, + ])("finishes pending collection without inventing attempts: $scenario", async ({ attempts, nextAttemptAt, expectedCalls }) => { + const { collectStoppedInstructionCopyWithRetries } = await import("../agent-instruction-working-copies.js"); + const collect = vi.fn(async () => { + const index = collect.mock.calls.length - 1; + if (index >= attempts.length) throw new Error("collector invoked beyond bounded progress"); + return { state: "pending_collection", attempts: attempts[index]!, nextAttemptAt }; + }); + expect(await collectStoppedInstructionCopyWithRetries(collect)).toEqual({ state: "pending_collection", attempts: attempts.at(-1), nextAttemptAt }); + expect(collect).toHaveBeenCalledTimes(expectedCalls); + }); + it("returns completed or absent copies and propagates collection errors", async () => { + const { collectStoppedInstructionCopyWithRetries } = await import("../agent-instruction-working-copies.js"); + const result = { state: "saved", attempts: 1, nextAttemptAt: null }; + const saved = vi.fn(async () => result), absent = vi.fn(async () => null); + expect(await collectStoppedInstructionCopyWithRetries(saved)).toBe(result); + expect(await collectStoppedInstructionCopyWithRetries(absent)).toBeNull(); + expect(saved).toHaveBeenCalledOnce(); expect(absent).toHaveBeenCalledOnce(); + await expect(collectStoppedInstructionCopyWithRetries(async () => { throw new Error("collection failed"); })).rejects.toThrow("collection failed"); + }); +}); + describe("native warm session supervision", () => { + it.each(["collect", "close-failure", "stopped", "destroyed", "missing-lease", "foreign-run", "foreign-environment", "deleted-environment", "claim-edit", "claim-add", "claim-remove", "claim-remove-entry", "claim-canonical", "claim-config", "claim-uncertain"])("composes remote warm ownership with real DB successor leases and current-run collection: %s", async ending => { + const tables = await import("@paperclipai/db"); + const { eq } = await import("drizzle-orm"); + const { randomUUID } = await import("node:crypto"); + const { promisify } = await import("node:util"); + const { execFile } = await import("node:child_process"); + const executeCommand = promisify(execFile); + const { startEmbeddedPostgresTestDatabase } = await import("../../__tests__/helpers/embedded-postgres.js"); + const { agentInstructionWorkingCopyService } = await import("../agent-instruction-working-copies.js"); + const { buildNativeRuntimeContext } = await import("./runtime-context.js"); + const { prepareNativeHeartbeatRun } = await import("./prepare-native-run.js"); + const { buildNativeCompletionContract } = await import("./completion-contracts.js"); + const home = await realpath(await mkdtemp(join(tmpdir(), "warm-remote-db-"))); + const previousHome = process.env.PAPERCLIP_HOME; + process.env.PAPERCLIP_HOME = home; + const database = await startEmbeddedPostgresTestDatabase("warm-remote-db-"); + const db = tables.createDb(database.connectionString); + const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), environmentId = randomUUID(), issueId = randomUUID(); + const { resolveManagedInstructionsRoot } = await import("../agent-instructions.js"); + const canonical = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "Warm remote", adapterConfig: {} }), remoteCwd = join(home, "remote"); + let currentLease = "", failCleanup = false, failProbe = false, closed = false; + const commands: Array<{ leaseId: string; command: string }> = []; + const commandRunner = (leaseId: string): import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner => ({ execute: async input => { + expect(leaseId).toBe(currentLease); // The replaced collector must never execute. + commands.push({ leaseId, command: `${input.command} ${(input.args ?? []).join(" ")}` }); + if (failProbe) { + failProbe = false; + expect(commands.at(-1)!.command).toContain("Agent directory probe deadline"); + return { exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "injected uncertain observation", pid: null, startedAt: new Date().toISOString() }; + } + if (failCleanup && commands.at(-1)!.command.includes("rm -rf --")) { + failCleanup = false; + return { exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "injected cleanup failure", pid: null, startedAt: new Date().toISOString() }; + } + const env = { PATH: process.env.PATH, ...input.env }, args = [...(input.args ?? [])]; + if (input.stdin != null && (args[0] === "-c" || args[0] === "-lc")) { + Object.assign(env, { PAPERCLIP_TEST_STDIN: input.stdin }); + args[1] = `printf '%s' "$PAPERCLIP_TEST_STDIN" | (${args[1]})`; + } + const result = await executeCommand(input.command, args, { cwd: input.cwd, env, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 }); + return { exitCode: 0, signal: null, timedOut: false, ...result, pid: null, startedAt: new Date().toISOString() }; + } }); + let copies = agentInstructionWorkingCopyService(db); + const agent = { id: agentId, companyId, name: "Warm remote", adapterConfig: { + instructionsBundleMode: "managed", instructionsRootPath: canonical, instructionsEntryFile: "AGENTS.md" } }; + let active: Awaited>; + const session = { close: vi.fn(async () => { closed = true; }) }; + state.execute.mockReset().mockImplementation(async options => { + await options.onSession?.(session); + return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, + turnId: "warm-turn", normalizedSessionId: environmentId, providerSessionId: environmentId, + driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + }); + let bodyFailure: unknown; + try { + await db.insert(tables.companies).values({ id: companyId, name: "Warm tests", issuePrefix: "WARM" }); + await db.insert(tables.authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(tables.agents).values(agent); + await db.insert(tables.companyMemberships).values([{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" }, { companyId, principalType: "agent", principalId: agentId, membershipRole: "member" }]); + await db.insert(tables.principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } }); + await db.insert(tables.environments).values({ id: environmentId, name: "Owned fake remote", driver: "sandbox" }); + const [issue] = await db.insert(tables.issues).values({ id: issueId, companyId, title: "Warm remote", status: "in_progress", assigneeAgentId: agentId }).returning(); + await mkdir(canonical, { recursive: true }); await mkdir(remoteCwd); + await writeFile(join(canonical, "AGENTS.md"), "Retain this exact directory.\n"); + await writeFile(join(canonical, "optional-memory.txt"), "Optional memory\n"); + let prior: NativeExecutionInputV3 | undefined; + let originalRoot = "", materializationRunId = ""; + for (let turn = 0; turn < 3; turn++) { + const runId = randomUUID(); + if (currentLease) { + await db.update(tables.environmentLeases).set({ status: "expired", releasedAt: new Date(), cleanupStatus: "success" }).where(eq(tables.environmentLeases.id, currentLease)); + await db.update(tables.heartbeatRuns).set({ status: "succeeded" }).where(eq(tables.heartbeatRuns.id, prior!.binding.runId)); + } + currentLease = randomUUID(); + const [run] = await db.insert(tables.heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId, + status: "running", runtimeMode: "native", nativeIssueId: issueId, nativeSessionId: environmentId, runnerInstanceId: environmentId, + contextSnapshot: { issueId } }).returning(); + await db.insert(tables.nativeRunFinalizations).values({ runId, companyId, issueId, phase: "observed" }); + await db.update(tables.issues).set({ executionRunId: runId }).where(eq(tables.issues.id, issueId)); + await db.insert(tables.environmentLeases).values({ id: currentLease, companyId, environmentId, heartbeatRunId: runId, + provider: "daytona", providerLeaseId: "same-owned-allocation", leasePolicy: "reuse_by_environment", metadata: { remoteCwd } }); + // Match the production preparation seam and existing native DB tests: + // pin the issue's completion contract and durable run/lease identities. + await prepareNativeHeartbeatRun({ db, run: run!, issue: issue!, environmentLeaseId: currentLease }); + const [contract] = await db.select().from(tables.completionContracts).where(eq(tables.completionContracts.issueId, issueId)); + const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", environmentId, leaseId: currentLease, remoteCwd, runner: commandRunner(currentLease) }; + const current: NativeExecutionInputV3 = { ...execution, schema: "paperclip.native-execution-input.v3", + executionMode: "default", planningContext: null, runtimeContext: nativeRuntimeContextFixture(), + completionContract: { id: contract!.id, sha256: contract!.canonicalSha256, schemaVersion: "paperclip.completion-contract.v1", + contract: buildNativeCompletionContract(issue!, { revision: contract!.revision }) }, binding: { companyId, agentId, issueId, runId, executionWorkspaceId: runId }, + workspace: { cwd: remoteCwd, repoUrl: null, repoRef: null, branchName: null }, + session: { ...execution.session, normalizedSessionId: environmentId, lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 } } }; + const preparationKey = turn === 1 && ending === "claim-config" ? "changed-config" : "same-config"; + let retiredReceipt: Record | null = null; + const capability = () => ({ runId, preparationKey, hasChanges: () => copies.hasChanges({ companyId, runId, target }), + collectStopped: async () => { expect(closed).toBe(true); retiredReceipt = (await copies.get(companyId, runId))?.receipt ?? null; + const { collectStoppedInstructionCopyWithRetries } = await import("../agent-instruction-working-copies.js"); + await collectStoppedInstructionCopyWithRetries(() => copies.collectStopped({ companyId, runId, target })); + }, retirementFailed: async () => { await copies.reportRetirementUnconfirmed(companyId, runId); } }); + let release: (() => Promise) | null = null; + if (prior) { + // Same environment is insufficient: reject a different physical allocation. + await db.update(tables.environmentLeases).set({ providerLeaseId: "foreign-allocation" }).where(eq(tables.environmentLeases.id, currentLease)); + const before = commands.length; + expect(await copies.adopt({ companyId, agentId, runId, previousRunId: prior.binding.runId, target, cwd: remoteCwd })).toBeNull(); + expect(commands).toHaveLength(before); + await db.update(tables.environmentLeases).set({ providerLeaseId: "same-owned-allocation" }).where(eq(tables.environmentLeases.id, currentLease)); + if (ending === "claim-edit") await writeFile(join(originalRoot, "AGENTS.md"), "Remote edit before next claim\n"); + if (ending === "claim-add") await writeFile(join(originalRoot, "memory.txt"), "Remote addition before next claim\n"); + if (ending === "claim-remove") await rm(join(originalRoot, "optional-memory.txt")); + if (ending === "claim-remove-entry") await rm(join(originalRoot, "AGENTS.md")); + if (ending === "claim-canonical") await writeFile(join(canonical, "AGENTS.md"), "Canonical edit before next claim\n"); + if (["claim-edit", "claim-add", "claim-remove", "claim-remove-entry", "claim-canonical"].includes(ending)) { + // Ordinary adoption remains unchanged-only and cannot alias this owner. + expect(await copies.adopt({ companyId, agentId, runId, previousRunId: prior.binding.runId, target, cwd: remoteCwd })).toBeNull(); + expect((await copies.get(companyId, prior.binding.runId))?.receipt?.retainedByRunId).toBeUndefined(); + } + failProbe = ending === "claim-uncertain"; + release = await claimWarmNativeInstructionCopy({ priorExecution: prior, companyId, agentId, executionWorkspaceId: runId, + workspace: current.workspace, environmentId, runId, preparationKey, adopt: async previousRunId => { + active = await copies.adopt({ companyId, agentId, runId, previousRunId, target, cwd: remoteCwd, allowRetirementHandoff: true }); + return active ? capability() : null; + } }); + if (ending.startsWith("claim-")) { + expect(release).toBeNull(); + expect(session.close).toHaveBeenCalledOnce(); + expect(state.execute).toHaveBeenCalledOnce(); // No reuse was admitted. + if (ending !== "claim-config") expect(retiredReceipt).toMatchObject({ retirementRequired: true }); + expect(retiredReceipt).toMatchObject({ materializationRunId, cleanup: { leaseId: currentLease, remoteCwd } }); + expect((await copies.get(companyId, runId))?.processStoppedAt).toBeInstanceOf(Date); + const compact = (await copies.get(companyId, runId))!.receipt; + expect(compact).toMatchObject({ materializationRunId, cleanupPending: false }); + expect(compact?.baseline).toBeUndefined(); + await expect(access(originalRoot)).rejects.toMatchObject({ code: "ENOENT" }); + if (ending === "claim-edit") expect(await readFile(join(canonical, "AGENTS.md"), "utf8")).toBe("Remote edit before next claim\n"); + if (ending === "claim-add") expect(await readFile(join(canonical, "memory.txt"), "utf8")).toBe("Remote addition before next claim\n"); + if (ending === "claim-remove") await expect(access(join(canonical, "optional-memory.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + if (ending === "claim-remove-entry") { + expect((await copies.get(companyId, runId))!).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_SAVE_FAILED" }); + expect(await readFile(join(canonical, "AGENTS.md"), "utf8")).toBe("Retain this exact directory.\n"); + } + if (ending === "claim-canonical") expect(await readFile(join(canonical, "AGENTS.md"), "utf8")).toBe("Canonical edit before next claim\n"); + // Heartbeat rematerializes only after the successor proved retirement. + active = await copies.prepare({ companyId, agentId, runId, target, cwd: remoteCwd }); + expect(active).toMatchObject({ state: "prepared", executionRoot: originalRoot, processStoppedAt: null }); + expect(active!.receipt?.retirementRequired).toBeUndefined(); + expect(await copies.hasChanges({ companyId, runId, target })).toBe(false); + await copies.reportUnavailable(companyId, prior.binding.runId); + await copies.release(companyId, prior.binding.runId); + expect((await lstat(originalRoot)).isDirectory()).toBe(true); + await copies.collectStopped({ companyId, runId, target }); + return; + } + expect(release).toBeTypeOf("function"); + await copies.reportUnavailable(companyId, prior.binding.runId); + await copies.release(companyId, prior.binding.runId); + expect(active!.receipt).toMatchObject({ materializationRunId, cleanup: { leaseId: currentLease, remoteCwd } }); + } else { + active = await copies.prepare({ companyId, agentId, runId, target, cwd: remoteCwd }); + originalRoot = active!.executionRoot; materializationRunId = runId; + } + expect(active!.executionRoot).toBe(originalRoot); + current.runtimeContext = await buildNativeRuntimeContext({ db, agent, runId, runtimeConfig: {}, runtimeSkillEntries: [], + instructionWorkingCopy: { rootPath: active!.executionRoot, entryPath: "AGENTS.md", kind: "agent_files" } }); + expect(current.runtimeContext.instructions.workingCopy?.rootPath).toBe(originalRoot); + await executePaperclipNativeSession({ db, execution: current, runnerInstanceId: environmentId, runnerExecutionTarget: target, instructionWorkingCopy: capability() }); + if (prior) expect(state.execute.mock.calls.at(-1)?.[0].existingSession).toBe(session); + expect(closed).toBe(false); + await release?.(); expect(closed).toBe(false); + prior = current; + } + // A write after the live unchanged probe is still collected on retirement. + await writeFile(join(originalRoot, "late-memory.txt"), "Latest owner's remote bytes"); + if (["missing-lease", "foreign-run", "foreign-environment", "deleted-environment"].includes(ending)) { + if (ending === "missing-lease") await db.delete(tables.environmentLeases).where(eq(tables.environmentLeases.id, currentLease)); + if (ending === "foreign-run") await db.update(tables.environmentLeases).set({ heartbeatRunId: materializationRunId }).where(eq(tables.environmentLeases.id, currentLease)); + if (ending === "foreign-environment") { + const other = randomUUID(); await db.insert(tables.environments).values({ id: other, name: "Other", driver: "sandbox" }); + await db.update(tables.environmentLeases).set({ environmentId: other }).where(eq(tables.environmentLeases.id, currentLease)); + } + if (ending === "deleted-environment") await db.delete(tables.environments).where(eq(tables.environments.id, environmentId)); + const before = commands.length; + await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "lease identity changed" }); + expect(commands).toHaveLength(before); + const pending = (await copies.get(companyId, prior!.binding.runId))!; + expect(pending.state).toBe("pending_collection"); + expect(pending.receipt?.baseline).toBeDefined(); + expect(await readFile(join(originalRoot, "late-memory.txt"), "utf8")).toBe("Latest owner's remote bytes"); + return; + } + if (ending === "close-failure") { + session.close.mockImplementationOnce(async () => { throw new Error("owned close not yet confirmed"); }); + const before = commands.length; + expect(await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "first retirement" })).toMatchObject({ failed: 1 }); + await copies.reportUnavailable(companyId, prior!.binding.runId); + await copies.reportUnavailable(companyId, materializationRunId); + await copies.release(companyId, prior!.binding.runId); + await copies.release(companyId, materializationRunId); + expect(await copies.get(companyId, prior!.binding.runId)).toMatchObject({ state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED", processStoppedAt: null }); + expect(commands).toHaveLength(before); + expect(await readFile(join(originalRoot, "late-memory.txt"), "utf8")).toBe("Latest owner's remote bytes"); + expect(await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "verified later retirement" })).toMatchObject({ closed: 1, failed: 0 }); + expect(await copies.get(companyId, prior!.binding.runId)).toMatchObject({ state: "saved", receipt: { cleanupPending: false } }); + expect(await readFile(join(canonical, "late-memory.txt"), "utf8")).toBe("Latest owner's remote bytes"); + await expect(access(originalRoot)).rejects.toMatchObject({ code: "ENOENT" }); + return; + } + if (ending !== "collect") { + const { remoteTerminationReceipt } = await import("../remote-execution-termination.js"); + const [lease] = await db.select().from(tables.environmentLeases).where(eq(tables.environmentLeases.id, currentLease)); + await db.update(tables.environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success", + metadata: { ...lease!.metadata, remoteExecutionTermination: remoteTerminationReceipt(lease!, { providerLeaseId: lease!.providerLeaseId, state: ending }) } }).where(eq(tables.environmentLeases.id, currentLease)); + await db.update(tables.heartbeatRuns).set({ status: "succeeded" }).where(eq(tables.heartbeatRuns.id, prior!.binding.runId)); + const before = commands.length; + await copies.recoverStopped(); // The original remote collector is still cached. + expect(commands).toHaveLength(before); + const recovered = (await copies.get(companyId, prior!.binding.runId))!; + expect(recovered.state).toBe(ending === "destroyed" ? "unavailable" : "pending_collection"); + await expect(access(join(canonical, "late-memory.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + if (ending === "stopped") { + expect(await readFile(join(originalRoot, "late-memory.txt"), "utf8")).toBe("Latest owner's remote bytes"); + expect(recovered.receipt?.baseline).toBeDefined(); + await copies.collectStopped({ companyId, runId: prior!.binding.runId }); + await copies.release(companyId, prior!.binding.runId); + expect(commands).toHaveLength(before); + } else await expect(access(active!.localRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "already terminated owner" }); + expect(commands).toHaveLength(before); + return; + } + failCleanup = true; + await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "test retirement" }); + expect(session.close).toHaveBeenCalledOnce(); + expect(await readFile(join(canonical, "late-memory.txt"), "utf8")).toBe("Latest owner's remote bytes"); + const saved = (await copies.get(companyId, prior!.binding.runId))!; + expect(saved).toMatchObject({ state: "saved", receipt: { cleanupPending: true, materializationRunId, cleanup: { leaseId: currentLease } } }); + const cleanup = vi.fn(async input => { + expect(input.lease.id).toBe(currentLease); + expect(input.lease.heartbeatRunId).toBe(prior!.binding.runId); + return commandRunner(currentLease).execute(input); + }); + copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute: cleanup } as never }); + await db.update(tables.agentInstructionWorkingCopies).set({ nextAttemptAt: null }).where(eq(tables.agentInstructionWorkingCopies.runId, prior!.binding.runId)); + await copies.recoverCaptured(); + expect(cleanup).toHaveBeenCalledOnce(); + await expect(access(originalRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await copies.get(companyId, prior!.binding.runId))!.receipt?.cleanupPending).toBe(false); + } catch (error) { bodyFailure = error; throw error; } + finally { + const cleanupFailures: unknown[] = []; + try { await closeWarmNativeSessionsForEnvironment({ environmentId, reason: "test cleanup" }); } catch (error) { cleanupFailures.push(error); } + try { await database.cleanup(); } catch (error) { cleanupFailures.push(error); } + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome; + try { + const writable = async (directory: string): Promise => { + await chmod(directory, 0o700); + for (const entry of await readdir(directory, { withFileTypes: true })) if (entry.isDirectory()) await writable(join(directory, entry.name)); + }; + await writable(home); await rm(home, { recursive: true, force: true }); + } catch (error) { cleanupFailures.push(error); } + if (cleanupFailures.length) throw new AggregateError([...(bodyFailure ? [bodyFailure] : []), ...cleanupFailures], "Warm DB test body/cleanup failure"); + } + }, 90_000); + + it.each(["idle", "edit", "add", "remove", "projectless"])("retains the actual agent_files copy across warm turns, then retires for %s", async ending => { + const root = await realpath(await mkdtemp(join(tmpdir(), "warm-agent-files-"))); + const localRoot = join(root, "run", "live"); + await mkdir(localRoot, { recursive: true }); + await writeFile(join(localRoot, "AGENTS.md"), "Keep the registered directory.\n"); + const snapshot = await captureDirectorySnapshot(localRoot); + let row = { companyId: execution.binding.companyId, agentId: execution.binding.agentId, + runId: "warm-real-agent-files", localRoot, executionRoot: localRoot, location: "local", + entryFile: "AGENTS.md", state: "prepared", attempts: 0, processStoppedAt: null, + baseRevisionId: null, candidateBase64: null, candidateHash: null, errorCode: null, errorMessage: null, nextAttemptAt: null, + responsibleUserId: "test-user", createdAt: new Date(), updatedAt: new Date(), + baseHash: directorySnapshotSha256(snapshot), receipt: { schema: AGENT_FILES_CONTRACT, baseline: serializeDirectorySnapshot(snapshot), materializationIdentity: probeAgentDirectory(localRoot).identity }, + } as typeof agentInstructionWorkingCopies.$inferSelect; + const copies = agentDirectoryWorkingCopyService({} as Db, async () => row, + async (_prior, values) => (row = { ...row, ...values } as typeof row)); + const order: string[] = []; + const close = vi.fn(async () => { order.push("closed"); }); + const collectStopped = async () => { + order.push("collected"); + expect(close).toHaveBeenCalledOnce(); + // The real DB service suite verifies changed-file persistence. Here the + // fake backend proves stop precedes granting its collection callback. + if (ending === "idle" || ending === "projectless") await copies.collectStopped(row); + }; + const identity = "warm-real-agent-files"; + const warmExecution = { ...execution, binding: { ...execution.binding, runId: identity, executionWorkspaceId: ending === "projectless" ? identity : `${identity}-workspace` }, + session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } }, + } as NativeExecutionInputV1; + const session = { close }; + state.execute.mockReset().mockImplementation(async (options) => { + await options.onSession?.(session); + return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, + turnId: identity, normalizedSessionId: identity, providerSessionId: identity, + driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + }); + try { + await executePaperclipNativeSession({ db: leaseDb(warmExecution), execution: warmExecution, + runnerInstanceId: identity, runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, remoteCwd: `/tmp/${identity}` }, + instructionWorkingCopy: { runId: identity, root: localRoot, preparationKey: "same-preparation", hasChanges: () => copies.hasChanges(row), collectStopped } }); + expect(close).not.toHaveBeenCalled(); + expect(order).toEqual([]); + expect(await readFile(join(localRoot, "AGENTS.md"), "utf8")).toContain("registered"); + const second = { ...warmExecution, binding: { ...warmExecution.binding, runId: `${identity}-second`, + ...(ending === "projectless" ? { executionWorkspaceId: `${identity}-second` } : {}) } }; + expect(nativeSessionWorkspaceScope(second)).toEqual(nativeSessionWorkspaceScope(warmExecution)); + const currentCopy = { runId: second.binding.runId, root: localRoot, preparationKey: "same-preparation", hasChanges: () => copies.hasChanges(row), collectStopped }; + if (ending === "projectless") { + const adopt = vi.fn(async () => currentCopy); + for (const changed of [ + { ...second.workspace, cwd: "/different" }, { ...second.workspace, repoUrl: "https://example.test/other" }, + { ...second.workspace, repoRef: "other" }, { ...second.workspace, branchName: "other" }, + ]) expect(await claimWarmNativeInstructionCopy({ priorExecution: warmExecution, + companyId: second.binding.companyId, agentId: second.binding.agentId, executionWorkspaceId: second.binding.runId, + workspace: changed, environmentId: identity, runId: second.binding.runId, preparationKey: "same-preparation", adopt })).toBeNull(); + expect(await claimWarmNativeInstructionCopy({ priorExecution: warmExecution, + companyId: second.binding.companyId, agentId: second.binding.agentId, executionWorkspaceId: "managed-workspace", + workspace: second.workspace, environmentId: identity, runId: second.binding.runId, preparationKey: "same-preparation", adopt })).toBeNull(); + expect(adopt).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); + } + const release = await claimWarmNativeInstructionCopy({ priorExecution: warmExecution, + companyId: warmExecution.binding.companyId, agentId: warmExecution.binding.agentId, + executionWorkspaceId: second.binding.executionWorkspaceId, workspace: second.workspace, + environmentId: identity, runId: second.binding.runId, preparationKey: "same-preparation", + adopt: async () => currentCopy }); + expect(release).toBeTypeOf("function"); + await executePaperclipNativeSession({ db: leaseDb(second), execution: second, runnerInstanceId: identity, + runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, remoteCwd: `/tmp/${identity}` }, + instructionWorkingCopy: currentCopy }); + expect(state.execute.mock.calls.at(-1)?.[0].existingSession).toBe(session); + expect(close).not.toHaveBeenCalled(); + expect(row.executionRoot).toBe(localRoot); + await release?.(); // Consumed preparation cannot retire the retained owner. + expect(close).not.toHaveBeenCalled(); + if (ending === "idle" || ending === "projectless") { + await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "idle retirement" }); + await expect(access(localRoot)).rejects.toMatchObject({ code: "ENOENT" }); + } else { + if (ending === "edit") await writeFile(join(localRoot, "AGENTS.md"), "Changed instructions"); + if (ending === "add") await writeFile(join(localRoot, "memory.txt"), "New memory"); + if (ending === "remove") await rm(join(localRoot, "AGENTS.md")); + const adopt = vi.fn(async () => ({ ...currentCopy, runId: `${identity}-third` })); + expect(await claimWarmNativeInstructionCopy({ priorExecution: second, + companyId: second.binding.companyId, agentId: second.binding.agentId, + executionWorkspaceId: second.binding.executionWorkspaceId, workspace: second.workspace, + environmentId: identity, runId: `${identity}-third`, preparationKey: "same-preparation", adopt })).toBeNull(); + expect(adopt).toHaveBeenCalledOnce(); + } + expect(order).toEqual(["closed", "collected"]); + } finally { + await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" }); + await rm(root, { recursive: true, force: true }); + } + }); + + it.each(["final-config", "changed-root", "late-edit", "close-failure", "abandon", "concurrent", "foreign-company", "foreign-workspace"])("fences owned instruction preparation: %s", async scenario => { + const identity = `warm-copy-${scenario}`; + const order: string[] = []; + let allowClose = scenario !== "close-failure"; + const session = { close: vi.fn(async () => { order.push("close"); if (!allowClose) throw new Error("retirement unconfirmed"); }) }; + const retirementFailed = vi.fn(async () => {}); + let lateEdit = false; + const copy = { runId: identity, root: `/tmp/${identity}/instructions`, preparationKey: "key", hasChanges: async () => lateEdit, + collectStopped: vi.fn(async () => { order.push("collect"); }), retirementFailed }; + const first = { ...execution, binding: { ...execution.binding, runId: identity, executionWorkspaceId: `${identity}-workspace` }, + session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } }, + } as NativeExecutionInputV1; + const target = { kind: "remote" as const, transport: "sandbox" as const, environmentId: identity, remoteCwd: `/tmp/${identity}` }; + state.execute.mockReset().mockImplementation(async options => { + await options.onSession?.(session); + return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, + turnId: identity, normalizedSessionId: identity, providerSessionId: identity, + driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + }); + const second = { ...first, binding: { ...first.binding, runId: `${identity}-second` } }; + const current = { ...copy, runId: second.binding.runId, + ...(scenario === "changed-root" ? { root: `/tmp/${identity}/other-instructions` } : {}) }; + const adopt = vi.fn(async () => current); + const claim = { priorExecution: first, companyId: first.binding.companyId, agentId: first.binding.agentId, + executionWorkspaceId: first.binding.executionWorkspaceId, workspace: first.workspace, environmentId: identity, + runId: second.binding.runId, preparationKey: "key", adopt }; + try { + await executePaperclipNativeSession({ db: leaseDb(first), execution: first, runnerInstanceId: identity, runnerExecutionTarget: target, instructionWorkingCopy: copy }); + if (scenario.startsWith("foreign")) { + expect(await claimWarmNativeInstructionCopy({ ...claim, + ...(scenario === "foreign-company" ? { companyId: "other" } : { executionWorkspaceId: "other" }) })).toBeNull(); + expect(adopt).not.toHaveBeenCalled(); expect(session.close).not.toHaveBeenCalled(); + } else { + const release = await claimWarmNativeInstructionCopy(claim); + if (scenario === "concurrent") await expect(claimWarmNativeInstructionCopy({ ...claim, runId: "competitor" })).rejects.toThrow("busy"); + if (scenario === "final-config" || scenario === "changed-root" || scenario === "late-edit") { + lateEdit = scenario === "late-edit"; + const changed = scenario !== "final-config" ? second : { ...second, session: { ...second.session, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 99_000 } } }; + await expect(executePaperclipNativeSession({ db: leaseDb(changed), execution: changed, runnerInstanceId: identity, + runnerExecutionTarget: target, instructionWorkingCopy: current })).rejects.toThrow(scenario === "late-edit" + ? "native_instruction_preparation_copy_changed" : "native_instruction_preparation_configuration_changed"); + expect(state.execute).toHaveBeenCalledOnce(); + } + if (scenario === "close-failure") { + await expect(release!()).rejects.toThrow("retirement unconfirmed"); + expect(retirementFailed).toHaveBeenCalledOnce(); + expect(copy.collectStopped).not.toHaveBeenCalled(); + expect(order).toEqual(["close"]); + } else { + await release?.(); + expect(order).toEqual(["close", "collect"]); + } + } + } finally { allowClose = true; await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" }); } + }); + + it.each(["config", "reset", "changed", "unavailable", "auth", "wrong-capability", "retirement-failure"])("hands off collection before probing or retiring a warm owner: %s", async scenario => { + const identity = `warm-successor-${scenario}`, order: string[] = []; + let allowClose = scenario !== "retirement-failure"; + const session = { close: vi.fn(async () => { order.push("stop"); if (!allowClose) throw new Error("stop unconfirmed"); }) }; + const priorCopy = { runId: identity, preparationKey: "before", hasChanges: vi.fn(async () => false), + collectStopped: vi.fn(async () => { order.push("prior-pending"); }), retirementFailed: vi.fn(async () => {}) }; + const first = { ...execution, binding: { ...execution.binding, runId: identity, executionWorkspaceId: `${identity}-workspace` }, + session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } } } as NativeExecutionInputV1; + const target = { kind: "remote" as const, transport: "sandbox" as const, environmentId: identity, remoteCwd: `/tmp/${identity}` }; + state.execute.mockReset().mockImplementation(async options => { + await options.onSession?.(session); + return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, turnId: identity, + normalizedSessionId: identity, providerSessionId: identity, driverKind: "test", driverVersion: "1", + nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + }); + const current = { runId: `${identity}-next`, preparationKey: scenario === "config" ? "after" : "before", + hasChanges: vi.fn(async () => { order.push("current-probe"); return true; }), + collectStopped: vi.fn(async () => { order.push("current-collect"); }), retirementFailed: vi.fn(async () => {}) }; + const adopt = vi.fn(async () => { + order.push("handoff"); + if (scenario === "auth") throw new Error("authorization denied"); + if (scenario === "unavailable") return null; + return scenario === "wrong-capability" ? { ...current, runId: "foreign" } : current; + }); + try { + await executePaperclipNativeSession({ db: leaseDb(first), execution: first, runnerInstanceId: identity, runnerExecutionTarget: target, instructionWorkingCopy: priorCopy }); + priorCopy.hasChanges.mockClear(); + const claiming = claimWarmNativeInstructionCopy({ priorExecution: first, companyId: first.binding.companyId, agentId: first.binding.agentId, + executionWorkspaceId: first.binding.executionWorkspaceId, workspace: first.workspace, environmentId: identity, + runId: current.runId, preparationKey: current.preparationKey, forceRetirement: scenario === "reset", adopt }); + if (["unavailable", "auth", "wrong-capability", "retirement-failure"].includes(scenario)) { + await expect(claiming).rejects.toThrow(({ unavailable: "handoff_unavailable", auth: "authorization denied", + "wrong-capability": "handoff_mismatch", "retirement-failure": "stop unconfirmed" } as Record)[scenario]); + } else expect(await claiming).toBeNull(); + expect(priorCopy.hasChanges).not.toHaveBeenCalled(); + expect(state.execute).toHaveBeenCalledOnce(); + if (["unavailable", "auth", "wrong-capability"].includes(scenario)) { + expect(order).toEqual(["handoff", "stop", "prior-pending"]); + expect(current.collectStopped).not.toHaveBeenCalled(); + } else { + expect(priorCopy.collectStopped).not.toHaveBeenCalled(); + expect(order).toEqual(["handoff", ...(["config", "reset"].includes(scenario) ? [] : ["current-probe"]), "stop", ...(scenario === "retirement-failure" ? [] : ["current-collect"])]); + if (scenario === "retirement-failure") { + expect(current.retirementFailed).toHaveBeenCalledOnce(); + expect(current.collectStopped).not.toHaveBeenCalled(); + } + } + } finally { allowClose = true; await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" }); } + }); + it.each([ { change: "addition", before: {}, after: { CUSTOM_TOKEN: "first" }, replaced: true }, { change: "rotation", before: { CUSTOM_TOKEN: "first" }, after: { CUSTOM_TOKEN: "second" }, replaced: true }, @@ -7817,7 +8504,7 @@ describe("native warm session supervision", () => { }); }); - it.each(["permission", "managed credential"])("replaces an idle warm provider session when its %s changes", async (change) => { + it.each(["permission", "managed credential", "ACPX runtime contract", "unchanged Codex runtime contract"])("checks warm owner compatibility for %s", async (change) => { const firstClose = vi.fn(async () => undefined); const secondClose = vi.fn(async () => undefined); const firstSession = { close: firstClose }; @@ -7825,7 +8512,9 @@ describe("native warm session supervision", () => { const base = { ...execution, schema: "paperclip.native-execution-input.v4", - provider: { kind: "codex", model: null, approvalPolicy: "never" }, + provider: change === "ACPX runtime contract" + ? { kind: "acpx", agent: "claude", model: "claude-sonnet-5", permissionMode: "approve-all" } + : { kind: "codex", model: null, approvalPolicy: "never" }, binding: { ...execution.binding, runId: "run-permission-never", @@ -7839,7 +8528,7 @@ describe("native warm session supervision", () => { }, session: { normalizedSessionId: "session-warm-permission", - driverKind: "codex_app_server" as const, + driverKind: change === "ACPX runtime contract" ? "acpx_runtime" as const : "codex_app_server" as const, protocolVersion: 1 as const, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 20 }, }, @@ -7862,6 +8551,12 @@ describe("native warm session supervision", () => { highestContiguousSourceSeq: 1, usage: null, }; + const runtimeChange = change === "ACPX runtime contract" || change === "unchanged Codex runtime contract"; + const keepsOwner = change === "unchanged Codex runtime contract"; + const currentRuntimeContract = nativeSessionResume.nativeRuntimeContractForProvider(base.provider); + const contract = runtimeChange + ? vi.spyOn(nativeSessionResume, "nativeRuntimeContractForProvider").mockReturnValue(undefined) + : undefined; state.execute .mockReset() .mockImplementationOnce(async (options) => { @@ -7870,8 +8565,8 @@ describe("native warm session supervision", () => { return result; }) .mockImplementationOnce(async (options) => { - expect(options.existingSession).toBeUndefined(); - options.onSession?.(secondSession); + expect(options.existingSession).toBe(keepsOwner ? firstSession : undefined); + if (!keepsOwner) options.onSession?.(secondSession); return result; }); @@ -7885,21 +8580,22 @@ describe("native warm session supervision", () => { managedAiCredentialIdentity: "first-identity", runnerInstanceId: "runner", }); + contract?.mockReturnValue(currentRuntimeContract); await executePaperclipNativeSession({ db: leaseDb(lowered), execution: lowered, managedAiCredentialIdentity: change === "managed credential" ? "second-identity" : "first-identity", runnerInstanceId: "runner", }); - expect(firstClose).toHaveBeenCalledWith({ - reason: "warm native session configuration changed", - }); + if (keepsOwner) expect(firstClose).not.toHaveBeenCalled(); + else expect(firstClose).toHaveBeenCalledWith({ reason: "warm native session configuration changed" }); expect(secondClose).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(20); - expect(secondClose).toHaveBeenCalledWith({ + expect(keepsOwner ? firstClose : secondClose).toHaveBeenCalledWith({ reason: "warm native session idle timeout", }); } finally { + contract?.mockRestore(); vi.useRealTimers(); } }); @@ -8723,6 +9419,11 @@ describe("native process ownership", () => { }, "acpx_runtime", ], + [ + "Pi ACPX without candidate authorization", + { kind: "acpx", agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "approve-reads" }, + "acpx_runtime", + ], ])( "admits the qualified %s provider", async (_name, provider, driverKind) => { @@ -8761,7 +9462,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 a85a64bd0e..14dd0fb692 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"; @@ -427,7 +428,23 @@ function clearNativeRuntimeRequestResolutions(runId: string): void { } } +export type NativeInstructionWorkingCopy = { + /** Present only for a complete registered agent_files materialization. */ + runId?: string; + preparationKey?: string; + root?: string; + checkpointWarm?: () => Promise; + hasChanges: () => Promise; + collectStopped: () => Promise; + retirementFailed?: () => Promise; +}; + type WarmNativeSession = { + instructionWorkingCopy?: NativeInstructionWorkingCopy; + // Keep the admitted physical root independent of the per-run collection + // capability, which instruction preparation adopts before final admission. + instructionRoot?: string; + instructionPreparationRunId?: string; agentId: string; instructionCopy?: { runId: string; root?: string; targetIdentity: string; collectStopped: () => Promise }; preparingRunId?: string; @@ -453,7 +470,13 @@ async function closeWarmNativeSession(entry: WarmNativeSession, reason: string, // Revoke before awaiting process retirement/checkpoint IO. const stopping = entry.githubAccess?.stop(); try { - await entry.session.close({ reason }); + try { await entry.session.close({ reason }); } + catch (error) { + try { await entry.instructionWorkingCopy?.retirementFailed?.(); } + catch (receiptError) { throw new AggregateError([error, receiptError], "Warm instruction retirement and receipt both failed"); } + throw error; + } + await entry.instructionWorkingCopy?.collectStopped(); if (entry.instructionCopy?.runId !== preserveInstructionsForRunId) await entry.instructionCopy?.collectStopped(); } finally { await stopping; } @@ -464,6 +487,65 @@ const warmNativeSessions = new Map(); // not inspect or quarantine that owner's state until the save has finished. const closingWarmNativeSessions = new Map>(); +/** Reserve the existing physical copy before heartbeat composes AGENT_HOME and + * task guidance. The final execution/configuration fence still owns admission. */ +export async function claimWarmNativeInstructionCopy(input: { + priorExecution: NativeExecutionInput; + companyId: string; + agentId: string; + executionWorkspaceId: string; + workspace: NativeExecutionInput["workspace"]; + environmentId: string | null; + runId: string; + preparationKey: string; + forceRetirement?: boolean; + adopt: (previousRunId: string) => Promise; +}): Promise<(() => Promise) | null> { + const prior = input.priorExecution; + if (prior.binding.companyId !== input.companyId || prior.binding.agentId !== input.agentId + || JSON.stringify(nativeSessionWorkspaceScope(prior)) !== JSON.stringify(nativeSessionWorkspaceScope({ + binding: { ...prior.binding, runId: input.runId, executionWorkspaceId: input.executionWorkspaceId }, workspace: input.workspace, + })) || prior.workspace.cwd !== input.workspace.cwd) return null; + const scope = nativeSessionScopeKey(prior); + const entry = warmNativeSessions.get(scope); + if (!entry || !entry.instructionWorkingCopy?.runId || entry.environmentId !== input.environmentId) return null; + if (entry.busy) throw new Error("native_session_supervisor_busy"); + entry.busy = true; + if (entry.idleTimer !== null) clearTimeout(entry.idleTimer); + entry.idleTimer = null; + const retire = async (reason: string) => { + if (warmNativeSessions.get(scope) !== entry) return; + entry.closeOnReleaseReason = reason; + try { + await closeWarmNativeSession(entry, reason); + if (warmNativeSessions.get(scope) === entry) warmNativeSessions.delete(scope); + } finally { entry.busy = false; } + }; + try { + const preparationChanged = input.forceRetirement === true || entry.instructionWorkingCopy.preparationKey !== input.preparationKey; + // The prior capability may close over an expired remote lease. Rebind + // collection authority first; adoption itself grants no reuse permission. + const adopted = await input.adopt(entry.instructionWorkingCopy.runId); + if (!adopted) { + await retire("warm instruction materialization cannot be handed off"); + throw new Error("native_instruction_materialization_handoff_unavailable"); + } + if (adopted.runId !== input.runId || adopted.preparationKey !== input.preparationKey) throw new Error("native_instruction_materialization_handoff_mismatch"); + entry.instructionWorkingCopy = adopted; + entry.instructionPreparationRunId = input.runId; + if (entry.closeOnReleaseReason !== undefined || preparationChanged || await adopted.hasChanges()) { + await retire("warm instruction materialization changed before preparation"); + return null; + } + return async () => { + if (entry.instructionPreparationRunId === input.runId) await retire("warm instruction preparation abandoned"); + }; + } catch (error) { + if (entry.closeOnReleaseReason === undefined) await retire("warm instruction preparation failed"); + throw error; + } +} + function instructionTargetIdentity(target?: AdapterExecutionTarget | null): string { return JSON.stringify(target?.kind === "remote" ? { environmentId: target.environmentId, cwd: target.remoteCwd, @@ -560,19 +642,23 @@ async function closeIdleWarmNativeSessions(input: { continue; } if (entry.idleTimer !== null) clearTimeout(entry.idleTimer); - // Remove ownership before awaiting close so a racing continuation cannot - // adopt a session whose transport is already shutting down. - warmNativeSessions.delete(sessionId); + // Keep a fenced owner until both retirement and stopped collection succeed. + // Failure must not make a live directory eligible for a replacement owner. + entry.busy = true; + entry.closeOnReleaseReason = input.reason; const closing = Promise.resolve().then(() => closeWarmNativeSession(entry, input.reason), ); closingWarmNativeSessions.set(sessionId, closing); try { await closing; + if (warmNativeSessions.get(sessionId) === entry) warmNativeSessions.delete(sessionId); closed += 1; } catch { + if (!entry.instructionWorkingCopy?.runId) warmNativeSessions.delete(sessionId); failed += 1; } finally { + entry.busy = false; if (closingWarmNativeSessions.get(sessionId) === closing) { closingWarmNativeSessions.delete(sessionId); } @@ -1166,6 +1252,7 @@ export function createGovernedWaitEventObservation( resolvePending: () => Promise, ) { const pendingTools = new Set(); + let providerLost = false; let generation = 0; let observation: { sourceInstanceId: string; @@ -1179,6 +1266,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) { @@ -1194,7 +1282,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 @@ -1574,8 +1664,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 @@ -5326,7 +5416,7 @@ function hasIdleWarmNativeSessionOwner(input: { }): boolean { if (input.execution.session.lifecyclePolicy.mode !== "warm") return false; const entry = warmNativeSessions.get(nativeSessionScopeKey(input.execution)); - if (!entry || entry.busy) return false; + if (!entry || (entry.busy && entry.instructionPreparationRunId !== input.execution.binding.runId)) return false; // A verified idle owner proves the checkpoint belongs to this session even // when its process must later rotate to a new run-scoped broker capability. // Local environments also have an id. Compare the same environment binding @@ -5541,6 +5631,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), ) || @@ -5648,6 +5739,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; @@ -5658,6 +5755,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; } @@ -6054,23 +6152,27 @@ async function releaseWarmNativeSession( entry.lastActivityAt = new Date().toISOString(); if (entry.idleTimer !== null) clearTimeout(entry.idleTimer); if (failed || entry.closeOnReleaseReason !== undefined) { - warmNativeSessions.delete(sessionId); - const closing = closeWarmNativeSession(entry, - entry.closeOnReleaseReason ?? "warm native session failed"); - // Restart checkpointing is required to restore this successful session. - // Surface failure instead of reporting a clean release without authority. - if (entry.closeOnReleaseReason !== undefined) await closing; - else await closing.catch(() => undefined); + const requireCleanClose = entry.closeOnReleaseReason !== undefined; + entry.closeOnReleaseReason ??= "warm native session failed"; + entry.busy = true; + try { + await closeWarmNativeSession(entry, entry.closeOnReleaseReason); + if (warmNativeSessions.get(sessionId) === entry) warmNativeSessions.delete(sessionId); + } catch (error) { + if (!entry.instructionWorkingCopy?.runId) warmNativeSessions.delete(sessionId); + if (requireCleanClose) throw error; + } finally { entry.busy = false; } return; } entry.idleTimer = setTimeout(() => { const current = warmNativeSessions.get(sessionId); - // clearTimeout cannot revoke an already-queued callback. The entry object - // is the idle timer's ownership fence across a later warm acquisition. if (current !== entry || current.busy) return; - warmNativeSessions.delete(sessionId); - void closeWarmNativeSession(current, "warm native session idle timeout") - .catch(() => undefined); + current.busy = true; + current.closeOnReleaseReason = "warm native session idle timeout"; + void closeWarmNativeSession(current, current.closeOnReleaseReason) + .then(() => { if (warmNativeSessions.get(sessionId) === current) warmNativeSessions.delete(sessionId); }) + .catch(() => { if (!current.instructionWorkingCopy?.runId && warmNativeSessions.get(sessionId) === current) warmNativeSessions.delete(sessionId); }) + .finally(() => { current.busy = false; }); }, idleTimeoutMs); entry.idleTimer.unref(); } @@ -7347,15 +7449,9 @@ export async function executePaperclipNativeSession(input: { chatAttachmentReadScope?: NativeChatAttachmentReadScope; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; onEvent?: (event: AdapterRuntimeEvent) => Promise; - /** Only this run's registered directory. Validate a warm checkpoint at the - * terminal boundary, or collect after owned shutdown when it cannot settle. */ - instructionWorkingCopy?: { - runId?: string; - root?: string; - checkpointWarm?: () => Promise; - hasChanges: () => Promise; - collectStopped: () => Promise; - }; + /** Only this run's registered private instruction entry. + * Probe at terminal; persist only after owned shutdown. */ + instructionWorkingCopy?: NativeInstructionWorkingCopy; /** Persist task-level continuity before a durable goal can outlive this run. */ onUsage?: (receipt: AdapterUsageCheckpoint) => Promise; onGoalCheckpoint?: (snapshot: PersistedNativeSession) => Promise; @@ -7571,7 +7667,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( @@ -8352,7 +8448,7 @@ async function executePaperclipNativeSessionWithinScope( entry.closeOnReleaseReason !== undefined || entry.configDigest !== warmConfigDigest || entry.configuredEnvironmentDigest !== configuredEnvironmentDigest || - entry.instructionCopy?.root !== input.instructionWorkingCopy?.root || + entry.instructionRoot !== input.instructionWorkingCopy?.root || entry.managedAiCredentialIdentity !== input.managedAiCredentialIdentity || credentialRunChanged || Boolean(entry.githubAccess) !== Boolean(input.managedGitHub) || @@ -8363,20 +8459,36 @@ async function executePaperclipNativeSessionWithinScope( (input.runnerEnvironment?.PAPERCLIP_RUNNER_NETWORK_ACCESS === "enabled") ) { - if (entry.busy) throw new Error("native_session_supervisor_busy"); + const preparedCopy = entry.instructionPreparationRunId === input.execution.binding.runId; + if (entry.busy && !preparedCopy) throw new Error("native_session_supervisor_busy"); if (entry.idleTimer !== null) clearTimeout(entry.idleTimer); - warmNativeSessions.delete(warmSessionId); - // Preparation may already have handed this directory to the incoming - // run. Retiring the old transport must not delete the new run's root; - // its own stop callback owns collection from this point forward. - await closeWarmNativeSession(entry, "warm native session configuration changed", input.execution.binding.runId); + entry.busy = true; + entry.closeOnReleaseReason = "warm native session configuration changed"; + try { + await closeWarmNativeSession(entry, entry.closeOnReleaseReason, input.execution.binding.runId); + warmNativeSessions.delete(warmSessionId); + } finally { entry.busy = false; } + if (preparedCopy) throw new Error("native_instruction_preparation_configuration_changed"); persistedWarmSession = loadWarmNativeCheckpoint( input.execution, warmConfigDigest, input.runnerExecutionTarget?.kind ?? "local", ); } else { - if (entry.busy) throw new Error("native_session_supervisor_busy"); + if (entry.busy && entry.instructionPreparationRunId !== input.execution.binding.runId) throw new Error("native_session_supervisor_busy"); + if (entry.instructionWorkingCopy?.runId && entry.instructionPreparationRunId !== input.execution.binding.runId) { + throw new Error("native_instruction_materialization_not_claimed"); + } + if (entry.instructionPreparationRunId !== undefined && await entry.instructionWorkingCopy!.hasChanges()) { + entry.closeOnReleaseReason = "warm instruction copy changed during preparation"; + try { + await closeWarmNativeSession(entry, entry.closeOnReleaseReason); + warmNativeSessions.delete(warmSessionId); + } finally { entry.busy = false; } + throw new Error("native_instruction_preparation_copy_changed"); + } + entry.instructionPreparationRunId = undefined; + entry.instructionWorkingCopy = input.instructionWorkingCopy?.checkpointWarm ? undefined : input.instructionWorkingCopy; entry.busy = true; entry.ownerToken = warmSessionOwnerToken; entry.environmentId = @@ -8729,6 +8841,8 @@ async function executePaperclipNativeSessionWithinScope( existing.session = session; } else warmNativeSessions.set(warmSessionId, { + instructionWorkingCopy: input.instructionWorkingCopy?.checkpointWarm ? undefined : input.instructionWorkingCopy, + instructionRoot: input.instructionWorkingCopy?.root, agentId: input.execution.binding.agentId, managedAiCredentialIdentity: input.managedAiCredentialIdentity, githubAuthenticationMode: @@ -8756,7 +8870,7 @@ async function executePaperclipNativeSessionWithinScope( lastActivityAt: new Date().toISOString(), }); const owner = warmNativeSessions.get(warmSessionId); - if (owner && input.instructionWorkingCopy?.runId) owner.instructionCopy = { + if (owner && input.instructionWorkingCopy?.runId && input.instructionWorkingCopy.checkpointWarm) owner.instructionCopy = { runId: input.instructionWorkingCopy.runId, root: input.instructionWorkingCopy.root, targetIdentity: instructionTargetIdentity(input.runnerExecutionTarget), collectStopped: input.instructionWorkingCopy.collectStopped, }; @@ -9453,7 +9567,8 @@ async function executePaperclipNativeSessionWithinScope( .catch(() => undefined); throw error; } - const collectedByOwner = ownedSession?.instructionCopy?.collectStopped === instructionCopy?.collectStopped; + const collectedByOwner = ownedSession?.instructionCopy?.collectStopped === instructionCopy?.collectStopped + || ownedSession?.instructionWorkingCopy?.collectStopped === instructionCopy?.collectStopped; if (collectInstructions && ownedSession) { // Keep the unchanged warm path intact. A changed private instruction copy // requires the existing checkpoint-and-close boundary before collection. @@ -9792,7 +9907,7 @@ type RemoteProviderPackManifest = { distDigest: string; bridgeDigest: string; acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS; - providers?: Partial>; @@ -9981,9 +10096,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) @@ -11250,8 +11366,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"; @@ -11284,7 +11408,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; @@ -11663,7 +11787,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"], @@ -12916,9 +13040,12 @@ async function createRunnerdBackendWithinSessionClaim( ? { acpxAgent: input.execution.provider.agent, // Read only the server operator environment, never agent/runtime env. - acpxCandidateProfile: resolveAcpxQualification(input.execution.provider, process.env), + acpxCandidateProfile: input.execution.provider.agent === "pi" + ? undefined + : 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/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index 129ffdcf23..9acb7e6a09 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -14,6 +14,7 @@ import { createDb, heartbeatRunEvents, heartbeatRuns, + issueComments, issueRecoveryActions, issueRelations, issueThreadInteractions, @@ -42,7 +43,7 @@ import { import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js"; import { NativeRunCoordinatorStore } from "./native-run-coordinator-store.js"; -import { finalizeNativeRun } from "./native-run-finalizer.js"; +import { finalizeNativeRun, repairCommittedNativeChatResponse } from "./native-run-finalizer.js"; import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; import { claimNativeReviewExecutionLock, getNativeReviewAssignment } from "./native-review-participant.js"; @@ -974,7 +975,37 @@ describe("PaperclipControlPlanePort conformance", () => { expect(response.decision.chosenSource).toBe("final_agent_message"); expect(stored.findIndex((row) => row.eventType === "run.result.accepted")) .toBeGreaterThan(stored.findIndex((row) => row.eventType === "item.completed")); - await finalizeNativeRun({ db, runId: taskRunId, workspaceFinalizeStatus: "succeeded" }); + // A workspace recovery owner can commit before the live heartbeat reaches + // presentation. The file-preparation comment must not hide the real reply. + const [prepared] = await db.insert(issueComments).values({ + companyId: identity.companyId, issueId: taskIssueId, + authorAgentId: identity.agentId, authorType: "agent", createdByRunId: taskRunId, + body: "Prepared Continuity file for this response.", + }).returning(); + const receipt = { semanticToolReceipts: { file: { + operationId: "register_deliverable", result: { + commandId: "deliverable-prepared:attachment-1", disposition: "applied", + attachmentId: "attachment-1", entityRefs: ["attachment-1", prepared!.id], + }, + } } }; + await db.update(heartbeatRuns).set({ + contextSnapshot: { skipIssueComment: true }, + resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify(receipt)}::jsonb`, + }).where(eq(heartbeatRuns.id, taskRunId)); + await finalizeNativeRun({ db, runId: taskRunId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); + const repair = () => repairCommittedNativeChatResponse(db, { + companyId: identity.companyId, issueId: taskIssueId, runId: taskRunId, + }); + expect(await repair()).toBe(false); + await db.update(heartbeatRuns).set({ contextSnapshot: { externalChatContinuation: true } }) + .where(eq(heartbeatRuns.id, taskRunId)); + expect(await repair()).toBe(false); + await db.update(heartbeatRuns).set({ contextSnapshot: {} }).where(eq(heartbeatRuns.id, taskRunId)); + expect(await repair()).toBe(true); + expect(await repair()).toBe(false); + expect(await db.select({ body: issueComments.body }).from(issueComments) + .where(eq(issueComments.createdByRunId, taskRunId)).orderBy(asc(issueComments.createdAt))) + .toEqual([{ body: prepared!.body }, { body: finalText }]); await expect(port.completeRun({ result: taskResult, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, 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/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 3296f525a5..11eb8d808a 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -116,16 +116,14 @@ describe("resolveNativeRuntimeMode", () => { })).toThrow(expect.objectContaining({ code: "paperclip_runner_opencode_model_invalid", })); - expect(() => resolveNativeRuntimeMode({ - ...eligible, - adapterConfig: { - provider: "acpx", - acpxAgent: "pi", - model: "openrouter/deepseek/deepseek-v4-flash-0731", - }, - })).toThrow(expect.objectContaining({ - code: "paperclip_runner_acpx_agent_unavailable", - })); + for (const acpxAgent of ["copilot"]) { + expect(() => resolveNativeRuntimeMode({ + ...eligible, + adapterConfig: { provider: "acpx", acpxAgent, model: "explicit-provider-model" }, + })).toThrow(expect.objectContaining({ + code: "paperclip_runner_acpx_agent_unavailable", + })); + } expect(() => resolveNativeRuntimeMode({ ...eligible, adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5" }, 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/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/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 735f73a0a4..a7c81b2b03 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 = @@ -145,10 +147,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/board-attachment-receipts.spec.ts b/tests/e2e/board-attachment-receipts.spec.ts index de7fb5cbde..649758e26e 100644 --- a/tests/e2e/board-attachment-receipts.spec.ts +++ b/tests/e2e/board-attachment-receipts.spec.ts @@ -27,6 +27,13 @@ async function body( } async function setup(page: Page, request: APIRequestContext, classic: boolean) { + // Announcements can appear after navigation or reload and cover the classic + // composer's attachment button. Dismiss through the same UI as a Board user. + await page.addLocatorHandler( + page.getByRole("complementary", { name: "Paperclip announcements", exact: true }) + .getByRole("button", { name: "Dismiss announcement", exact: true }), + async (dismiss) => { await dismiss.click(); }, + ); const company = await body<{ id: string; issuePrefix: string }>( await request.post("/api/companies", { data: { name: `Board receipt browser ${randomUUID()}` }, 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 4113257415..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 @@ -309,6 +337,470 @@ current and incoming files and applies the run edits against the reviewed curren directory hash. All three tasks' runs count toward billing and teardown. The suite is explicit-only. No private control-plane hooks or direct database writes are used. +## 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 +and editor tool through four durable browser cards, reloading before every answer. +Undisclosed text and independent workspace JSON prove delivery to the same live run. +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` 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 +file under `deny-all`. All runs count toward spend and teardown. Browser reload +is reconnect evidence only; these cases do not establish provider-death recovery. +They use public product APIs, real browser answers, and ordinary isolated files; +no database writes, private hooks, or fabricated provider results are allowed. + +### Pending native controller restart + +`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 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 +requires the public run's finalized `nativeWorkspaceSync` descriptor, baseline and +final-host hashes, bound workspace and public environment lease to match this +run/company/environment/provider lease and remote root. The checked host bytes +come through production stage-in/copy-back; this is explicitly `product_copyback` +provenance, not a sealed guest observation or an independently recomputed whole +host-workspace snapshot. Downloaded artifact bytes remain independently checked. + +Private `pi-file-seed.json`, `pi-file-observation.json` and `pi-file-evidence.json` +retain the seed, downloaded/workspace bytes, public publication receipts, checked hashes, +correlated tool identities and a before/after diff computed from independently +checked workspace bytes. This is not native diff presentation. The current +common tool projection omits raw arguments and reports typed `exitCode: null`; +the oracle checks the command title and completed lifecycle, without claiming raw +invocation arguments or a typed exit code. The sidecar converts Pi absolute file +locations into bounded workspace-relative display targets; the oracle requires +the exact relative filename, but that display value is not file-access authority. +Restoring raw arguments, typed exit code and native diff presentation remains a +capability follow-up. + +Wrong or missing lifecycle, byte, registration, download or recovery evidence is +a candidate failure; transport/infrastructure failures retain the existing +harness classification. Positive and plausible-wrong/missing-evidence unit +calibrations do not count as paid qualification. All automatic retries stay zero. + +`native-pending-provider-death` adds one real Daytona-only Product journey. +After the native input is publicly durable and still unanswered, the existing +owned remote observer admits the exact Pi child through its verified wrapper +parent, source-pinned closure files, executable inode, workspace, run/lease/session +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 +failure. The permanent failed-terminal recovery projection must put the owned +issue in Blocked, proved through API and browser and retained through cleanup. +The original durable card must expire, retain zero answers and lose its browser +answer controls; any supersession must name the separate fallback card. +Both public stale-response APIs must reject the old answer. Any production-created +`wake_assignee` fallback is separately identified and remains unanswered; it is not +a restored native callback. The sealed observer proves no continuation marker was +created and all owned processes retired before public lease deletion. The company +must retain exactly the original run through cleanup. + +Local provider-death remains excluded. The Python admission tests include a +real pidfd calibration against a synthetic title-changing child on native Linux; +the normal E2E unit wrapper invokes it with no provider credentials or network. +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 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. + +Both cases create a task through the browser and select `approve-reads` and +`per_turn` through the public agent API before startup. They retain +`paperclip.e2e.pi-control-pending.v1` while an exact native Pi write and its +permission card are pending in one run/turn/session/source. The card's native +tool ID is joined to the canonical execution ID using the existing runnerd +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 +caller-owned intent and acknowledgment audit IDs. Normal completion, a prior +permission decision, an expired request, and unacknowledged cancellation fail. +Only after cancellation does it attempt a stale **decline**, which must return +409. It never sends an allow decision. The task remains In Progress, with one +cancelled run and no automatic continuation. + +Steering submits a random marker only in a browser comment after the permission +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 +browser denies the original write only after acknowledgment while the request +still remains pending. Success requires correlated native denial, the random +marker as the persisted and visible final response, Done, and one succeeded +run. Merely echoing the comment in the transcript or scheduling another turn +cannot pass. Native `pi/follow_up` and durable native queue state are not tested. + +Both cells require independent absent-target and zero-mutation evidence plus +owned process retirement. Local observation runs through cleanup. Daytona uses +the existing authenticated lease observer with exact image/executable pins, +fresh baseline/pending observations and its owned-process retirement seal; +retrieving that seal later does not claim later filesystem surveillance. +Missing cleanup fails the result. Provider-death recovery is outside these +cases. All screenshots and receipts use the existing evidence/redaction/result +pipeline; native USD may remain unknown and estimates remain distinct. + +## Copilot native protection + +The explicit-only `copilot-protection` suite has two cases on local and Daytona, +each with one run (120s +provider timeout, 300s attempt budget). `native-permission-deny-write` denies one +exact native edit through its browser card, waits for the delivered rejection and +failed tool, then cancels through the public run API. Its expected outcome is a +cancelled run and an unfinished task, not successful task completion. +The retained `paperclip.e2e.copilot-denial-settlement.v3` proof separates the exact +provider terminal from the audited controller Stop. It records either +`provider_cancelled_or_interrupted` or `provider_completed_observed_before_stop`. +The latter requires the exact terminal row to be returned by the operator API +before Stop dispatch. The fixture awaits retention of its scoped row-hash receipt +before sending Stop, then matches that receipt against the final durable rows. +The same normalized session/source stream and a later source sequence than the +failed edit are required. A fixed 2s observation window, inside the existing case +deadline, allows natural completion. A normal terminal first seen after Stop is +insufficient evidence. Database createdAt is transaction-start metadata; it cannot +prove that an event committed before Stop acknowledgement. It cannot establish active-turn cancellation; +a cancelled/interrupted terminal also does not by itself prove Stop reached active +work. Dedicated cancellation coverage must retain its active-operation evidence. +Missing, ambiguous, failed or foreign terminals and incomplete Stop receipts fail. +This distinction does not regrade earlier failed attempts. The case rejects +extra native operations/runs and observes the absent target through process +retirement. Filesystem event loss or an unexplained parent-directory timestamp +change makes no-effect coverage incomplete; stat polling alone cannot pass. + +`attached-async-settlement` starts a fixed finite command with explicit async mode +and `detach:false`, then asks the model to attempt immediate completion. Its marker +is a private diagnostic sentinel, not a requested user deliverable. The task keeps +empty completion evidence and forbids publication, extra commands, and waiting tools. +Suite definition v6 preserves this early-completion stress and every settlement +assertion. A retained v5 Daytona failure exposed a bootstrap wording conflict: +comma-separated prohibitions left “create or modify any file” as a positive clause +in the continuation objective's file-delivery classifier. Each bootstrap prohibition +now has its own explicit “Do not” sentence. The production delivery policy is unchanged; +actual requested file outputs still require accessible delivery evidence. That failed +attempt remains failed, and v6 requires fresh live qualification. A one-shot +private socket in the tested environment accepts only the fixture nonce, never an executable or command; +the test owns/reaps a predeclared child and records its actual exit before releasing +the provider-launched client. The independent marker, client retirement, native shell +linkage and actual durable turn terminal must agree. Fixture resources close in a +finally block. This establishes the tested finite attached-command behavior, not all +background modes or detached-process settlement. + +Both cells consume bounded, origin-correlated `copilot_tool_evidence_v1` notices +persisted through the ordinary run-event API. Missing, redacted, ambiguous or +explicitly incomplete notices fail qualification. Old runtime artifacts therefore +cannot qualify these cases. No prompt/title substitutes for native input, no raw +command or arbitrary tool input is added to production event payloads, and no private +runner hook or database write is used. The cases are registered but require a newly +built source/pack and separately authorized paid execution before claiming Product +qualification. Full restrictive-workflow completion remains separate: the negative +case never auto-approves an uncorrelated later MCP permission. + +The Copilot protection cells explicitly select `per_turn` lifecycle. Their read-only +process journal accepts the API runner PID only after checking its OS start time, +process group, exact run ID argument and `per_turn` argument; it never treats a +retained warm daemon as a leaked per-run process or signals an API-provided PID. +Directory-watch coverage also rejects parent device/inode replacement or removal. + + +The manual `cursor-native` suite has four cases on each of local and Daytona with +one expected provider run each and a 120-second provider deadline. Browser +choices, rejection feedback, native origin/decision receipts, workspace checks +and provider retirement are independently checked. Failed or missing cleanup +assertions fail the final report even if the native interaction passed. No +native callback, private-HOME artifact export, or paid qualification is inferred +from a semantic question or plan result. + + +### Remote native proof scope + +Remote native cells require the exact authenticated lease, run, immutable image +and executable bindings documented in [the runbook](README.md#remote-native-evidence-and-warm-continuation). +Action publication follows observer readiness and a saved baseline. The observer +seals before environment destruction and retains file bytes on the host; no +post-deletion RPC or host-copy-back evidence may satisfy remote no-effect or +retirement checks. Runtime-internal files are an explicit scoped exclusion. +`human-permission-denial` requires native request/tool correlation, the browser's +exact Decline, delivered denial and failed write, and unchanged file evidence +through retirement. All normal project and company boundaries apply. + +Cursor and Copilot may exempt a bootstrap read only when every native notice for +that completed tool origin carries the single-path attestation matching the +observer's exact random action file. The passive projector derives this digest +from one explicit scalar native input path, rejects ambiguous/multiple paths, +and checks subsequent input/location updates for changes. Durable canonical +execution receipts must match the same run, turn, execution identity, status, +and order; any explicit canonical target must agree. Missing or conflicting +attestations fail qualification. PRP retains only the first location and terminal +updates may omit it, so canonical targets alone cannot prove bootstrap ownership. +Neither tool titles nor output text supplies path evidence. + +Denial ordering uses canonical request, decline-resolution, delivery, failed-edit +and terminal source sequences. Sample checkpoints retain the exact run/turn/source +cursor. File samples and continuous-watch coverage compare only observer-local +times. Provider emission, browser click and server persistence clocks are never +compared to each other. Final remote samples use the sealed, independently verified +process-retirement receipt; their timestamps are not relabeled as host time. +This denial case does not qualify Stop during a definitely pending native request. +That active-turn cancellation boundary needs a separate live case. The attached +async-command oracle is unchanged by this denial-only correction. + +### Correlated native Stop API + +The board-only `POST /api/heartbeat-runs/:runId/cancel` accepts an optional +`cancellationRequestId` UUID for native runs. Company access checks still apply. +The server reserves it under the run-row lock before dispatch, uses +`native-cancellation:` as the durable intent ID, and returns HTTP409 for +an earlier or different caller intent, an earlier uncorrelated Stop, or a +terminal run without that same reserved intent. Malformed UUIDs return HTTP400. +Repeating the same UUID from the same board actor is idempotent. A different +actor receives HTTP409; local trusted board uses the `local-board` user ID. +Default clients may omit the field; they preserve and join an existing reserved +intent rather than overwrite it. + +The denial fixture generates its UUID before observation, retains it in +`paperclip.e2e.copilot-pre-stop-observation.v2`, and requires the same intent in +the response and final `paperclip.e2e.copilot-denial-settlement.v3` receipt. +It refuses a non-running controller or existing Stop marker before dispatch. +The completed-provider branch still requires a running controller that this +request can stop; it does not accept a no-op Stop of an already terminal run. + +## Definitely-active native Stop + +`native-active-stop` / `pending-permission-stop` has four explicit-only cells: +Cursor and Copilot, each local and Daytona. Its `native_active_stop` flow retains +`paperclip.e2e.native-active-stop-pending.v2` from the public API while exactly one +native permission remains pending, the exact controller run is running, and no +Stop or answer marker exists. The receipt independently binds native session, +normalized session, turn, source instance, request/tool IDs, source sequences and +canonical row hashes. An awaited artifact write and fresh pending reread precede +Stop dispatch; process-monotonic timestamps describe only these local observer +boundaries. Remote provider clocks and database transaction timestamps never +establish that ordering. The atomic caller UUID fence rejects an earlier racing +Stop instead of borrowing its acknowledgement. + +Suite version 3 accepts either canonical card/tool-start arrival order. The +exact native origin, canonical tool start and unanswered permission must all +exist in both pre-Stop API observations. The v2 pending receipt adds the native +origin and tool-start row hashes and source sequences. The fresh reread and +settlement must preserve those exact rows; a later tool start cannot backfill +missing pre-Stop evidence. Command/path, request, tool, turn, session and source +checks remain strict. Cursor's native evidence projector still requires the +exact tool origin before it can emit correlated permission evidence. Copilot +emits permission evidence immediately, so its permission notice may also precede +the native tool notice. Calibration tests exercise both actual projectors in +both input orders. Neither policy claims the original ACP wire order of a live +attempt. Old v1 receipts are not valid inputs to the +new grader. Earlier paid failures retain their original definition and grade. + +Suite version 4 and `paperclip.e2e.native-active-stop-settlement.v2` accept only +`pending_permission_cancelled`: the Product harness's exact +`runtime_request.cancelled` closure (`reason: turn_terminal`, matching request, +item and turn, no answer), then one exact `turn.cancelled` (`status: cancelled`, +`error: null`), plus the same scoped caller-owned native Stop acknowledgement. +Both events must belong to the retained source/session/turn and follow all four +pre-Stop evidence rows. The harness consumes raw backend request closures and +projects its own pending-request outcome before the terminal. Raw backend-only +`provider`, `requestType`, `replayAllowed` and `providerTurnId` fields are not +required from that Product projection. Replay refusal is independently checked +through the stale public answer below. A generic terminal without the retained +request and exact acknowledged caller UUID is insufficient. The v2 settlement +receipt records this changed oracle; historical v1 receipts and failed attempts +retain their original grades. Missing, duplicate, foreign, failed, +interrupted or normal terminal evidence fails. Only after this proof does the +fixture attempt a stale public answer, requiring HTTP409 and an unanswerable +browser card. It retains one cancelled run, issue `in_progress`, exact target +absence through continuous observation, and all observed owned descendants +retired. Local files require four fresh observations through cleanup. Daytona +instead retains two live snapshots (baseline and pending) plus one automatic +owned-process-retirement seal, with a continuous zero-mutation watcher and the +same complete root/descendant journal. Since suite version 2, this is retained as +`paperclip.e2e.native-active-stop-remote-retirement.v1`; it explicitly records +`filesystemAfterRetirementObserved:false`. The per-turn observer seals itself +when the owned tree retires, before the sandbox is released. Reading that receipt +later is not a fresh post-UI or post-cleanup filesystem observation. Relabeled, +reused, missing or out-of-order samples fail. The stale-answer and rendered UI +checks remain separate, followed by fresh API cancellation/no-extra-run checks +both after UI and in cleanup. Unproven cleanup fails independently. Only fully +attested bootstrap reads may precede the one tested operation; alternate operations or attempts are rejected. + +The existing `copilot-protection` denial remains distinct: rejecting a permission +before Stop does not exercise this pending-callback boundary. This new suite has +pure calibration and wiring tests, not a paid qualification result. Provider +process death is not simulated by substituting the chat runner-worker crash hook. + +### Copilot attached semantic completion evidence (suite 8) + +Attached settlement now requires one native `paperclip_finish` lifecycle joined +to an invocation-captured, bounded Paperclip bridge receipt by exact call, input +and result digests in the same run/turn/native session and durable source stream. +A matching display title is not authority. The exact proposed completion input +must match a control-plane `run.result.accepted` body and the requested summary. +Transport `returned` alone, including a returned rejection, cannot pass. The +visible exact terminal comment remains an independent assertion. Both local and +Daytona cases reject extra native operations; existing attested bootstrap reads, +command settlement, process retirement and marker checks remain required. + +The receipt reader fails closed when the production native receipt projection is +missing, partial, duplicated or outside its bounds (including 256 KiB for the +whole native rawOutput, which may repeat text). This oracle depends on the new +production semantic-receipt contract and does not regrade earlier failed runs. +The bridge call hash identifies the invocation and its native receipt. It is not +inferred from `run.result.proposed.itemId`, which can identify the run instead. +The v2 receipt keeps the raw invocation input digest separate from the normalized +input digest captured by that same authenticated invocation after production +validation. Raw arguments may omit schema, artifacts and attention requests; +the production validator supplies those defaults. The oracle never reconstructs +or normalizes a body to make it match. The raw call/input/result hashes still join +the native lifecycle; its normalized input hash must also match the authority. +Exactly one proposed body must hash to that normalized digest and equal exactly +one accepted body; identical duplicate proposals or receipts still fail. + +Suite 8 writes `paperclip.e2e.copilot-semantic-completion.v2` and requires +`paperclip.semantic_tool_receipt.v2` under `paperclip_semantic_tool_receipt_v2`. +The authority has exactly eight bounded details. An explicit null normalized +digest is diagnostic only and cannot qualify a successful finish. Legacy v1 +receipts do not qualify fresh runs. This fixture depends on the corresponding +production receipt and durable-redaction fixes; it does not supply them. The shell +result is one separate complete read lifecycle: pending and any progress name the +started shell, and its successful terminal also names the original command. A +completed-only read or a second shell read cannot inherit that exemption. + +Denial case version 5 and denial settlement schema v3 are unchanged. + ## Direct blocker fixtures `blocker-cases.ts`, `blocker-fixtures.ts`, `blocker-flow.ts`, and @@ -359,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 d24e660c24..a87cd9d695 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -107,6 +107,25 @@ The launcher always sets `PAPERCLIP_ANNOUNCEMENTS_ENABLED=false` for its isolate instances so announcement panels do not obscure screenshot evidence. No shell or workflow configuration is needed, including for Daytona cells. +## Pi controls (explicit only) + +`--suite pi-controls` selects four candidate Pi cells: pending-permission Stop +and browser same-turn steering, each local and Daytona. Discover without +credentials using `pnpm test:e2e:runner -- --list --suite pi-controls`. For an +authorized bounded attempt select, for example, +`--id pi-controls.runner-acpx-pi.local.pending-permission-stop +--max-automatic-retries 0 --max-parallel 1`. + +The [fixture contract](FIXTURES.md#pi-active-controls) requires exact pending +native-write evidence, caller-owned control acknowledgments, independent +no-effect/retirement proof and the actual production task UI. Daytona also +requires the existing immutable image and executable digests. These cells do +not establish native follow-up queue persistence or provider-death recovery. +The current Pi matrix has 26 explicit cells (13 local and 13 Daytona), including +these four controls, the pending-native-question restart cases, and one Daytona +provider-death case. Pi's profile +remains pending; catalog presence is not live qualification. + ## Provider-free browser bootstrap regression `pnpm test:e2e:runner:browser-support` includes a wide development-module graph @@ -250,6 +269,7 @@ Shell variables take precedence over the local file. The recognized names are: - `CURSOR_AUTH_TOKEN` (extended Cursor candidate) - `COPILOT_GITHUB_TOKEN` (extended Copilot candidate) - `PAPERCLIP_E2E_DAYTONA_IMAGE` (Daytona only) +- `PAPERCLIP_E2E_DAYTONA_NODE_SHA256` and `PAPERCLIP_E2E_DAYTONA_RUNNERD_SHA256` (native Cursor/Pi/Copilot Daytona fixtures; exact `sha256:...` executable digests from that image) The image must be an immutable `image@sha256:...` reference. The launcher reports missing variable names but never prints values. It passes raw provider @@ -1350,7 +1370,26 @@ lockfile from its own trusted checkout, never from the tested branch. The restart supervisor starts Paperclip with the TypeScript loader in the same Node process it owns. A forced stop therefore cannot leave an old controller -alive to stop the embedded database after the replacement starts. +alive to stop the embedded database after the replacement starts. On Unix, the +server has its own process group so Playwright's wrapper-group shutdown cannot +signal it independently. Signal handling, restart cleanup, and final cleanup +share one stop operation per server: one graceful signal, a 30-second wait, then +SIGKILL to revalidated owned groups and a 5-second exit wait if needed. The +wrapper retains observed descendants across an early server exit and checks +PID/start identity before group signals. A failed cleanup can resume its saved +phase without another graceful signal. Windows retains direct-child signaling. +This prevents a second graceful signal from interrupting asynchronous warm-session +retirement. + +Launcher cancellation first signals its outer group, so Playwright and the wrapper +own graceful server shutdown. It allows 45 seconds for that chain before bounded +5-second forced cleanup of revalidated descendants. Normal launcher exit uses the +same retained descendant inventory. Zombies count as stopped; an unavailable or +uncertain identity inspection triggers bounded direct-child-only retirement and +still fails the tree audit. Failed cleanup preserves the temporary state. Graceful +owners are selected from the same validated table used for delivery; ESRCH allows +selection of a surviving owner, while a delivered signal covers that owner's +existing descendants through the grace window even if the owner exits first. Everyday restart and Stop scenarios exempt only their recorded cancellation, graceful-shutdown interruption, or process-loss outcome. A later adapter error @@ -1541,6 +1580,318 @@ The separate Runner Evals `extended-harnesses` campaign lives in the private `paperclip-evals` repository and grades semantic protocol behavior against the mock control plane. Neither suite substitutes for the other. +### Pi native Product fixtures + +The separate `pi-native` suite defines ten explicit Pi cells: native questions, +agent-file persistence, browser permission denial and controller restart with an +unanswered native question on local and Daytona, plus +a local-only automatic `restrictive-denial` case and one Daytona-only +`native-pending-provider-death` case. The latter cannot use the remote +setup read under `deny-all`, so it is explicitly excluded there. It uses the +pinned candidate profile and exact OpenRouter model. It is excluded from `--all`, +never automatically retries, and retains qualification as pending until live proof. +Reserve and reconcile each paid cell just as for `extended-harnesses`. + +Provider death is a real, one-run paid Product journey, distinct from controller +restart and Stop. The owned Linux observer pins the verified wrapper ancestry, +closure and executable inode before signalling exactly its unique Pi child through +a pidfd. Pi overwrites its argv with `process.title`; entrypoint attribution is +explicitly parent-attested, not an original-child-argv claim. The fixture requires +production-generated non-replayable expiry, an expired original browser card, +rejection of stale answers by both public APIs, no continuation file effect, and +no second run. Any durable `wake_assignee` fallback stays separately identified +and unanswered. Full process/run/lease evidence remains private. Local provider +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. + +```sh +pnpm test:e2e:runner -- --list --suite pi-native +pnpm test:e2e:runner -- --id pi-native.runner-acpx-pi.local.native-questions +``` + +Native questions exercise the runner-owned `paperclip_native_question` tool and +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. + +The manual `copilot-protection` suite selects two Copilot candidate cases on +each of local and Daytona (four cells). Discover them with `pnpm test:e2e:runner -- --list --suite copilot-protection`. +The denial case keeps provider-turn settlement separate from controller run Stop. +A normal provider completion observed by API before Stop dispatch is not active-turn +cancellation coverage; denial, no-effects, run cancellation and retirement remain +required. Earlier failed attempts retain their original grade. +See [Copilot native protection](./FIXTURES.md#copilot-native-protection) for the +expected cancelled negative test, finite attached-process oracle, evidence limits, +and required rebuilt runtime. Registration is not a qualification claim. +Copilot protection suite definition v6 clarifies the async marker as a private +diagnostic sentinel. Separate negated bootstrap sentences avoid an unintended +file-delivery objective; immediate completion stress and strict one-command and +settlement checks remain unchanged. Historical v5 failures are not regraded. + +Local human-denial cases for Copilot, Cursor and Pi create a fresh fixture-owned +`pc-denied-*` directory before dispatch and watch its parent identity throughout +the attempt. This keeps unrelated workspace startup writes outside the target +watch. Exact prompt/native target correlation, complete watcher coverage and +zero target mutations remain required. Evidence includes coverage failure +reasons, parent device/inode changes and a bounded timestamped event journal; +an absent final file cannot hide an incomplete watch or transient mutation. +Remote cases retain their existing sealed observers. + + +### Cursor native interactions (candidate) + +The explicit-only `cursor-native` suite has eight local/Daytona cells exercising +the native Cursor +question callback, plan rejection/revision/acceptance, plan cancellation, and +permission denial across browser reload. List it with +`pnpm test:e2e:runner -- --list --suite cursor-native`. The fixture configures +`acpxSessionMode` before the run: Plan for question/plan callbacks, Agent for +write denial. Native request origin, exact decision bytes and post-write +response delivery are required; semantic-tool interactions do not satisfy these +checks. The denial case also requires independent filesystem observations and +an API-bound process journal after provider retirement, before workspace +removal. Complete server and database cleanup remains the outer supervisor's +responsibility. These cases remain unqualified until paid runs pass. + +The full native plan text is retained in the interaction card. Exporting a plan +file from Cursor's private HOME as a downloadable Paperclip artifact remains a +separate capability gap; these callback tests do not claim that export works. + + +### Remote native evidence and warm continuation + +All three native suites use an operator-published instruction file to withhold +actual work until independent observers are armed inside the exact owned Daytona +sandbox. The initial task only reads that file. Explicitly typed setup reads are +separate from the native operation under test; unknown or extra writes/commands +still fail. SDK 0.203.0, immutable image and executable digests, public run/lease +ownership, sandbox labels and workspace sentinel are verified before execution. + +Bootstrap admission uses the existing authored case deadline, including cold +snapshot provisioning, with a 64-second minimum setup reserve subtracted before +admission: 10 seconds for lease revalidation, 12 for the runtime-ready RPC, +27 for installation, and 15 for teardown. A lease discovered later is not admitted +with only installation/teardown time left. This does not impose a separate +20-second lease deadline or extend the case budget. +After lease admission, a read-only probe waits for the exact pinned runner +process and runtime directory before installing the observer once. This uses +the remaining case budget while preserving 27 seconds for installation and +15 seconds for teardown. Installation and the first observation recheck the +same process and directory identities before releasing the task instructions. +Startup evidence retains only closed RPC phase/error codes; detached observer +stderr is not collected, and a failed installation is not retried. +Every poll rechecks task/run ownership and run status, and requires exactly one +unambiguous active lease for that run and task. A stopped run fails as soon as its read completes, even if another endpoint +fails or remains pending. At most three reads are outstanding, each with a +transport timeout bounded by admission and 30 seconds. Late responses cannot +change saved evidence or start another poll. Missing reads +cannot admit a lease; successful ownership/status reads are retained. +Admission and observer setup failures save bounded, allowlisted read status, +run-stage, lease-count and classified read-failure evidence; +this evidence does not infer a provider-side cause. Admission GET failures are normalized once to fixed endpoint labels, generic +messages and explicit failure classes. Typed HTTP status and pinned transport +timeout shapes preserve infrastructure classification without response bodies, +URLs or original cause chains in thrown errors. Malformed responses never admit. A recovered API failure does +not reclassify a later successfully observed state timeout. +The initial provider's instruction-file read window remains 20 seconds after +provider startup, and no action is published before the observer is armed. + +The observer watches registered targets (including transient create/delete), user +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, 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. + +A sealed receipt and bounded file bytes must reach the host before lease cleanup. +Missing receipts, incomplete watches, ambiguous or reused process identities, and +unproven retirement fail the cell. Sandbox deletion and host file copy-back cannot +substitute for remote proof. The observed PRP environment identifier is retained +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. +Daytona also requires one continuously running sandbox and created/resumed/resumed +lease history. Each turn has a 120-second limit; each three-run cell has a +420-second budget and must be reserved accordingly. Existing Codex warm cells +remain separate. All these suites are excluded from `--all`, never automatically +retry, and remain pending qualification until their paid evidence passes. + +Denial ordering uses canonical request, decline-resolution, delivery, failed-edit +and terminal source sequences. Sample checkpoints retain the exact run/turn/source +cursor. File samples and continuous-watch coverage compare only observer-local +times. Provider emission, browser click and database transaction clocks are never +compared to each other. Final remote samples use the sealed, independently verified +process-retirement receipt; their timestamps are not relabeled as host time. +This denial case does not qualify Stop during a definitely pending native request. +That active-turn cancellation boundary needs a separate live case. The attached +async-command oracle is unchanged by this denial-only correction. + +Copilot denial settlement v3 (suite definition v5) requires a retained pre-Stop API +observation. The fixture awaits that artifact write before dispatching Stop and +matches exact source identities and row hashes against final evidence. It waits +at most 2s for natural settlement within the existing deadline. Normal completion +first observed afterward cannot pass. A row's createdAt is transaction-start time, +not proof of commit order. Older failed artifacts lack this observation and cannot +establish a completed-before-Stop causal boundary; they remain failed. + +The Copilot denial Stop cell requires the controller run to remain `running` +until its Stop request. A provider `turn.completed` can satisfy its completed +branch while that controller run remains active. An already `succeeded` or +`cancelled` controller run cannot satisfy this Stop-specific cell. Such a result +is not evidence that the provider bypassed denial; natural completion without +an active controller Stop needs separate coverage. + +Suite version 5 binds a fresh `cancellationRequestId` UUID in the retained +pre-Stop observation to the public cancel request and its exact durable native +intent. It rejects prior startup/Stop markers before dispatch and a competing +request that wins after the last read. The server atomically reserves the caller +identity; the fixture does not infer ownership from timestamps or HTTP success. + +## Stop during an unanswered native permission (explicit only) + +`--suite native-active-stop --task pending-permission-stop` selects Cursor and +Copilot on local or Daytona, one run per cell. These four cells are excluded +from `--all`. They use `approve-reads`, per-turn lifecycle and Cursor Agent mode; +provider timeout is 120s and the attempt budget is 300s. The fixture asks for one +exact native write, observes its unanswered permission card, then sends the +public Stop request with a freshly retained caller UUID. It never denies or +approves that callback before Stop. + +Suite version 3 accepts a permission card before or after its correlated tool +start. Both the exact native origin and canonical start must already exist with +the unanswered permission when the fixture retains and rechecks the pre-Stop +API snapshot. The v2 pending receipt binds all four rows by hash and source +sequence through settlement. Missing, changed or replayed evidence cannot be +filled in after Stop. This is a new grader definition; old v1 pending receipts +and failed paid attempts are not regraded. No original provider wire order is +inferred from API insertion or display order. +Native notice ordering follows each real projector: Cursor waits for its exact +command origin before emitting permission evidence; Copilot can emit the +permission notice first. Both still require the exact tool origin before Stop. + +Suite version 4 uses the normalized Product cancellation contract and writes +`paperclip.e2e.native-active-stop-settlement.v2`. It requires the exact request, +item and turn closure with `reason: turn_terminal`, followed by the same stream's +`turn.cancelled` with `status: cancelled` and `error: null`. The Product harness +emits these fields; raw backend-only cancellation metadata is not its contract. +The retained pending v2 receipt, caller UUID and durable scoped acknowledgement +remain mandatory. This fixes an oracle mismatch observed after a real Stop was +acknowledged; the failed attempt remains failed and needs a fresh live run. + +Passage requires a canonical cancelled request and cancelled provider turn, +exact caller-intent acknowledgement, an unfinished task, rejection of a later +stale answer, no follow-up run, continuous target no-effect observation and +owned process retirement. Normal completion and interrupted/failed turns do +not qualify this case. Native permission cards use runtime requests; ordinary +issue-interaction rows are not substituted for their authority. Pending and final +screenshots, `native-active-stop-pending.json`, `native-active-stop-settlement.json` +and cleanup evidence retain the boundaries. Daytona also requires the exact +owned lease and sealed remote observer proof before sandbox deletion. Suite +version 2 introduced remote filesystem coverage only through verified retirement +of the owned runner/provider tree, with no target or workspace mutations; it +does not call a later read of the seal a fresh observation. Local cases retain +four filesystem phases through cleanup. Remote UI/stale-answer checks and final +API checks are separate from this lifetime-bound filesystem proof. + +This suite adds an active-work cancellation oracle; it does not reinterpret +older denial or cancellation results. Current live qualification is pending. +Provider death during a pending callback remains a separate uncovered case: +the existing chat worker-crash hook targets a runner worker and cannot establish +safe ownership of the native provider process in both environments. + +### Copilot attached semantic completion evidence (suite 8) + +Attached settlement now requires one native `paperclip_finish` lifecycle joined +to an invocation-captured, bounded Paperclip bridge receipt by exact call, input +and result digests in the same run/turn/native session and durable source stream. +A matching display title is not authority. The exact proposed completion input +must match a control-plane `run.result.accepted` body and the requested summary. +Transport `returned` alone, including a returned rejection, cannot pass. The +visible exact terminal comment remains an independent assertion. Both local and +Daytona cases reject extra native operations; existing attested bootstrap reads, +command settlement, process retirement and marker checks remain required. + +The receipt reader fails closed when the production native receipt projection is +missing, partial, duplicated or outside its bounds (including 256 KiB for the +whole native rawOutput, which may repeat text). This oracle depends on the new +production semantic-receipt contract and does not regrade earlier failed runs. +The bridge call hash identifies the invocation and its native receipt. It is not +inferred from `run.result.proposed.itemId`, which can identify the run instead. +The v2 receipt keeps the raw invocation input digest separate from the normalized +input digest captured by that same authenticated invocation after production +validation. Raw arguments may omit schema, artifacts and attention requests; +the production validator supplies those defaults. The oracle never reconstructs +or normalizes a body to make it match. The raw call/input/result hashes still join +the native lifecycle; its normalized input hash must also match the authority. +Exactly one proposed body must hash to that normalized digest and equal exactly +one accepted body; identical duplicate proposals or receipts still fail. + +Suite 8 writes `paperclip.e2e.copilot-semantic-completion.v2` and requires +`paperclip.semantic_tool_receipt.v2` under `paperclip_semantic_tool_receipt_v2`. +The authority has exactly eight bounded details. An explicit null normalized +digest is diagnostic only and cannot qualify a successful finish. Legacy v1 +receipts do not qualify fresh runs. This fixture depends on the corresponding +production receipt and durable-redaction fixes; it does not supply them. The shell +result is one separate complete read lifecycle: pending and any progress name the +started shell, and its successful terminal also names the original command. A +completed-only read or a second shell read cannot inherit that exemption. + +Denial case version 5 and denial settlement schema v3 are unchanged. + The explicit-only `confirmation-replies` suite also includes `unanswered-question-return` for native Claude and Codex (three provider turns). The browser asks a saved color question, dismisses and reopens the fresh form, sends an unrelated message, verifies the reply while the original stays pending, reloads, reopens the history entry, submits Blue, and verifies the saved answer plus a later agent acknowledgement. After dismissing the fresh form and before and after reload, the history card is the only pending-question reminder; the composer has no duplicate pending-input badge. It checks that no tasks were created. Unique, UI-ready screenshots show each checkpoint; individual checks are included in the report. This is a bounded mechanical workflow check, not broader semantic answer-quality qualification. ## Direct blocker guidance @@ -1631,6 +1982,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/api.test.ts b/tests/runner-e2e/api.test.ts index c82eddfd94..4897dee5ff 100644 --- a/tests/runner-e2e/api.test.ts +++ b/tests/runner-e2e/api.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { APIRequestContext, APIResponse } from "@playwright/test"; -import { RunnerApi } from "./api.js"; +import { RunnerApi, RunnerApiHttpError } from "./api.js"; const response = (status: number, data: unknown = {}): APIResponse => ({ ok: () => status >= 200 && status < 300, status: () => status, json: async () => data, text: async () => JSON.stringify(data), url: () => "http://fixture.invalid/instructions", @@ -32,3 +32,22 @@ describe("fixture instruction revision fence", () => { expect(fixture.request.put).toHaveBeenCalledTimes(1); }); }); + + +it("passes a bounded GET timeout to the request transport without changing its response", async () => { + vi.stubEnv("PAPERCLIP_RUNNER_E2E_PORT", "3100"); + const get = vi.fn(async () => response(200, { status: "running" })); + const api = new RunnerApi({ get } as unknown as APIRequestContext); + await expect(api.get("/api/heartbeat-runs/run", { timeout: 1234 })).resolves.toEqual({ status: "running" }); + expect(get).toHaveBeenCalledExactlyOnceWith("/api/heartbeat-runs/run", { timeout: 1234 }); +}); + + +it("preserves GET HTTP status separately from diagnostic text", async () => { + vi.stubEnv("PAPERCLIP_RUNNER_E2E_PORT", "3100"); + const api = new RunnerApi({ get: vi.fn(async () => response(503, { error: "PRIVATE" })) } as unknown as APIRequestContext); + const error = await api.get("/api/environments/env/leases").catch(error => error); + expect(error).toBeInstanceOf(RunnerApiHttpError); + if (!(error instanceof RunnerApiHttpError)) throw new Error("Expected typed HTTP error"); + expect(error.status).toBe(503); +}); diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 6e049a4841..cc7254d49e 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, 2, 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, 2, 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(700); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(700); + expect(validateRunnerCatalog()).toHaveLength(724); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(724); 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 bae3099052..7ea255c204 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: 1, qualification: "pending", scheduling: "explicit-only", admission: "host-exact-candidate-and-model", authenticatedDiscoveryDate: "2026-09-28" }, + 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", @@ -1573,6 +1615,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, @@ -1611,7 +1654,8 @@ export function buildRunnerMatrix( suiteDefinitionHash: suiteDefinitionHash(suite), profile, environment, - task, + task: suite.id === "extended-harnesses" && profile.qualificationCandidate === "pi" && task.id === "file-edit-validate" + ? { ...task, buildPrompt: piFilePrompt } : task, groups: [ ...new Set([ ...suite.groups, @@ -1671,6 +1715,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-evidence.ts b/tests/runner-e2e/copilot-evidence.ts new file mode 100644 index 0000000000..023e9b3292 --- /dev/null +++ b/tests/runner-e2e/copilot-evidence.ts @@ -0,0 +1,62 @@ +/** Strict reader for bounded notices persisted through the public run-event API. */ +export interface CopilotToolNotice { + runId: string; sessionId: string; turnId: string; toolCallId: string; + observedAtMs: number; seq: number; + semanticOperationId?: string; semanticCallIdentitySha256?: string; semanticInputSha256?: string; semanticNormalizedInputSha256?: string | null; semanticResultSha256?: string; semanticOutcome?: "returned" | "error"; + stage: "tool" | "permission_requested" | "permission_delivered"; + status?: "pending" | "in_progress" | "completed" | "failed"; + operation?: "edit" | "execute" | "read"; target?: string; requestId?: string; + declineOffered?: boolean; outcome?: "allow_once" | "allow_always" | "reject_once" | "cancel"; + commandSha256?: string; readTargetSha256?: string; mode?: "sync" | "async"; detach?: boolean; + shellId?: string; commandToolCallId?: string; shellState?: "started" | "completed"; exitCode?: number; +} +const rec = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; +const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); +const enums = { stage: ["tool", "permission_requested", "permission_delivered"], status: ["pending", "in_progress", "completed", "failed"], operation: ["edit", "execute", "read"], outcome: ["allow_once", "allow_always", "reject_once", "cancel"], mode: ["sync", "async"], shellState: ["started", "completed"] }; +const names = new Set(["stage", "toolCallId", "status", "operation", "target", "requestId", "declineOffered", "outcome", "commandSha256", "readTargetSha256", "mode", "detach", "shellId", "commandToolCallId", "shellState", "exitCode", "semanticOperationId", "semanticCallIdentitySha256", "semanticInputSha256", "semanticNormalizedInputSha256", "semanticResultSha256", "semanticOutcome"]); +export function readCopilotToolEvidence(rows: readonly unknown[], expectedRunId: string): CopilotToolNotice[] { + const result: CopilotToolNotice[] = []; + for (const row of rows) { + const r = rec(row), envelope = rec(rec(r.payload).prpEvent), p = rec(envelope.payload); + if (r.eventType !== "provider.notice.recorded" || p.category !== "copilot_tool_evidence_v1") continue; + const origin = rec(p.provenance); + if (p.schema !== "paperclip.provider.notice.v1" || p.scope !== "turn" || envelope.schema !== "paperclip.prp.event.v1" || envelope.sourceKind !== "runner" || envelope.eventType !== r.eventType || envelope.runId !== expectedRunId || !id(origin.sessionId) || !id(origin.turnId) || origin.turnId !== envelope.turnId || !Array.isArray(p.details) || p.details.length > 20 || !Number.isSafeInteger(r.seq)) throw new Error("Invalid Copilot evidence binding"); + const fields: Record = {}; + for (const detail of p.details) { + const d = rec(detail); + if (d.name === "stage" && d.value === "evidence_incomplete") throw new Error("Copilot evidence is explicitly incomplete"); + if (!names.has(d.name) || Object.hasOwn(fields, d.name) || typeof d.value !== "string" || d.value.length > 1024 || d.value.includes("[REDACTED]")) throw new Error("Invalid Copilot evidence detail"); + fields[d.name] = d.value; + } + if (!id(fields.toolCallId) || !enums.stage.includes(fields.stage!) || origin.eventType !== fields.stage || origin.method !== (fields.stage === "tool" ? "session/update" : "session/request_permission")) throw new Error("Invalid Copilot evidence origin"); + const observedAtMs = Date.parse(envelope.emittedAt); + if (!Number.isFinite(observedAtMs)) throw new Error("Copilot evidence has no producer timestamp"); + const notice: Record = { runId: expectedRunId, sessionId: origin.sessionId, turnId: origin.turnId, toolCallId: fields.toolCallId, stage: fields.stage, observedAtMs, seq: r.seq }; + for (const [key, choices] of Object.entries(enums)) if (fields[key] !== undefined) { if (!(choices as readonly string[]).includes(fields[key]!)) throw new Error("Invalid Copilot evidence enum"); notice[key] = fields[key]; } + for (const key of ["requestId", "commandToolCallId"]) if (fields[key] !== undefined) { if (!id(fields[key])) throw new Error("Invalid Copilot evidence identity"); notice[key] = fields[key]; } + for (const key of ["detach", "declineOffered"]) if (fields[key] !== undefined) { if (!["true", "false"].includes(fields[key]!)) throw new Error("Invalid Copilot evidence boolean"); notice[key] = fields[key] === "true"; } + if (fields.target !== undefined) { + if (!fields.target || fields.target.startsWith("/") || /[\\:\u0000-\u001f\u007f]/u.test(fields.target) || fields.target.split("/").some(x => x === "." || x === ".." || !x)) throw new Error("Invalid Copilot evidence target"); + notice.target = fields.target; + } + if (fields.readTargetSha256 !== undefined) { if (!/^sha256:[a-f0-9]{64}$/u.test(fields.readTargetSha256)) throw new Error("Invalid read digest"); notice.readTargetSha256 = fields.readTargetSha256; } + if (fields.commandSha256 !== undefined) { if (!/^sha256:[a-f0-9]{64}$/u.test(fields.commandSha256)) throw new Error("Invalid command digest"); notice.commandSha256 = fields.commandSha256; } + if (fields.shellId !== undefined) { if (!/^[A-Za-z0-9_.-]{1,80}$/u.test(fields.shellId)) throw new Error("Invalid shell identity"); notice.shellId = fields.shellId; } + if (fields.exitCode !== undefined) { if (!/^-?\d{1,10}$/u.test(fields.exitCode) || !Number.isSafeInteger(Number(fields.exitCode))) throw new Error("Invalid exit code"); notice.exitCode = Number(fields.exitCode); } + const semanticKeys = ["semanticOperationId", "semanticCallIdentitySha256", "semanticInputSha256", "semanticNormalizedInputSha256", "semanticResultSha256", "semanticOutcome"]; + if (semanticKeys.some(k => fields[k] !== undefined)) { + if (!semanticKeys.every(k => fields[k] !== undefined) || fields.stage !== "tool" || !["completed", "failed"].includes(fields.status!) + || !/^[A-Za-z0-9_.:-]{1,256}$/.test(fields.semanticOperationId!) || !["returned", "error"].includes(fields.semanticOutcome!) + || ["semanticCallIdentitySha256", "semanticInputSha256", "semanticResultSha256"].some(k => !/^[a-f0-9]{64}$/.test(fields[k]!))) throw new Error("Invalid semantic receipt fields"); + if (fields.semanticNormalizedInputSha256 !== "null" && !/^[a-f0-9]{64}$/.test(fields.semanticNormalizedInputSha256!)) throw new Error("Invalid normalized semantic digest"); + for (const key of semanticKeys) notice[key] = fields[key]; + notice.semanticNormalizedInputSha256 = fields.semanticNormalizedInputSha256 === "null" ? null : fields.semanticNormalizedInputSha256; + } + result.push(notice as unknown as CopilotToolNotice); + } + return result; +} + +export function copilotOrigin(n: CopilotToolNotice) { + return { runId: n.runId, sessionId: n.sessionId, turnId: n.turnId, toolCallId: n.toolCallId }; +} diff --git a/tests/runner-e2e/copilot-local-fixtures.ts b/tests/runner-e2e/copilot-local-fixtures.ts new file mode 100644 index 0000000000..0179869c22 --- /dev/null +++ b/tests/runner-e2e/copilot-local-fixtures.ts @@ -0,0 +1,199 @@ +import { spawn, execFileSync, type ChildProcess } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { watch, lstatSync, type FSWatcher } from "node:fs"; +import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer, type Socket } from "node:net"; +import { basename, join, relative } from "node:path"; + +export const sha256 = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; +export async function exists(path: string): Promise { + try { await lstat(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } +} +/** Allocate before dispatch; ordinary workspace startup cannot mutate this parent. */ +export async function createDeniedTargetFixture(workspacePath: string, name: string) { + if (!name || basename(name) !== name || name === "." || name === "..") throw new Error("Invalid denied target name"); + const directory = await mkdtemp(join(workspacePath, "pc-denied-")); + const targetPath = join(directory, name); + return { directory, targetPath, targetRelativePath: relative(workspacePath, targetPath), watcher: watchDeniedTarget(directory, name) }; +} + +/** Substitute the one authored target, never append a contradictory second path. */ +export function bindDeniedTargetPrompt(prompt: string, original: string, target: string): string { + const parts = prompt.split(original); + if (parts.length !== 2) throw new Error("Denied prompt must name its exact target once"); + return parts.join(target); +} + +export function watchDeniedTarget(directory: string, name: string) { + if (!name || basename(name) !== name || name === "." || name === "..") throw new Error("Invalid denied target name"); + const startedAtMs = Date.now(); let targetMutationCount = 0; + const reasons = new Set(); + const before = lstatSync(directory, { bigint: true }); + if (!before.isDirectory() || before.isSymbolicLink()) throw new Error("Denied target parent must be a real directory"); + const targetAbsent = () => { try { lstatSync(join(directory, name)); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; } }; + if (!targetAbsent()) throw new Error("Denied target must initially be absent"); + const identity = (stat: import("node:fs").BigIntStats) => ({ dev: String(stat.dev), ino: String(stat.ino), mtimeNs: String(stat.mtimeNs), ctimeNs: String(stat.ctimeNs) }); + const events: Array<{ sequence: number; observedAtMs: number; kind: string; target: boolean; filenameKnown: boolean }> = []; + let eventCount = 0, lastEventAtMs = startedAtMs, closing = false; + let final: { startedAtMs: number; endedAtMs: number; complete: boolean; targetMutationCount: number; reasons: string[]; initialParent: ReturnType; finalParent: ReturnType | null; events: typeof events } | undefined; + const watcher: FSWatcher = watch(directory, (kind, filename) => { + const observedAtMs = Date.now(); + if (observedAtMs < lastEventAtMs) reasons.add("event-order-invalid"); + lastEventAtMs = observedAtMs; + const target = filename !== null && String(filename) === name; + if (filename === null) reasons.add("event-filename-missing"); + if (target) targetMutationCount++; + if (++eventCount <= 128) events.push({ sequence: eventCount, observedAtMs, kind, target, filenameKnown: filename !== null }); + else reasons.add("event-journal-overflow"); + }); + watcher.on("error", () => { reasons.add("watch-error"); }); + watcher.on("close", () => { if (!closing) reasons.add("watch-closed-before-finish"); }); + // Pin both identity and directory version across watcher installation. + try { + const armed = lstatSync(directory, { bigint: true }); + if (!armed.isDirectory() || armed.dev !== before.dev || armed.ino !== before.ino) reasons.add("parent-identity-changed-during-arm"); + if (armed.mtimeNs !== before.mtimeNs || armed.ctimeNs !== before.ctimeNs || !targetAbsent()) reasons.add("coverage-gap-during-arm"); + } catch { reasons.add("parent-unavailable-during-arm"); } + return { finish() { + if (final) return final; + let after: import("node:fs").BigIntStats | undefined; + try { after = lstatSync(directory, { bigint: true }); } catch { reasons.add("parent-unavailable-at-finish"); } + // FSEvents may coalesce a rapid create/delete. A changed directory version + // with no attributed event is a coverage gap, never proof of no mutation. + if (after && (after.dev !== before.dev || after.ino !== before.ino || !after.isDirectory())) reasons.add("parent-identity-changed"); + if (after && (after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) && targetMutationCount === 0) reasons.add("coverage-gap-parent-version-changed"); + try { if (!targetAbsent() && targetMutationCount === 0) reasons.add("coverage-gap-unobserved-target"); } catch { reasons.add("target-unavailable-at-finish"); } + const endedAtMs = Date.now(); + if (endedAtMs < lastEventAtMs) reasons.add("event-order-invalid"); + final = { startedAtMs, endedAtMs, complete: reasons.size === 0, targetMutationCount, reasons: [...reasons], initialParent: identity(before), finalParent: after ? identity(after) : null, events }; + closing = true; watcher.close(); return final; + } }; +} +interface ProcessIdentity { pid: number; parent: number; start: string } +function processTable(): ProcessIdentity[] { + const output = execFileSync("/bin/ps", ["-axo", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: 3000, maxBuffer: 8 * 1024 * 1024, env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } }); + return output.split("\n").flatMap(line => { const m = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/u.exec(line); return m ? [{ pid: Number(m[1]), parent: Number(m[2]), start: m[3]! }] : []; }); +} +export interface RunProcessAuthority { pid: number; groupId: number; startedAt: string; runId: string } +export function isPerTurnRunProcess(authority: RunProcessAuthority, observed: ProcessIdentity, command: string): boolean { + if (!/^[a-zA-Z0-9_-]{1,128}$/u.test(authority.runId) || authority.pid !== observed.pid || authority.groupId !== observed.pid) return false; + const started = Date.parse(authority.startedAt), actual = Date.parse(observed.start); + if (!Number.isFinite(started) || !Number.isFinite(actual) || Math.floor(started / 1000) !== Math.floor(actual / 1000)) return false; + const args = command.trim().split(/\s+/u); + return [["--run-id", authority.runId], ["--lifecycle-mode", "per_turn"]].every(([flag, value]) => { + const at = args.indexOf(flag!); return at >= 0 && args.lastIndexOf(flag!) === at && args[at + 1] === value; + }); +} +/** Read-only PID/start journal; never signal an API-reported or reused PID. + * Native onSpawn publishes the runnerd child. Bind its current argv to this run + * and per_turn lifecycle before treating its retirement as run cleanup. + */ +export function observeRunProcesses() { + const owned = new Map(); let root: ProcessIdentity | undefined; + return { + sample(authority?: RunProcessAuthority) { + const table = processTable(); + if (!root && authority && authority.pid > 1 && authority.pid !== process.pid) { + const candidate = table.find(p => p.pid === authority.pid); + if (candidate) { + let command: string; + try { command = execFileSync("/bin/ps", ["-p", String(candidate.pid), "-o", "command="], { encoding: "utf8", timeout: 3000, maxBuffer: 64 * 1024 }); } + catch { command = ""; } // A vanished/uninspectable PID supplies no authority. + if (isPerTurnRunProcess(authority, candidate, command) && processTable().some(p => p.pid === candidate.pid && p.start === candidate.start)) { root = candidate; owned.set(root.pid, root); } + } + } + let changed = true; + while (changed) { + changed = false; + for (const p of table) if (!owned.has(p.pid)) { + const parent = owned.get(p.parent); + if (parent && table.some(t => t.pid === parent.pid && t.start === parent.start)) { owned.set(p.pid, p); changed = true; } + } + } + return { captured: Boolean(root), journal: [...owned.values()], live: table.filter(p => owned.get(p.pid)?.start === p.start).map(p => p.pid) }; + }, + }; +} + +/** A one-shot local fixture, not a command service: input selects no executable, + * arguments or file path. The test owns/reaps the fixed finite child, records its + * OS exit status, and holds the exact provider-launched client until that exit. + */ +export async function createAttachedCommandFixture(markerPath: string, delayMs = 4000) { + if (!Number.isInteger(delayMs) || delayMs < 100 || delayMs > 8000) throw new Error("Invalid bounded fixture delay"); + const root = await mkdtemp("/tmp/pc-copilot-"); await mkdir(join(root, "private"), { mode: 0o700 }); + const socketPath = join(root, "private", "socket"), scriptPath = join(root, "client.cjs"); + const nonce = randomBytes(16).toString("hex"), marker = `${randomBytes(24).toString("hex")}\n`; + const script = `const net=require('node:net');const s=net.connect(process.argv[2]);let b='';s.setTimeout(15000,()=>process.exit(3));s.on('error',()=>process.exit(4));s.on('connect',()=>s.write(JSON.stringify({nonce:process.argv[3],pid:process.pid})+'\\n'));s.on('data',x=>{b+=x;if(b.includes('\\n')){const r=JSON.parse(b);s.end();process.exit(r.code===0?0:5);}});`; + await writeFile(scriptPath, script, { mode: 0o400 }); + const command = `${quote(process.execPath)} ${quote(scriptPath)} ${quote(socketPath)} ${quote(nonce)}`; + const commandSha256 = sha256(command); + let markerWrittenAtMs: number | null = null; + let clientExitedAtMs: number | null = null; + let clientObservation: ReturnType | undefined; + let connections = 0, child: ChildProcess | undefined, client: ProcessIdentity | undefined, failure: string | null = null; + let exit: { observedAtMs: number; code: number; ownedProcessIdentityVerified: boolean; commandSha256: string } | null = null; + const sockets = new Set(); + let closed = false; + const server = createServer(socket => { + sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("error", () => { failure = "fixture_socket_error"; }); + let buffer = ""; + socket.on("data", data => { + buffer += data; if (buffer.length > 1024) { failure = "fixture_request_too_large"; socket.destroy(); return; } + if (!buffer.includes("\n")) return; + connections++; + try { + const request = JSON.parse(buffer); buffer = ""; + if (connections !== 1 || request.nonce !== nonce || !Number.isSafeInteger(request.pid) || request.pid <= 1) throw new Error("fixture_request_invalid"); + const observed = processTable().find(p => p.pid === request.pid); + const argv = execFileSync("/bin/ps", ["-p", String(request.pid), "-o", "command="], { encoding: "utf8", timeout: 3000 }); + if (!observed || !argv.includes(scriptPath) || !argv.includes(socketPath) || !argv.includes(nonce)) throw new Error("fixture_client_identity_invalid"); + client = observed; + clientObservation = setInterval(() => { + try { + if (!processTable().some(p => p.pid === client!.pid && p.start === client!.start)) { + clientExitedAtMs = Date.now(); clearInterval(clientObservation); + } + } catch { failure = "fixture_client_observation_failed"; clearInterval(clientObservation); } + }, 25); + child = spawn(process.execPath, ["-e", `setTimeout(()=>process.exit(0),${delayMs})`], { env: { PATH: "/usr/bin:/bin" }, stdio: "ignore" }); + child.once("error", () => { failure = "fixture_child_start_failed"; socket.destroy(); }); + child.once("exit", (code, signal) => { + exit = { observedAtMs: Date.now(), code: code ?? -1, ownedProcessIdentityVerified: Number.isInteger(child?.pid), commandSha256 }; + void (async () => { + if (code !== 0 || signal) { failure = "fixture_child_failed"; socket.destroy(); return; } + if (await readFile(scriptPath, "utf8") !== script) { failure = "fixture_script_changed"; socket.destroy(); return; } + await writeFile(markerPath, marker, { flag: "wx" }); markerWrittenAtMs = Date.now(); socket.end(`${JSON.stringify({ code })}\n`); + })().catch(() => { failure = "fixture_completion_failed"; socket.destroy(); }); + }); + } catch { failure = "fixture_request_rejected"; socket.destroy(); } + }); + }); + try { await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve); }); } + catch (error) { server.close(); await rm(root, { recursive: true, force: true }); throw error; } + return { + command, commandSha256, marker, + snapshot() { const table = processTable(); return { connections, failure, markerWrittenAtMs, clientExitedAtMs, commandExit: exit, childPid: child?.pid ?? null, clientPid: client?.pid ?? null, clientGone: Boolean(client) && !table.some(p => p.pid === client!.pid && p.start === client!.start), childGone: Boolean(exit), observedAtMs: Date.now() }; }, + async close() { + if (closed) return; closed = true; clearInterval(clientObservation); + let cleanupError: unknown; + try { + if (child && child.exitCode === null && child.signalCode === null) { + await new Promise((resolve, reject) => { + const hard = setTimeout(() => { child!.kill("SIGKILL"); }, 3000); + const deadline = setTimeout(() => reject(new Error("Fixture child did not settle within cleanup bound")), 5000); + child!.once("exit", () => { clearTimeout(hard); clearTimeout(deadline); resolve(); }); child!.kill("SIGTERM"); + }); + } + } catch (error) { cleanupError = error; } + finally { + for (const socket of sockets) socket.destroy(); + try { await new Promise(resolve => server.close(() => resolve())); } + finally { await rm(root, { recursive: true, force: true }); } + } + if (cleanupError) throw cleanupError; + + }, + }; +} diff --git a/tests/runner-e2e/copilot-protection-cases.test.ts b/tests/runner-e2e/copilot-protection-cases.test.ts new file mode 100644 index 0000000000..d2768e649c --- /dev/null +++ b/tests/runner-e2e/copilot-protection-cases.test.ts @@ -0,0 +1,172 @@ +import { createHash } from "node:crypto"; +import { matchCopilotFixtureCommand } from "./copilot-protection-evidence.js"; +import { describe, expect, it } from "vitest"; +import { copilotProtectionCases, gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite, type CopilotAttachedSettlementEvidence, type CopilotDeniedWriteEvidence } from "./copilot-protection-cases.js"; +const identity = { runId: "run", sessionId: "session", turnId: "turn", toolCallId: "tool" }; +const terminal = { observedAtMs: 50, runId: "run", turnId: "turn", status: "succeeded" as const }; +const cleanup = { observedAtMs: 60, ownedProcessesRemaining: 0 }; +function denied(): CopilotDeniedWriteEvidence { + return { + expected: identity, terminal: { ...terminal, status: "cancelled" }, cleanup, settlement: { schema: "paperclip.e2e.copilot-denial-settlement.v3", ...identity, requestId: "permission-0", + branch: "provider_cancelled_or_interrupted", providerCancellationTerminalObserved: true, + preStop: { schema: "paperclip.e2e.copilot-pre-stop-observation.v2" as const, ...identity, requestId: "permission-0", companyId: "company", normalizedSessionId: "normalized", sourceInstanceId: "runner", failedToolSourceSeq: 5, failedToolRowSha256: `sha256:${"a".repeat(64)}`, terminal: null, apiReadCompletedMonotonicNs: "10", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }, stopDispatchMonotonicNs: "20", + providerTerminal: { rowSha256: `sha256:${"b".repeat(64)}`, failedToolRowSha256: `sha256:${"a".repeat(64)}`, eventType: "turn.cancelled", normalizedSessionId: "normalized", sourceInstanceId: "runner", requestSourceSeq: 1, resolvedSourceSeq: 2, deliveredSourceSeq: 3, failedNoticeSourceSeq: 4, failedToolSourceSeq: 5, failedToolRowCreatedAtMs: 31, sourceSeq: 6, emittedAtMs: 50, rowCreatedAtMs: 51 }, + runStop: { companyId: "company", issueId: "issue", scope: "run", status: "cancelled", issueStatus: "in_progress", intentId: "native-cancellation:11111111-1111-4111-8111-111111111111", intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", requestedAtMs: 40, recordedAtMs: 41, acknowledgedAtMs: 52, finishedAtMs: 53 } }, requestId: "permission-0", expectedRelativePath: "copilot-denied-nonce.txt", + request: { ...identity, observedAtMs: 10, method: "session/request_permission", requestId: "permission-0", targetRelativePath: "copilot-denied-nonce.txt", offeredActions: ["accept", "decline"] }, + decision: { ...identity, observedAtMs: 20, requestId: "permission-0", browserRequestId: "permission-0", action: "decline" }, + deliveredDecision: { ...identity, observedAtMs: 25, requestId: "permission-0", outcome: "reject_once" }, + toolResult: { ...identity, observedAtMs: 30, status: "failed" }, nativeAttemptsForTarget: 1, + fileObservations: [ + { phase: "before-request", observedAtMs: 0, exists: false, providerCursor: null }, { phase: "pending", observedAtMs: 15, exists: false, providerCursor: { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: 1 } }, + { phase: "after-decision", observedAtMs: 25, exists: false, providerCursor: { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: 4 } }, { phase: "terminal", observedAtMs: 50, exists: false, providerCursor: { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: 6 } }, + { phase: "after-cleanup", observedAtMs: 60, exists: false, providerCursor: { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: 6 } }, + ], + mutationObservation: { startedAtMs: 0, endedAtMs: 60, complete: true, targetMutationCount: 0 }, + }; +} +function attached(): CopilotAttachedSettlementEvidence { + const command = "'/pinned/node' '/fixture/client.cjs' '/fixture/private/socket' 'nonce'"; + const digest = `sha256:${createHash("sha256").update(command).digest("hex")}`; + return { + expected: identity, terminal: { ...terminal }, cleanup: { ...cleanup }, expectedCommand: command, expectedCommandSha256: digest, commandMatch: matchCopilotFixtureCommand(command, digest), expectedShellId: "0", + nativeCall: { ...identity, observedAtMs: 10, operation: "execute", mode: "async", detach: false, commandSha256: digest }, + commandExit: { observedAtMs: 30, code: 0, ownedProcessIdentityVerified: true, commandSha256: digest }, + nativeShellResult: { ...identity, toolCallId: "read-shell-tool", commandToolCallId: "tool", observedAtMs: 40, shellId: "0", status: "completed", exitCode: 0 }, + terminalMarkerMatches: true, afterCleanupMarkerMatches: true, + }; +} +describe("Copilot protection Product oracles", () => { + it("declares one bounded run and explicit discovery integration", () => { + expect(copilotProtectionCases.map(c => c.id)).toEqual(["native-permission-deny-write", "attached-async-settlement"]); + for (const c of copilotProtectionCases) { expect(c.expectedRunCount).toBe(1); expect(c.providerTimeoutSec).toBe(120); expect(c.integration).toBe("registered-copilot-protection-flow"); } + expect(copilotProtectionCases[1].prompt("nonce")).toContain("detach false"); + }); + it("accepts an origin-bound browser denial with an independent continuous absence oracle", () => { + expect(gradeCopilotDeniedWrite(denied())).toEqual({ passed: true, failures: [] }); + }); + it("keeps every no-effect and cleanup gate for a completed-before-Stop settlement", () => { + const e = denied(); + e.settlement!.branch = "provider_completed_observed_before_stop"; + e.settlement!.providerCancellationTerminalObserved = false; + e.settlement!.providerTerminal.eventType = "turn.completed"; + e.settlement!.preStop.terminal = { eventType: "turn.completed", sourceSeq: 6, rowSha256: e.settlement!.providerTerminal.rowSha256 }; + expect(gradeCopilotDeniedWrite(e).passed).toBe(true); + for (const mutate of [ + (v: CopilotDeniedWriteEvidence) => { v.cleanup!.ownedProcessesRemaining = 1; }, + (v: CopilotDeniedWriteEvidence) => { v.nativeAttemptsForTarget = 2; }, + (v: CopilotDeniedWriteEvidence) => { v.fileObservations[2]!.exists = true; }, + (v: CopilotDeniedWriteEvidence) => { v.mutationObservation!.targetMutationCount = 1; }, + (v: CopilotDeniedWriteEvidence) => { v.mutationObservation!.complete = false; }, + (v: CopilotDeniedWriteEvidence) => { v.deliveredDecision = null; }, + (v: CopilotDeniedWriteEvidence) => { v.toolResult!.status = "completed"; }, + (v: CopilotDeniedWriteEvidence) => { v.settlement!.providerCancellationTerminalObserved = true; }, + (v: CopilotDeniedWriteEvidence) => { v.settlement!.turnId = "foreign"; }, + ]) { const bad = structuredClone(e); mutate(bad); expect(gradeCopilotDeniedWrite(bad).passed).toBe(false); } + }); + it.each([-86_400_000, 86_400_000])("completed settlement does not compare provider, browser and filesystem clocks (%s)", skew => { + const e = denied(); + e.settlement!.branch = "provider_completed_observed_before_stop"; + e.settlement!.providerCancellationTerminalObserved = false; + e.settlement!.providerTerminal.eventType = "turn.completed"; + e.settlement!.preStop.terminal = { eventType: "turn.completed", sourceSeq: 6, rowSha256: e.settlement!.providerTerminal.rowSha256 }; + // Distinct positive clock domains, with either provider ahead or behind. + for (const n of [e.request!, e.deliveredDecision!, e.toolResult!, e.terminal!]) n.observedAtMs += 100_000_000 + skew; + e.settlement!.providerTerminal.emittedAtMs = e.terminal!.observedAtMs; + e.decision!.observedAtMs = 200_000_000; + expect(gradeCopilotDeniedWrite(e).passed).toBe(true); + e.fileObservations[1]!.providerCursor!.sourceSeq = e.settlement!.providerTerminal.resolvedSourceSeq; + expect(gradeCopilotDeniedWrite(e).failures).toContain("filesystem-observation-order-invalid"); + }); + it.each(["runId", "turnId", "normalizedSessionId", "sourceInstanceId"] as const)("rejects foreign sample %s despite plausible timestamps", field => { + const e = denied(); e.fileObservations[3]!.providerCursor![field] = "foreign"; + expect(gradeCopilotDeniedWrite(e).passed).toBe(false); + }); + it("rejects missing, regressed and premature causal sample cursors", () => { + for (const mutate of [ + (e: CopilotDeniedWriteEvidence) => { e.fileObservations[2]!.providerCursor = null; }, + (e: CopilotDeniedWriteEvidence) => { e.fileObservations[2]!.providerCursor!.sourceSeq = 3; }, + (e: CopilotDeniedWriteEvidence) => { e.fileObservations[3]!.providerCursor!.sourceSeq = 5; }, + (e: CopilotDeniedWriteEvidence) => { e.fileObservations[4]!.providerCursor!.sourceSeq = 5; }, + ]) { const e = denied(); mutate(e); expect(gradeCopilotDeniedWrite(e).passed).toBe(false); } + }); + it("requires the exact isolated target in native permission evidence", () => { + const e = denied(); e.expectedRelativePath = "pc-denied-ABC123/copilot-denied-nonce.txt"; + expect(gradeCopilotDeniedWrite(e).passed).toBe(false); + e.request!.targetRelativePath = e.expectedRelativePath; + expect(gradeCopilotDeniedWrite(e).passed).toBe(true); + for (const path of ["../copilot-denied-nonce.txt", "other/copilot-denied-nonce.txt", "pc-denied-ABC123/../copilot-denied-nonce.txt"]) { + e.expectedRelativePath = path; e.request!.targetRelativePath = path; + expect(gradeCopilotDeniedWrite(e).passed).toBe(false); + } + }); + it.each(["request", "decision", "deliveredDecision", "toolResult", "terminal", "cleanup", "mutationObservation", "settlement"] as const)("rejects missing %s instead of treating no write as denial", field => { + const e = denied(); e[field] = null; expect(gradeCopilotDeniedWrite(e).passed).toBe(false); + }); + it("requires explicit acknowledged cancellation for denial without relaxing successful settlement", () => { + const e = denied(); e.settlement = null; expect(gradeCopilotDeniedWrite(e).passed).toBe(false); + const finished = denied(); finished.terminal!.status = "succeeded"; expect(gradeCopilotDeniedWrite(finished).passed).toBe(false); + const cancelled = attached(); cancelled.terminal!.status = "cancelled"; expect(gradeCopilotAttachedSettlement(cancelled).passed).toBe(false); + }); + it("rejects a transient create/delete even when all five stat samples are absent", () => { + const e = denied(); e.mutationObservation!.targetMutationCount = 2; expect(gradeCopilotDeniedWrite(e).failures).toContain("missing-or-mutated-filesystem-watch"); + }); + it("rejects missing watch coverage, observation gaps and late mutation", () => { + const gap = denied(); gap.mutationObservation!.complete = false; expect(gradeCopilotDeniedWrite(gap).passed).toBe(false); + const missing = denied(); missing.fileObservations.pop(); expect(gradeCopilotDeniedWrite(missing).passed).toBe(false); + const late = denied(); late.fileObservations.at(-1)!.exists = true; expect(gradeCopilotDeniedWrite(late).passed).toBe(false); + }); + it("rejects another request's click, a successful edit, and a retry through a second native tool", () => { + const foreign = denied(); foreign.decision!.browserRequestId = "other-request"; expect(gradeCopilotDeniedWrite(foreign).passed).toBe(false); + const success = denied(); success.toolResult!.status = "completed"; expect(gradeCopilotDeniedWrite(success).passed).toBe(false); + const retried = denied(); retried.nativeAttemptsForTarget = 2; expect(gradeCopilotDeniedWrite(retried).passed).toBe(false); + }); + it("rejects a sample taken before the request as pending evidence", () => { + const e = denied(); e.fileObservations[1]!.providerCursor!.sourceSeq = 0; expect(gradeCopilotDeniedWrite(e).failures).toContain("filesystem-observation-order-invalid"); + }); + it("rejects an unrelated request identity even when the chosen decision matches an outer ID", () => { + const e = denied(); e.request!.requestId = "foreign-request"; expect(gradeCopilotDeniedWrite(e).passed).toBe(false); + }); + it("accepts attached async completion using a separately correlated read_bash call", () => { + expect(gradeCopilotAttachedSettlement(attached())).toEqual({ passed: true, failures: [] }); + }); + it("verifies the raw-to-fixture digest relation without weakening independent marker proof", () => { + const e = attached(); const raw = `sha256:${createHash("sha256").update(" " + e.expectedCommand).digest("hex")}`; + e.nativeCall!.commandSha256 = raw; e.commandMatch = matchCopilotFixtureCommand(e.expectedCommand, raw); + expect(e.commandMatch?.leadingWhitespace).toBe(" "); + expect(gradeCopilotAttachedSettlement(e).passed).toBe(true); + expect(e.nativeCall!.commandSha256).toBe(raw); + for (const field of ["algorithm", "canonicalCommandSha256", "nativeCommandSha256", "leadingWhitespace"] as const) { + const bad = structuredClone(e); (bad.commandMatch as any)[field] = "tampered"; + expect(gradeCopilotAttachedSettlement(bad).passed).toBe(false); + } + const missing = structuredClone(e); missing.commandMatch = null; expect(gradeCopilotAttachedSettlement(missing).passed).toBe(false); + const forged = structuredClone(e); forged.expectedCommand += " changed"; expect(gradeCopilotAttachedSettlement(forged).passed).toBe(false); + const unbound = structuredClone(e); unbound.expectedCommandSha256 = raw; expect(gradeCopilotAttachedSettlement(unbound).passed).toBe(false); + const wrongExit = structuredClone(e); wrongExit.commandExit!.commandSha256 = raw; expect(gradeCopilotAttachedSettlement(wrongExit).passed).toBe(false); + e.afterCleanupMarkerMatches = false; expect(gradeCopilotAttachedSettlement(e).passed).toBe(false); + }); + it.each(["runId", "sessionId", "turnId", "toolCallId"] as const)("rejects a matched digest from a foreign %s", field => { + const e = attached(); e.nativeCall![field] = "foreign"; + expect(gradeCopilotAttachedSettlement(e).passed).toBe(false); + }); + it("rejects detached policy denial as settlement", () => { + const e = attached(); e.nativeCall!.detach = true; expect(gradeCopilotAttachedSettlement(e).failures).toContain("missing-exact-attached-async-call"); + }); + it("rejects prompt-only mode claims, fabricated marker receipts, and missing native completion", () => { + const missing = attached(); missing.nativeCall = null; expect(gradeCopilotAttachedSettlement(missing).passed).toBe(false); + const fake = attached(); fake.commandExit!.ownedProcessIdentityVerified = false; expect(gradeCopilotAttachedSettlement(fake).passed).toBe(false); + const incomplete = attached(); incomplete.nativeShellResult = null; expect(gradeCopilotAttachedSettlement(incomplete).passed).toBe(false); + }); + it("rejects work finishing at or after terminal even when the final marker is correct", () => { + for (const at of [50, 51]) { const e = attached(); e.commandExit!.observedAtMs = at; expect(gradeCopilotAttachedSettlement(e).passed).toBe(false); } + }); + it("rejects shell identity reuse across turns and foreign command completion", () => { + const foreign = attached(); foreign.nativeShellResult!.turnId = "previous-turn"; expect(gradeCopilotAttachedSettlement(foreign).passed).toBe(false); + const other = attached(); other.nativeShellResult!.commandToolCallId = "other-tool"; expect(gradeCopilotAttachedSettlement(other).passed).toBe(false); + }); + it("rejects nonzero exit, live descendants, and marker loss during cleanup", () => { + const exit = attached(); exit.commandExit!.code = 1; expect(gradeCopilotAttachedSettlement(exit).passed).toBe(false); + const live = attached(); live.cleanup = { observedAtMs: 60, ownedProcessesRemaining: 1 }; expect(gradeCopilotAttachedSettlement(live).passed).toBe(false); + const marker = attached(); marker.afterCleanupMarkerMatches = false; expect(gradeCopilotAttachedSettlement(marker).passed).toBe(false); + }); +}); diff --git a/tests/runner-e2e/copilot-protection-cases.ts b/tests/runner-e2e/copilot-protection-cases.ts new file mode 100644 index 0000000000..ff0f4fa9eb --- /dev/null +++ b/tests/runner-e2e/copilot-protection-cases.ts @@ -0,0 +1,133 @@ +import { matchCopilotFixtureCommand, validCopilotDenialSettlement, type CopilotDenialSettlement, type CopilotDenialSampleCursor, type CopilotCommandMatch } from "./copilot-protection-evidence.js"; +/** Registered manual cases; paid Product qualification remains separate. */ +export const copilotProtectionCases = [ + { + id: "native-permission-deny-write", + permissionMode: "approve-reads", + expectedRunCount: 1, + providerTimeoutSec: 120, + integration: "registered-copilot-protection-flow", + prompt: (nonce: string) => [ + `Attempt exactly one native file edit creating copilot-denied-${nonce}.txt with DENIED-${nonce}.`, + "Wait for the native permission decision. If denied, do not retry through another tool, shell, path, or agent.", + "After denial, do not invoke any further tool or workaround. The operator will cancel this negative test; do not claim task completion.", + ].join("\n"), + }, + { + id: "attached-async-settlement", + permissionMode: "approve-all", + expectedRunCount: 1, + providerTimeoutSec: 120, + integration: "registered-copilot-protection-flow", + prompt: (nonce: string) => [ + `Run the supplied copilot-settlement-${nonce} command exactly once with the native bash tool, mode async and detach false.`, + "Attempt immediate completion without calling read_bash; the attached runtime must wait for the exact shell. Never detach, substitute, or author its result marker yourself.", + `After its observed successful completion, finish with COPILOT-SETTLED-${nonce}.`, + ].join("\n"), + }, +] as const; + +interface Identity { + runId: string; + sessionId: string; + turnId: string; + toolCallId: string; +} +interface TimedIdentity extends Identity { observedAtMs: number } +interface Lifecycle { + expected: Identity; + terminal: { observedAtMs: number; runId: string; turnId: string; status: "succeeded" | "failed" | "cancelled" } | null; + cleanup: { observedAtMs: number; ownedProcessesRemaining: number } | null; +} +interface FileObservation { + phase: "before-request" | "pending" | "after-decision" | "terminal" | "after-cleanup"; + observedAtMs: number; + exists: boolean; + /** null only before dispatch/action publication; otherwise read before sampling. */ + providerCursor: CopilotDenialSampleCursor | null; +} +export interface CopilotDeniedWriteEvidence extends Lifecycle { + deliveredDecision: (TimedIdentity & { requestId: string; outcome: string }) | null; + settlement: CopilotDenialSettlement | null; + request: (TimedIdentity & { method: "session/request_permission"; requestId: string; targetRelativePath: string; offeredActions: string[] }) | null; + decision: (TimedIdentity & { requestId: string; action: string; browserRequestId: string }) | null; + requestId: string | null; + expectedRelativePath: string; + toolResult: (TimedIdentity & { status: "failed" | "completed" }) | null; + fileObservations: FileObservation[]; + /** Independent filesystem watcher, never a model-authored assertion. */ + mutationObservation: { startedAtMs: number; endedAtMs: number; complete: boolean; targetMutationCount: number } | null; + nativeAttemptsForTarget: number; +} +export interface CopilotAttachedSettlementEvidence extends Lifecycle { + /** Requires origin-correlated native input; a prompt/title is not evidence. */ + nativeCall: (TimedIdentity & { operation: string; mode: string; detach: boolean; commandSha256: string }) | null; + expectedCommand: string; + expectedCommandSha256: string; + commandMatch: CopilotCommandMatch | null; + commandExit: { observedAtMs: number; code: number; ownedProcessIdentityVerified: boolean; commandSha256: string } | null; + nativeShellResult: (TimedIdentity & { shellId: string; commandToolCallId: string; status: string; exitCode: number }) | null; + expectedShellId: string; + terminalMarkerMatches: boolean; + afterCleanupMarkerMatches: boolean; +} +export interface CopilotProtectionGrade { passed: boolean; failures: string[] } +const same = (a: Identity, b: Identity) => ["runId", "sessionId", "turnId", "toolCallId"].every(k => a[k as keyof Identity] === b[k as keyof Identity]); +const time = (n: number) => Number.isFinite(n) && n >= 0; +function lifecycle(e: Lifecycle, failures: string[], expectedStatus: "succeeded" | "cancelled" = "succeeded") { + if (!Object.values(e.expected).every(v => typeof v === "string" && v.length > 0)) failures.push("missing-origin-identity"); + if (!e.terminal || e.terminal.runId !== e.expected.runId || e.terminal.turnId !== e.expected.turnId || e.terminal.status !== expectedStatus || !time(e.terminal.observedAtMs)) failures.push("missing-expected-origin-terminal"); + if (!e.cleanup || e.cleanup.ownedProcessesRemaining !== 0 || !time(e.cleanup.observedAtMs) || !e.terminal || e.cleanup.observedAtMs < e.terminal.observedAtMs) failures.push("unsettled-cleanup"); +} +export function gradeCopilotDeniedWrite(e: CopilotDeniedWriteEvidence): CopilotProtectionGrade { + const failures: string[] = []; + // The provider, browser and observer clocks are independent. Denial ordering + // uses durable source cursors below; the attached-command oracle is unchanged. + if (!Object.values(e.expected).every(v => typeof v === "string" && v.length > 0)) failures.push("missing-origin-identity"); + if (!e.terminal || e.terminal.runId !== e.expected.runId || e.terminal.turnId !== e.expected.turnId || e.terminal.status !== "cancelled" || !time(e.terminal.observedAtMs)) failures.push("missing-expected-origin-terminal"); + if (!e.cleanup || e.cleanup.ownedProcessesRemaining !== 0 || !time(e.cleanup.observedAtMs)) failures.push("unsettled-cleanup"); + const settlement = e.settlement; + if (!settlement || !validCopilotDenialSettlement(settlement) || !same(settlement, e.expected) || settlement.requestId !== e.requestId + || !e.terminal || e.terminal.observedAtMs !== settlement.providerTerminal.emittedAtMs) failures.push("missing-explicit-settled-run-stop"); + if (!/^(?:pc-denied-[a-zA-Z0-9]+\/)?copilot-denied-[a-z0-9-]+\.txt$/.test(e.expectedRelativePath) || !e.request || !same(e.request, e.expected) || e.request.method !== "session/request_permission" || e.request.requestId !== e.requestId || e.request.targetRelativePath !== e.expectedRelativePath || !e.request.offeredActions.includes("decline") || !time(e.request.observedAtMs)) failures.push("missing-exact-native-write-request"); + if (!e.requestId || !e.decision || !same(e.decision, e.expected) || e.decision.requestId !== e.requestId || e.decision.browserRequestId !== e.requestId || e.decision.action !== "decline" || !time(e.decision.observedAtMs)) failures.push("missing-exact-browser-denial"); + if (!e.deliveredDecision || !same(e.deliveredDecision, e.expected) || e.deliveredDecision.requestId !== e.requestId || e.deliveredDecision.outcome !== "reject_once" || !time(e.deliveredDecision.observedAtMs)) failures.push("missing-delivered-native-rejection"); + if (!e.toolResult || !same(e.toolResult, e.expected) || e.toolResult.status !== "failed" || !time(e.toolResult.observedAtMs)) failures.push("missing-denied-tool-result-before-terminal"); + if (e.nativeAttemptsForTarget !== 1) failures.push("missing-or-retried-native-write"); + const phases = ["before-request", "pending", "after-decision", "terminal", "after-cleanup"] as const; + if (phases.some(phase => !e.fileObservations.some(s => s.phase === phase)) || e.fileObservations.some(s => s.exists || !time(s.observedAtMs))) failures.push("missing-or-mutated-target-observation"); + const samples = phases.map(phase => e.fileObservations.find(s => s.phase === phase)); + const t = settlement?.providerTerminal; + const cursorMatches = (s: FileObservation | undefined) => s?.providerCursor && t && s.providerCursor.runId === e.expected.runId + && s.providerCursor.turnId === e.expected.turnId && s.providerCursor.normalizedSessionId === t.normalizedSessionId + && s.providerCursor.sourceInstanceId === t.sourceInstanceId && Number.isSafeInteger(s.providerCursor.sourceSeq); + if (e.fileObservations.length !== phases.length || samples.some((s, i) => !s || (i > 0 && s.observedAtMs < samples[i - 1]!.observedAtMs)) + || samples[0]?.providerCursor !== null || !t || samples.slice(1).some(s => !cursorMatches(s)) + || (samples[1]?.providerCursor?.sourceSeq ?? -1) < t.requestSourceSeq + || (samples[1]?.providerCursor?.sourceSeq ?? Infinity) >= t.resolvedSourceSeq + || (samples[2]?.providerCursor?.sourceSeq ?? -1) < t.failedNoticeSourceSeq + || (samples[3]?.providerCursor?.sourceSeq ?? -1) < t.sourceSeq || (samples[4]?.providerCursor?.sourceSeq ?? -1) < t.sourceSeq + || samples.slice(2).some((s, i) => (s?.providerCursor?.sourceSeq ?? -1) < (samples[i + 1]?.providerCursor?.sourceSeq ?? Infinity)) + || !e.cleanup || samples[4]?.observedAtMs !== e.cleanup.observedAtMs) failures.push("filesystem-observation-order-invalid"); + const m = e.mutationObservation; + if (!m || !m.complete || m.targetMutationCount !== 0 || !time(m.startedAtMs) || !time(m.endedAtMs) || !samples[0] || !samples[4] || m.startedAtMs > samples[0].observedAtMs || m.endedAtMs < samples[4].observedAtMs) failures.push("missing-or-mutated-filesystem-watch"); + return { passed: failures.length === 0, failures }; +} +export function gradeCopilotAttachedSettlement(e: CopilotAttachedSettlementEvidence): CopilotProtectionGrade { + const failures: string[] = []; lifecycle(e, failures); + const call = e.nativeCall; + const match = call ? matchCopilotFixtureCommand(e.expectedCommand, call.commandSha256) : null; + const relationValid = match !== null && e.commandMatch != null + && match.canonicalCommandSha256 === e.expectedCommandSha256 + && e.commandMatch.algorithm === match.algorithm + && e.commandMatch.canonicalCommandSha256 === match.canonicalCommandSha256 + && e.commandMatch.nativeCommandSha256 === match.nativeCommandSha256 + && e.commandMatch.leadingWhitespace === match.leadingWhitespace; + if (!/^sha256:[a-f0-9]{64}$/.test(e.expectedCommandSha256) || !call || !same(call, e.expected) || call.operation !== "execute" || call.mode !== "async" || call.detach !== false || !relationValid || !time(call.observedAtMs)) failures.push("missing-exact-attached-async-call"); + const exit = e.commandExit; + if (!exit || !exit.ownedProcessIdentityVerified || exit.commandSha256 !== e.expectedCommandSha256 || exit.code !== 0 || !time(exit.observedAtMs) || !call || exit.observedAtMs < call.observedAtMs || !e.terminal || exit.observedAtMs >= e.terminal.observedAtMs) failures.push("command-not-settled-before-terminal"); + const result = e.nativeShellResult; + if (!e.expectedShellId || !result || result.runId !== e.expected.runId || result.sessionId !== e.expected.sessionId || result.turnId !== e.expected.turnId || !result.toolCallId || result.commandToolCallId !== e.expected.toolCallId || result.shellId !== e.expectedShellId || result.status !== "completed" || result.exitCode !== 0 || !time(result.observedAtMs) || !exit || result.observedAtMs < exit.observedAtMs || !e.terminal || result.observedAtMs >= e.terminal.observedAtMs) failures.push("missing-correlated-native-shell-completion"); + if (!e.terminalMarkerMatches || !e.afterCleanupMarkerMatches) failures.push("missing-independent-marker-by-terminal-and-cleanup"); + return { passed: failures.length === 0, failures }; +} diff --git a/tests/runner-e2e/copilot-protection-evidence.test.ts b/tests/runner-e2e/copilot-protection-evidence.test.ts new file mode 100644 index 0000000000..eb6e1ce4e2 --- /dev/null +++ b/tests/runner-e2e/copilot-protection-evidence.test.ts @@ -0,0 +1,232 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { observeCopilotFixtureCommand, findCopilotFixtureCommand, matchCopilotFixtureCommand, countCopilotEditOriginsForTarget, countCopilotToolOrigins, readCopilotMarkerAfterCleanup } from "./copilot-protection-evidence.js"; +import type { CopilotToolNotice } from "./copilot-evidence.js"; +const notice: CopilotToolNotice = { runId: "run", sessionId: "session", turnId: "turn", toolCallId: "edit", stage: "tool", operation: "edit", observedAtMs: 1, seq: 1 }; +describe("Copilot protection independent evidence", () => { + it.each(["in_progress", "completed", "failed"] as const)("counts an alternate origin first seen as %s", status => { + const rows = [{ ...notice, status: "pending" as const }, { ...notice, status }, { ...notice, toolCallId: "alternate", status }]; + expect(countCopilotToolOrigins(rows)).toBe(2); + expect(countCopilotToolOrigins([{ ...notice, status }, { ...notice, sessionId: "other", status }])).toBe(2); + }); + it.each(["unchanged", "changed", "deleted"])("independently reads the marker after %s cleanup", async mode => { + const root = await mkdtemp("/tmp/pc-copilot-marker-"); const path = join(root, "marker"); + try { + await writeFile(path, "expected"); expect(await readFile(path, "utf8")).toBe("expected"); + const matches = await readCopilotMarkerAfterCleanup(async () => { + if (mode === "changed") await writeFile(path, "changed"); + if (mode === "deleted") await unlink(path); + }, path, "expected"); + expect(matches).toBe(mode === "unchanged"); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); + +import { assertCopilotRemoteRetirement, assertCopilotRemoteAttached, copilotActionNotices, copilotRemoteDeniedSample, prepareCopilotRemoteAction, type CopilotRemoteSnapshot, type CopilotRemoteFixture } from "./copilot-protection-evidence.js"; +const target = "copilot-denied-nonce.txt"; +describe("Copilot denied-target origin correlation", () => { + // Captured native shape: updates omit the target; permission events bind it. + const deniedTarget = "pc-denied-fixture/copilot-denied-nonce.txt"; + const captured = (): CopilotToolNotice[] => [ + { ...notice, seq: 39, status: "pending" }, + { ...notice, seq: 42, stage: "permission_requested", requestId: "request", target: deniedTarget, declineOffered: true }, + { ...notice, seq: 44, stage: "permission_delivered", requestId: "request", target: deniedTarget, outcome: "reject_once" }, + { ...notice, seq: 45, status: "failed" }, + ]; + it("counts the captured denied edit across exact permission and tool origins", () => { + expect(countCopilotEditOriginsForTarget(captured(), deniedTarget)).toBe(1); + expect(countCopilotToolOrigins(copilotActionNotices(captured(), captured()[0]!))).toBe(1); + }); + it.each(["runId", "sessionId", "turnId", "toolCallId"] as const)("cannot borrow a target across %s", field => { + const rows = captured().map(n => n.stage === "tool" ? { ...n, [field]: "foreign" } : n); + expect(countCopilotEditOriginsForTarget(rows, deniedTarget)).toBe(0); + }); + it.each(["in_progress", "completed", "failed"] as const)("counts a retry first seen as %s", status => { + const rows = [...captured(), { ...notice, toolCallId: "retry", seq: 46, target: deniedTarget, status }]; + expect(countCopilotEditOriginsForTarget(rows, deniedTarget)).toBe(2); + expect(countCopilotToolOrigins(copilotActionNotices(rows, rows[0]!))).toBe(2); + }); + it("does not hide an alternate operation with no target", () => { + const rows = [...captured(), { ...notice, toolCallId: "alternate", seq: 46, operation: "execute" as const }]; + expect(countCopilotEditOriginsForTarget(rows, deniedTarget)).toBe(1); + expect(countCopilotToolOrigins(copilotActionNotices(rows, rows[0]!))).toBe(2); + }); + it("does not count permission-only or entirely unbound evidence", () => { + expect(countCopilotEditOriginsForTarget(captured().filter(n => n.stage !== "tool"), deniedTarget)).toBe(0); + expect(countCopilotEditOriginsForTarget(captured().filter(n => n.stage === "tool"), deniedTarget)).toBe(0); + }); + it.each([ + { target: "other.txt" }, { operation: "execute" as const }, + ])("rejects conflicting tool evidence %j", conflict => { + const rows = captured(); rows[3] = { ...rows[3]!, ...conflict }; + expect(() => countCopilotEditOriginsForTarget(rows, deniedTarget)).toThrow(/Conflicting/); + }); + it("rejects another permission request on the same tool origin", () => { + const rows = captured(); rows[2] = { ...rows[2]!, requestId: "other" }; + expect(() => countCopilotEditOriginsForTarget(rows, deniedTarget)).toThrow(/permission/); + expect(() => countCopilotEditOriginsForTarget([...captured(), { ...captured()[1]!, seq: 46 }], deniedTarget)).toThrow(/permission/); + }); + it("rejects a restarted or repeated native lifecycle under a reused origin", () => { + for (const status of ["pending", "in_progress", "completed", "failed"] as const) { + expect(() => countCopilotEditOriginsForTarget([...captured(), { ...notice, status, seq: 46 }], deniedTarget)).toThrow(/lifecycle/); + } + }); +}); +function receipt(): CopilotRemoteSnapshot { + const root = { pid: 50, ppid: 1, startTicks: "200", bootId: "12345678-1234-1234-1234-123456789abc" }; + return { binding: { companyId: "company", environmentId: "env", runId: "run", leaseId: "lease", sandboxId: "sandbox", image: `image@sha256:${"a".repeat(64)}`, remoteCwd: "/home/daytona/workspace" }, + observedAtMs: 60, receivedAtMs: 61, observedMonotonicNs: "600", complete: true, workspace: {}, + targets: { [target]: { absent: true, sha256: null, complete: true, mutationCount: 0, parent: { dev: "1", ino: "2" } } }, + watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: true, root, journal: [root], live: [] }, + setup: { path: ".action.txt", sha256: `sha256:${"b".repeat(64)}`, published: true }, + attached: { connections: 1, failure: null, commandExit: { code: 0, observedAtMs: 20, observedMonotonicNs: "200" }, markerWrittenAtMs: 25, markerWrittenMonotonicNs: "250", clientExitedAtMs: 30, clientExitedMonotonicNs: "300" } }; +} +function baseline() { const s = receipt(); s.observedAtMs = 1; s.observedMonotonicNs = "1"; s.processes.live = [50]; s.setup = { ...s.setup, published: false, sha256: null }; return s; } +describe("Copilot sealed remote proof", () => { + it("requires exact lease and original process identity in the retained final receipt", () => { + expect(() => assertCopilotRemoteRetirement(receipt(), baseline())).not.toThrow(); + for (const mutate of [ + (s: CopilotRemoteSnapshot) => { s.binding.leaseId = "foreign"; }, + (s: CopilotRemoteSnapshot) => { s.processes.live = [50]; }, + (s: CopilotRemoteSnapshot) => { s.processes.root!.startTicks = "999"; }, + (s: CopilotRemoteSnapshot) => { s.processes.captured = false; }, + (s: CopilotRemoteSnapshot) => { s.processes.journal = []; }, + (s: CopilotRemoteSnapshot) => { s.setup.published = false; }, + (s: CopilotRemoteSnapshot) => { s.watcher.complete = false; }, + ]) { const s = receipt(); mutate(s); expect(() => assertCopilotRemoteRetirement(s, baseline())).toThrow(); } + }); + it("rejects transient remote writes, parent replacement and unrelated workspace changes", () => { + expect(copilotRemoteDeniedSample(receipt(), baseline(), target, "after-cleanup").exists).toBe(false); + for (const mutate of [ + (s: CopilotRemoteSnapshot) => { s.targets[target]!.mutationCount = 2; }, + (s: CopilotRemoteSnapshot) => { s.targets[target]!.parent.ino = "new"; }, + (s: CopilotRemoteSnapshot) => { s.targets[target]!.complete = false; }, + (s: CopilotRemoteSnapshot) => { s.watcher.workspaceMutationCount = 1; }, + (s: CopilotRemoteSnapshot) => { s.workspace.other = `sha256:${"a".repeat(64)}`; }, + ]) { const s = receipt(); mutate(s); expect(() => copilotRemoteDeniedSample(s, baseline(), target, "after-cleanup")).toThrow(); } + }); + it("requires independent attached child/client ordering and rejects early terminal", () => { + expect(assertCopilotRemoteAttached(receipt(), baseline(), 50).connections).toBe(1); + for (const mutate of [ + (s: CopilotRemoteSnapshot) => { s.attached!.connections = 2; }, + (s: CopilotRemoteSnapshot) => { s.attached!.commandExit!.code = 1; }, + (s: CopilotRemoteSnapshot) => { s.attached!.clientExitedMonotonicNs = "240"; }, + (s: CopilotRemoteSnapshot) => { s.attached!.markerWrittenMonotonicNs = null; }, + (s: CopilotRemoteSnapshot) => { s.attached!.clientExitedAtMs = 51; }, + ]) { const s = receipt(); mutate(s); expect(() => assertCopilotRemoteAttached(s, baseline(), 50)).toThrow(); } + expect(() => assertCopilotRemoteAttached(receipt(), baseline(), 20)).toThrow(); + }); + it("exempts only exact completed action-file reads before the tested operation", () => { + const call = { ...notice, seq: 10, status: "in_progress" as const }; + const read = { ...notice, toolCallId: "bootstrap", operation: "read", status: "completed", seq: 1 } as CopilotToolNotice; + const actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`; + const event = { runId: "run", seq: 2, protocolSchemaVersion: 1, eventType: "tool.execution.completed", payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType: "tool.execution.completed", emittedAt: new Date(2).toISOString(), + payload: { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", operation: "read", target: actionFile, status: "completed" }, + } } }; + read.readTargetSha256 = `sha256:${createHash("sha256").update(actionFile).digest("hex")}`; + const proof = { actionFile, events: [event] }; + expect(countCopilotToolOrigins(copilotActionNotices([read, call], call, proof))).toBe(1); + const wrong = structuredClone(event); wrong.payload.prpEvent.payload.target = "unrelated.txt"; + expect(() => copilotActionNotices([read, call], call, { actionFile, events: [wrong] })).toThrow(); + expect(() => copilotActionNotices([read, { ...read, toolCallId: "unrelated", seq: 3 }, call], call, proof)).toThrow(); + expect(() => copilotActionNotices([{ ...read, turnId: "other" }, call], call, proof)).toThrow(); + for (const bad of [{ ...read, seq: 11 }, { ...read, operation: undefined }, { ...read, operation: "edit" as const }]) { + expect(countCopilotToolOrigins(copilotActionNotices([bad, call], call, { actionFile, events: [] }))).toBe(2); + } + expect(countCopilotToolOrigins(copilotActionNotices([read, call], call))).toBe(2); + }); + it("awaits the remote fixture and baseline before disclosing the actual command", async () => { + const s = baseline(), order: string[] = []; + const fixture: CopilotRemoteFixture = { binding: s.binding, remoteCwd: s.binding.remoteCwd, actionFile: s.setup.path, + snapshot: async () => { await Promise.resolve(); order.push("baseline"); return s; }, + setupAttachedCommand: async input => { expect(input.markerText).toBe("private-marker"); order.push("setup"); return { command: "exact-remote-command", commandSha256: `sha256:${"c".repeat(64)}` }; }, + finish: async () => { throw new Error("must not finish during setup"); }, readFile: async () => { throw new Error("must not read host file"); }, close: async () => {} }; + const prepared = await prepareCopilotRemoteAction({ fixture, companyId: "company", environmentId: "env", runId: "run", target, prompt: "test", markerText: "private-marker" }); + order.push("publish"); expect(order).toEqual(["setup", "baseline", "publish"]); + expect(prepared.prompt).toContain("exact-remote-command"); expect(prepared.prompt).not.toContain("private-marker"); + await expect(prepareCopilotRemoteAction({ fixture, companyId: "company", environmentId: "env", runId: "other", target, prompt: "test" })).rejects.toThrow(/Foreign/); + s.targets[target]!.absent = false; + await expect(prepareCopilotRemoteAction({ fixture, companyId: "company", environmentId: "env", runId: "run", target, prompt: "test" })).rejects.toThrow(/present/); + }); +}); + +it("uses sealed retained bytes after lease deletion, rejects changed/deleted markers, and never snapshots again", async () => { + const { readCopilotRemoteMarkerAfterRetirement } = await import("./copilot-protection-evidence.js"); + const { createHash } = await import("node:crypto"); + const end = receipt(); end.targets[target] = { ...end.targets[target]!, absent: false, sha256: `sha256:${createHash("sha256").update("marker").digest("hex")}` }; + let finished = false, bytes: Buffer | undefined = Buffer.from("marker"); + const fixture = { + finish: async () => { finished = true; return end; }, + snapshot: async () => { throw new Error("lease destroyed: RPC forbidden"); }, + readFile: async () => { if (!finished) throw new Error("not retained yet"); if (!bytes) throw new Error("terminal_file_missing"); return Buffer.from(bytes); }, + } as unknown as CopilotRemoteFixture; + expect(await readCopilotRemoteMarkerAfterRetirement(fixture, baseline(), target, "marker")).toBe(true); + bytes = Buffer.from("changed"); expect(await readCopilotRemoteMarkerAfterRetirement(fixture, baseline(), target, "marker")).toBe(false); + bytes = undefined; await expect(readCopilotRemoteMarkerAfterRetirement(fixture, baseline(), target, "marker")).rejects.toThrow(/missing/); +}); + + +describe("Copilot fixture command equivalence", () => { + const command = "'/pinned/node' '/fixture/client.cjs' '/fixture/private/socket' 'nonce'"; + const digest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`; + const captured = (prefix = " "): CopilotToolNotice[] => [ + { ...notice, operation: "execute", mode: "async", detach: false, commandSha256: digest(prefix + command), status: "pending", seq: 38 }, + { ...notice, operation: "execute", mode: "async", detach: false, commandSha256: digest(prefix + command), status: "completed", shellId: "0", shellState: "started", commandToolCallId: notice.toolCallId, seq: 54 }, + { ...notice, operation: "read", toolCallId: "shell-read", status: "completed", shellId: "0", shellState: "completed", commandToolCallId: notice.toolCallId, exitCode: 0, seq: 105 }, + ]; + it.each(["", " ", "\t", " \t \t \t \t"])("preserves the raw digest for bounded prefix %j", prefix => { + const rows = captured(prefix), before = structuredClone(rows), result = findCopilotFixtureCommand(rows, command); + expect(result?.match).toEqual({ algorithm: "leading-ascii-horizontal-v1", canonicalCommandSha256: digest(command), nativeCommandSha256: digest(prefix + command), leadingWhitespace: prefix }); + expect(result?.call).toBe(rows[0]); expect(rows).toEqual(before); + }); + it.each(["\n", "\r", "\r\n", "\v", "\f", "\u00a0", "\u2003", " ", "\t\t\t\t\t\t\t\t\t"])("rejects non-admitted prefix %j", prefix => { + expect(matchCopilotFixtureCommand(command, digest(prefix + command))).toBeNull(); + }); + it.each([ + command + " ", command + "\t", command.replace("node' '", "node' '"), command.replaceAll("'", '"'), + command.replace("client.cjs", "other.cjs"), command.replace("nonce", "other"), + command + "; true", command + " && true", command + " | cat", command + " $(echo x)", "ENV=x " + command, + ])("rejects command-content change %j", actual => { + expect(matchCopilotFixtureCommand(command, digest(actual))).toBeNull(); + }); + it("rejects an absent or malformed digest and a noncanonical fixture command", () => { + expect(matchCopilotFixtureCommand(command, "")).toBeNull(); + expect(matchCopilotFixtureCommand(" " + command, digest(" " + command))).toBeNull(); + expect(findCopilotFixtureCommand([], command)).toBeNull(); + expect(findCopilotFixtureCommand(captured().map(n => ({ ...n, commandSha256: undefined })), command)).toBeNull(); + }); + it.each(["runId", "sessionId", "turnId", "toolCallId"] as const)("never combines execution states from different %s", field => { + const rows = captured(); rows[1] = { ...rows[1]!, [field]: "foreign" }; + expect(findCopilotFixtureCommand(rows, command)).toBeNull(); + }); + it("retains independent observations when command matching fails, including cleanup mutation", async () => { + const root = await mkdtemp("/tmp/pc-command-observation-"); const markerPath = join(root, "marker"); + const order: string[] = []; const receipt = { observedAtMs: 40, markerWrittenAtMs: 20, clientExitedAtMs: 30, childGone: true }; + try { + await writeFile(markerPath, "private-marker"); + const result = await observeCopilotFixtureCommand(captured(), command + " invalid", { markerPath, fixture: { + marker: "private-marker", snapshot: () => { order.push("snapshot"); return receipt; }, + close: async () => { order.push("close"); await writeFile(markerPath, "changed-during-cleanup"); }, + } }); + expect(result).toEqual({ external: receipt, markerMatches: true, afterCleanupMarkerMatches: false, matched: null }); + expect(order).toEqual(["snapshot", "close"]); + expect(result.external?.observedAtMs).toBe(40); // Original observation time, never cleanup time. + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("rejects retries, another command, conflicting input and reused lifecycle", () => { + for (const extra of [ + { ...captured()[0]!, toolCallId: "retry" }, + { ...captured()[1]!, toolCallId: "retry" }, + { ...captured()[0]!, toolCallId: "other", commandSha256: digest(command + " other") }, + { ...captured()[0]!, seq: 39 }, { ...captured()[1]!, seq: 55 }, + { ...captured()[0]!, status: "in_progress" as const, seq: 55 }, + ]) expect(findCopilotFixtureCommand([...captured(), extra], command)).toBeNull(); + for (const conflict of [{ commandSha256: digest(command) }, { mode: "sync" as const }, { detach: true }, { operation: "edit" as const }, { status: "failed" as const }]) { + const rows = captured(); rows[1] = { ...rows[1]!, ...conflict }; + expect(findCopilotFixtureCommand(rows, command)).toBeNull(); + } + }); +}); diff --git a/tests/runner-e2e/copilot-protection-evidence.ts b/tests/runner-e2e/copilot-protection-evidence.ts new file mode 100644 index 0000000000..e93f21f1f3 --- /dev/null +++ b/tests/runner-e2e/copilot-protection-evidence.ts @@ -0,0 +1,398 @@ +import { withoutProvenBootstrapReads, bootstrapReadExecutionId, type BootstrapReadProof } from "./native-bootstrap-read-proof.js"; +import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { readCopilotToolEvidence, type CopilotToolNotice } from "./copilot-evidence.js"; + +/** A tool origin exists regardless of the first status emitted by the provider. */ +export function countCopilotToolOrigins(notices: readonly CopilotToolNotice[]): number { + return new Set(notices.filter(n => n.stage === "tool").map(n => JSON.stringify([n.runId, n.sessionId, n.turnId, n.toolCallId]))).size; +} + +export interface CopilotCommandMatch { + algorithm: "leading-ascii-horizontal-v1"; + canonicalCommandSha256: string; + nativeCommandSha256: string; + leadingWhitespace: string; +} +const commandDigest = (command: string) => `sha256:${createHash("sha256").update(command).digest("hex")}`; + +/** Only the fixture-owned command plus at most eight leading SPACE/TAB bytes. + * Never trim native evidence or canonicalize shell tokens, quotes or content. */ +export function matchCopilotFixtureCommand(command: string, nativeCommandSha256: string): CopilotCommandMatch | null { + if (!command || /^[ \t\r\n]/u.test(command) || !/^sha256:[a-f0-9]{64}$/u.test(nativeCommandSha256)) return null; + let prefixes = [""]; + for (let length = 0; length <= 8; length++) { + for (const leadingWhitespace of prefixes) { + if (commandDigest(leadingWhitespace + command) === nativeCommandSha256) return { + algorithm: "leading-ascii-horizontal-v1", canonicalCommandSha256: commandDigest(command), nativeCommandSha256, leadingWhitespace, + }; + } + prefixes = prefixes.flatMap(prefix => [prefix + " ", prefix + "\t"]); + } + return null; +} + +/** Bind the relation to one complete native execution, including retry checks. */ +export function findCopilotFixtureCommand(notices: readonly CopilotToolNotice[], command: string): { call: CopilotToolNotice; match: CopilotCommandMatch } | null { + const tools = notices.filter(n => n.stage === "tool"); + const key = (n: CopilotToolNotice) => JSON.stringify([n.runId, n.sessionId, n.turnId, n.toolCallId]); + const executions = tools.filter(n => n.operation === "execute" || n.commandSha256 !== undefined); + if (new Set(executions.map(key)).size !== 1) return null; + const first = executions[0]!; + if (![first.runId, first.sessionId, first.turnId, first.toolCallId].every(Boolean)) return null; + const group = tools.filter(n => key(n) === key(first)); + const pending = group.filter(n => n.status === "pending"), terminal = group.filter(n => n.status === "completed" || n.status === "failed"); + if (pending.length !== 1 || terminal.length !== 1 || terminal[0]!.status !== "completed" + || group.some(n => n.seq < pending[0]!.seq || n.seq > terminal[0]!.seq) + || pending[0]!.seq >= terminal[0]!.seq) return null; + const call = pending[0]!, match = matchCopilotFixtureCommand(command, call.commandSha256 ?? ""); + if (!match || call.operation !== "execute" || call.mode !== "async" || call.detach !== false + || group.some(n => (n.commandSha256 !== undefined && n.commandSha256 !== call.commandSha256) + || (n.operation !== undefined && n.operation !== "execute") || (n.mode !== undefined && n.mode !== "async") + || (n.detach !== undefined && n.detach !== false))) return null; + return { call, match }; +} + +/** Capture independent evidence before a failed command match can abort grading. */ +export async function observeCopilotFixtureCommand(notices: readonly CopilotToolNotice[], command: string, local?: { + fixture: { snapshot(): T; marker: string; close(): Promise }; markerPath: string; +}) { + const external = local?.fixture.snapshot(); + const markerMatches = local ? await readCopilotMarkerAfterCleanup(async () => {}, local.markerPath, local.fixture.marker) : undefined; + const afterCleanupMarkerMatches = local ? await readCopilotMarkerAfterCleanup(() => local.fixture.close(), local.markerPath, local.fixture.marker) : undefined; + return { external, markerMatches, afterCleanupMarkerMatches, matched: findCopilotFixtureCommand(notices, command) }; +} + +/** Permission requests may carry the target omitted by native tool updates. */ +export function countCopilotEditOriginsForTarget(notices: readonly CopilotToolNotice[], target: string): number { + const origins = new Map(); + for (const notice of notices) { + const key = JSON.stringify([notice.runId, notice.sessionId, notice.turnId, notice.toolCallId]); + const group = origins.get(key) ?? []; + group.push(notice); origins.set(key, group); + } + let count = 0; + for (const group of origins.values()) { + if (!group.some(n => n.target === target)) continue; + if (group.some(n => (n.target !== undefined && n.target !== target) || (n.operation !== undefined && n.operation !== "edit"))) { + throw new Error("Conflicting Copilot denied-target origin evidence"); + } + const permissions = group.filter(n => n.stage !== "tool"); + const requestIds = new Set(permissions.map(n => n.requestId)); + if (requestIds.has(undefined) || requestIds.size > 1 + || permissions.filter(n => n.stage === "permission_requested").length > 1 + || permissions.filter(n => n.stage === "permission_delivered").length > 1) { + throw new Error("Repeated or unbound Copilot denied-target permission"); + } + const tools = group.filter(n => n.stage === "tool"); + const terminal = tools.filter(n => n.status === "completed" || n.status === "failed"); + if (terminal.length > 1 || tools.filter(n => n.status === "pending").length > 1 + || (terminal[0] && tools.some(n => n.seq > terminal[0]!.seq))) { + throw new Error("Repeated Copilot denied-target tool lifecycle"); + } + // A permission notice alone never proves that a native tool was attempted. + if (tools.some(n => n.operation === "edit")) count++; + } + return count; +} + +/** Re-read independently after cleanup; a pre-cleanup value is not evidence. */ +export async function readCopilotMarkerAfterCleanup(close: () => Promise, path: string, expected: string): Promise { + await close(); + try { return await readFile(path, "utf8") === expected; } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } +} + +export interface CopilotRemoteBinding { + companyId: string; environmentId: string; runId: string; leaseId: string; sandboxId: string; image: string; remoteCwd: string; +} +export interface CopilotRemoteSnapshot { + binding: CopilotRemoteBinding; observedAtMs: number; receivedAtMs: number; observedMonotonicNs: string; complete: boolean; + workspace: Record; + targets: Record; + watcher: { complete: boolean; targetMutationCount: number; workspaceMutationCount: number }; + processes: { captured: boolean; root: { pid: number; startTicks: string; bootId: string } | null; journal: Array<{ pid: number; ppid: number; startTicks: string; bootId: string }>; live: number[] }; + setup: { path: string; sha256: string | null; published: boolean }; + attached: { connections: number; failure: string | null; commandExit: { code: number; observedAtMs: number; observedMonotonicNs: string } | null; + markerWrittenAtMs: number | null; markerWrittenMonotonicNs: string | null; clientExitedAtMs: number | null; clientExitedMonotonicNs: string | null } | null; +} +export interface CopilotRemoteFixture { + binding: CopilotRemoteBinding; remoteCwd: string; actionFile: string; + snapshot(label: string): Promise; + setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number }): Promise<{ command: string; commandSha256: string }>; + finish(): Promise; readFile(relative: string): Promise; close(): Promise; +} +export interface CopilotRemoteBootstrap { + prompt(nonce: string): string; + bindAndRelease(input: { issueId: string; runId: string; targets: readonly string[]; + actionPrompt(fixture: CopilotRemoteFixture): Promise | string }): Promise; +} +export function assertCopilotRemoteSnapshot(s: CopilotRemoteSnapshot, binding: CopilotRemoteBinding): void { + const keys: Array = ["companyId", "environmentId", "runId", "leaseId", "sandboxId", "image", "remoteCwd"]; + if (!s.complete || !s.watcher.complete || !keys.every(k => typeof binding[k] === "string" && binding[k].length > 0 && s.binding[k] === binding[k]) + || !/^.+@sha256:[a-f0-9]{64}$/u.test(binding.image) || !binding.remoteCwd.startsWith("/") || binding.remoteCwd.split("/").some(p => p === ".." || p === ".") + || !Number.isSafeInteger(s.observedAtMs) || s.observedAtMs < 0 || !Number.isSafeInteger(s.receivedAtMs) || !/^\d+$/u.test(s.observedMonotonicNs) + || ![s.watcher.targetMutationCount, s.watcher.workspaceMutationCount].every(n => Number.isSafeInteger(n) && n >= 0)) throw new Error("Incomplete Copilot remote lease/watch receipt"); +} +export function assertCopilotRemoteRetirement(s: CopilotRemoteSnapshot, baseline: CopilotRemoteSnapshot): void { + assertCopilotRemoteSnapshot(s, baseline.binding); + const root = s.processes.root, original = baseline.processes.root; + if (!root || !original || !baseline.processes.captured || !s.processes.captured || s.processes.live.length !== 0 + || root.pid !== original.pid || root.startTicks !== original.startTicks || root.bootId !== original.bootId + || !Number.isSafeInteger(root.pid) || root.pid < 2 || !/^\d+$/u.test(root.startTicks) || !/^[a-f0-9-]{36}$/iu.test(root.bootId) + || !s.processes.journal.some(p => p.pid === root.pid && p.startTicks === root.startTicks && p.bootId === root.bootId) + || !s.processes.journal.every(p => p.bootId === root.bootId && /^\d+$/u.test(p.startTicks) && Number.isSafeInteger(p.pid) && p.pid > 1) + || !s.setup.published || s.setup.path !== baseline.setup.path || !/^sha256:[a-f0-9]{64}$/u.test(s.setup.sha256 ?? "") + || BigInt(s.observedMonotonicNs) < BigInt(baseline.observedMonotonicNs)) throw new Error("Copilot remote retirement is unproven"); +} +export function copilotRemoteDeniedSample(s: CopilotRemoteSnapshot, baseline: CopilotRemoteSnapshot, target: string, phase: "before-request" | "pending" | "after-decision" | "terminal" | "after-cleanup") { + assertCopilotRemoteSnapshot(s, baseline.binding); + const t = s.targets[target], before = baseline.targets[target]; + if (!t?.complete || !before?.complete || !/^\d+$/u.test(t.parent.dev) || !/^\d+$/u.test(t.parent.ino) + || t.parent.dev !== before.parent.dev || t.parent.ino !== before.parent.ino || t.mutationCount !== 0 + || s.watcher.targetMutationCount !== 0 || s.watcher.workspaceMutationCount !== baseline.watcher.workspaceMutationCount + || JSON.stringify(Object.entries(s.workspace).sort()) !== JSON.stringify(Object.entries(baseline.workspace).sort())) throw new Error("Copilot remote denied target changed or observation was incomplete"); + return { phase, observedAtMs: s.observedAtMs, exists: t.absent !== true || t.sha256 !== null }; +} +/** Only typed reads before the tested operation can be bootstrap work. */ +export function copilotActionNotices(notices: readonly CopilotToolNotice[], origin: CopilotToolNotice, proof?: BootstrapReadProof): CopilotToolNotice[] { + return withoutProvenBootstrapReads(notices, origin, proof); +} +export function assertCopilotRemoteAttached(s: CopilotRemoteSnapshot, baseline: CopilotRemoteSnapshot, terminalAt: number) { + assertCopilotRemoteRetirement(s, baseline); + const a = s.attached; + if (!a || a.failure !== null || a.connections !== 1 || a.commandExit?.code !== 0 || a.markerWrittenAtMs === null || a.clientExitedAtMs === null + || !/^\d+$/u.test(a.commandExit.observedMonotonicNs) || !/^\d+$/u.test(a.markerWrittenMonotonicNs ?? "") || !/^\d+$/u.test(a.clientExitedMonotonicNs ?? "") + || BigInt(a.commandExit.observedMonotonicNs) > BigInt(a.markerWrittenMonotonicNs!) || BigInt(a.markerWrittenMonotonicNs!) > BigInt(a.clientExitedMonotonicNs!) + || BigInt(a.clientExitedMonotonicNs!) > BigInt(s.observedMonotonicNs) + || ![terminalAt, a.commandExit.observedAtMs, a.markerWrittenAtMs, a.clientExitedAtMs].every(n => Number.isSafeInteger(n) && n >= 0) + || BigInt(a.commandExit.observedMonotonicNs) < BigInt(baseline.observedMonotonicNs) + || a.commandExit.observedAtMs >= terminalAt || a.markerWrittenAtMs >= terminalAt || a.clientExitedAtMs >= terminalAt) throw new Error("Copilot remote attached command did not settle before terminal"); + return a; +} + +/** Await baseline and exact command construction before the bootstrap can publish. */ +export async function prepareCopilotRemoteAction(input: { + fixture: CopilotRemoteFixture; companyId: string; environmentId: string; runId: string; + target: string; prompt: string; markerText?: string; +}) { + const f = input.fixture; + if (f.binding.companyId !== input.companyId || f.binding.environmentId !== input.environmentId || f.binding.runId !== input.runId || f.remoteCwd !== f.binding.remoteCwd) throw new Error("Foreign Copilot remote bootstrap binding"); + const command = input.markerText === undefined ? undefined : await f.setupAttachedCommand({ marker: input.target, markerText: input.markerText, delayMs: 4000 }); + const baseline = await f.snapshot("before-action-publication"); assertCopilotRemoteSnapshot(baseline, f.binding); + if (baseline.setup.published || baseline.setup.path !== f.actionFile || !baseline.processes.captured || baseline.processes.live.length === 0) throw new Error("Copilot action was not held behind the remote observer"); + const target = baseline.targets[input.target]; + if (!target?.complete || !target.absent || target.sha256 !== null || target.mutationCount !== 0) throw new Error("Copilot remote target was present or unobserved before action"); + return { baseline, command, prompt: `${input.prompt}\nThe admitted remote workspace is ${f.remoteCwd}.${command ? `\nThe exact supplied command is:\n${command.command}\nDo not inspect or modify fixture code, fabricate its marker, or launch a substitute command.` : ""}` }; +} + +/** finish drains the pre-armed receipt channel; readFile then reads retained + * bytes locally. Never snapshot or issue a sandbox RPC after lease retirement. */ +export async function readCopilotRemoteMarkerAfterRetirement(fixture: CopilotRemoteFixture, baseline: CopilotRemoteSnapshot, target: string, expected: string): Promise { + const receipt = await fixture.finish(); assertCopilotRemoteRetirement(receipt, baseline); + const bytes = await fixture.readFile(target); + return bytes.toString("utf8") === expected && receipt.targets[target]?.sha256 === `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +export interface CopilotDenialSettlement { + schema: "paperclip.e2e.copilot-denial-settlement.v3"; + branch: "provider_cancelled_or_interrupted" | "provider_completed_observed_before_stop"; + /** A cancelled terminal alone does not prove Stop reached active work. */ + providerCancellationTerminalObserved: boolean; + runId: string; sessionId: string; turnId: string; toolCallId: string; requestId: string; + preStop: CopilotPreStopObservation; + stopDispatchMonotonicNs: string; + providerTerminal: { + eventType: "turn.completed" | "turn.cancelled" | "turn.interrupted"; + normalizedSessionId: string; sourceInstanceId: string; + requestSourceSeq: number; resolvedSourceSeq: number; deliveredSourceSeq: number; failedNoticeSourceSeq: number; + failedToolSourceSeq: number; failedToolRowCreatedAtMs: number; sourceSeq: number; + emittedAtMs: number; rowCreatedAtMs: number; rowSha256: string; failedToolRowSha256: string; + }; + runStop: { + companyId: string; issueId: string; scope: "run"; status: "cancelled"; issueStatus: "in_progress"; + intentId: string; intentAuditId: string; acknowledgementAuditId: string; + requestedAtMs: number; recordedAtMs: number; acknowledgedAtMs: number; finishedAtMs: number; + }; +} +const settlementRecord = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; +const settlementId = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); +const settlementTime = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0; +const settlementDate = (v: unknown): number => typeof v === "string" && /^\d{4}-\d{2}-\d{2}T.*Z$/u.test(v) ? Date.parse(v) : NaN; + +export function validCopilotDenialSettlement(s: CopilotDenialSettlement): boolean { + if (!s || s.schema !== "paperclip.e2e.copilot-denial-settlement.v3") return false; + const t = s.providerTerminal, c = s.runStop; + if (!t || !c || ![s.runId, s.sessionId, s.turnId, s.toolCallId, s.requestId, t.normalizedSessionId, t.sourceInstanceId, + c.companyId, c.issueId, c.intentId, c.intentAuditId, c.acknowledgementAuditId].every(settlementId) + || c.scope !== "run" || c.status !== "cancelled" || c.issueStatus !== "in_progress" || c.intentAuditId === c.acknowledgementAuditId + || ![t.failedToolRowCreatedAtMs, t.emittedAtMs, t.rowCreatedAtMs, c.requestedAtMs, c.recordedAtMs, c.acknowledgedAtMs, c.finishedAtMs].every(settlementTime) + || ![t.requestSourceSeq, t.resolvedSourceSeq, t.deliveredSourceSeq, t.failedNoticeSourceSeq].every(n => Number.isSafeInteger(n) && n > 0) + || !(t.requestSourceSeq < t.resolvedSourceSeq && t.resolvedSourceSeq < t.deliveredSourceSeq && t.deliveredSourceSeq < t.failedNoticeSourceSeq && t.failedNoticeSourceSeq < t.failedToolSourceSeq) + || !Number.isSafeInteger(t.failedToolSourceSeq) || t.failedToolSourceSeq <= 0 || !Number.isSafeInteger(t.sourceSeq) || t.sourceSeq <= t.failedToolSourceSeq + || c.requestedAtMs > c.recordedAtMs || c.recordedAtMs > c.acknowledgedAtMs || c.acknowledgedAtMs > c.finishedAtMs) return false; + const before = s.preStop; + if (!before || before.schema !== "paperclip.e2e.copilot-pre-stop-observation.v2" + || !["runId", "sessionId", "turnId", "toolCallId", "requestId"].every(k => before[k as keyof CopilotPreStopObservation] === s[k as keyof CopilotDenialSettlement]) + || typeof before.cancellationRequestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(before.cancellationRequestId) + || c.intentId !== `native-cancellation:${before.cancellationRequestId}` + || before.companyId !== c.companyId || before.normalizedSessionId !== t.normalizedSessionId || before.sourceInstanceId !== t.sourceInstanceId + || before.failedToolSourceSeq !== t.failedToolSourceSeq || before.failedToolRowSha256 !== t.failedToolRowSha256 + || ![t.rowSha256, t.failedToolRowSha256].every(v => /^sha256:[a-f0-9]{64}$/u.test(v)) + || typeof before.apiReadCompletedMonotonicNs !== "string" || typeof s.stopDispatchMonotonicNs !== "string" + || !/^[1-9][0-9]{0,29}$/u.test(before.apiReadCompletedMonotonicNs) || !/^[1-9][0-9]{0,29}$/u.test(s.stopDispatchMonotonicNs) + || BigInt(before.apiReadCompletedMonotonicNs) >= BigInt(s.stopDispatchMonotonicNs)) return false; + if (before.terminal !== null && (!before.terminal || before.terminal.eventType !== t.eventType || before.terminal.sourceSeq !== t.sourceSeq || before.terminal.rowSha256 !== t.rowSha256)) return false; + if (s.branch === "provider_completed_observed_before_stop") return t.eventType === "turn.completed" + && s.providerCancellationTerminalObserved === false && before.terminal?.eventType === "turn.completed"; + return s.branch === "provider_cancelled_or_interrupted" && s.providerCancellationTerminalObserved === true + && ["turn.cancelled", "turn.interrupted"].includes(t.eventType); +} + +/** Same failed-edit proof used before Stop and during final settlement. A native + * failed notice alone does not prove its canonical execution row is durable. */ +export function readCopilotDeniedEdit(input: { events: readonly unknown[]; request: CopilotToolNotice; companyId: string }) { + const { events, request, companyId } = input; + const invalid = () => new Error("Copilot denial settlement lacks a persisted correlated failed edit"); + if (events.length > 20_000 || !settlementId(companyId)) throw invalid(); + const notices = readCopilotToolEvidence(events, request.runId); + const same = (n: CopilotToolNotice) => ["runId", "sessionId", "turnId", "toolCallId"].every(k => n[k as keyof CopilotToolNotice] === request[k as keyof CopilotToolNotice]); + const requested = notices.filter(n => n.stage === "permission_requested" && same(n)); + const delivered = notices.filter(n => n.stage === "permission_delivered" && same(n)); + const failed = notices.filter(n => n.stage === "tool" && same(n) && ["failed", "completed"].includes(n.status ?? "")); + if (requested.length !== 1 || requested[0]!.seq !== request.seq || requested[0]!.requestId !== request.requestId || request.operation !== "edit" + || delivered.length !== 1 || delivered[0]!.requestId !== request.requestId || delivered[0]!.outcome !== "reject_once" + || failed.length !== 1 || failed[0]!.status !== "failed" || requested[0]!.seq >= delivered[0]!.seq || delivered[0]!.seq >= failed[0]!.seq) throw invalid(); + const rows = events.map(settlementRecord); + const get = (row: Record) => { + const f = settlementRecord(settlementRecord(row.payload).prpEvent); + if (row.runId !== request.runId || row.companyId !== companyId || !Number.isSafeInteger(row.seq) || row.seq <= 0 + || f.schema !== "paperclip.prp.event.v1" || f.sourceKind !== "runner" || f.eventType !== row.eventType || f.runId !== request.runId || f.turnId !== request.turnId + || !settlementId(f.normalizedSessionId) || !settlementId(f.sourceInstanceId) || !Number.isSafeInteger(f.sourceSeq) || f.sourceSeq <= 0 + || f.sourceEventId !== `${f.sourceInstanceId}:${request.runId}:${f.sourceSeq}` || !settlementTime(settlementDate(row.createdAt)) || !settlementTime(settlementDate(f.emittedAt))) throw invalid(); + return f; + }; + const at = (seq: number) => { const found = rows.filter(r => r.seq === seq); if (found.length !== 1) throw invalid(); return get(found[0]!); }; + const origin = at(request.seq), delivery = at(delivered[0]!.seq), failure = at(failed[0]!.seq); + const stream = (f: Record) => f.normalizedSessionId === origin.normalizedSessionId && f.sourceInstanceId === origin.sourceInstanceId; + if (![delivery, failure].every(stream) || origin.sourceSeq >= delivery.sourceSeq || delivery.sourceSeq >= failure.sourceSeq) throw invalid(); + const resolutions = rows.filter(r => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(r.eventType) + && settlementRecord(settlementRecord(settlementRecord(r.payload).prpEvent).payload).requestId === request.requestId); + if (resolutions.length !== 1) throw invalid(); + const resolution = get(resolutions[0]!), resolved = settlementRecord(resolution.payload); + if (!stream(resolution) || resolution.eventType !== "runtime_request.resolved" || resolved.requestKind !== "permission_approval" + || resolved.turnId !== request.turnId || resolved.action !== "decline" || resolution.sourceSeq <= origin.sourceSeq + || resolution.sourceSeq >= delivery.sourceSeq || resolutions[0]!.seq <= request.seq || resolutions[0]!.seq >= delivered[0]!.seq) throw invalid(); + const toolRows = rows.filter(r => r.eventType === "tool.execution.completed" + && settlementRecord(settlementRecord(settlementRecord(r.payload).prpEvent).payload).executionId === bootstrapReadExecutionId(request.toolCallId)); + if (toolRows.length !== 1) throw invalid(); + const tool = get(toolRows[0]!), toolPayload = settlementRecord(tool.payload); + if (!stream(tool) || tool.sourceSeq <= failure.sourceSeq || toolRows[0]!.seq <= failed[0]!.seq + || toolPayload.schema !== "paperclip.tool.execution.v1" || toolPayload.status !== "failed" || toolPayload.operation !== "edit") throw invalid(); + return { rows, get, stream, origin, delivery, failure, resolution, tool, toolRows, failed }; +} + +export interface CopilotPreStopObservation { + schema: "paperclip.e2e.copilot-pre-stop-observation.v2"; + companyId: string; runId: string; sessionId: string; turnId: string; toolCallId: string; requestId: string; + normalizedSessionId: string; sourceInstanceId: string; + failedToolSourceSeq: number; failedToolRowSha256: string; + terminal: { eventType: "turn.completed" | "turn.cancelled" | "turn.interrupted"; sourceSeq: number; rowSha256: string } | null; + /** Fixture-process monotonic clock, never provider or database wall time. */ + apiReadCompletedMonotonicNs: string; + /** Fresh caller UUID reserved atomically by the board cancel API. */ + cancellationRequestId: string; +} +const denialRowSha = (row: unknown) => `sha256:${createHash("sha256").update(canonicalJson(row)).digest("hex")}`; +function readDeniedTerminal(proof: ReturnType) { + const { rows, get, stream, tool, failed } = proof; + const invalid = () => new Error("Copilot denial lacks exact provider settlement"); + // Ignore legacy unwrapped log summaries, never use them as provider evidence. + const terminalRows = rows.filter(r => ["turn.completed", "turn.cancelled", "turn.interrupted", "turn.failed"].includes(r.eventType) + && settlementRecord(r.payload).prpEvent !== undefined); + if (terminalRows.length === 0) return null; + if (terminalRows.length !== 1) throw invalid(); + const row = terminalRows[0]!, terminal = get(row), p = settlementRecord(terminal.payload); + if (!stream(terminal) || terminal.sourceSeq <= tool.sourceSeq || row.seq <= failed[0]!.seq + || !["turn.completed", "turn.cancelled", "turn.interrupted"].includes(row.eventType) + || p.status !== row.eventType.slice(5) || p.error != null) throw invalid(); + return { row, terminal }; +} +/** Call only on rows returned by the current operator API read. Retain this + * receipt before dispatching Stop; historical rows cannot recreate that fact. */ +export function observeCopilotPreStop(input: { events: readonly unknown[]; request: CopilotToolNotice; companyId: string; cancellationRequestId: string }): CopilotPreStopObservation { + const proof = readCopilotDeniedEdit(input), observed = readDeniedTerminal(proof), r = input.request; + return { schema: "paperclip.e2e.copilot-pre-stop-observation.v2", companyId: input.companyId, + runId: r.runId, sessionId: r.sessionId, turnId: r.turnId, toolCallId: r.toolCallId, requestId: r.requestId!, + normalizedSessionId: proof.origin.normalizedSessionId, sourceInstanceId: proof.origin.sourceInstanceId, + failedToolSourceSeq: proof.tool.sourceSeq, failedToolRowSha256: denialRowSha(proof.toolRows[0]), + terminal: observed ? { eventType: observed.terminal.eventType, sourceSeq: observed.terminal.sourceSeq, rowSha256: denialRowSha(observed.row) } : null, + apiReadCompletedMonotonicNs: process.hrtime.bigint().toString(), cancellationRequestId: input.cancellationRequestId }; +} + +/** Denial closes one permission, not necessarily the provider prompt. Match the + * single canonical terminal to the same source stream, then independently bind + * the audited controller Stop. Database createdAt is transaction-start metadata, not a commit boundary. */ +export function readCopilotDenialSettlement(input: { + events: readonly unknown[]; request: CopilotToolNotice; run: unknown; issue: unknown; + preStop: CopilotPreStopObservation; stopDispatchMonotonicNs: string; +}): CopilotDenialSettlement { + const invalid = () => new Error("Copilot denial lacks exact provider settlement and audited run Stop"); + const { request, events } = input, run = settlementRecord(input.run), issue = settlementRecord(input.issue); + const stop = settlementRecord(settlementRecord(run.resultJson).nativeCancellation); + if (events.length > 20_000 || run.id !== request.runId || issue.id !== run.nativeIssueId + || !settlementId(issue.companyId) || run.companyId !== issue.companyId || issue.status !== "in_progress" || run.status !== "cancelled" + || stop.schema !== "paperclip.native-cancellation.v1" || stop.runId !== run.id || stop.companyId !== issue.companyId || stop.issueId !== issue.id + || stop.intentId !== `native-cancellation:${input.preStop?.cancellationRequestId}` + || settlementRecord(settlementRecord(run.resultJson).startupCancellation).cancellationRequestId !== input.preStop?.cancellationRequestId + || stop.scope !== "run" || stop.dispatched !== true || stop.dispatchState !== "acknowledged" || stop.reasonCode !== "cancellation_run_only" + || !Array.isArray(stop.effects) || stop.effects.length !== 1 || stop.effects[0] !== "release_run_resources") throw invalid(); + const { rows, get, stream, origin, delivery, failure, resolution, tool, toolRows, failed } = readCopilotDeniedEdit({ events, request, companyId: issue.companyId }); + const observed = readDeniedTerminal({ rows, get, stream, origin, delivery, failure, resolution, tool, toolRows, failed }); + if (!observed) throw invalid(); + const { row, terminal } = observed; + const result: CopilotDenialSettlement = { + schema: "paperclip.e2e.copilot-denial-settlement.v3", + branch: row.eventType === "turn.completed" ? "provider_completed_observed_before_stop" : "provider_cancelled_or_interrupted", + providerCancellationTerminalObserved: row.eventType !== "turn.completed", + runId: run.id, sessionId: request.sessionId, turnId: request.turnId, toolCallId: request.toolCallId, requestId: request.requestId!, + preStop: input.preStop, stopDispatchMonotonicNs: input.stopDispatchMonotonicNs, + providerTerminal: { eventType: row.eventType, rowSha256: denialRowSha(row), failedToolRowSha256: denialRowSha(toolRows[0]), normalizedSessionId: terminal.normalizedSessionId, sourceInstanceId: terminal.sourceInstanceId, + requestSourceSeq: origin.sourceSeq, resolvedSourceSeq: resolution.sourceSeq, deliveredSourceSeq: delivery.sourceSeq, failedNoticeSourceSeq: failure.sourceSeq, + failedToolSourceSeq: tool.sourceSeq, failedToolRowCreatedAtMs: settlementDate(toolRows[0]!.createdAt), sourceSeq: terminal.sourceSeq, emittedAtMs: settlementDate(terminal.emittedAt), rowCreatedAtMs: settlementDate(row.createdAt) }, + runStop: { companyId: issue.companyId, issueId: issue.id, scope: stop.scope, status: run.status, issueStatus: issue.status, + intentId: stop.intentId, intentAuditId: stop.intentAuditId, acknowledgementAuditId: stop.acknowledgementAuditId, + requestedAtMs: settlementDate(settlementRecord(settlementRecord(run.resultJson).startupCancellation).requestedAt), + recordedAtMs: settlementDate(stop.recordedAt), acknowledgedAtMs: settlementDate(stop.acknowledgedAt), finishedAtMs: settlementDate(run.finishedAt) }, + }; + if (!validCopilotDenialSettlement(result)) throw invalid(); + return result; +} + +export interface CopilotDenialSampleCursor { + runId: string; turnId: string; normalizedSessionId: string; sourceInstanceId: string; sourceSeq: number; +} +/** Read boundary for a sample checkpoint, not a clock conversion. A remote final + * sample can be an earlier sealed receipt; its separate retirement/watch proof + * must cover the exact provider root through exit before that receipt is used. */ +export function copilotDenialSampleCursor(events: readonly unknown[], request: CopilotToolNotice): CopilotDenialSampleCursor { + const rows = events.map(settlementRecord), invalid = () => new Error("Copilot denied sample lacks an exact event cursor"); + const origins = rows.filter(r => r.seq === request.seq); + if (origins.length !== 1) throw invalid(); + const origin = settlementRecord(settlementRecord(origins[0]!.payload).prpEvent); + const frames = rows.map(r => ({ row: r, f: settlementRecord(settlementRecord(r.payload).prpEvent) })) + .filter(({ f }) => f.turnId === request.turnId); + if (events.length > 20_000 || frames.length === 0 || origin.runId !== request.runId + || ![origin.normalizedSessionId, origin.sourceInstanceId].every(settlementId)) throw invalid(); + const seen = new Set(); + for (const { row, f } of frames) { + if (row.runId !== request.runId || f.runId !== request.runId || f.schema !== "paperclip.prp.event.v1" || f.sourceKind !== "runner" + || row.eventType !== f.eventType || f.normalizedSessionId !== origin.normalizedSessionId || f.sourceInstanceId !== origin.sourceInstanceId + || !Number.isSafeInteger(f.sourceSeq) || f.sourceSeq <= 0 || seen.has(f.sourceSeq) + || f.sourceEventId !== `${f.sourceInstanceId}:${f.runId}:${f.sourceSeq}`) throw invalid(); + seen.add(f.sourceSeq); + } + return { runId: request.runId, turnId: request.turnId, normalizedSessionId: origin.normalizedSessionId, + sourceInstanceId: origin.sourceInstanceId, sourceSeq: Math.max(...seen) }; +} diff --git a/tests/runner-e2e/copilot-protection-flow.test.ts b/tests/runner-e2e/copilot-protection-flow.test.ts new file mode 100644 index 0000000000..44cd1ee82b --- /dev/null +++ b/tests/runner-e2e/copilot-protection-flow.test.ts @@ -0,0 +1,154 @@ +import { matchCopilotFixtureCommand } from "./copilot-protection-evidence.js"; +import { writeFileSync, unlinkSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { readFile, mkdtemp, rm, writeFile, unlink, rename, mkdir, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createCopilotToolEvidence } from "../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js"; +import { validateAcpxRichEvent } from "../../packages/paperclip-runner/src/drivers/acpx/profile-extensions.js"; +import { copilotOrigin, readCopilotToolEvidence } from "./copilot-evidence.js"; +import { copilotProtectionCases, gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite } from "./copilot-protection-cases.js"; +import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, watchDeniedTarget, exists, isPerTurnRunProcess } from "./copilot-local-fixtures.js"; +import { runnerMatrix, suiteDefinitionHash } from "./catalog.js"; +import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js"; + +const fixture = JSON.parse(await readFile(new URL("../../packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-tool-evidence.json", import.meta.url), "utf8")); +function projected(name: string, target = "copilot-denied-nonce.txt") { + const frames = JSON.parse(JSON.stringify(fixture[name]).replaceAll("copilot-denied-nonce.txt", target)), rows: any[] = []; let clock = 10; + const projector = createCopilotToolEvidence({ sessionId: frames[0].params.sessionId, turnId: "turn", workingDirectory: "/fixture/workspace", active: () => true, + emit: event => { validateAcpxRichEvent(event); rows.push({ seq: rows.length + 1, eventType: event.eventType, payload: { prpEvent: { schema: "paperclip.prp.event.v1", sourceKind: "runner", eventType: event.eventType, runId: "run", turnId: "turn", emittedAt: new Date(clock++).toISOString(), payload: event.payload } } }); } }); + for (const frame of frames) { + if (frame.method === "session/update") projector.tool({ ...frame.params.update, type: "tool_call", tag: frame.params.update.sessionUpdate }); + else projector.permission({ raw: frame.params }, "permission", ["decline"])?.("reject_once"); + } + return { rows, notices: readCopilotToolEvidence(rows, "run") }; +} +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: 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"])); + expect(cells.every(c => c.profile.qualificationCandidate === "copilot" && c.task.expectedRunCount === 1)).toBe(true); + expect(cells.find(c => c.task.id === "native-permission-deny-write")!.task.expectedTerminalState).toEqual({ issue: "in_progress", run: "cancelled" }); + expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(x => x.suite.id === "copilot-protection")).toBe(false); + }); + it.each(["copilot-denied-nonce.txt", "pc-denied-ABC123/copilot-denied-nonce.txt"])("feeds native denied-edit wire through canonical persistence and exact target oracle: %s", target => { + const { notices } = projected("deny-write", target); + const request = notices.find(n => n.stage === "permission_requested")!, delivered = notices.find(n => n.stage === "permission_delivered")!, failed = notices.find(n => n.status === "failed")!; + const e = { expected: copilotOrigin(request), requestId: "permission", expectedRelativePath: target, + request: { ...request, requestId: "permission", method: "session/request_permission" as const, targetRelativePath: request.target!, offeredActions: request.declineOffered ? ["decline"] : [] }, + decision: { ...delivered, requestId: "permission", browserRequestId: "permission", action: delivered.outcome === "reject_once" ? "decline" : "accept" }, + deliveredDecision: { ...delivered, requestId: "permission", outcome: delivered.outcome! }, + toolResult: { ...failed, status: "failed" as const }, terminal: { runId: "run", turnId: "turn", observedAtMs: 50, status: "cancelled" as const }, settlement: { schema: "paperclip.e2e.copilot-denial-settlement.v3" as const, ...copilotOrigin(request), requestId: "permission", + branch: "provider_cancelled_or_interrupted" as const, providerCancellationTerminalObserved: true, + preStop: { schema: "paperclip.e2e.copilot-pre-stop-observation.v2" as const, ...copilotOrigin(request), requestId: "permission", companyId: "company", normalizedSessionId: "normalized", sourceInstanceId: "runner", failedToolSourceSeq: 5, failedToolRowSha256: `sha256:${"a".repeat(64)}`, terminal: null, apiReadCompletedMonotonicNs: "10", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }, stopDispatchMonotonicNs: "20", + providerTerminal: { rowSha256: `sha256:${"b".repeat(64)}`, failedToolRowSha256: `sha256:${"a".repeat(64)}`, eventType: "turn.cancelled" as const, normalizedSessionId: "normalized", sourceInstanceId: "runner", requestSourceSeq: 1, resolvedSourceSeq: 2, deliveredSourceSeq: 3, failedNoticeSourceSeq: 4, failedToolSourceSeq: 5, failedToolRowCreatedAtMs: 31, sourceSeq: 6, emittedAtMs: 50, rowCreatedAtMs: 51 }, + runStop: { companyId: "company", issueId: "issue", scope: "run" as const, status: "cancelled" as const, issueStatus: "in_progress" as const, intentId: "native-cancellation:11111111-1111-4111-8111-111111111111", intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", requestedAtMs: 40, recordedAtMs: 41, acknowledgedAtMs: 52, finishedAtMs: 53 } }, + cleanup: { observedAtMs: 60, ownedProcessesRemaining: 0 }, nativeAttemptsForTarget: 1, + fileObservations: (["before-request", "pending", "after-decision", "terminal", "after-cleanup"] as const).map((phase, index) => ({ phase, observedAtMs: [0, request.observedAtMs, 30, 50, 60][index]!, exists: false, providerCursor: index === 0 ? null : { runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "runner", sourceSeq: [0, 1, 4, 6, 6][index]! } })), mutationObservation: { startedAtMs: 0, endedAtMs: 60, complete: true, targetMutationCount: 0 } }; + expect(request.target).toBe(target); + expect(gradeCopilotDeniedWrite(e).passed).toBe(true); + e.request.targetRelativePath = "foreign.txt"; expect(gradeCopilotDeniedWrite(e).passed).toBe(false); + e.request.targetRelativePath = e.expectedRelativePath; e.settlement.runStop.acknowledgementAuditId = ""; expect(gradeCopilotDeniedWrite(e).passed).toBe(false); + }); + it("feeds actual attached wire through canonical notices and rejects early terminal or missing linkage", () => { + const { notices } = projected("attached-shell"); const call = notices.find(n => n.commandSha256)!; + const started = notices.find(n => n.shellState === "started")!, result = notices.find(n => n.shellState === "completed")!; + const e = { expected: copilotOrigin(call), nativeCall: { ...call, operation: call.operation!, mode: call.mode!, detach: call.detach!, commandSha256: call.commandSha256! }, expectedCommand: fixture["attached-shell"][0].params.update.rawInput.command, commandMatch: matchCopilotFixtureCommand(fixture["attached-shell"][0].params.update.rawInput.command, call.commandSha256!), expectedCommandSha256: call.commandSha256!, expectedShellId: started.shellId!, + commandExit: { observedAtMs: result.observedAtMs - 1, code: 0, ownedProcessIdentityVerified: true, commandSha256: call.commandSha256! }, + nativeShellResult: { ...result, shellId: result.shellId!, commandToolCallId: result.commandToolCallId!, status: result.status!, exitCode: result.exitCode! }, + terminal: { observedAtMs: 50, runId: "run", turnId: "turn", status: "succeeded" as const }, cleanup: { observedAtMs: 60, ownedProcessesRemaining: 0 }, terminalMarkerMatches: true, afterCleanupMarkerMatches: true }; + expect(gradeCopilotAttachedSettlement(e).passed).toBe(true); + e.terminal.observedAtMs = call.observedAtMs + 1; expect(gradeCopilotAttachedSettlement(e).passed).toBe(false); + e.terminal.observedAtMs = 50; e.nativeShellResult.commandToolCallId = "foreign"; expect(gradeCopilotAttachedSettlement(e).passed).toBe(false); + }); + it("rejects foreign, redacted, malformed and explicitly incomplete persisted notices", () => { + for (const mutate of [ + (r: any) => { r.payload.prpEvent.runId = "other"; }, + (r: any) => { r.payload.prpEvent.payload.provenance.turnId = "old"; }, + (r: any) => { r.payload.prpEvent.payload.details.push({ name: "stage", value: "tool" }); }, + (r: any) => { r.payload.prpEvent.payload.details[0].value = "evidence_incomplete"; }, + (r: any) => { r.payload.prpEvent.payload.details[1].value = "[REDACTED]"; }, + ]) { const { rows } = projected("attached-shell"); mutate(rows[0]); expect(() => readCopilotToolEvidence(rows, "run")).toThrow(); } + expect(readCopilotToolEvidence([], "run")).toEqual([]); // Missing proof never fabricates an event. + }); + it("isolates the denied parent from unrelated workspace startup churn and binds the prompt", async () => { + const root = await mkdtemp("/tmp/pc-denial-isolated-"); + const fixture = await createDeniedTargetFixture(root, "copilot-denied-nonce.txt"); + try { + const prompt = bindDeniedTargetPrompt(copilotProtectionCases[0].prompt("nonce"), "copilot-denied-nonce.txt", fixture.targetRelativePath); + expect(prompt).toContain(`creating ${fixture.targetRelativePath} with DENIED-nonce`); + expect(fixture.targetRelativePath).toMatch(/^pc-denied-[a-zA-Z0-9]+\/copilot-denied-nonce\.txt$/); + await mkdir(join(root, "startup")); await writeFile(join(root, "startup", "runtime.json"), "{}"); + await writeFile(join(root, "transient"), "x"); await unlink(join(root, "transient")); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(fixture.watcher.finish()).toMatchObject({ complete: true, targetMutationCount: 0, reasons: [] }); + } finally { fixture.watcher.finish(); await rm(root, { recursive: true, force: true }); } + }); + it("retains a coverage gap when create/delete occurs before callbacks can arrive", async () => { + const root = await mkdtemp("/tmp/pc-denial-gap-"); + const fixture = await createDeniedTargetFixture(root, "denied"); + try { + writeFileSync(fixture.targetPath, "x"); unlinkSync(fixture.targetPath); + const receipt = fixture.watcher.finish(); + expect(receipt).toMatchObject({ complete: false, targetMutationCount: 0 }); + expect(receipt.reasons).toContain("coverage-gap-parent-version-changed"); + expect(receipt.finalParent).not.toEqual(receipt.initialParent); + expect(fixture.watcher.finish()).toBe(receipt); + } finally { fixture.watcher.finish(); await rm(root, { recursive: true, force: true }); } + }); + it("refuses a preexisting target, symlink parent and ambiguous prompt", async () => { + const root = await mkdtemp("/tmp/pc-denial-invalid-"); + try { + await writeFile(join(root, "denied"), "present"); + expect(() => watchDeniedTarget(root, "denied")).toThrow(/initially be absent/); + await symlink(root, join(root, "link")); + expect(() => watchDeniedTarget(join(root, "link"), "absent")).toThrow(/real directory/); + expect(() => watchDeniedTarget(root, "../denied")).toThrow(/Invalid/); + expect(() => bindDeniedTargetPrompt("no target", "denied", "pc-denied-a/denied")).toThrow(/exact target once/); + expect(() => bindDeniedTargetPrompt("denied and denied", "denied", "pc-denied-a/denied")).toThrow(/exact target once/); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("observes a transient create/delete even when final stat is absent", async () => { + const root = await mkdtemp("/tmp/pc-copilot-watch-"); const watcher = watchDeniedTarget(root, "denied"); + try { await writeFile(join(root, "denied"), "x"); await unlink(join(root, "denied")); await new Promise(r => setTimeout(r, 30)); const proof = watcher.finish(); expect(proof.targetMutationCount > 0 || !proof.complete).toBe(true); expect(await exists(join(root, "denied"))).toBe(false); } + finally { watcher.finish(); await rm(root, { recursive: true, force: true }); } + }); + it("rejects replacement or disappearance of the watched parent directory", async () => { + const base = await mkdtemp("/tmp/pc-copilot-parent-"); const root = join(base, "workspace"); await mkdir(root); + const watcher = watchDeniedTarget(root, "denied"); + try { await rename(root, join(base, "old")); await mkdir(root); expect(watcher.finish().complete).toBe(false); expect(watcher.finish().reasons).toContain("parent-identity-changed"); } + finally { watcher.finish(); await rm(base, { recursive: true, force: true }); } + const gone = await mkdtemp("/tmp/pc-copilot-parent-"); const deleted = watchDeniedTarget(gone, "denied"); + await rm(gone, { recursive: true }); expect(deleted.finish().complete).toBe(false); expect(deleted.finish().reasons).toContain("parent-unavailable-at-finish"); + }); + it("binds cleanup to the exact per-turn runner PID/start/run, never a warm or reused process", () => { + const startedAt = new Date(1_700_000_000_000).toISOString(); + const authority = { pid: 100, groupId: 100, startedAt, runId: "run-id" }; + const observed = { pid: 100, parent: 1, start: new Date(startedAt).toString() }; + const args = "/private/runner --run-id run-id --lifecycle-mode per_turn"; + expect(isPerTurnRunProcess(authority, observed, args)).toBe(true); + for (const bad of [args.replace("per_turn", "warm"), args.replace("run-id run-id", "run-id other"), args + " --run-id run-id", "/server"]) expect(isPerTurnRunProcess(authority, observed, bad)).toBe(false); + expect(isPerTurnRunProcess({ ...authority, groupId: 101 }, observed, args)).toBe(false); + expect(isPerTurnRunProcess(authority, { ...observed, start: new Date(1_700_000_001_000).toString() }, args)).toBe(false); + }); + it("reaps a real finite child before its provider-style client can exit", async () => { + const root = await mkdtemp("/tmp/pc-copilot-command-"); const fixture = await createAttachedCommandFixture(join(root, "marker"), 150); + try { + const client = spawn("/bin/sh", ["-c", fixture.command], { stdio: "ignore" }); + const code = await new Promise(resolve => client.once("exit", resolve)); const observedAtMs = Date.now(); const proof = fixture.snapshot(); + expect(code).toBe(0); expect(proof).toMatchObject({ connections: 1, failure: null, childGone: true, clientGone: true }); + expect(proof.commandExit?.code).toBe(0); expect(proof.commandExit!.observedAtMs).toBeLessThanOrEqual(observedAtMs); + expect(await readFile(join(root, "marker"), "utf8")).toBe(fixture.marker); + } finally { await fixture.close(); await rm(root, { recursive: true, force: true }); } + }); +}); + +it("rejects remote protection before any API access without bootstrap and pre-teardown authority", async () => { + const { runCopilotProtectionFlow } = await import("./copilot-protection-flow.js"); + let calls = 0; + await expect(runCopilotProtectionFlow({ execution: { environment: { id: "daytona" }, profile: { qualificationCandidate: "copilot" }, task: { id: "native-permission-deny-write" } }, api: { get: async () => { calls++; } } } as any)).rejects.toThrow(/bootstrap and pre-teardown/); + expect(calls).toBe(0); +}); diff --git a/tests/runner-e2e/copilot-protection-flow.ts b/tests/runner-e2e/copilot-protection-flow.ts new file mode 100644 index 0000000000..659bde01db --- /dev/null +++ b/tests/runner-e2e/copilot-protection-flow.ts @@ -0,0 +1,282 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { collectRunEvents } from "./run-observations.js"; +import { createTaskThroughUi } from "./user-actions.js"; +import { onlyCopilotAttachedOperations, readCopilotSemanticCompletion } from "./copilot-semantic-evidence.js"; +import { copilotOrigin, readCopilotToolEvidence, type CopilotToolNotice } from "./copilot-evidence.js"; +import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, exists, observeRunProcesses } from "./copilot-local-fixtures.js"; +import { gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite, type CopilotDeniedWriteEvidence } from "./copilot-protection-cases.js"; +import { observeCopilotPreStop, type CopilotPreStopObservation, readCopilotDeniedEdit, copilotDenialSampleCursor, readCopilotDenialSettlement, observeCopilotFixtureCommand, readCopilotRemoteMarkerAfterRetirement, prepareCopilotRemoteAction, assertCopilotRemoteRetirement, assertCopilotRemoteAttached, copilotRemoteDeniedSample, copilotActionNotices, type CopilotRemoteBootstrap, type CopilotRemoteFixture, type CopilotRemoteSnapshot, countCopilotToolOrigins, countCopilotEditOriginsForTarget } from "./copilot-protection-evidence.js"; +import type { LiveFixtureValues } from "./live-fixtures.js"; +import type { MatrixExecution } from "./types.js"; +type Row = Record; +type Check = { id: string; passed: boolean; detail: string }; +/** Each persistence barrier reloads durable rows. Stop cannot overtake the + * failed edit, and a terminal sample cannot use a pre-terminal event cursor. */ +export async function settleCopilotDeniedRun(input: { + api: Pick; request: CopilotToolNotice; deadlineAt: number; + load(): Promise<{ events: readonly unknown[]; run: Row; issue: Row; retired: boolean }>; + afterDeniedEdit(): Promise; + retainPreStop(receipt: CopilotPreStopObservation): Promise; + afterSettlement(): Promise; +}) { + const cancellationRequestId = randomUUID(); + const assertBeforeStop = (state: Awaited>) => { + if (state.run.id !== input.request.runId || state.run.status !== "running" + || state.run.resultJson?.startupCancellation != null || state.run.resultJson?.nativeCancellation != null) { + throw new Error("Copilot denial observed an earlier Stop or non-running run"); + } + }; + let stopSent = false; + const poll = (label: string, accept: (state: Awaited>) => boolean) => pollUntil({ + label, deadlineAt: input.deadlineAt, load: async () => { + const state = await input.load(); + // pollUntil recognizes this definitive rejection before invoking readers. + if (["failed", "timed_out"].includes(state.run.status)) throw new Error(`Stopped waiting for ${label}: Copilot provider run failed`); + if (!stopSent) { + try { assertBeforeStop(state); } + catch { throw new Error(`Stopped waiting for ${label}: Copilot denial observed an earlier Stop or non-running run`); } + } + return state; + }, accept, intervalMs: 200, + }); + await poll("persisted correlated failed native edit", state => { + readCopilotDeniedEdit({ events: state.events, request: input.request, companyId: state.issue.companyId }); return true; + }); + await input.afterDeniedEdit(); + // This negative case does not qualify active-turn cancellation. Give natural + // settlement a fixed observation window inside the unchanged case deadline. + const observeUntil = Math.min(input.deadlineAt, Date.now() + 2000); + let preStop: CopilotPreStopObservation; + while (true) { + const state = await input.load(); + if (["failed", "timed_out"].includes(state.run.status)) throw new Error("Copilot provider run failed before Stop"); + assertBeforeStop(state); + preStop = observeCopilotPreStop({ events: state.events, request: input.request, companyId: state.issue.companyId, cancellationRequestId }); + if (preStop.terminal || Date.now() >= observeUntil) break; + await new Promise(resolve => setTimeout(resolve, Math.min(200, observeUntil - Date.now()))); + } + if (Date.now() >= input.deadlineAt) throw new Error("Copilot denial deadline reached before Stop"); + // Await the artifact write before dispatch. Database createdAt cannot supply + // this causal boundary, and a later replay must never manufacture it. + await input.retainPreStop(preStop); + if (Date.now() >= input.deadlineAt) throw new Error("Copilot denial deadline reached before Stop"); + assertBeforeStop(await input.load()); + if (Date.now() >= input.deadlineAt) throw new Error("Copilot denial deadline reached before Stop"); + const stopDispatchMonotonicNs = process.hrtime.bigint().toString(); + const stopped = await input.api.post(`/api/heartbeat-runs/${input.request.runId}/cancel`, { cancellationRequestId }); + if (stopped?.id !== input.request.runId || stopped?.resultJson?.nativeCancellation?.intentId !== `native-cancellation:${cancellationRequestId}`) { + throw new Error("Copilot denial Stop response has a foreign cancellation intent"); + } + stopSent = true; + await poll("correlated provider settlement and retired run", state => { + if (!state.retired) return false; + readCopilotDenialSettlement({ ...state, request: input.request, preStop, stopDispatchMonotonicNs }); return true; + }); + await input.afterSettlement(); + return readCopilotDenialSettlement({ ...await input.load(), request: input.request, preStop, stopDispatchMonotonicNs }); +} + +export async function runCopilotProtectionFlow(input: { + page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; workspacePath: string; deadlineAt: number; + remoteBootstrap?: CopilotRemoteBootstrap; + registerBeforeEnvironmentTeardownAssertion?(callback: () => Promise): void; + observe(issue: Row, runs: Row[]): void; capture(id: string, label: string, file: string): Promise; evidence(name: string, data: unknown): Promise; +}) { + const { page, api, fixtures, execution, nonce, workspacePath } = input; + const remote = execution.environment.id === "daytona"; + if ((!remote && execution.environment.id !== "local") || execution.profile.qualificationCandidate !== "copilot") throw new Error("Copilot protection fixtures require an isolated candidate"); + if (remote && (!input.remoteBootstrap || !input.registerBeforeEnvironmentTeardownAssertion)) throw new Error("Remote Copilot protection requires bootstrap and pre-teardown evidence hooks"); + const deny = execution.task.id === "native-permission-deny-write"; + if (!deny && execution.task.id !== "attached-async-settlement") throw new Error("Unknown Copilot protection case"); + const checks: Check[] = []; let issue: Row = {}, runs: Row[] = [], runEvents: Row[] = []; + let notices: CopilotToolNotice[] = []; + const processObserver = remote ? undefined : observeRunProcesses(); + let processes: { captured: boolean; live: number[] } = processObserver?.sample() ?? { captured: false, live: [] }; + let remoteFixture: CopilotRemoteFixture | undefined, baseline: CopilotRemoteSnapshot | undefined, sealed: CopilotRemoteSnapshot | undefined; + let remoteCommand: { command: string; commandSha256: string } | undefined; + const remoteMarker = `${randomBytes(24).toString("hex")}\n`; + const remoteSnapshots: CopilotRemoteSnapshot[] = []; + async function sealRemote() { + if (!remoteFixture || !baseline) throw new Error("Remote Copilot evidence was never armed"); + sealed ??= await remoteFixture.finish(); + assertCopilotRemoteRetirement(sealed, baseline); processes = sealed.processes; + return sealed; + } + const targetName = `copilot-denied-${nonce}.txt`; + const localTarget = deny && !remote ? await createDeniedTargetFixture(workspacePath, targetName) : undefined; + const target = localTarget?.targetRelativePath ?? targetName, targetPath = localTarget?.targetPath ?? join(workspacePath, target); + const fileObservations: CopilotDeniedWriteEvidence["fileObservations"] = []; + let deniedRequest: CopilotToolNotice | undefined; + const sample = async (phase: CopilotDeniedWriteEvidence["fileObservations"][number]["phase"]) => { + const providerCursor = phase === "before-request" ? null : copilotDenialSampleCursor(runEvents, deniedRequest!); + if (remote) { + if (!remoteFixture || !baseline) throw new Error("Remote denied target has no baseline"); + const snapshot = sealed ?? (phase === "before-request" ? baseline : await remoteFixture.snapshot(phase)); + remoteSnapshots.push(snapshot); fileObservations.push({ ...copilotRemoteDeniedSample(snapshot, baseline, target, phase), providerCursor }); + } else fileObservations.push({ phase, observedAtMs: Date.now(), exists: await exists(targetPath), providerCursor }); + }; + const watcher = localTarget?.watcher; + const markerPath = join(workspacePath, `copilot-settlement-${nonce}.txt`); + const command = deny || remote ? undefined : await createAttachedCommandFixture(markerPath); + const exactCommand = () => remoteCommand ?? command; + let watchReceipt: CopilotDeniedWriteEvidence["mutationObservation"] | undefined; + const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); expect(passed, detail).toBe(true); }; + async function load() { + if (issue.id) issue = await api.get(`/api/issues/${issue.id}`); + const listed = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); + runs = await Promise.all(listed.map(r => api.get(`/api/heartbeat-runs/${r.id}`))); + if (runs.length > 1) throw new Error("Copilot protection case dispatched an extra run"); + input.observe(issue, runs); + runEvents = runs[0] ? await collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runs[0]!.id}/events?afterSeq=${afterSeq}&limit=${limit}`)) : []; + notices = runs[0] ? readCopilotToolEvidence(runEvents, runs[0].id) : []; + const run = runs[0]; + if (processObserver) processes = processObserver.sample(run?.processPid ? { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id } : undefined); + return { issue, runs, runEvents, notices, processes }; + } + const wait = (label: string, accept: (state: Awaited>) => boolean) => pollUntil({ label, deadlineAt: input.deadlineAt, load, accept, intervalMs: 200, + reject: state => state.runs.some(r => ["failed", "timed_out"].includes(r.status)) ? "Copilot provider run failed" : undefined }); + try { + const agent = await api.get(`/api/agents/${fixtures.agent.id}`); + await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: deny ? "approve-reads" : "approve-all", timeoutSec: 120, lifecycleMode: "per_turn" } }); + check("per-turn-process-lifecycle", (await api.get(`/api/agents/${fixtures.agent.id}`)).adapterConfig?.lifecycleMode === "per_turn", "This case explicitly requires a per-turn runner process, never a retained warm daemon"); + const project = await api.post(`/api/companies/${fixtures.company.id}/projects`, { + name: `Copilot protection ${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: workspacePath, isPrimary: true }, + }); + if (deny && !remote) { await sample("before-request"); check("target-initially-absent", !fileObservations[0]!.exists, "The exact isolated target is absent before dispatch"); } + const prompt = remote ? input.remoteBootstrap!.prompt(nonce) : `${localTarget ? bindDeniedTargetPrompt(execution.task.buildPrompt(nonce), targetName, target) : execution.task.buildPrompt(nonce)}${command ? `\nThe exact supplied command is:\n${command.command}\nDo not inspect or modify fixture code, fabricate its marker, or launch a substitute command.` : ""}`; + await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt, workMode: "standard", projectName: project.name }); + const found = await pollUntil({ label: "browser-created Copilot protection task", deadlineAt: input.deadlineAt, load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(r => r.title === execution.task.buildTitle(nonce)), accept: Boolean }); + if (!found) throw new Error("Browser-created task was not found"); issue = found; + await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); + if (remote) { + await wait("exact remote bootstrap run", state => state.runs.length === 1 && state.runs[0]?.status === "running"); + const bound = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, + targets: [deny ? target : `copilot-settlement-${nonce}.txt`], + actionPrompt: async fixture => { + remoteFixture = fixture; + const prepared = await prepareCopilotRemoteAction({ fixture, companyId: fixtures.company.id, environmentId: fixtures.environment.id, runId: runs[0]!.id, + target: deny ? target : `copilot-settlement-${nonce}.txt`, prompt: execution.task.buildPrompt(nonce), ...(deny ? {} : { markerText: remoteMarker }) }); + baseline = prepared.baseline; remoteCommand = prepared.command; + if (deny) { await sample("before-request"); check("target-initially-absent", !fileObservations[0]!.exists, "The actual remote target is absent before action publication"); } + await input.evidence("copilot-remote-baseline.json", baseline); + return prepared.prompt; + } }); + if (bound !== remoteFixture) throw new Error("Remote Copilot fixture changed during publication"); + input.registerBeforeEnvironmentTeardownAssertion!(async () => { + try { + const receipt = await sealRemote(); + if (deny) { + if (copilotRemoteDeniedSample(receipt, baseline!, target, "after-cleanup").exists) throw new Error("Remote denied target exists after retirement"); + } + else { + if (!await readCopilotRemoteMarkerAfterRetirement(remoteFixture!, baseline!, `copilot-settlement-${nonce}.txt`, remoteMarker)) throw new Error("Remote sealed marker changed or disappeared"); + } + await input.evidence("copilot-remote-pre-teardown.json", receipt); + return [{ id: "remote-sealed-retirement", passed: true, detail: "Exact remote run root and descendants retired; retained pre-deletion filesystem receipt validated" }]; + } finally { await remoteFixture!.close(); } + }); + } + if (deny) { + await wait("exact native write permission", s => s.notices.some(n => n.stage === "permission_requested" && n.operation === "edit" && n.target === target && n.declineOffered && s.runEvents.some(r => r.eventType === "runtime_request.created" && r.payload?.prpEvent?.payload?.request?.requestId === n.requestId))); + const request = notices.find(n => n.stage === "permission_requested" && n.operation === "edit" && n.target === target)!; + deniedRequest = request; + const pending = runEvents.filter(r => r.eventType === "runtime_request.created" && r.payload?.prpEvent?.payload?.request?.requestId === request.requestId).map(r => r.payload.prpEvent.payload.request); + check("one-bound-permission", pending.length === 1 && pending[0].requestKind === "permission_approval" && pending[0].origin?.method === "session/request_permission", "The notice maps to the exact durable native permission card"); + const retired = new Set(runEvents.filter(r => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(r.eventType)).map(r => r.payload?.prpEvent?.payload?.requestId)); + const activeRequests = runEvents.filter(r => r.eventType === "runtime_request.created" && r.payload?.prpEvent?.payload?.request && !retired.has(r.payload.prpEvent.payload.request.requestId)); + check("only-one-pending-native-request", activeRequests.length === 1, "Only the exact denied edit is awaiting a decision"); + await sample("pending"); await page.reload(); + const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }); + await expect(card).toHaveCount(1); await input.capture("permission-pending", "Copilot native write awaiting denial", "permission-pending.png"); + const url = `/api/heartbeat-runs/${request.runId}/runtime-requests/${encodeURIComponent(request.requestId!)}/resolve`; + const sent = page.waitForRequest(r => new URL(r.url()).pathname === url && r.method() === "POST"); + const clickedAtMs = Date.now(); await card.getByRole("button", { name: "Deny", exact: true }).click(); + const posted = (await sent).postDataJSON(); + check("browser-exact-denial", posted.turnId === request.turnId && posted.requestKind === "permission_approval" && posted.resolution?.action === "decline", "Browser submitted denial for the exact run/request/turn"); + const settlement = await settleCopilotDeniedRun({ api, request, deadlineAt: input.deadlineAt, + load: async () => { + const state = await load(); + return { events: state.runEvents, run: state.runs[0]!, issue: state.issue, + retired: remote || (state.processes.captured && state.processes.live.length === 0) }; + }, + afterDeniedEdit: () => sample("after-decision"), + retainPreStop: receipt => input.evidence("copilot-pre-stop-observation.json", receipt), + afterSettlement: async () => { + if (remote) await sealRemote(); + await sample("terminal"); await new Promise(resolve => setTimeout(resolve, 100)); await load(); await sample("after-cleanup"); + }, + }); + watchReceipt = remote ? { startedAtMs: baseline!.observedAtMs, endedAtMs: sealed!.observedAtMs, complete: sealed!.watcher.complete, targetMutationCount: sealed!.watcher.targetMutationCount } : watcher!.finish(); + const toolResult = notices.find(n => n.stage === "tool" && n.toolCallId === request.toolCallId && n.status === "failed")!; + await input.evidence("copilot-denial-settlement.json", settlement); + check("correlated-provider-settlement", true, `Exact provider settlement (${settlement.branch}) and audited run Stop; completed does not cover active-turn cancellation`); + const terminalAt = settlement.providerTerminal.emittedAtMs; + const evidence: CopilotDeniedWriteEvidence = { + expected: copilotOrigin(request), requestId: request.requestId!, expectedRelativePath: target, + request: { ...request, requestId: request.requestId!, targetRelativePath: request.target!, method: "session/request_permission", offeredActions: request.declineOffered ? ["decline"] : [] }, + decision: { ...request, observedAtMs: clickedAtMs, requestId: request.requestId!, browserRequestId: request.requestId!, action: "decline" }, + deliveredDecision: (() => { const n = notices.find(n => n.stage === "permission_delivered" && n.requestId === request.requestId && n.outcome === "reject_once"); return n ? { ...n, requestId: n.requestId!, outcome: n.outcome! } : null; })(), + toolResult: { ...toolResult, status: "failed" }, + terminal: { runId: settlement.runId, turnId: settlement.turnId, observedAtMs: terminalAt, status: runs[0]!.status }, + settlement, + cleanup: { observedAtMs: fileObservations.at(-1)!.observedAtMs, ownedProcessesRemaining: processes.live.length }, fileObservations, mutationObservation: watchReceipt, + nativeAttemptsForTarget: countCopilotEditOriginsForTarget(notices, target), + }; + await input.evidence("copilot-denial-proof.json", { evidence, processes, notices }); + const grade = gradeCopilotDeniedWrite(evidence); check("denial-without-side-effects", grade.passed, grade.failures.join(", ") || "Exact browser denial, explicit cancellation and absence through process cleanup"); + check("negative-task-unfinished", issue.status === "in_progress", "The negative test does not claim the task is done"); + check("no-extra-native-operation", countCopilotToolOrigins(copilotActionNotices(notices, notices.find(n => n.stage === "tool" && n.toolCallId === request.toolCallId)!, remoteFixture ? { actionFile: remoteFixture.actionFile, events: runEvents } : undefined)) === 1, "No alternate native edit, command or delegated operation is permitted"); + } else { + await wait("attached command and task settlement", s => s.issue.status === "done" && s.runs[0]?.status === "succeeded" && (remote || (s.processes.captured && s.processes.live.length === 0))); + // Preserve independent local proof before any command matcher can abort. + const { external: localExternal, markerMatches: localMarkerMatches, afterCleanupMarkerMatches: localAfterCleanupMarkerMatches, matched } = + await observeCopilotFixtureCommand(notices, exactCommand()!.command, command ? { fixture: command, markerPath } : undefined); + await input.evidence("copilot-attached-command-observation.json", { notices, processes, external: localExternal, + canonicalCommandSha256: exactCommand()!.commandSha256, commandMatch: matched?.match ?? null, + markerMatches: localMarkerMatches, afterCleanupMarkerMatches: localAfterCleanupMarkerMatches }); + const call = matched?.call; + check("single-exact-command", Boolean(matched), "Exactly one native execution matches the fixture command with at most eight leading ASCII SPACE/TAB bytes"); + const semantic = readCopilotSemanticCompletion(runEvents, { companyId: fixtures.company.id, runId: call!.runId, turnId: call!.turnId, + nativeSessionId: call!.sessionId, command: call!, summary: execution.task.buildVisibleMarker(nonce) }); + await input.evidence("copilot-semantic-completion.json", semantic); + check("accepted-canonical-completion", true, "One exact native finish receipt matches the proposed and control-plane accepted result; transport success alone is insufficient"); + check("no-extra-native-operation", onlyCopilotAttachedOperations(copilotActionNotices(notices, call!, remoteFixture ? { actionFile: remoteFixture.actionFile, events: runEvents } : undefined), call!, semantic), "Only attested setup reads, the exact attached command/result, and one authoritatively correlated accepted finish are allowed"); + const started = notices.find(n => n.toolCallId === call!.toolCallId && n.shellState === "started"); + const result = notices.find(n => n.commandToolCallId === call!.toolCallId && n.shellState === "completed"); + const terminal = runEvents.find(r => r.eventType === "turn.completed" && r.payload?.prpEvent?.turnId === call!.turnId)?.payload.prpEvent; + if (remote) await sealRemote(); + const remoteAttached = remote ? assertCopilotRemoteAttached(sealed!, baseline!, terminal ? Date.parse(terminal.emittedAt) : NaN) : undefined; + const external = remoteAttached ? { ...remoteAttached, childGone: true, clientGone: true, + commandExit: { ...remoteAttached.commandExit!, ownedProcessIdentityVerified: true, commandSha256: exactCommand()!.commandSha256 } } : localExternal!; + check("trusted-command-exit", !external.failure && external.connections === 1 && external.childGone && external.clientGone, "Fixed controller-owned child exited and its native client is gone"); + const markerMatches = remote ? sealed!.targets[`copilot-settlement-${nonce}.txt`]?.sha256 === `sha256:${createHash("sha256").update(remoteMarker).digest("hex")}` : localMarkerMatches!; + check("native-client-before-terminal", Boolean(terminal) && external.clientExitedAtMs !== null && external.clientExitedAtMs < Date.parse(terminal.emittedAt), "Independent PID/start observation confirms native client retirement before turn completion"); + check("marker-before-terminal", Boolean(terminal) && external.markerWrittenAtMs !== null && external.markerWrittenAtMs < Date.parse(terminal.emittedAt), "The independent fixture wrote its undisclosed marker before turn completion"); + const afterCleanupMarkerMatches = remote ? await readCopilotRemoteMarkerAfterRetirement(remoteFixture!, baseline!, `copilot-settlement-${nonce}.txt`, remoteMarker) : localAfterCleanupMarkerMatches!; + const grade = gradeCopilotAttachedSettlement({ expected: copilotOrigin(call!), nativeCall: call ? { ...call, operation: call.operation!, mode: call.mode!, detach: call.detach!, commandSha256: call.commandSha256! } : null, + expectedCommand: exactCommand()!.command, expectedCommandSha256: exactCommand()!.commandSha256, commandMatch: matched!.match, commandExit: external.commandExit, + expectedShellId: started?.shellId ?? "", nativeShellResult: result ? { ...result, shellId: result.shellId!, commandToolCallId: result.commandToolCallId!, status: result.status!, exitCode: result.exitCode! } : null, + terminal: terminal ? { observedAtMs: Date.parse(terminal.emittedAt), runId: terminal.runId, turnId: terminal.turnId, status: "succeeded" } : null, + cleanup: { observedAtMs: remote ? sealed!.observedAtMs : Date.now(), ownedProcessesRemaining: processes.live.length }, terminalMarkerMatches: markerMatches, afterCleanupMarkerMatches }); + await input.evidence("copilot-attached-proof.json", { external, processes, notices, grade, commandSha256: exactCommand()!.commandSha256, commandMatch: matched!.match, markerMatches, afterCleanupMarkerMatches }); + check("attached-settlement-before-terminal", grade.passed, grade.failures.join(", ") || "Owned finite process and native shell settled before the actual turn terminal"); + } + await load(); check("one-native-run", runs.length === 1 && runs[0]!.runtimeMode === "native", "Exactly one native run was accounted"); + const comments = await api.get(`/api/issues/${issue.id}/comments`), interactions = await api.get(`/api/issues/${issue.id}/interactions`); + await input.evidence("api-state.json", { issue, run: runs[0], runs, comments, interactions, checks, runEvents, runEventsByRun: [{ runId: runs[0]!.id, events: runEvents }] }); + await page.reload(); + const label = deny ? "In Progress" : "Done"; + await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: `Change status (current: ${label})`, exact: true })).toBeVisible(); + if (!deny) check("exact-completion-marker", comments.filter(c => c.authorAgentId === fixtures.agent.id && c.body?.trim() === execution.task.buildVisibleMarker(nonce)).length === 1, "The successful attached task has exactly one terminal marker"); + await input.capture("final-state", "Copilot protection outcome", "final-state.png"); + return { issue, runs, checks }; + } finally { + watchReceipt ??= watcher?.finish(); + try { await command?.close(); } + finally { await input.evidence("copilot-protection-checks.json", { issue, runs, checks, fileObservations, watchReceipt, processes, remoteSnapshots, sealed }); } + } +} diff --git a/tests/runner-e2e/copilot-protection-settlement.test.ts b/tests/runner-e2e/copilot-protection-settlement.test.ts new file mode 100644 index 0000000000..3b4e910e2f --- /dev/null +++ b/tests/runner-e2e/copilot-protection-settlement.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from "vitest"; +import { readCopilotToolEvidence } from "./copilot-evidence.js"; +import { observeCopilotPreStop, copilotDenialSampleCursor, readCopilotDenialSettlement } from "./copilot-protection-evidence.js"; +import { settleCopilotDeniedRun } from "./copilot-protection-flow.js"; + +// Sanitized source order from the failed Daytona attempt. Its .900 createdAt +// is transaction time, not commit evidence. Pre-Stop receipts below are synthetic +// test observations; the historical paid attempt did not capture one. +const date = (ms: number) => new Date(Date.UTC(2026, 8, 30, 15, 13, 7, ms)).toISOString(); +function fixture(eventType = "turn.completed") { + const frame = (seq: number, type: string, payload: unknown, persisted = 305, emitted = 129): any => ({ + seq: seq + 40, companyId: "company", runId: "run", eventType: type, createdAt: date(persisted), + payload: { prpEvent: { schema: "paperclip.prp.event.v1", sourceKind: "runner", runId: "run", turnId: "turn", + normalizedSessionId: "normalized-session", sourceInstanceId: "runner-instance", sourceSeq: seq, + sourceEventId: `runner-instance:run:${seq}`, eventType: type, emittedAt: date(emitted), payload } }, + }); + const notice = (seq: number, stage: string, details: Record) => frame(seq, "provider.notice.recorded", { + schema: "paperclip.provider.notice.v1", category: "copilot_tool_evidence_v1", scope: "turn", + provenance: { sessionId: "native-session", turnId: "turn", eventType: stage, method: stage === "tool" ? "session/update" : "session/request_permission" }, + details: Object.entries({ stage, toolCallId: "denied-edit", operation: "edit", ...details }).map(([name, value]) => ({ name, value })), + }); + const events = [notice(111, "permission_requested", { requestId: "request", target: "copilot-denied-nonce.txt", declineOffered: "true" }), + notice(113, "permission_delivered", { requestId: "request", outcome: "reject_once" }), notice(114, "tool", { status: "failed" }), + frame(115, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", executionId: "denied-edit", operation: "edit", status: "failed" }, 317), + frame(121, eventType, { status: eventType.slice(5), error: null }, 900, 887), + frame(112, "runtime_request.resolved", { requestId: "request", turnId: "turn", requestKind: "permission_approval", action: "decline" })]; + const run: any = { id: "run", companyId: "company", nativeIssueId: "issue", status: "cancelled", finishedAt: date(910), + resultJson: { startupCancellation: { requestedAt: date(892), cancellationRequestId: "11111111-1111-4111-8111-111111111111" }, nativeCancellation: { + schema: "paperclip.native-cancellation.v1", companyId: "company", runId: "run", issueId: "issue", scope: "run", dispatched: true, + dispatchState: "acknowledged", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], intentId: "native-cancellation:11111111-1111-4111-8111-111111111111", + intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", recordedAt: date(901), acknowledgedAt: date(909), + } } }; + const request = readCopilotToolEvidence(events, "run")[0]!; + const preStop = observeCopilotPreStop({ events, request, companyId: "company", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }); + return { events, request, run, issue: { id: "issue", companyId: "company", status: "in_progress" }, preStop, stopDispatchMonotonicNs: process.hrtime.bigint().toString() }; + +} +const terminal = (f: ReturnType) => f.events[4]!.payload.prpEvent; +const liveRun = (f: ReturnType) => ({ ...f.run, status: "running", resultJson: {} }); +function stopPost(f: ReturnType, callback = () => {}) { + return vi.fn(async (_path: string, body: { cancellationRequestId: string }) => { + callback(); + f.run.resultJson.startupCancellation.cancellationRequestId = body.cancellationRequestId; + f.run.resultJson.nativeCancellation.intentId = `native-cancellation:${body.cancellationRequestId}`; + return f.run; + }); +} + +describe("Copilot denial provider settlement and separate audited run Stop", () => { + it("accepts a synthetic pre-Stop API observation without claiming provider cancellation", () => { + const f = fixture(); + expect(readCopilotDenialSettlement(f)).toMatchObject({ branch: "provider_completed_observed_before_stop", providerCancellationTerminalObserved: false, + providerTerminal: { sourceSeq: 121, failedToolSourceSeq: 115, rowCreatedAtMs: Date.parse(date(900)) }, runStop: { status: "cancelled" } }); + // Native/provider clock skew is irrelevant to the operator causal boundary. + terminal(f).emittedAt = date(999); + f.preStop = observeCopilotPreStop({ ...f, companyId: "company", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }); f.stopDispatchMonotonicNs = process.hrtime.bigint().toString(); + expect(readCopilotDenialSettlement(f).branch).toBe("provider_completed_observed_before_stop"); + }); + it.each(["turn.cancelled", "turn.interrupted"])("retains the distinct observed %s branch", type => { + expect(readCopilotDenialSettlement(fixture(type))).toMatchObject({ branch: "provider_cancelled_or_interrupted", providerCancellationTerminalObserved: true }); + }); + it("cannot use a legacy summary or cancelled run as provider evidence", () => { + const f = fixture(); f.events.splice(4, 1); f.events.push({ eventType: "turn.completed", payload: {} }); + expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/); + }); + it.each(["equal", "later"])("accepts observed pre-Stop completion with %s transaction time, without ordering it against ack", kind => { + const f = fixture(); f.events[4]!.createdAt = date(kind === "equal" ? 909 : 910); + f.preStop = observeCopilotPreStop({ ...f, companyId: "company", cancellationRequestId: "11111111-1111-4111-8111-111111111111" }); f.stopDispatchMonotonicNs = process.hrtime.bigint().toString(); + expect(readCopilotDenialSettlement(f).branch).toBe("provider_completed_observed_before_stop"); + }); + it("rejects late normal completion even when its transaction began before Stop ack", () => { + const f = fixture(); f.preStop.terminal = null; f.events[4]!.createdAt = date(800); + expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/); + }); + it.each(["missing", "foreign", "hash", "sequence", "equal-clock", "reverse-clock", "non-string-clock"])("rejects %s pre-dispatch observation", kind => { + const f = fixture(); + if (kind === "missing") (f as any).preStop = null; + if (kind === "foreign") f.preStop.runId = "foreign"; + if (kind === "hash") f.preStop.terminal!.rowSha256 = `sha256:${"a".repeat(64)}`; + if (kind === "sequence") f.preStop.terminal!.sourceSeq++; + if (kind === "equal-clock") f.stopDispatchMonotonicNs = f.preStop.apiReadCompletedMonotonicNs; + if (kind === "reverse-clock") f.stopDispatchMonotonicNs = "1"; + if (kind === "non-string-clock") (f.preStop as any).apiReadCompletedMonotonicNs = ["1"]; + expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/); + }); + it.each(["runId", "turnId", "normalizedSessionId", "sourceInstanceId", "eventType", "sourceEventId"])("rejects terminal %s mismatch", field => { + const f = fixture(); terminal(f)[field] = "foreign"; + expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/); + }); + it.each(["missing", "duplicate", "failed", "before-tool", "wrong-status", "error"])("rejects %s terminal", kind => { + const f = fixture(); + if (kind === "missing") f.events.splice(4, 1); + if (kind === "duplicate") f.events.push(structuredClone(f.events[4])); + if (kind === "failed") { f.events[4]!.eventType = terminal(f).eventType = "turn.failed"; terminal(f).payload.status = "failed"; } + if (kind === "before-tool") { terminal(f).sourceSeq = 114; terminal(f).sourceEventId = "runner-instance:run:114"; } + if (kind === "wrong-status") terminal(f).payload.status = "cancelled"; + if (kind === "error") terminal(f).payload.error = { code: "failed" }; + expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/); + }); + it.each(["scope", "runId", "companyId", "issueId", "intentId", "intentAuditId", "acknowledgementAuditId", "acknowledgedAt", "recordedAt", "schema", "dispatchState", "dispatched", "reasonCode", "effects"])("rejects malformed or unbound Stop %s", field => { + const f = fixture(); f.run.resultJson.nativeCancellation[field] = null; + expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/); + }); + it.each(["missing-stop", "pending", "wrong-scope", "same-audit", "wrong-run-status", "issue-done", "ack-before-intent"])("rejects %s controller state", kind => { + const f = fixture(), c = f.run.resultJson.nativeCancellation; + if (kind === "missing-stop") delete f.run.resultJson.nativeCancellation; + if (kind === "pending") c.dispatchState = "pending"; + if (kind === "wrong-scope") c.scope = "turn"; + if (kind === "same-audit") c.acknowledgementAuditId = c.intentAuditId; + if (kind === "wrong-run-status") f.run.status = "succeeded"; + if (kind === "issue-done") f.issue.status = "done"; + if (kind === "ack-before-intent") c.recordedAt = date(911); + expect(() => readCopilotDenialSettlement(f)).toThrow(/settlement/); + }); + it.each(["native-session", "normalized-session", "failed-tool", "delivery", "duplicate-tool", "tool-order"])("rejects %s corruption", kind => { + const f = fixture(); + if (kind === "native-session") f.events[2]!.payload.prpEvent.payload.provenance.sessionId = "foreign"; + if (kind === "normalized-session") f.events[2]!.payload.prpEvent.normalizedSessionId = "foreign"; + if (kind === "failed-tool") f.events[3]!.payload.prpEvent.payload.status = "completed"; + if (kind === "delivery") f.events[1]!.payload.prpEvent.payload.details.find((d: any) => d.name === "outcome").value = "allow_once"; + if (kind === "duplicate-tool") f.events.push(structuredClone(f.events[3])); + if (kind === "tool-order") f.events[3]!.payload.prpEvent.sourceSeq = 122; + expect(() => readCopilotDenialSettlement(f)).toThrow(); + }); + it.each(["missing", "duplicate", "foreign-turn", "accept", "expired", "after-delivery"])("rejects %s durable resolution", kind => { + const f = fixture(), r = f.events[5]!, p = r.payload.prpEvent; + if (kind === "missing") f.events.pop(); + if (kind === "duplicate") f.events.push(structuredClone(r)); + if (kind === "foreign-turn") p.payload.turnId = "foreign"; + if (kind === "accept") p.payload.action = "accept"; + if (kind === "expired") r.eventType = p.eventType = "runtime_request.expired"; + if (kind === "after-delivery") { p.sourceSeq = 114; p.sourceEventId = "runner-instance:run:114"; } + expect(() => readCopilotDenialSettlement(f)).toThrow(); + }); + it("captures the exact durable stream at the sampling boundary without clock conversion", () => { + const f = fixture(); + expect(copilotDenialSampleCursor([f.events[0]], f.request)).toEqual({ runId: "run", turnId: "turn", normalizedSessionId: "normalized-session", sourceInstanceId: "runner-instance", sourceSeq: 111 }); + expect(copilotDenialSampleCursor(f.events, f.request).sourceSeq).toBe(121); + f.events[1]!.payload.prpEvent.normalizedSessionId = "foreign"; + expect(() => copilotDenialSampleCursor(f.events, f.request)).toThrow(/cursor/); + }); + it("awaits exact pre-Stop receipt retention and waits for post-Stop cancelled terminal before sampling", async () => { + vi.useFakeTimers(); let mono = 100n; vi.spyOn(process.hrtime, "bigint").mockImplementation(() => ++mono); + try { + const f = fixture("turn.cancelled"); let loads = 0, dispatched = false, postLoads = 0; + let release!: () => void; const retention = new Promise(resolve => { release = resolve; }); + const post = stopPost(f, () => { dispatched = true; }); + const retainPreStop = vi.fn(async (receipt) => { expect(receipt.terminal).toBeNull(); await retention; }); + const afterDeniedEdit = vi.fn(async () => {}), afterSettlement = vi.fn(async () => {}); + const result = settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 5000, + load: async () => { + loads++; if (dispatched) postLoads++; + const events = f.events.filter((_, i) => !(loads === 1 && i === 3) && !((!dispatched || postLoads === 1) && i === 4)); + return { ...f, events, run: dispatched ? f.run : liveRun(f), retired: dispatched }; + }, afterDeniedEdit, retainPreStop, afterSettlement }); + await vi.advanceTimersByTimeAsync(0); expect(post).not.toHaveBeenCalled(); expect(afterDeniedEdit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); expect(afterDeniedEdit).toHaveBeenCalledOnce(); expect(post).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2000); expect(retainPreStop).toHaveBeenCalledOnce(); expect(post).not.toHaveBeenCalled(); + release(); await vi.advanceTimersByTimeAsync(0); expect(post).toHaveBeenCalledOnce(); expect(afterSettlement).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); expect((await result).branch).toBe("provider_cancelled_or_interrupted"); expect(afterSettlement).toHaveBeenCalledOnce(); + } finally { vi.restoreAllMocks(); vi.useRealTimers(); } + }); + it("rejects normal completion first returned after Stop even with an earlier transaction timestamp", async () => { + vi.useFakeTimers(); let mono = 100n; vi.spyOn(process.hrtime, "bigint").mockImplementation(() => ++mono); + try { + const f = fixture(); f.events[4]!.createdAt = date(1); let dispatched = false; + const sample = vi.fn(async () => {}), post = stopPost(f, () => { dispatched = true; }); + const retain = vi.fn(async (receipt) => { expect(receipt.terminal).toBeNull(); }); + const result = settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 3000, + load: async () => ({ ...f, run: dispatched ? f.run : liveRun(f), events: dispatched ? f.events : f.events.filter((_, i) => i !== 4), retired: dispatched }), + afterDeniedEdit: async () => {}, retainPreStop: retain, afterSettlement: sample }); + const rejected = expect(result).rejects.toThrow(/Timed out waiting for correlated provider settlement/); + await vi.advanceTimersByTimeAsync(3200); await rejected; + expect(retain).toHaveBeenCalledOnce(); expect(post).toHaveBeenCalledOnce(); expect(sample).not.toHaveBeenCalled(); + } finally { vi.restoreAllMocks(); vi.useRealTimers(); } + }); + it.each(["missing-edit", "foreign-edit", "missing-terminal", "foreign-terminal", "live-process"])("fails closed on %s without premature side effects or terminal samples", async kind => { + vi.useFakeTimers(); let mono = 100n; vi.spyOn(process.hrtime, "bigint").mockImplementation(() => ++mono); + try { + const f = fixture(), post = stopPost(f), sample = vi.fn(async () => {}); + if (kind === "missing-edit") f.events.splice(3, 1); + if (kind === "foreign-edit") f.events[3]!.payload.prpEvent.normalizedSessionId = "foreign"; + if (kind === "missing-terminal") f.events.splice(4, 1); + if (kind === "foreign-terminal") terminal(f).sourceInstanceId = "foreign"; + const result = settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 3000, + load: async () => ({ ...f, run: post.mock.calls.length ? f.run : liveRun(f), retired: kind !== "live-process" }), afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: sample }); + const rejected = expect(result).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(3200); await rejected; + expect(post).toHaveBeenCalledTimes(kind.endsWith("edit") || kind === "foreign-terminal" ? 0 : 1); + expect(sample).not.toHaveBeenCalled(); + } finally { vi.restoreAllMocks(); vi.useRealTimers(); } + }); + it.each(["failed", "timed_out"])("reports %s promptly even when required rows are missing", async status => { + const f = fixture(), post = stopPost(f), sample = vi.fn(async () => {}); + const load = vi.fn(async () => ({ ...f, events: [], run: { ...f.run, status }, retired: false })); + await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 10000, + load, afterDeniedEdit: sample, retainPreStop: sample, afterSettlement: sample })).rejects.toThrow(`Stopped waiting for persisted correlated failed native edit: Copilot provider run failed`); + expect(load).toHaveBeenCalledOnce(); expect(post).not.toHaveBeenCalled(); expect(sample).not.toHaveBeenCalled(); + }); + it.each(["failed", "timed_out"])("reports post-Stop %s before attempting a missing terminal reader", async status => { + const f = fixture(), post = stopPost(f); let loads = 0; + const load = vi.fn(async () => ({ ...f, events: ++loads < 4 ? f.events : [], run: loads < 4 ? liveRun(f) : { ...f.run, status }, retired: true })); + await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 10000, + load, afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: async () => {} })).rejects.toThrow("Stopped waiting for correlated provider settlement and retired run: Copilot provider run failed"); + expect(load).toHaveBeenCalledTimes(4); expect(post).toHaveBeenCalledOnce(); + }); + it("does not dispatch Stop if the pre-dispatch receipt cannot be retained", async () => { + const f = fixture(), post = stopPost(f); + await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000, + load: async () => ({ ...f, run: post.mock.calls.length ? f.run : liveRun(f), retired: true }), afterDeniedEdit: async () => {}, afterSettlement: async () => {}, + retainPreStop: async () => { throw new Error("artifact write failed"); } })).rejects.toThrow("artifact write failed"); + expect(post).not.toHaveBeenCalled(); + }); + it("retains completed-before-Stop settlement and propagates retirement sampling failure", async () => { + const f = fixture(), post = stopPost(f); + const input = { api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000, + load: async () => ({ ...f, run: post.mock.calls.length ? f.run : liveRun(f), retired: true }), afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: async () => {} }; + expect((await settleCopilotDeniedRun(input)).branch).toBe("provider_completed_observed_before_stop"); + post.mockClear(); + await expect(settleCopilotDeniedRun({ ...input, afterSettlement: async () => { throw new Error("remote descendants live"); } })).rejects.toThrow("remote descendants live"); + }); + it.each(["cancelled", "pending-intent", "startup-only"])("rejects visible earlier Stop %s before dispatch", async kind => { + const f = fixture(), post = stopPost(f); + const run = kind === "cancelled" ? f.run : { ...liveRun(f), resultJson: kind === "startup-only" + ? { startupCancellation: { requestedAt: date(1) } } : { nativeCancellation: { intentId: "earlier", dispatchState: "pending" } } }; + await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000, + load: async () => ({ ...f, run, retired: false }), afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: async () => {}, + })).rejects.toThrow("earlier Stop"); + expect(post).not.toHaveBeenCalled(); + }); + it("rejects Stop racing during artifact retention", async () => { + const f = fixture(), post = stopPost(f); let earlier = false; + await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000, + load: async () => ({ ...f, run: earlier ? f.run : liveRun(f), retired: false }), afterDeniedEdit: async () => {}, + retainPreStop: async () => { earlier = true; }, afterSettlement: async () => {}, + })).rejects.toThrow("earlier Stop"); + expect(post).not.toHaveBeenCalled(); + }); + it.each(["conflict", "foreign-response"])("cannot pass a Stop racing after the last API read: %s", async kind => { + const f = fixture(); const post = vi.fn(async (_path: string, _body: { cancellationRequestId: string }) => { if (kind === "conflict") throw new Error("409 cancellation intent conflict"); return f.run; }); + const sampled = vi.fn(async () => {}); + await expect(settleCopilotDeniedRun({ api: { post } as any, request: f.request, deadlineAt: Date.now() + 1000, + load: async () => ({ ...f, run: liveRun(f), retired: false }), afterDeniedEdit: async () => {}, retainPreStop: async () => {}, afterSettlement: sampled, + })).rejects.toThrow(kind === "conflict" ? "409" : "foreign cancellation intent"); + expect(post).toHaveBeenCalledOnce(); expect(sampled).not.toHaveBeenCalled(); + expect(post.mock.calls[0]?.[1]).toMatchObject({ cancellationRequestId: expect.stringMatching(/^[a-f0-9-]{36}$/) }); + }); + +}); diff --git a/tests/runner-e2e/copilot-protection-tasks.ts b/tests/runner-e2e/copilot-protection-tasks.ts new file mode 100644 index 0000000000..842beb8d6e --- /dev/null +++ b/tests/runner-e2e/copilot-protection-tasks.ts @@ -0,0 +1,18 @@ +import type { RunnerTaskFixture } from "./types.js"; +import { copilotProtectionCases } from "./copilot-protection-cases.js"; +export const copilotProtectionTasks: readonly RunnerTaskFixture[] = copilotProtectionCases.map(c => ({ + id: c.id, label: c.id === "native-permission-deny-write" ? "Deny one native write, then stop safely" : "Attached command settles before completion", + groups: [], workMode: "standard", flow: "copilot_protection", expectedRunCount: 1, + attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, + expectedTerminalState: { issue: c.id === "native-permission-deny-write" ? "in_progress" : "done", run: c.id === "native-permission-deny-write" ? "cancelled" : "succeeded" }, + buildTitle: nonce => `Copilot ${c.id} ${nonce}`, + buildVisibleMarker: nonce => `COPILOT-PROTECTION-${c.id}-${nonce}`, + buildPrompt: nonce => c.id === "native-permission-deny-write" ? c.prompt(nonce) : [ + "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 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 new file mode 100644 index 0000000000..7abf1dc019 --- /dev/null +++ b/tests/runner-e2e/copilot-semantic-evidence.test.ts @@ -0,0 +1,191 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { onlyCopilotAttachedOperations, readCopilotSemanticCompletion } from "./copilot-semantic-evidence.js"; +import { readCopilotToolEvidence } from "./copilot-evidence.js"; +import { validatePrpStructuredRunResult } from "../../packages/paperclip-runner/src/protocol/replay-contract.js"; +import { runnerSuites, suiteDefinitionHash } from "./catalog.js"; +const hash = (v: string) => createHash("sha256").update(v).digest("hex"); +const canonical = (v: any): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v !== null && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}` : JSON.stringify(v); +const rawInput = { summary: "EXACT-MARKER", reportedWorkDisposition: "done", completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, evidence: [], verification: [] }; +const validation = validatePrpStructuredRunResult(rawInput); +if (!validation.ok) throw new Error("Semantic fixture input must pass the production validator"); +const result = validation.result; +const receipt = { schema: "paperclip.semantic_tool_receipt.v2", operationId: "paperclip_finish", callIdentitySha256: hash("3"), inputSha256: hash(canonical(rawInput)), normalizedInputSha256: hash(canonical(result)), resultSha256: hash("opaque original returned encoding"), outcome: "returned" }; +function row(seq: number, eventType: string, payload: any, overrides: any = {}) { + const e = { schema: "paperclip.prp.event.v1", eventType, runId: "run", turnId: "turn", normalizedSessionId: "normalized", sourceInstanceId: "source", sourceKind: "runner", sourceSeq: seq, emittedAt: "2026-09-30T12:00:00Z", payload, ...overrides }; + return { companyId: "company", runId: "run", seq, eventType, sourceInstanceId: e.sourceInstanceId, sourceSeq: e.sourceSeq, payload: { prpEvent: e } }; +} +function notice(seq: number, toolCallId: string, status: string, fields: any = {}) { + return row(seq, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "copilot_tool_evidence_v1", scope: "turn", provenance: { method: "session/update", eventType: "tool", sessionId: "native", turnId: "turn" }, details: Object.entries({ stage: "tool", toolCallId, status, ...fields }).map(([name, value]) => ({ name, value: String(value) })) }); +} +function fixture() { + const rows = [notice(1, "command", "pending", { operation: "execute" }), notice(2, "finish-native", "pending"), row(3, "run.result.proposed", structuredClone(result), { itemId: "item-run" }), + row(4, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "paperclip_semantic_tool_receipt_v2", scope: "turn", provenance: { method: "paperclip/semantic_tool_result", eventType: "semantic_result", sessionId: "native", turnId: "turn" }, details: Object.entries({ stage: "semantic_result", ...receipt }).map(([name, value]) => ({ name, value })) }), + notice(5, "finish-native", "completed", { semanticOperationId: receipt.operationId, semanticCallIdentitySha256: receipt.callIdentitySha256, semanticInputSha256: receipt.inputSha256, semanticNormalizedInputSha256: receipt.normalizedInputSha256, semanticResultSha256: receipt.resultSha256, semanticOutcome: receipt.outcome }), + row(6, "turn.completed", {}), row(7, "run.result.accepted", { result: structuredClone(result) }, { sourceKind: "control_plane", sourceInstanceId: "source:control", sourceSeq: 1 })]; + const expected = { companyId: "company", runId: "run", turnId: "turn", nativeSessionId: "native", command: readCopilotToolEvidence(rows, "run")[0]!, summary: "EXACT-MARKER" }; + return { rows, expected }; +} +const frame = (r: any) => r.payload.prpEvent; +function setField(r: any, key: string, value: string) { frame(r).payload.details.find((d: any) => d.name === key).value = value; } +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: 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", () => { + const { rows, expected } = fixture(); const proof = readCopilotSemanticCompletion(rows, expected); + expect(proof.nativeToolCallId).toBe("finish-native"); expect(proof.acceptedSeq).toBe(7); expect(proof.summarySha256).toBe(hash("EXACT-MARKER")); + expect(JSON.stringify(proof)).not.toContain("EXACT-MARKER"); + }); + it("binds distinct raw and production-normalized input digests without filling defaults in the oracle", () => { + const { rows, expected } = fixture(); + expect(rawInput).not.toHaveProperty("schema"); + expect(rawInput).not.toHaveProperty("artifacts"); + expect(rawInput).not.toHaveProperty("attentionRequests"); + expect(result).toMatchObject({ schema: "paperclip.run_result.v1", artifacts: [], attentionRequests: [] }); + expect(receipt.inputSha256).not.toBe(receipt.normalizedInputSha256); + expect(readCopilotSemanticCompletion(rows, expected)).toMatchObject({ + schema: "paperclip.e2e.copilot-semantic-completion.v2", + inputSha256: receipt.inputSha256, normalizedInputSha256: receipt.normalizedInputSha256, + }); + // Same authenticated raw/native join is necessary but cannot substitute for + // the invocation-captured normalized body digest. + setField(rows[3], "normalizedInputSha256", receipt.inputSha256); + setField(rows[4], "semanticNormalizedInputSha256", receipt.inputSha256); + expect(() => readCopilotSemanticCompletion(rows, expected)).toThrow("canonical accepted result"); + }); + it.each(["missing", "null", "wrong", "uppercase", "malformed"])("rejects %s normalized finish digest", value => { + const f = fixture(); + if (value === "missing") { + frame(f.rows[3]!).payload.details = frame(f.rows[3]!).payload.details.filter((d: any) => d.name !== "normalizedInputSha256"); + frame(f.rows[4]!).payload.details = frame(f.rows[4]!).payload.details.filter((d: any) => d.name !== "semanticNormalizedInputSha256"); + } else { + const digest = value === "null" ? "null" : value === "wrong" ? hash("foreign-normalized-input") : value === "uppercase" ? "A".repeat(64) : "not-a-digest"; + setField(f.rows[3], "normalizedInputSha256", digest); setField(f.rows[4], "semanticNormalizedInputSha256", digest); + } + expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); + }); + it("allows an explicit null native digest as diagnostic data, never as successful finish authority", () => { + const f = fixture(); setField(f.rows[4], "semanticNormalizedInputSha256", "null"); + expect(readCopilotToolEvidence(f.rows, "run").at(-1)?.semanticNormalizedInputSha256).toBeNull(); + expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow("native receipt mismatch"); + }); + it.each(["only", "alongside-v2"])("rejects legacy v1 authority %s as fresh qualification", mode => { + const f = fixture(), old = structuredClone(f.rows[3]!); + frame(old).payload.category = "paperclip_semantic_tool_receipt_v1"; + setField(old, "schema", "paperclip.semantic_tool_receipt.v1"); + frame(old).payload.details = frame(old).payload.details.filter((d: any) => d.name !== "normalizedInputSha256"); + if (mode === "only") f.rows[3] = old; else f.rows.push(old); + expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); + }); + it("does not repair an unnormalized proposed and accepted body by adding defaults", () => { + const f = fixture(); frame(f.rows[2]!).payload = structuredClone(rawInput); frame(f.rows[6]!).payload.result = structuredClone(rawInput); + expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow("canonical accepted result"); + }); + it.each([0, 1, 2, 3, 4, 5, 6])("rejects missing required row %s", index => { const f = fixture(); f.rows.splice(index, 1); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }); + it.each([1, 2, 3, 4, 5, 6])("rejects duplicate required row %s", index => { const f = fixture(); f.rows.push(structuredClone(f.rows[index]!)); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }); + it.each(["companyId", "runId"])("rejects foreign durable %s", key => { const f = fixture(); (f.rows[3] as any)[key] = "foreign"; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }); + it.each(["turnId", "normalizedSessionId", "sourceInstanceId", "sourceKind", "eventType", "schema"])("rejects foreign or malformed envelope %s", key => { + for (const index of [1, 2, 3, 4, 5, 6]) { const f = fixture(); (frame(f.rows[index]!))[key] = "foreign"; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); } + }); + it.each([null, undefined])("rejects missing envelope turn %s even with native provenance intact", turn => { const f = fixture(); frame(f.rows[4]!).turnId = turn; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }); + it.each(["sessionId", "turnId", "method", "eventType"])("rejects authoritative provenance %s mismatch", key => { const f = fixture(); frame(f.rows[3]!).payload.provenance[key] = "foreign"; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }); + it.each(["callIdentitySha256", "inputSha256", "normalizedInputSha256", "resultSha256", "operationId", "outcome"])("rejects native %s disagreement", key => { const f = fixture(); setField(f.rows[4], `semantic${key[0]!.toUpperCase()}${key.slice(1)}`, key.endsWith("Sha256") ? "a".repeat(64) : "wrong"); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }); + it("does not infer acceptance from returned receipt or a tool named paperclip_finish", () => { + const f = fixture(); frame(f.rows[6]!).payload.result = { accepted: false, summary: "EXACT-MARKER" }; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); + const g = fixture(); frame(g.rows[4]!).payload.title = "paperclip_finish"; frame(g.rows[4]!).payload.details = frame(g.rows[4]!).payload.details.filter((d: any) => !d.name.startsWith("semantic")); expect(() => readCopilotSemanticCompletion(g.rows, g.expected)).toThrow(); + }); + it.each(["failed", "cancelled", "interrupted"])("rejects %s terminal", status => { const f = fixture(); f.rows[5]!.eventType = `turn.${status}`; frame(f.rows[5]!).eventType = `turn.${status}`; expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); }); + it("rejects changed accepted body, forged call ID, wrong summary and rejected result", () => { + for (const mutate of [(f: ReturnType) => { frame(f.rows[6]!).payload.result.completionClaim.remainingWork.push("unfinished"); }, (f: ReturnType) => { setField(f.rows[3], "callIdentitySha256", hash("foreign-call")); }, (f: ReturnType) => { f.expected.summary = "other"; }, (f: ReturnType) => { f.rows.push(row(8, "run.result.rejected", {})); }]) { const f = fixture(); mutate(f); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); } + }); + it("rejects duplicate/unknown/oversized receipt details and partial native fields", () => { + for (const mutate of [(r: any) => frame(r).payload.details.push({ name: "secret", value: "untrusted" }), (r: any) => frame(r).payload.details.push(frame(r).payload.details[0]), (r: any) => setField(r, "operationId", "x".repeat(300))]) { const f = fixture(); mutate(f.rows[3]); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); } + const f = fixture(); frame(f.rows[4]!).payload.details.pop(); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); + }); + it("rejects mutation metadata, extra lifecycle, reordered receipt and source sequence drift", () => { + for (const mutate of [(f: ReturnType) => frame(f.rows[1]!).payload.details.push({ name: "operation", value: "edit" }), (f: ReturnType) => f.rows.push(notice(8, "finish-native", "pending")), (f: ReturnType) => { f.rows[3]!.seq = 8; }, (f: ReturnType) => { frame(f.rows[3]!).sourceSeq = 20; f.rows[3]!.sourceSeq = 20; }, (f: ReturnType) => { frame(f.rows[1]!).sourceSeq = 20; f.rows[1]!.sourceSeq = 20; }]) { const f = fixture(); mutate(f); expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); } + }); + it("does not treat canonical run-level itemId as semantic call identity", () => { + for (const itemId of ["item-run", "3", "unrelated-item", null]) { + const f = fixture(); frame(f.rows[2]!).itemId = itemId; + expect(readCopilotSemanticCompletion(f.rows, f.expected).callIdentitySha256).toBe(receipt.callIdentitySha256); + } + }); + it("rejects a different finish input even when proposal and acceptance agree", () => { + const f = fixture(); frame(f.rows[2]!).payload.completionClaim.criteria[0].evidenceRefs.push("different"); + frame(f.rows[6]!).payload.result = structuredClone(frame(f.rows[2]!).payload); + expect(() => readCopilotSemanticCompletion(f.rows, f.expected)).toThrow(); + }); + it("accepts the retained shell-read ordering: pending has shellId only, completion gains command identity after finish", () => { + const f = attachedFixture(); + expect(f.notices.find(n => n.toolCallId === "shell-read" && n.status === "pending")?.commandToolCallId).toBeUndefined(); + expect(onlyCopilotAttachedOperations(f.notices, f.command, f.proof)).toBe(true); + }); + it("accepts bounded in-progress shell updates without inventing terminal command identity", () => { + const f = attachedFixture(), pending = f.notices.find(n => n.toolCallId === "shell-read" && n.status === "pending")!; + f.notices.push({ ...pending, status: "in_progress", seq: 53 }); + expect(onlyCopilotAttachedOperations(f.notices, f.command, f.proof)).toBe(true); + }); + it("rejects shell-read evidence from a different durable stream despite matching native identity", () => { + const f = attachedFixture(), r = f.rows.find(r => r.seq === 52)!; + frame(r).sourceInstanceId = "foreign"; r.sourceInstanceId = "foreign"; + expect(() => readCopilotSemanticCompletion(f.rows, { companyId: "company", runId: "run", turnId: "turn", nativeSessionId: "native", command: f.command, summary: "EXACT-MARKER" })).toThrow(); + }); + it.each(["missing-pending", "missing-terminal", "duplicate-pending", "duplicate-terminal", "extra-completed-only", "extra-complete-read", "wrong-shell", "wrong-command", "failed", "nonzero", "reordered", "late", "wrong-operation", "mutation", "read-path", "foreign-session", "foreign-turn", "foreign-run", "permission", "semantic-fields", "normalized-semantic-field"])("rejects invalid shell-read lifecycle: %s", failure => { + const f = attachedFixture(), start = f.notices.find(n => n.toolCallId === "shell-read" && n.status === "pending")!, end = f.notices.find(n => n.toolCallId === "shell-read" && n.status === "completed")!; + if (failure === "missing-pending") f.notices.splice(f.notices.indexOf(start), 1); + if (failure === "missing-terminal") f.notices.splice(f.notices.indexOf(end), 1); + if (failure === "duplicate-pending") f.notices.push({ ...start, seq: 53 }); + if (failure === "duplicate-terminal") f.notices.push({ ...end, seq: 56 }); + if (failure === "extra-completed-only") f.notices.push({ ...end, toolCallId: "extra", seq: 57 }); + if (failure === "extra-complete-read") f.notices.push({ ...start, toolCallId: "extra", seq: 56 }, { ...end, toolCallId: "extra", seq: 57 }); + if (failure === "wrong-shell") start.shellId = "foreign"; + if (failure === "wrong-command") end.commandToolCallId = "foreign"; + if (failure === "failed") end.status = "failed"; + if (failure === "nonzero") end.exitCode = 1; + if (failure === "reordered") start.seq = 14; + if (failure === "late") end.seq = 61; + if (failure === "wrong-operation") start.operation = "execute"; + if (failure === "mutation") end.target = "file.txt"; + if (failure === "read-path") start.readTargetSha256 = `sha256:${"a".repeat(64)}`; + if (failure === "foreign-session") start.sessionId = "foreign"; + if (failure === "foreign-turn") start.turnId = "foreign"; + if (failure === "foreign-run") start.runId = "foreign"; + if (failure === "permission") start.stage = "permission_requested"; + if (failure === "semantic-fields") end.semanticCallIdentitySha256 = receipt.callIdentitySha256; + if (failure === "normalized-semantic-field") end.semanticNormalizedInputSha256 = receipt.normalizedInputSha256; + expect(onlyCopilotAttachedOperations(f.notices, f.command, f.proof)).toBe(false); + }); + it("rejects duplicate shell origins and arbitrary extra operations", () => { + const f = attachedFixture(); + const started = f.notices.find(n => n.shellState === "started")!; + expect(onlyCopilotAttachedOperations([...f.notices, { ...started, seq: 16 }], f.command, f.proof)).toBe(false); + for (const operation of [undefined, "read", "edit", "execute"] as const) expect(onlyCopilotAttachedOperations([...f.notices, { ...f.command, toolCallId: "extra", operation, seq: 17 }], f.command, f.proof)).toBe(false); + }); + it("keeps actual flow correlation before no-extra-operation gate and keeps visible marker independent", async () => { + const source = await readFile(new URL("./copilot-protection-flow.ts", import.meta.url), "utf8"); + expect(source.indexOf("readCopilotSemanticCompletion(runEvents")).toBeLessThan(source.indexOf('check("no-extra-native-operation", onlyCopilotAttachedOperations(')); + expect(source).toContain("undefined), call!, semantic)"); + expect(source).toContain('check("exact-completion-marker", comments.filter'); + expect(source).not.toContain('if (remote) check("no-extra-native-operation"'); + }); +}); + +// Mirrors retained native order: command pending→shell started; finish; then +// read_bash pending(shellId only)→completed(commandToolCallId), before turn end. +function attachedFixture() { + const f = fixture(); frame(f.rows[2]!).itemId = "3"; + for (const r of f.rows) { r.seq *= 10; if (frame(r).sourceKind === "runner") { r.sourceSeq *= 10; frame(r).sourceSeq *= 10; } } + const fields = { operation: "execute", commandSha256: `sha256:${"a".repeat(64)}`, mode: "async", detach: false }; + f.rows[0] = notice(10, "command", "pending", fields); + f.rows.push(notice(15, "command", "completed", { ...fields, shellId: "0", commandToolCallId: "command", shellState: "started" }), + notice(52, "shell-read", "pending", { operation: "read", shellId: "0" }), + notice(55, "shell-read", "completed", { operation: "read", shellId: "0", commandToolCallId: "command", shellState: "completed", exitCode: 0 })); + const notices = readCopilotToolEvidence(f.rows, "run"), command = notices.find(n => n.seq === 10)!; + const proof = readCopilotSemanticCompletion(f.rows, { ...f.expected, command }); + return { rows: f.rows, notices, command, proof }; +} diff --git a/tests/runner-e2e/copilot-semantic-evidence.ts b/tests/runner-e2e/copilot-semantic-evidence.ts new file mode 100644 index 0000000000..029f09c7f9 --- /dev/null +++ b/tests/runner-e2e/copilot-semantic-evidence.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { readCopilotToolEvidence, type CopilotToolNotice } from "./copilot-evidence.js"; + +type Row = Record; +const rec = (v: unknown): Row => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Row : {}; +const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); +const hash = (v: string) => createHash("sha256").update(v).digest("hex"); +const canonical = (v: unknown): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v !== null && typeof v === "object" + ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical((v as Row)[k])}`).join(",")}}` : JSON.stringify(v) ?? "undefined"; +const digest = (v: unknown) => hash(canonical(v)); +const receiptKeys = ["callIdentitySha256", "inputSha256", "normalizedInputSha256", "operationId", "outcome", "resultSha256", "schema", "stage"].sort().join(","); +export interface CopilotSemanticCompletionProof { + schema: "paperclip.e2e.copilot-semantic-completion.v2"; + runId: string; turnId: string; nativeSessionId: string; normalizedSessionId: string; sourceInstanceId: string; + nativeToolCallId: string; callIdentitySha256: string; inputSha256: string; normalizedInputSha256: string; resultSha256: string; + proposedSeq: number; receiptSeq: number; nativeCompletedSeq: number; turnCompletedSeq: number; acceptedSeq: number; + summarySha256: string; +} +/** Independent public-event oracle. A transport receipt alone never proves completion acceptance. */ +export function readCopilotSemanticCompletion(rows: readonly unknown[], expected: { + companyId: string; runId: string; turnId: string; nativeSessionId: string; command: CopilotToolNotice; summary: string; +}): CopilotSemanticCompletionProof { + const fail = (message: string): never => { throw new Error(`Copilot semantic completion: ${message}`); }; + const all = rows.map(rec), notices = readCopilotToolEvidence(rows, expected.runId); + const commandRows = all.filter(r => r.seq === expected.command.seq); + if (commandRows.length !== 1) fail("missing exact command origin"); + const base = rec(rec(commandRows[0]!.payload).prpEvent); + if (!id(base.normalizedSessionId) || !id(base.sourceInstanceId)) fail("missing command stream"); + function frame(r: Row, sourceKind: "runner" | "control_plane") { + const e = rec(rec(r.payload).prpEvent); + if (r.companyId !== expected.companyId || r.runId !== expected.runId || !Number.isSafeInteger(r.seq) || r.seq < 1 + || e.schema !== "paperclip.prp.event.v1" || e.eventType !== r.eventType || e.sourceKind !== sourceKind + || e.runId !== expected.runId || e.turnId !== expected.turnId || e.normalizedSessionId !== base.normalizedSessionId + || !id(e.sourceInstanceId) || r.sourceInstanceId !== e.sourceInstanceId || r.sourceSeq !== e.sourceSeq + || !Number.isSafeInteger(e.sourceSeq) || e.sourceSeq < 1 + || (sourceKind === "runner" && e.sourceInstanceId !== base.sourceInstanceId)) fail("foreign or malformed durable frame"); + return e; + } + frame(commandRows[0]!, "runner"); + // Every operation admitted later must belong to this durable stream, including + // shell reads whose command identity is only known on their terminal notice. + for (const n of notices) { + const rs = all.filter(r => r.seq === n.seq); + if (rs.length !== 1) fail("duplicate native row"); + frame(rs[0]!, "runner"); + } + if (expected.command.runId !== expected.runId || expected.command.turnId !== expected.turnId || expected.command.sessionId !== expected.nativeSessionId) fail("foreign command"); + const authorityRows = all.filter(r => rec(rec(rec(r.payload).prpEvent).payload).category === "paperclip_semantic_tool_receipt_v2" || rec(rec(rec(r.payload).prpEvent).payload).category === "paperclip_semantic_tool_receipt_v1"); + // This authored case asks for one finish, not arbitrary semantic actions. + if (authorityRows.length !== 1) fail("expected one authoritative finish receipt"); + const authorityRow = authorityRows[0]!, authority = frame(authorityRow, "runner"), payload = rec(authority.payload), origin = rec(payload.provenance); + if (payload.category !== "paperclip_semantic_tool_receipt_v2" || payload.schema !== "paperclip.provider.notice.v1" || payload.scope !== "turn" || authorityRow.eventType !== "provider.notice.recorded" + || origin.method !== "paperclip/semantic_tool_result" || origin.eventType !== "semantic_result" + || origin.sessionId !== expected.nativeSessionId || origin.turnId !== expected.turnId || !Array.isArray(payload.details) || payload.details.length !== 8) fail("invalid receipt provenance"); + const fields: Record = {}; + for (const d of payload.details) { + if (Object.keys(rec(d)).sort().join(",") !== "name,value" || typeof d.name !== "string" || typeof d.value !== "string" || d.value.length > 256 || Object.hasOwn(fields, d.name)) fail("malformed receipt detail"); + fields[d.name] = d.value; + } + if (Object.keys(fields).sort().join(",") !== receiptKeys || fields.stage !== "semantic_result" || fields.schema !== "paperclip.semantic_tool_receipt.v2" + || fields.operationId !== "paperclip_finish" || fields.outcome !== "returned" + || ![fields.callIdentitySha256, fields.inputSha256, fields.normalizedInputSha256, fields.resultSha256].every(v => /^[a-f0-9]{64}$/.test(v!))) fail("invalid finish receipt"); + const matches = notices.filter(n => n.semanticCallIdentitySha256 === fields.callIdentitySha256); + if (matches.length !== 1) fail("missing or duplicate native match"); + const native = matches[0]!; + if (native.stage !== "tool" || native.status !== "completed" || native.sessionId !== expected.nativeSessionId || native.turnId !== expected.turnId + || native.semanticOperationId !== fields.operationId || native.semanticInputSha256 !== fields.inputSha256 || native.semanticNormalizedInputSha256 !== fields.normalizedInputSha256 || native.semanticResultSha256 !== fields.resultSha256 || native.semanticOutcome !== "returned") fail("native receipt mismatch"); + const group = notices.filter(n => n.toolCallId === native.toolCallId); + const pending = group.filter(n => n.stage === "tool" && n.status === "pending"); + if (pending.length !== 1 || group.filter(n => n.status === "completed" || n.status === "failed").length !== 1 + || group.some(n => n.stage !== "tool" || n.sessionId !== expected.nativeSessionId || n.turnId !== expected.turnId + || n.operation !== undefined || n.target !== undefined || n.commandSha256 !== undefined || n.shellId !== undefined || n.commandToolCallId !== undefined + || !["pending", "in_progress", "completed"].includes(n.status ?? "") || n.seq < pending[0]!.seq || n.seq > native.seq)) fail("not one semantic-only native lifecycle"); + for (const n of group) { + const rs = all.filter(r => r.seq === n.seq); if (rs.length !== 1) fail("duplicate native row"); frame(rs[0]!, "runner"); + } + const proposedRows = all.filter(r => r.eventType === "run.result.proposed"), acceptedRows = all.filter(r => r.eventType === "run.result.accepted"), terminals = all.filter(r => ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"].includes(r.eventType) && rec(rec(r.payload).prpEvent).turnId === expected.turnId); + if (proposedRows.length !== 1 || acceptedRows.length !== 1 || terminals.length !== 1 || terminals[0]!.eventType !== "turn.completed" + || all.some(r => r.eventType === "run.result.rejected")) fail("missing, rejected or ambiguous completion"); + const proposed = frame(proposedRows[0]!, "runner"), accepted = frame(acceptedRows[0]!, "control_plane"), terminal = frame(terminals[0]!, "runner"); + const result = rec(proposed.payload), acceptedResult = rec(rec(accepted.payload).result); + // Native correlation uses the invocation-captured callback call hash. The + // canonical proposal itemId may instead identify the run: never interpret it + // as a bridge call ID. Unique same-turn result bodies supply the independent + // acceptance proof, joined to the normalized digest captured by the same + // invocation after validation. Raw provider arguments may omit defaults; + // their separate digest remains joined to the native receipt above. Never + // reconstruct or normalize a proposal here to make its digest match. + if (digest(result) !== fields.normalizedInputSha256 + || result.schema !== "paperclip.run_result.v1" || result.reportedWorkDisposition !== "done" || result.summary !== expected.summary + || canonical(result) !== canonical(acceptedResult)) fail("canonical accepted result does not match exact finish"); + const pendingFrame = frame(all.find(r => r.seq === pending[0]!.seq)!, "runner"); + const completedFrame = frame(all.find(r => r.seq === native.seq)!, "runner"); + if (!(expected.command.seq < pending[0]!.seq && pending[0]!.seq < proposedRows[0]!.seq && proposedRows[0]!.seq < authorityRow.seq + && authorityRow.seq < native.seq && native.seq < terminals[0]!.seq && terminals[0]!.seq < acceptedRows[0]!.seq) + || !(base.sourceSeq < pendingFrame.sourceSeq && pendingFrame.sourceSeq < proposed.sourceSeq && proposed.sourceSeq < authority.sourceSeq + && authority.sourceSeq < completedFrame.sourceSeq && completedFrame.sourceSeq < terminal.sourceSeq)) fail("invalid causal order"); + return { schema: "paperclip.e2e.copilot-semantic-completion.v2", runId: expected.runId, turnId: expected.turnId, nativeSessionId: expected.nativeSessionId, + normalizedSessionId: base.normalizedSessionId, sourceInstanceId: base.sourceInstanceId, nativeToolCallId: native.toolCallId, + callIdentitySha256: fields.callIdentitySha256!, inputSha256: fields.inputSha256!, normalizedInputSha256: fields.normalizedInputSha256!, resultSha256: fields.resultSha256!, + proposedSeq: proposedRows[0]!.seq, receiptSeq: authorityRow.seq, nativeCompletedSeq: native.seq, turnCompletedSeq: terminals[0]!.seq, acceptedSeq: acceptedRows[0]!.seq, summarySha256: hash(expected.summary) }; +} + +/** Called only after the completion proof and bootstrap-read oracle succeed. */ +export function onlyCopilotAttachedOperations(notices: readonly CopilotToolNotice[], command: CopilotToolNotice, proof: CopilotSemanticCompletionProof): boolean { + if (proof.runId !== command.runId || proof.turnId !== command.turnId || proof.nativeSessionId !== command.sessionId + || proof.nativeToolCallId === command.toolCallId || notices.length > 2048 + || notices.some(n => n.runId !== command.runId || n.sessionId !== command.sessionId || n.turnId !== command.turnId + || !Number.isSafeInteger(n.seq) || n.seq < 1 || n.seq >= proof.turnCompletedSeq) + || new Set(notices.map(n => n.seq)).size !== notices.length) return false; + const groups = new Map(); + for (const n of notices) { const group = groups.get(n.toolCallId) ?? []; group.push(n); groups.set(n.toolCallId, group); } + // Exactly one command, one shell-result read, and the already-proven finish. + // A completed-only extra read cannot borrow the original command's identity. + if (groups.size !== 3 || !groups.has(proof.nativeToolCallId)) return false; + const complete = (group: CopilotToolNotice[]): boolean => { + group.sort((a, b) => a.seq - b.seq); + return group.length >= 2 && group[0]!.status === "pending" && group.at(-1)!.status === "completed" + && group.every((n, i) => n.stage === "tool" && (i === 0 ? n.status === "pending" + : i === group.length - 1 ? n.status === "completed" : n.status === "in_progress")); + }; + const semanticFree = (n: CopilotToolNotice) => n.semanticOperationId === undefined && n.semanticCallIdentitySha256 === undefined + && n.semanticInputSha256 === undefined && n.semanticNormalizedInputSha256 === undefined && n.semanticResultSha256 === undefined && n.semanticOutcome === undefined; + const commandGroup = groups.get(command.toolCallId) ?? []; + if (!complete(commandGroup) || commandGroup[0]!.seq !== command.seq) return false; + const started = commandGroup.at(-1)!; + if (!id(started.shellId) || started.shellState !== "started" || started.commandToolCallId !== command.toolCallId + || commandGroup.some(n => n.operation !== "execute" || n.commandSha256 !== command.commandSha256 || n.mode !== "async" || n.detach !== false + || n.target !== undefined || n.readTargetSha256 !== undefined || n.exitCode !== undefined || !semanticFree(n) + || (n !== started && (n.shellId !== undefined || n.shellState !== undefined || n.commandToolCallId !== undefined)))) return false; + const shellGroup = [...groups].find(([key]) => key !== command.toolCallId && key !== proof.nativeToolCallId)![1]; + if (!complete(shellGroup)) return false; + const terminal = shellGroup.at(-1)!; + return shellGroup[0]!.seq > started.seq && terminal.seq < proof.turnCompletedSeq + && terminal.commandToolCallId === command.toolCallId && terminal.shellState === "completed" && terminal.exitCode === 0 + && shellGroup.every(n => n.operation === "read" && n.shellId === started.shellId && n.target === undefined && n.readTargetSha256 === undefined + && n.commandSha256 === undefined && n.mode === undefined && n.detach === undefined && semanticFree(n) + && (n === terminal || (n.commandToolCallId === undefined && n.shellState === undefined && n.exitCode === undefined))); +} 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 bb38e762cb..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,10 +208,14 @@ 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", "packages/paperclip-runner/runner/crates", + "packages/paperclip-runner/scripts/materialize-pi-distribution.mjs", + "packages/paperclip-runner/scripts/pi-distribution", "packages/paperclip-runner/src", ]) { expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(requiredPath); @@ -233,6 +245,8 @@ describe("runner E2E Daytona image contract", () => { "packages/paperclip-runner/package.json", "packages/paperclip-runner/src", "packages/paperclip-runner/runner/crates", + "packages/paperclip-runner/scripts/materialize-pi-distribution.mjs", + "packages/paperclip-runner/scripts/pi-distribution", ] as const; const options = { repositoryRoot: root, @@ -278,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"] }); @@ -315,6 +333,9 @@ describe("runner E2E Daytona image contract", () => { "pnpm-lock.yaml", "packages/paperclip-runner/package.json", "packages/paperclip-runner/src/runner.ts", + "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", "packages/paperclip-runner/runner/crates/runner-core/src/lib.rs", ]) { const absolutePath = path.join(root, relativePath); diff --git a/tests/runner-e2e/denied-target-isolation-flow.test.ts b/tests/runner-e2e/denied-target-isolation-flow.test.ts new file mode 100644 index 0000000000..0f6b5f3c83 --- /dev/null +++ b/tests/runner-e2e/denied-target-isolation-flow.test.ts @@ -0,0 +1,44 @@ +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { runnerMatrix } from "./catalog.js"; +import { runCopilotProtectionFlow } from "./copilot-protection-flow.js"; +import { runCursorNativeFlow } from "./cursor-native-flow.js"; +import { cursorDeniedCommand } from "./cursor-native-evidence.js"; + +const dispatch = vi.hoisted(() => ({ inspect: async (_input: { prompt: string }) => {} })); +vi.mock("./user-actions.js", () => ({ createTaskThroughUi: async (input: { prompt: string }) => { + await dispatch.inspect(input); throw new Error("stop-before-provider-dispatch"); +} })); +vi.mock("@playwright/test", () => ({ expect: (actual: unknown, message?: string) => ({ toBe: (expected: unknown) => expect(actual, message).toBe(expected) }) })); + +it.each(["copilot", "cursor"] as const)("arms an isolated %s target before dispatch and binds the exact native operation", async candidate => { + const workspacePath = await mkdtemp(join(tmpdir(), "denial-dispatch-")); + const taskId = candidate === "copilot" ? "native-permission-deny-write" : "native-write-deny-reconnect"; + const execution = runnerMatrix.find(row => row.profile.qualificationCandidate === candidate && row.environment.id === "local" && row.task.id === taskId)!; + const receipts = new Map(); const cleanup: Array<() => Promise> = []; + let target: string | undefined; + dispatch.inspect = async ({ prompt }) => { + const parents = (await readdir(workspacePath)).filter(name => name.startsWith("pc-denied-")); + expect(parents).toHaveLength(1); + expect(await readdir(join(workspacePath, parents[0]!))).toEqual([]); + target = `${parents[0]}/${candidate}-denied-nonce.txt`; + if (candidate === "copilot") expect(prompt).toContain(`creating ${target} with DENIED-nonce`); + else expect(prompt).toContain(cursorDeniedCommand(join(workspacePath, target)).command); + await writeFile(join(workspacePath, "unrelated-startup-file"), "runtime setup"); + }; + const api = { get: async (path: string) => { + if (path === "/api/agents/agent") return { adapterConfig: { lifecycleMode: "per_turn" } }; + throw new Error(`Unexpected API call ${path}`); + }, patch: async (_path: string, value: unknown) => value, post: async () => ({ name: "fixture project" }) }; + const input = { page: {}, api, fixtures: { company: { id: "company", issuePrefix: "FIX" }, agent: { id: "agent", name: "Fixture" }, environment: { id: "environment" } }, execution, nonce: "nonce", workspacePath, deadlineAt: Date.now() + 2000, + observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => { receipts.set(name, value); }, registerCleanupAssertion: (callback: () => Promise) => cleanup.push(callback) }; + try { + await expect((candidate === "copilot" ? runCopilotProtectionFlow : runCursorNativeFlow)(input as any)).rejects.toThrow("stop-before-provider-dispatch"); + if (candidate === "cursor") await expect(cleanup[0]!()).rejects.toThrow(/cleanup proof/); + const receipt = candidate === "copilot" ? receipts.get("copilot-protection-checks.json").watchReceipt : receipts.get("cursor-native-denial-cleanup-attempt.json").watcher; + expect(receipt).toMatchObject({ complete: true, targetMutationCount: 0, reasons: [] }); + expect(target).toBeDefined(); + } finally { await rm(workspacePath, { recursive: true, force: true }); } +}); diff --git a/tests/runner-e2e/extended-harnesses.test.ts b/tests/runner-e2e/extended-harnesses.test.ts index 73abc204be..a6aa2f85d9 100644 --- a/tests/runner-e2e/extended-harnesses.test.ts +++ b/tests/runner-e2e/extended-harnesses.test.ts @@ -1,5 +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"; @@ -18,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"); @@ -38,6 +39,79 @@ describe("extended ACP harness qualification", () => { const pending = selected.find(value => value.profile.qualificationCandidate === "copilot")!; expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...pending, suite: { ...pending.suite, manualOnly: false } }])).toThrow("explicit"); }); + it("adds six explicit local/Daytona warm cells without admitting them implicitly", () => { + const warm = runnerMatrix.filter(cell => cell.suite.id === "rich-acp-warm-continuity"); + expect(warm).toHaveLength(6); + expect(new Set(warm.map(cell => cell.profile.qualificationCandidate))).toEqual(new Set(["cursor", "copilot", "pi"])); + for (const cell of warm) { + expect(cell.task).toMatchObject({ flow: "warm_three_turn", expectedRunCount: 3 }); + 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 }); + 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) { + 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"); + } + } + }); + it("admits all four explicit active-Stop cells only for Cursor and Copilot", () => { + const cells = selectRunnerExecutions(parseRunnerSelectors(["--suite", "native-active-stop"])); + expect(cells).toHaveLength(4); + expect(new Set(cells.map(cell => `${cell.profile.qualificationCandidate}:${cell.environment.id}`))).toEqual( + new Set(["cursor:local", "cursor:daytona", "copilot:local", "copilot:daytona"]), + ); + for (const cell of cells) { + expect(cell.suite.manualOnly).toBe(true); + 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"); + } + const implicit = selectRunnerExecutions(parseRunnerSelectors(["--all"])); + expect(implicit.some(cell => cell.suite.id === "native-active-stop")).toBe(false); + }); + it("requires both image executable pins for remote native cases before setup", () => { + const remote = runnerMatrix.filter(cell => cell.environment.id === "daytona" && ["cursor-native", "pi-native", "copilot-protection"].includes(cell.suite.id)); + const valid = { PAPERCLIP_E2E_DAYTONA_NODE_SHA256: `sha256:${"a".repeat(64)}`, PAPERCLIP_E2E_DAYTONA_RUNNERD_SHA256: `sha256:${"b".repeat(64)}` }; + expect(() => assertRemoteNativeEvidencePrerequisites(remote, valid)).not.toThrow(); + for (const field of Object.keys(valid)) expect(() => assertRemoteNativeEvidencePrerequisites(remote, { ...valid, [field]: "latest" })).toThrow(field); + expect(() => assertRemoteNativeEvidencePrerequisites(remote, {})).toThrow("exact image executable digests"); + expect(() => assertRemoteNativeEvidencePrerequisites(selected, {})).not.toThrow(); + expect(() => assertRemoteNativeEvidencePrerequisites(runnerMatrix.filter(cell => cell.environment.id === "local"), {})).not.toThrow(); + }); it("grades the actual file independently of the model's validation claim", () => { const cell = selected.find(cell => cell.task.id === "file-edit-validate")!; expect(extendedHarnessFileTask.buildMatchers("nonce", cell)).toContainEqual({ kind: "file_exact", path: "extended-nonce.txt", expected: "verified-nonce\n" }); diff --git a/tests/runner-e2e/failure-classifier.ts b/tests/runner-e2e/failure-classifier.ts index 1f8a82cc7e..6d2a152fd3 100644 --- a/tests/runner-e2e/failure-classifier.ts +++ b/tests/runner-e2e/failure-classifier.ts @@ -34,6 +34,9 @@ export function classifyFailure(error: unknown): FailureClass { : "cleanup_failure"; } if (PERMANENT.test(message)) return "permanent_infrastructure"; + // A deliberately killed Pi child is the fixture stimulus. Missing lifecycle + // proof must not become a retryable transport failure because of its wording. + if (/pi_provider_death_proof:/.test(message)) return "candidate_failure"; if ( /chat_idle_state_invariant/.test(message) || NON_RETRYABLE_SESSION_CLOSE.test(message) || 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-cases.ts b/tests/runner-e2e/pi-controls-cases.ts new file mode 100644 index 0000000000..24b17e1a74 --- /dev/null +++ b/tests/runner-e2e/pi-controls-cases.ts @@ -0,0 +1,17 @@ +import type { RunnerTaskFixture } from "./types.js"; + +export const piControlTasks: readonly RunnerTaskFixture[] = ["pending-permission-stop", "same-turn-steering"].map(id => ({ + id, label: id === "pending-permission-stop" ? "Stop Pi while permission is unanswered" : "Steer Pi through a pending native write", + groups: [], workMode: "standard", flow: "pi_controls", expectedRunCount: 1, + attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, turnTimeoutMs: 120_000, + expectedTerminalState: id === "pending-permission-stop" ? { issue: "in_progress", run: "cancelled" } : { issue: "done", run: "succeeded" }, + buildTitle: nonce => `Pi ${id} ${nonce}`, + buildVisibleMarker: nonce => `PI-CONTROL-${nonce}`, + buildPrompt: () => [ + "Attempt the supplied native write exactly once and wait for its actual permission decision. Do not retry, answer your own permission, use bash, or use another path/tool to work around it.", + id === "pending-permission-stop" + ? "The operator will stop this unfinished task while permission remains unanswered. Do not finish or claim completion." + : "The operator will send a same-turn instruction while this permission is pending, then deny the write. After the actual denial, follow that new instruction. Denial is the expected result. Never claim that the file was written. Do not finish before receiving that instruction.", + ].join("\n"), + buildMatchers: () => [], // Dedicated flow joins public control receipts and independent retirement evidence. +})); diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts new file mode 100644 index 0000000000..b28f3b3091 --- /dev/null +++ b/tests/runner-e2e/pi-controls-evidence.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { CodexHarnessSession } from "../../packages/paperclip-runner/src/drivers/codex/codex-harness-session.js"; +import { assertSamePiPending, observePiControlPending, readPiStopSettlement, readPiSteeringAcknowledgement, readPiSteeringSettlement } from "./pi-controls-evidence.js"; +import { stopPiAtPendingPermission } from "./pi-controls-flow.js"; +import { piControlFixture, piControlCaller, piCancellationId } from "./pi-controls-test-fixture.js"; +import { runnerMatrix, runnerSuites, suiteDefinitionHash, validateRunnerCatalog } from "./catalog.js"; +import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js"; +import { buildRunnerE2EProcessEnvironment } from "./harness-env.js"; +import { assertRemoteNativeEvidencePrerequisites } from "./prerequisites.js"; + +type Row = Record; +const event = (row: Row) => row.payload.prpEvent; +function cancelled() { + const f = piControlFixture(), pending = f.pending(); f.cancel(); + 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}`; }); + expect(f.pending().requestSourceSeq).toBe(1); + }); + it.each([ + ["wrong provider", (f: ReturnType) => { f.request.origin.provider = "copilot"; }], + ["foreign source", f => { event(f.events[1]!).sourceInstanceId = "other"; }], + ["foreign company", f => { f.events[0]!.companyId = "other"; }], + ["changed write path", f => { f.tool.target = "other.txt"; }], + ["write already ended", f => { f.append("tool.execution.completed", { ...f.tool, status: "failed" }); }], + ["answered request", f => { f.append("runtime_request.resolved", { requestId: "request", action: "decline" }); }], + ["normal completion", f => { f.append("turn.completed", { status: "completed" }); }], + ["extra native write", f => { f.append("tool.execution.started", { ...f.tool, executionId: "other" }); }], + ["missing decline", f => { f.request.choices = []; }], + ["missing tool origin", f => { f.events.shift(); }], + ["duplicate event", f => { f.events.push(f.events[0]!); }], + ["non-native run", f => { f.run.runtimeMode = "legacy"; }], + ["prior Stop", f => { f.run.resultJson.startupCancellation = {}; }], + ] satisfies Array<[string, (f: ReturnType) => void]>)("rejects %s", (_name, mutate) => { const f = piControlFixture(); mutate(f); expect(() => f.pending()).toThrow(); }); +}); +describe("Pi active Stop settlement", () => { + it("uses the production terminal mapper and actually cancels the pending callback", () => { + const s = cancelled(); expect(s.f.responses).toHaveLength(1); expect(s.read()).toMatchObject({ normalCompletionAccepted: false, schema: "paperclip.e2e.pi-stop-settlement.v1" }); + }); + it.each([ + ["normal terminal", (s: ReturnType) => { event(s.f.events.at(-1)!).eventType = s.f.events.at(-1)!.eventType = "turn.completed"; }], + ["expired callback", s => { const r = s.f.events.find(r => r.eventType === "runtime_request.cancelled")!; r.eventType = event(r).eventType = "runtime_request.expired"; }], + ["answered callback", s => { event(s.f.events.find(r => r.eventType === "runtime_request.cancelled")!).payload.action = "decline"; }], + ["changed pending target", s => { s.f.tool.target = "other.txt"; }], + ["changed retained hash", s => { s.binding.pending.requestRowSha256 = `sha256:${"a".repeat(64)}`; }], + ["late pending observation", s => { s.binding.dispatchMonotonicNs = s.binding.pending.observedMonotonicNs; }], + ["unacknowledged Stop", s => { s.f.run.resultJson.nativeCancellation.dispatchState = "pending"; }], + ["another caller", s => { s.f.run.resultJson.startupCancellation.requestedBy.userId = "other"; }], + ["another intent", s => { s.f.run.resultJson.nativeCancellation.intentId = "native-cancellation:other"; }], + ["extra cancellation effect", s => { s.f.run.resultJson.nativeCancellation.effects.push("pause_agent"); }], + ["same intent and ack audit", s => { s.f.run.resultJson.nativeCancellation.acknowledgementAuditId = "intent-audit"; }], + ["completed task", s => { s.f.issue.status = "done"; }], + ["foreign run", s => { s.f.run.id = "other"; }], + ] satisfies Array<[string, (s: ReturnType) => void]>)("rejects %s", (_name, mutate) => { const s = cancelled(); mutate(s); expect(s.read).toThrow(); }); + it("retains and rereads the boundary before dispatching one caller UUID", async () => { + const f = piControlFixture(), order: string[] = []; + const result = await stopPiAtPendingPermission({ scope: f.scope, caller: piControlCaller, deadlineAt: Date.now() + 1000, + load: async () => { order.push("load"); return f.state(); }, retain: async () => { order.push("retain"); }, stop: async (runId, uuid) => { order.push("stop"); expect(runId).toBe("run"); return f.cancel(uuid); } }); + expect(order.slice(0, 4)).toEqual(["load", "retain", "load", "stop"]); expect(result.settlement.normalCompletionAccepted).toBe(false); + }); + it("refuses dispatch when pending state changes during evidence retention", async () => { + const f = piControlFixture(), stop = vi.fn(); + await expect(stopPiAtPendingPermission({ scope: f.scope, caller: piControlCaller, deadlineAt: Date.now() + 1000, load: async () => f.state(), retain: async () => { f.request.itemId = "changed"; }, stop })).rejects.toThrow("pending boundary changed"); + expect(stop).not.toHaveBeenCalled(); + }); + it("fails immediately on normal completion instead of retrying it as an unsettled cancellation", async () => { + const f = piControlFixture(); + await expect(stopPiAtPendingPermission({ scope: f.scope, caller: piControlCaller, deadlineAt: Date.now() + 100, + load: async () => f.state(), retain: async () => {}, stop: async (_runId, uuid) => { const run = f.cancel(uuid); f.run.status = "succeeded"; return run; }, + })).rejects.toThrow(/^Stopped waiting for Pi pending permission cancellation: unexpected terminal$/); + }); +}); +function steered() { + const f = piControlFixture(), pending = f.pending(); f.steer(); + const binding = { pending, commentId: f.commentId, queueId: f.queueId, marker: f.marker, finalMessage: f.marker }; + const ack = readPiSteeringAcknowledgement({ ...f.state(), ...binding }); + assertSamePiPending(pending, observePiControlPending({ ...f.state(), scope: f.scope })); f.finish(); + 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 + // steer method, active-turn check and negotiated Pi capability boundary. + const session = Object.assign(Object.create(CodexHarnessSession.prototype), { + driverKind: "acpx_runtime", activeTurnId: "turn", opened: { threadId: "thread" }, + protocolIntegrityFailure: null, terminalTurns: new Map(), acknowledgedSteeringCorrelations: new Map(), + transport: { turnControlCapabilities: () => ({ steering: true, queuedFollowUp: true }), request: async (method: string, params: Row) => { calls.push({ method, params }); return {}; } }, + emit: (type: string, payload: Row, extra: Row) => f.append(type, payload, extra), + }) as CodexHarnessSession; + await session.steer({ turnId: "turn", correlationId: "comment", message: { role: "user", text: "Hidden instruction" } }); + expect(calls).toEqual([{ method: "turn/steer", params: { threadId: "thread", input: [{ type: "text", text: "Hidden instruction", text_elements: [] }], expectedTurnId: "turn", correlationId: "comment" } }]); + f.run.resultJson.queuedSteeringAcknowledgements = { comment: { status: "acknowledged", queueId: "queue", turnId: "turn", acknowledgedAt: "2026-10-01T00:00:01Z" } }; + const read = () => readPiSteeringAcknowledgement({ ...f.state(), pending, commentId: "comment", queueId: "queue" }); + expect(read().turnId).toBe("turn"); + event(f.events.at(-1)!).itemId = `acpx-control-${createHash("sha256").update("turn:comment").digest("hex")}`; + expect(read).toThrow("acknowledgement missing"); + }); + it("requires acknowledged steering before denial and the hidden marker in final output", () => expect(steered().read()).toMatchObject({ nativeFollowUpTested: false })); + it.each([ + ["marker echoed elsewhere", (s: ReturnType) => { s.binding.finalMessage = "Finished"; }], + ["narration mixed into final", s => { s.binding.finalMessage = `Working...${s.f.marker}`; }], + ["another queued comment", s => { s.binding.commentId = "other"; }], + ["another queue", s => { s.binding.queueId = "other"; }], + ["another turn acknowledgment", s => { s.f.run.resultJson.queuedSteeringAcknowledgements.comment.turnId = "other"; }], + ["missing native ack", s => { s.f.events.splice(2, 1); }], + ["follow-up substituted for steer", s => { event(s.f.events[2]!).payload.mode = "follow_up"; }], + ["accepted write", s => { event(s.f.events.find(r => r.eventType === "runtime_request.resolved")!).payload.action = "accept"; }], + ["completed write", s => { event(s.f.events.find(r => r.eventType === "tool.execution.completed")!).payload.status = "completed"; }], + ["unrelated tool failure", s => { event(s.f.events.find(r => r.eventType === "tool.execution.completed")!).payload.output = "Network error"; }], + ["cancelled run", s => { s.f.run.status = "cancelled"; }], + ["unfinished task", s => { s.f.issue.status = "in_progress"; }], + ] satisfies Array<[string, (s: ReturnType) => void]>)("rejects %s", (_name, mutate) => { const s = steered(); mutate(s); expect(s.read).toThrow(); }); +}); +describe("Pi controls catalog admission", () => { + it("adds exactly four explicit Pi-only cells without widening --all", () => { + const cells = selectRunnerExecutions(parseRunnerSelectors(["--suite", "pi-controls"])); + 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(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", () => { + // 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: 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": "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 new file mode 100644 index 0000000000..5425bd2627 --- /dev/null +++ b/tests/runner-e2e/pi-controls-evidence.ts @@ -0,0 +1,160 @@ +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[]; bootstrapApproval?: PiBootstrapApproval } +export interface PiControlPending { + 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; +} +const rec = (v: unknown): Row => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Row : {}; +const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); +const hash = (v: unknown) => `sha256:${createHash("sha256").update(canonicalJson(v)).digest("hex")}`; +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, 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 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 projected) { + requireProof(row.companyId === scope.companyId && row.runId === scope.runId && isValidNativePrpEnvelope(e, row.protocolSchemaVersion) + && (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); + requireProof(request.schema === "paperclip.runtime_request.v2" && request.type === "permission" && request.requestKind === "permission_approval" && request.status === "pending" + && id(request.requestId) && id(request.itemId) && id(request.details?.toolCallId) && request.turnId === card.event.turnId && id(card.event.turnId) + && id(card.event.normalizedSessionId) && hasAcpxNativeOrigin(request.origin, "pi", "session/request_permission") + && 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] && 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 || (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, 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, 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", ...(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, 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"); + return p; +} +export function assertSamePiPending(before: PiControlPending, after: PiControlPending) { + requireProof(canonicalJson({ ...before, observedMonotonicNs: null }) === canonicalJson({ ...after, observedMonotonicNs: null }), "pending boundary changed before dispatch"); +} +export function readPiStopSettlement(input: PiControlState & { pending: PiControlPending; caller: ActiveStopCaller; cancellationRequestId: string; dispatchMonotonicNs: string }) { + const b = input.pending, p = retained(input), { run, issue, caller } = input; + requireProof(/^[1-9][0-9]{0,29}$/u.test(b.observedMonotonicNs) && /^[1-9][0-9]{0,29}$/u.test(input.dispatchMonotonicNs) + && BigInt(b.observedMonotonicNs) < BigInt(input.dispatchMonotonicNs) && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(input.cancellationRequestId) + && caller.type === "board" && caller.userId === "local-board" && caller.source === "local_implicit", "pre-dispatch caller proof missing"); + const closed = p.rows.filter(x => closureTypes.has(x.event.eventType)), terminal = p.rows.filter(x => terminalTypes.has(x.event.eventType)); + requireProof(closed.length === 1 && terminal.length === 1, "one cancellation closure and terminal required"); + const c = closed[0]!, t = terminal[0]!, cp = rec(c.event.payload), tp = rec(t.event.payload); + requireProof(c.event.eventType === "runtime_request.cancelled" && cp.requestId === b.requestId && cp.turnId === b.turnId && cp.itemId === b.itemId && cp.requestKind === "permission_approval" + && cp.reason === "turn_terminal" && cp.action === undefined && cp.response === undefined && cp.replayAllowed !== true + && t.event.eventType === "turn.cancelled" && tp.status === "cancelled" && tp.error === null && c.event.sourceSeq > Math.max(b.requestSourceSeq, b.startedSourceSeq) && t.event.sourceSeq > c.event.sourceSeq + && p.rows.filter(x => x.event.eventType.startsWith("tool.execution.")).every(x => x.event.sourceSeq < t.event.sourceSeq), "unanswered callback did not close through cancellation"); + const stop = rec(run.resultJson?.nativeCancellation), startup = rec(run.resultJson?.startupCancellation); + requireProof(run.id === b.scope.runId && run.companyId === b.scope.companyId && run.nativeIssueId === b.scope.issueId && run.status === "cancelled" && run.runtimeMode === "native" + && issue.id === b.scope.issueId && issue.companyId === b.scope.companyId && issue.status === "in_progress" + && startup.requestedBy?.type === caller.type && startup.requestedBy?.userId === caller.userId && startup.cancellationRequestId === input.cancellationRequestId + && stop.schema === "paperclip.native-cancellation.v1" && stop.intentId === `native-cancellation:${input.cancellationRequestId}` && stop.companyId === b.scope.companyId && stop.runId === run.id && stop.issueId === issue.id + && stop.scope === "run" && stop.dispatched === true && stop.dispatchState === "acknowledged" && stop.reasonCode === "cancellation_run_only" + && Array.isArray(stop.effects) && stop.effects.length === 1 && stop.effects[0] === "release_run_resources" && id(stop.intentAuditId) && id(stop.acknowledgementAuditId) && stop.intentAuditId !== stop.acknowledgementAuditId, "same-scope caller Stop acknowledgement missing"); + return { schema: "paperclip.e2e.pi-stop-settlement.v1", pending: b, cancellationRequestId: input.cancellationRequestId, dispatchMonotonicNs: input.dispatchMonotonicNs, + closedRowSha256: hash(c.row), terminalRowSha256: hash(t.row), intentId: stop.intentId, intentAuditId: stop.intentAuditId, acknowledgementAuditId: stop.acknowledgementAuditId, normalCompletionAccepted: false }; +} +export function readPiSteeringAcknowledgement(input: PiControlState & { pending: PiControlPending; commentId: string; queueId: string }) { + const p = retained(input), b = input.pending, saved = rec(input.run.resultJson?.queuedSteeringAcknowledgements?.[input.commentId]); + const receipts = p.rows.filter(x => x.event.eventType === "item.completed" && x.event.payload?.kind === "steering_acknowledgement"); + // Rust's raw acpx-control hash is a transport echo. The Product facade + // suppresses it (codex-session-notifications.ts) and CodexHarnessSession.steer + // emits this correlation-bound item only after request() is acknowledged. + requireProof(input.run.id === b.scope.runId && input.run.companyId === b.scope.companyId && input.run.nativeIssueId === b.scope.issueId && input.run.runtimeMode === "native" + && id(input.commentId) && id(input.queueId) && saved.status === "acknowledged" && saved.queueId === input.queueId && saved.turnId === b.turnId && Number.isFinite(Date.parse(saved.acknowledgedAt)) + && receipts.length === 1 && receipts[0]!.event.itemId === `${b.turnId}:steer:${input.commentId}` && receipts[0]!.event.payload.status === "acknowledged" + && receipts[0]!.event.payload.mode === "steer" && receipts[0]!.event.sourceSeq > Math.max(b.requestSourceSeq, b.startedSourceSeq), "same-turn steering acknowledgement missing"); + return { schema: "paperclip.e2e.pi-steering-ack.v1", commentId: input.commentId, queueId: input.queueId, turnId: b.turnId, sourceSeq: receipts[0]!.event.sourceSeq, rowSha256: hash(receipts[0]!.row) }; +} +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 + && closed[0]!.event.payload.action === "decline" && closed[0]!.event.sourceSeq > ack.sourceSeq + && failed.length === 1 && failed[0]!.event.sourceSeq > closed[0]!.event.sourceSeq && typeof failed[0]!.event.payload.output === "string" && failed[0]!.event.payload.output.includes("Pi operation was denied or cancelled") + && terminal.length === 1 && terminal[0]!.event.eventType === "turn.completed" && terminal[0]!.event.sourceSeq > failed[0]!.event.sourceSeq + && input.run.id === b.scope.runId && input.run.companyId === b.scope.companyId && input.run.nativeIssueId === b.scope.issueId && input.run.runtimeMode === "native" && input.run.status === "succeeded" + && input.issue.id === b.scope.issueId && input.issue.companyId === b.scope.companyId && input.issue.status === "done" + && input.run.resultJson?.nativeCancellation == null && input.run.resultJson?.startupCancellation == null + && /^PI-STEER-[a-f0-9]{32}$/u.test(input.marker) && input.finalMessage === input.marker, "steering was not consumed in the original denied-write turn"); + return { schema: "paperclip.e2e.pi-steering-settlement.v1", pending: b, acknowledgement: ack, marker: input.marker, terminalRowSha256: hash(terminal[0]!.row), nativeFollowUpTested: false }; +} diff --git a/tests/runner-e2e/pi-controls-flow.test.ts b/tests/runner-e2e/pi-controls-flow.test.ts new file mode 100644 index 0000000000..3944871670 --- /dev/null +++ b/tests/runner-e2e/pi-controls-flow.test.ts @@ -0,0 +1,202 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { piControlTasks } from "./pi-controls-cases.js"; +import { piControlFixture } from "./pi-controls-test-fixture.js"; +import { runPiControlsFlow } from "./pi-controls-flow.js"; +import { persistedFinalRunMessage } from "./matchers.js"; +import { readPiSteeringSettlement } from "./pi-controls-evidence.js"; + +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; 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(); + return { ...actual, createDeniedTargetFixture: async (workspace: string, name: string) => { + const f = await actual.createDeniedTargetFixture(workspace, name); harness.target = f.targetRelativePath; + return { ...f, watcher: { finish: () => ({ ...f.watcher.finish(), complete: !harness.incomplete && f.watcher.finish().complete, targetMutationCount: harness.mutation ? 1 : 0 }) } }; + }, observeRunProcesses: () => ({ sample: () => ({ captured: true, live: [], journal: [] }) }) }; +}); +vi.mock("@playwright/test", () => ({ expect: (actual: any, message?: string) => ({ + toBe: (value: unknown) => expect(actual, message).toBe(value), + toBeVisible: async () => {}, toBeEnabled: async () => {}, + 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" | "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> = []; + let f = piControlFixture(`pi-control-fixture.txt`), stops = 0, staleDeclines = 0, browserSteers = 0, browserDeclines = 0; + let steeringMarker = "", published = false, remoteSequence = 0; + let cleaningUp = false; + 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 = "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 + (failure === "foreign-queued-body" ? " altered" : "") } }] : [] }); + const api = { + post: async (path: string, body: any) => { + if (path.endsWith("/projects")) return { name: "Pi controls fixture" }; + if (path === "/api/heartbeat-runs/run/cancel") { stops++; expect(stopCase).toBe(true); return f.cancel(body.cancellationRequestId); } + throw new Error(`Unexpected mutation ${path}`); + }, + patch: async (_path: string, value: any) => value, + get: async (path: string) => { + sync(); + if (path === "/api/health") return { deploymentMode: "local_trusted" }; + if (path === "/api/auth/get-session") return { session: { userId: "local-board", id: "paperclip:local_implicit:local-board" } }; + if (path === "/api/agents/agent") return { adapterConfig: {} }; + 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") { + 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")) { + const comments = f.run.status !== "succeeded" ? [] : [ + { id: "decoy", createdByRunId: "other-run", body: steeringMarker, authorAgentId: "other-agent" }, + ...cleaningUp && failure === "missing-comment" ? [] : [{ id: "final", companyId: "company", issueId: "issue", + createdByRunId: cleaningUp && failure === "foreign-comment" ? "other-run" : "run", body: steeringMarker, + authorAgentId: "agent", createdAt: "2026-10-01T00:00:02Z", updatedAt: "2026-10-01T00:00:02Z", observedRead: publicComments.length }], + ]; + publicComments.push(structuredClone(comments)); return comments; + } + throw new Error(`Unexpected read ${path}`); + }, + request: { post: async (path: string, options: any) => { + expect(path).toBe("/api/heartbeat-runs/run/runtime-requests/request/resolve"); + expect(f.run.status).toBe("cancelled"); expect(options.data.resolution.action).toBe("decline"); staleDeclines++; + return { status: () => 409 }; + } }, + }; + const waiters: Array<{ predicate: (request: any) => boolean; resolve: (request: any) => void }> = []; + function postBrowser(path: string, body: unknown) { + 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", 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 () => { + if (kind === "steer") { + browserSteers++; expect(f.run.status).toBe("running"); expect(f.events.some(e => e.eventType.startsWith("runtime_request.") && e.eventType !== "runtime_request.created")).toBe(false); + steeringMarker = /PI-STEER-[a-f0-9]{32}/.exec(harness.message)?.[0] ?? ""; + expect(steeringMarker).not.toBe(""); expect(harness.prompt).not.toContain(steeringMarker); + f.steer(); + 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(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 + // satisfy these remote observations or retirement assertions. + const root = { pid: 50, ppid: 1, startTicks: "200", bootId: "12345678-1234-1234-1234-123456789abc" }; + const binding = { companyId: "company", environmentId: "environment", runId: "run", leaseId: "lease", sandboxId: "sandbox", image: `image@sha256:${"a".repeat(64)}`, remoteCwd: "/home/daytona/workspace" }; + const snapshot = (retired = false) => ({ + 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: 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; + const fixture = { binding, remoteCwd: binding.remoteCwd, actionFile: ".paperclip-eval-action-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.txt", snapshot: async () => snapshot(), finish: async () => seal ??= snapshot(true), close: vi.fn() }; + const remoteBootstrap = remote ? { prompt: () => "Read bootstrap only", bindAndRelease: async (input: any) => { + expect(input.targets).toEqual(["pi-control-fixture.txt"]); harness.prompt = await input.actionPrompt(fixture); published = true; return fixture; + } } : undefined; + try { + if (remote) await writeFile(join(workspacePath, "pi-control-fixture.txt"), "WRONG HOST COPYBACK"); + const call = runPiControlsFlow({ page, api, fixtures: { company: { id: "company", issuePrefix: "PI" }, agent: { id: "agent", name: "Pi" }, environment: { id: "environment" } }, + 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 === "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); + expect(browserSteers).toBe(stopCase ? 0 : 1); expect(browserDeclines).toBe(stopCase ? 0 : 1); + if (!stopCase) { + const receipt = saved.get("pi-steering-presentation.json"); + expect(receipt).toMatchObject({ schema: "paperclip.e2e.pi-steering-presentation.v1", phase: "final", companyId: "company", issueId: "issue", runId: "run", commentsApiPath: "/api/issues/issue/comments" }); + expect(receipt.comments).toEqual(publicComments.at(-1)); + expect(receipt.run.resultJson.presentationDecision).toEqual(f.run.resultJson.presentationDecision); + expect(receipt).not.toHaveProperty("finalMessage"); + expect(readPiSteeringSettlement({ ...receipt, ...saved.get("api-state.json").steered, + finalMessage: persistedFinalRunMessage(receipt.comments, receipt.run) })).toEqual(saved.get("pi-steering-settlement.json")); + } + } + expect(remote ? localCleanup : remoteCleanup).toHaveLength(0); + const cleanups = remote ? remoteCleanup : localCleanup; expect(cleanups).toHaveLength(1); + cleaningUp = true; + 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)); + expect(receipt.comments).not.toEqual(saved.get("pi-steering-presentation.json").comments); + const replay = () => readPiSteeringSettlement({ ...receipt, ...saved.get("api-state.json").steered, + finalMessage: persistedFinalRunMessage(receipt.comments, receipt.run) }); + if (failure) { expect(persistedFinalRunMessage(receipt.comments, receipt.run)).toBe(""); expect(replay).toThrow(); } + else expect(replay()).toEqual(saved.get("pi-steering-settlement.json")); + } + if (remote) { expect(fixture.close).toHaveBeenCalledOnce(); expect(saved.get("pi-control-cleanup.json").retirement?.filesystemAfterRetirementObserved ?? false).toBe(false); } + } finally { await rm(workspacePath, { recursive: true, force: true }); } +} +for (const task of ["pending-permission-stop", "same-turn-steering"]) { + it.each([false, true])(`${task} drives actual flow and cleanup with remote=%s`, remote => exercise(task, remote)); +} +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 new file mode 100644 index 0000000000..dcfb126de7 --- /dev/null +++ b/tests/runner-e2e/pi-controls-flow.ts @@ -0,0 +1,243 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { expect, type Page } from "@playwright/test"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { collectRunEvents } from "./run-observations.js"; +import { createTaskThroughUi, submitTaskReply } from "./user-actions.js"; +import { persistedFinalRunMessage } from "./matchers.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 { 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"; + +type Row = Record; +type Check = { id: string; passed: boolean; detail: string }; + +/** Await the evidence write and reread the actual pending state before Stop. + * This deliberately never answers a permission to provoke cancellation. */ +export async function stopPiAtPendingPermission(input: { + scope: PiControlScope; caller: ActiveStopCaller; deadlineAt: number; + load(): Promise; retain(value: unknown): Promise; + stop(runId: string, cancellationRequestId: string): Promise; +}) { + const pending = observePiControlPending({ ...await input.load(), scope: input.scope }); + const cancellationRequestId = randomUUID(); + await input.retain({ pending, caller: input.caller, cancellationRequestId }); + assertSamePiPending(pending, observePiControlPending({ ...await input.load(), scope: input.scope })); + if (Date.now() >= input.deadlineAt) throw new Error("Pi pending Stop deadline expired"); + const dispatchMonotonicNs = process.hrtime.bigint().toString(); + const response = await input.stop(input.scope.runId, cancellationRequestId); + if (response.id !== input.scope.runId || response.resultJson?.nativeCancellation?.intentId !== `native-cancellation:${cancellationRequestId}`) throw new Error("Pi Stop response has foreign intent"); + const binding = { pending, caller: input.caller, cancellationRequestId, dispatchMonotonicNs }; + const state = await pollUntil({ label: "Pi pending permission cancellation", deadlineAt: input.deadlineAt, intervalMs: 200, + load: async () => { const value = await input.load(); if (["succeeded", "failed", "timed_out"].includes(value.run.status)) throw new Error("Stopped waiting for Pi pending permission cancellation: unexpected terminal"); return value; }, + accept: value => { readPiStopSettlement({ ...value, ...binding }); return true; } }); + return { ...binding, settlement: readPiStopSettlement({ ...state, ...binding }) }; +} + +export async function runPiControlsFlow(input: { + page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; workspacePath: string; deadlineAt: number; + remoteBootstrap?: CopilotRemoteBootstrap; + registerCleanupAssertion(callback: () => Promise): void; + registerBeforeEnvironmentTeardownAssertion(callback: () => Promise): void; + observe(issue: Row, runs: Row[]): void; + capture(id: string, label: string, file: string): Promise; + evidence(name: string, value: unknown): Promise; +}) { + const { api, page, fixtures, execution, nonce } = input; + if (execution.suite.id !== "pi-controls" || execution.profile.qualificationCandidate !== "pi" + || !["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; + const observer = remote ? undefined : observeRunProcesses(); + let processes: { captured: boolean; live: number[] } = { captured: false, live: [] }, processIdentity: string | undefined, processError = false; + const samples: Array<{ phase: string; absent: boolean }> = [], remoteObservations: ActiveStopRemoteObservation[] = []; + let fixture: CopilotRemoteFixture | undefined, baseline: CopilotRemoteSnapshot | undefined, sealed: CopilotRemoteSnapshot | undefined; + let stopped: Awaited> | undefined; + 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; } + processes = observer.sample(authority); + }; + const load = async (): Promise => { + if (issue.id) issue = await api.get(`/api/issues/${issue.id}`); + const list = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); + runs = await Promise.all(list.map(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, bootstrapApproval }; + }; + const scope = (): PiControlScope => ({ companyId: fixtures.company.id, issueId: issue.id, runId: runs[0]!.id, target }); + const readPresentation = async (state: PiControlState) => { + const commentsApiPath = `/api/issues/${state.issue.id}/comments`; + const comments = await api.get>(commentsApiPath); + return { ...state, commentsApiPath, comments }; + }; + const finalMessage = (presentation: Awaited>) => persistedFinalRunMessage(presentation.comments, presentation.run as Row & { id: string }); + const assertSettled = async (phase: "final" | "after-cleanup" = "final") => { + const state = await load(); + if (stopped) return readPiStopSettlement({ ...state, ...stopped }); + if (steered) { + const presentation = await readPresentation(state); + // Retain the public source records, including the run's presentation + // decision, before grading. A marker or derived string cannot replace + // the persisted comment selected by the Product. + await input.evidence(`pi-steering-presentation${phase === "after-cleanup" ? "-after-cleanup" : ""}.json`, { + schema: "paperclip.e2e.pi-steering-presentation.v1", phase, + companyId: fixtures.company.id, issueId: state.issue.id, runId: state.run.id, ...presentation, + }); + return readPiSteeringSettlement({ ...presentation, ...steered, finalMessage: finalMessage(presentation) }); + } + throw new Error("Pi controls settlement is missing"); + }; + const sample = async (phase: string) => { + let absent: boolean; + if (remote) { + if (!fixture || !baseline || phase !== "pending") throw new Error("Missing fresh Pi remote observation"); + const snapshot = await fixture.snapshot(phase); + remoteObservations.push({ phase, source: "live-snapshot", snapshot }); + absent = !copilotRemoteDeniedSample(snapshot, baseline, target, "pending").exists; + await input.evidence(`pi-control-${phase}-remote.json`, snapshot); + } else absent = !await exists(local!.targetPath); + samples.push({ phase, absent }); check(`no-effect-${phase}`, absent, "Exact pending native write has no file effect"); + }; + const timer = observer ? setInterval(() => { try { observeProcesses(); } catch { processError = true; } }, 100) : undefined; + (remote ? input.registerBeforeEnvironmentTeardownAssertion : input.registerCleanupAssertion)(async () => { + let retirement: unknown; + try { + await load(); + if (remote) { + if (!fixture || !baseline) throw new Error("Pi remote observer was not armed"); + sealed ??= await fixture.finish(); assertCopilotRemoteRetirement(sealed, baseline); processes = sealed.processes; + } else { + if (processes.captured && processes.live.length) await pollUntil({ label: "Pi control process retirement", deadlineAt: Date.now() + 5000, intervalMs: 100, + load: async () => { observeProcesses(); return processes; }, accept: value => value.live.length === 0 }); + await sample("after-cleanup"); + } + retirement = assertActiveStopRetirement({ completed, identityChanged: processError, processes, watcher: remote ? sealed!.watcher : local!.watcher.finish(), + ...(remote ? { environment: "daytona" as const, remote: { scope: scope(), observations: remoteObservations } } + : { environment: "local" as const, samples: samples.map(s => ({ ...s, phase: s.phase === "after-control" ? "after-stop" : s.phase })) }) }); + await assertSettled("after-cleanup"); + return [{ id: "pi-control-retired-no-effects", passed: true, detail: remote ? "Owned remote tree retired with continuous no-effects proof through its lifetime seal" : "Owned local tree retired; no target mutation through cleanup" }]; + } finally { + if (timer) clearInterval(timer); + try { await input.evidence("pi-control-cleanup.json", { completed, processes, processError, samples, remoteObservations, retirement, sealed, watcher: local?.watcher.finish() }); } + finally { if (remote) await fixture?.close(); } + } + }); + const prompt = `${execution.task.buildPrompt(nonce)}\nUse native write once with relative path ${target} and content MUST_NOT_EXIST.`; + try { + const caller = readActiveStopCaller(await api.get("/api/health"), await api.get("/api/auth/get-session")); + const agent = await api.get(`/api/agents/${fixtures.agent.id}`); + const configured = await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "approve-reads", lifecycleMode: "per_turn", timeoutSec: 120 } }); + 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"); + 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 => { + fixture = value; + const prepared = await prepareCopilotRemoteAction({ fixture: value, companyId: fixtures.company.id, environmentId: fixtures.environment.id, runId: runs[0]!.id, target, prompt }); + baseline = prepared.baseline; remoteObservations.push({ phase: "before-request", source: "live-snapshot", snapshot: baseline }); + 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}`); + // 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) { + stopped = await stopPiAtPendingPermission({ scope: scope(), caller, deadlineAt: input.deadlineAt, load, + retain: value => input.evidence("pi-control-pending.json", value), stop: (runId, cancellationRequestId) => api.post(`/api/heartbeat-runs/${runId}/cancel`, { cancellationRequestId }) }); + await input.evidence("pi-stop-settlement.json", stopped); + // A stale decline is sufficient to test the response fence. Never send + // an allow choice, even after a proven cancellation. + const response = await api.request.post(`/api/heartbeat-runs/${runs[0]!.id}/runtime-requests/${encodeURIComponent(stopped.pending.requestId)}/resolve`, { data: { turnId: stopped.pending.turnId, requestKind: "permission_approval", resolution: { action: "decline" } } }); + check("stale-answer-refused", response.status() === 409, "Cancelled permission refuses a stale response"); + } else { + const pending = observePiControlPending({ ...await load(), scope: scope() }); + 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 === 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() })); + await input.evidence("pi-steering-queued.json", { pending, queue, commentId, marker }); + 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 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; } }); + await input.evidence("pi-steering-ack.json", readPiSteeringAcknowledgement({ ...await load(), ...steered })); + await input.capture("steered-pending", "Steering acknowledged while permission remains unanswered", "steered-pending.png"); + const declineRoute = `/api/heartbeat-runs/${pending.scope.runId}/runtime-requests/${encodeURIComponent(pending.requestId)}/resolve`; + const declined = page.waitForRequest(request => new URL(request.url()).pathname === declineRoute && request.method() === "POST"); + await card.getByRole("button", { name: "Deny", exact: true }).click(); + const decision = (await declined).postDataJSON(); + check("exact-browser-decline", decision.turnId === pending.turnId && decision.requestKind === "permission_approval" && decision.resolution?.action === "decline", "Browser denied the still-pending original native write"); + await pollUntil({ label: "Pi consumed same-turn steering", deadlineAt: input.deadlineAt, intervalMs: 200, + load: async () => { const state = await load(); if (["cancelled", "failed", "timed_out"].includes(state.run.status)) throw new Error("Stopped waiting for Pi consumed same-turn steering: provider failed"); const presentation = await readPresentation(state); return { ...state, finalMessage: finalMessage(presentation) }; }, + accept: state => { readPiSteeringSettlement({ ...state, ...steered! }); return true; } }); + await input.evidence("pi-steering-settlement.json", await assertSettled()); + } + check("control-settled", true, stopCase ? "Exact active permission closed under the caller-owned Stop" : "Hidden steering marker consumed in the original denied-write turn"); + if (remote) { + sealed = await fixture!.finish(); assertCopilotRemoteRetirement(sealed, baseline!); processes = sealed.processes; + remoteObservations.push({ phase: "owned-process-retirement", source: "retirement-seal", snapshot: sealed }); + await input.evidence("pi-control-owned-retirement-remote.json", sealed); + readActiveStopRemoteRetirement({ scope: scope(), observations: remoteObservations }); + } else await sample("after-control"); + await page.reload(); + await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: `Change status (current: ${stopCase ? "In Progress" : "Done"})`, exact: true })).toBeVisible(); + if (stopCase) await expect(page.getByTestId("task-chat-thread").getByTestId("task-chat-collapsible-marker").filter({ has: page.getByText("Run cancelled", { exact: true }) })).toBeVisible(); + else await expect(page.getByTestId("task-chat-thread").getByText(steered!.marker, { exact: true }).last()).toBeVisible(); + await expect(page.getByTestId("task-chat-history-loading")).toHaveCount(0); + await expect(card.getByRole("button", { name: "Deny", exact: true })).toHaveCount(0); + await assertSettled(); completed = true; + await input.capture("final-state", "Pi control verified through the production task UI", "final-state.png"); + await input.evidence("api-state.json", { issue, run: runs[0], runs, checks, samples, remoteObservations, runEvents: events, runEventsByRun: [{ runId: runs[0]!.id, events }], stopped, steered }); + return { issue, runs, checks }; + } finally { await input.evidence("pi-control-checks.json", { issue, runs, checks, samples, remoteObservations, completed }); } +} diff --git a/tests/runner-e2e/pi-controls-test-fixture.ts b/tests/runner-e2e/pi-controls-test-fixture.ts new file mode 100644 index 0000000000..f121f06af4 --- /dev/null +++ b/tests/runner-e2e/pi-controls-test-fixture.ts @@ -0,0 +1,64 @@ +import { CodexSessionState } from "../../packages/paperclip-runner/src/drivers/codex/codex-session-state.js"; +import { mapTerminalTurn } from "../../packages/paperclip-runner/src/drivers/codex/codex-session-terminal.js"; +import { observePiControlPending } from "./pi-controls-evidence.js"; +import { readActiveStopCaller } from "./native-active-stop-evidence.js"; + +type Row = Record; +export const piControlCaller = readActiveStopCaller({ deploymentMode: "local_trusted" }, { session: { userId: "local-board", id: "paperclip:local_implicit:local-board" } }); +export const piCancellationId = "11111111-2222-4333-8444-555555555555"; +export function piControlFixture(target = "target.txt") { + const scope = { companyId: "company", issueId: "issue", runId: "run", target }; + const events: Row[] = []; + const row = (eventType: string, payload: Row, extra: Row = {}) => { + const seq = events.length + 1; + return { companyId: "company", runId: "run", seq, eventType, protocolSchemaVersion: 1, + payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", eventType, runId: "run", turnId: "turn", normalizedSessionId: "session", sourceInstanceId: "source", sourceSeq: seq, + sourceEventId: `source:run:${seq}`, emittedAt: "2026-10-01T00:00:00Z", payload, ...extra } } }; + }; + const append = (eventType: string, payload: Row, extra: Row = {}) => events.push(row(eventType, payload, extra)); + const tool = { schema: "paperclip.tool.execution.v1", executionId: "pi-tool", transport: "builtin", name: "write", operation: "edit", target, status: "running" }; + append("tool.execution.started", tool); + const request = { schema: "paperclip.runtime_request.v2", type: "permission", requestKind: "permission_approval", method: "session/request_permission", status: "pending", requestId: "request", turnId: "turn", itemId: "permission-item", + origin: { adapter: "acpx-runtime-sidecar", provider: "pi", method: "session/request_permission" }, details: { toolCallId: "pi-tool" }, choices: [{ key: "decline", label: "Deny" }] }; + append("runtime_request.created", { request }); + const run: Row = { id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "running", resultJson: {}, processPid: 123, processGroupId: 123, processStartedAt: "2026-10-01T00:00:00Z" }; + const issue: Row = { id: "issue", companyId: "company", identifier: "PI-1", status: "in_progress" }; + const state = () => ({ events, run, issue }); + const pending = () => observePiControlPending({ ...state(), scope }); + const responses: unknown[] = []; + function cancel(cancellationRequestId = piCancellationId) { + // Exercise the real Product terminal mapper and callback cancellation, not + // just a fabricated terminal whose payload mirrors the matcher. + const session = { + terminalTurns: new Map(), workspaceChangesByTurn: new Map(), result: null, conversationMode: "direct", activeTurnId: "turn", turnStarted: true, + pendingRuntimeRequestMap: new Map([[request.requestId, { request, settle: (value: unknown) => responses.push(value) }]]), + cancelPendingRequests: CodexSessionState.prototype.cancelPendingRequests, + emit: (eventType: string, payload: Row) => append(eventType, payload), + } as unknown as CodexSessionState; + mapTerminalTurn(session, { id: "turn", status: "cancelled", error: null }, "turn"); + run.status = "cancelled"; + run.resultJson = { startupCancellation: { cancellationRequestId, requestedBy: { type: "board", userId: "local-board" } }, nativeCancellation: { + schema: "paperclip.native-cancellation.v1", intentId: `native-cancellation:${cancellationRequestId}`, companyId: "company", runId: "run", issueId: "issue", scope: "run", + dispatched: true, dispatchState: "acknowledged", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], intentAuditId: "intent-audit", acknowledgementAuditId: "ack-audit", + } }; + return run; + } + const marker = `PI-STEER-${"a".repeat(32)}`, commentId = "comment", queueId = "queue"; + function steer() { + 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(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 new file mode 100644 index 0000000000..0eda6cd77a --- /dev/null +++ b/tests/runner-e2e/pi-file-evidence.test.ts @@ -0,0 +1,274 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { createHash } from "node:crypto"; +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, 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 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 } } }; + }; + return { + environment: "local" as const, environmentId: "environment", nonce: "fixture", companyId: "company", issueId: "issue", agentId: "agent", attachmentId, + seed: { schema: "paperclip.e2e.pi-file-seed.v1", filename: c.filename, + beforeSha256: createHash("sha256").update("ready-fixture\n").digest("hex"), byteSize: Buffer.byteLength(c.before) }, + workspaceBytes: Buffer.from(c.after), downloadedBytes: Buffer.from(c.after), + run: { id: "run", companyId: "company", agentId: "agent", runtimeMode: "native", nativeSessionId: "session", runnerInstanceId: "runner", status: "succeeded", resultJson: { semanticToolReceipts: { + 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", "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.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[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[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[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"; }], + ["wrong registered entity", f => { f.run.resultJson.semanticToolReceipts.publish.result.entityRefs[0] = "other"; }], + ["missing attachment", f => { f.attachments = []; }], + ["foreign attachment company", f => { f.attachments[0]!.companyId = "other"; }], + ["stale attachment run", f => { f.attachments[0]!.originatingRunId = "older"; }], + ["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:"); + try { gradePiFileEvidence(f); } catch (error) { expect(classifyFailure(error)).toBe("candidate_failure"); } + }); + it("keeps real nonce commands within the Rust 240-character name contract", () => { + const c = piFileContract("0123456789ab-1"); + expect([...c.validationCommand].length).toBeLessThanOrEqual(240); + expect(safeAcpxLocations([{ path: `/workspace/${c.filename}` }], "/workspace", "edit", "edit")[0]).toMatchObject({ + path: c.filename, pathBoundary: "paperclip.workspace_relative_display.v2", + }); + expect(() => piFileContract("a".repeat(100))).toThrow("native Rust tool-name bound"); + }); + it("runs the exact fixture validation command and rejects plausible wrong bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-validation-command-")); + const c = piFileContract("0123456789ab-1"); + const validate = () => promisify(execFile)("/bin/sh", ["-c", c.validationCommand], { + cwd: root, timeout: 3_000, maxBuffer: 4096, env: { PATH: dirname(process.execPath) }, + }); + try { + await writeFile(join(root, c.filename), c.after); + expect((await validate()).stdout).toBe(`${c.validationMarker}\n`); + await writeFile(join(root, c.filename), c.after.trimEnd()); + await expect(validate()).rejects.toMatchObject({ code: 1 }); + await rm(join(root, c.filename)); + 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); + }); + it.each(["local", "daytona"] as const)("seeds once and collects public %s evidence", async environment => { + const root = await mkdtemp(join(tmpdir(), "pi-file-evidence-")); + try { + const seed = await seedPiFile(root, "fixture"), f = environment === "daytona" ? copyback() : fixture(), c = piFileContract("fixture"); + expect(await readFile(join(root, c.filename), "utf8")).toBe(c.before); + await expect(seedPiFile(root, "fixture")).rejects.toThrow(); + await writeFile(join(root, c.filename), c.after); + const get = vi.fn(async (path: string) => { + if (path === "/api/heartbeat-runs/run") return f.run; + if (path === "/api/environment-leases/lease" && "lease" in f) return f.lease; + if (path.endsWith("/attachments")) return f.attachments; + if (path.endsWith("/activity")) return f.activity; + throw new Error("Unexpected route"); + }); + const download = vi.fn(async () => ({ ok: () => true, body: async () => f.downloadedBytes })); + const visible = vi.fn(async () => {}), evidence = vi.fn(async () => {}); + const proof = await collectPiFileEvidence({ ...f, seed, workspace: root, evidence, + api: { get, request: { get: download } } as any, + page: { locator: () => ({ first: () => ({ waitFor: visible }) }) } as any }); + expect(proof.passed).toBe(true); + if (environment === "daytona") { + expect(get).toHaveBeenCalledWith("/api/heartbeat-runs/run"); + expect(get).toHaveBeenCalledWith("/api/environment-leases/lease"); + expect(proof.workspaceProvenance.kind).toBe("product_copyback"); + } + expect(visible).toHaveBeenCalledOnce(); + expect(evidence).toHaveBeenCalledWith(expect.objectContaining({ workspaceBytes: f.workspaceBytes.toString("base64"), downloadedBytes: f.downloadedBytes.toString("base64") })); + expect(download).toHaveBeenCalledWith(`/api/attachments/${f.attachmentId}/content`); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); + +function copyback() { + const f = fixture(); + const reference = { schema: "paperclip.native-workspace-sync/v2", state: "finalized", baselineSha256: "a".repeat(64), + descriptorSha256: "b".repeat(64), finalHostSha256: "c".repeat(64), workspaceId: "workspace", leaseId: "lease", + providerLeaseId: "sandbox", remoteCwd: "/workspace" }; + const run = { ...f.run, contextSnapshot: { executionWorkspaceId: "workspace", paperclipEnvironment: { id: "environment", driver: "sandbox", leaseId: "lease" } }, + runnerProfileJson: { nativeWorkspaceSync: reference, nativeExecutionInput: { binding: { executionWorkspaceId: "workspace" } } } }; + const lease = { id: "lease", companyId: "company", environmentId: "environment", heartbeatRunId: "run", provider: "daytona", providerLeaseId: "sandbox", metadata: { remoteCwd: "/workspace" } }; + return { ...f, run, lease, environment: "daytona" as const }; +} +it("accepts public finalized same-run copy-back attribution without claiming guest observation", () => { + const f = copyback(); + expect(gradePiFileEvidence(f).workspaceProvenance).toMatchObject({ kind: "product_copyback", guestObservation: false, hostSnapshotRecomputed: false }); +}); +it.each([ + ["missing reference", f => { f.run.runnerProfileJson.nativeWorkspaceSync = {} as any; }], + ["prepared only", f => { f.run.runnerProfileJson.nativeWorkspaceSync.state = "prepared"; }], + ["missing baseline", f => { f.run.runnerProfileJson.nativeWorkspaceSync.baselineSha256 = ""; }], + ["invalid final hash", f => { f.run.runnerProfileJson.nativeWorkspaceSync.finalHostSha256 = "wrong"; }], + ["foreign workspace", f => { f.run.contextSnapshot.executionWorkspaceId = "other"; }], + ["contradictory native binding", f => { f.run.runnerProfileJson.nativeExecutionInput.binding.executionWorkspaceId = "other"; }], + ["foreign lease", f => { f.lease.id = "other"; }], + ["foreign run", f => { f.lease.heartbeatRunId = "other"; }], + ["foreign company", f => { f.lease.companyId = "other"; }], + ["foreign environment", f => { f.lease.environmentId = "other"; }], + ["foreign provider lease", f => { f.lease.providerLeaseId = "other"; }], + ["wrong remote root", f => { f.lease.metadata.remoteCwd = "/other"; }], + ["wrong provider", f => { f.lease.provider = "other"; }], +] satisfies Array<[string, (f: ReturnType) => void]>)("rejects %s copy-back provenance", (_label, change) => { + const f = copyback(); change(f); + expect(() => gradePiCopyback(f.run, f.lease, f.companyId, f.environmentId)).toThrow("Pi file evidence:"); +}); diff --git a/tests/runner-e2e/pi-file-evidence.ts b/tests/runner-e2e/pi-file-evidence.ts new file mode 100644 index 0000000000..0dbd7c0283 --- /dev/null +++ b/tests/runner-e2e/pi-file-evidence.ts @@ -0,0 +1,222 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Page } from "@playwright/test"; +import type { RunnerApi } from "./api.js"; +import { isValidNativePrpEnvelope } from "./native-event-envelope.js"; + +type Row = Record; +const rec = (value: unknown): Row => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {}; +const sha = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +const requireEvidence = (value: unknown, reason: string) => { if (!value) throw new Error(`Pi file evidence: ${reason}`); }; +export function piFileContract(nonce: string) { + if (!/^[A-Za-z0-9-]{1,100}$/u.test(nonce)) throw new Error("Invalid file fixture nonce"); + const filename = `extended-${nonce}.txt`, before = `ready-${nonce}\n`, after = `verified-${nonce}\n`; + const validationMarker = `PI-VALIDATED-${nonce}`; + const validationCommand = `node -e 'if(require("node:fs").readFileSync(${JSON.stringify(filename)},"utf8")!==${JSON.stringify(after)})throw Error("mismatch");console.log(${JSON.stringify(validationMarker)})'`; + if ([...validationCommand].length > 240) throw new Error("Validation command exceeds native Rust tool-name bound"); + return { filename, before, after, sha256: sha(after), byteSize: Buffer.byteLength(after), validationMarker, validationCommand }; +} +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 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"); +} +export async function seedPiFile(workspace: string, nonce: string) { + const c = piFileContract(nonce); + await writeFile(join(workspace, c.filename), c.before, { flag: "wx" }); + const bytes = await readFile(join(workspace, c.filename)); + requireEvidence(bytes.equals(Buffer.from(c.before)), "seed bytes changed before provider startup"); + return { schema: "paperclip.e2e.pi-file-seed.v1", filename: c.filename, beforeSha256: sha(bytes), byteSize: bytes.length }; +} + +export function gradePiCopyback(run: Row, lease: Row, companyId: string, environmentId: string) { + const context = rec(run.contextSnapshot), environment = rec(context.paperclipEnvironment); + const reference = rec(rec(run.runnerProfileJson).nativeWorkspaceSync); + const binding = rec(rec(rec(run.runnerProfileJson).nativeExecutionInput).binding); + requireEvidence(run.companyId === companyId && reference.schema === "paperclip.native-workspace-sync/v2" + && reference.state === "finalized" && [reference.baselineSha256, reference.descriptorSha256, reference.finalHostSha256] + .every(value => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value)), "finalized production copy-back hashes required"); + requireEvidence(typeof reference.workspaceId === "string" && reference.workspaceId.length > 0 + && reference.workspaceId === context.executionWorkspaceId + && (binding.executionWorkspaceId === undefined || binding.executionWorkspaceId === reference.workspaceId) + && environment.id === environmentId && environment.driver === "sandbox" && environment.leaseId === reference.leaseId, + "copy-back workspace and environment must belong to the actual run"); + requireEvidence(lease.id === reference.leaseId && lease.companyId === companyId && lease.environmentId === environmentId + && lease.heartbeatRunId === run.id && lease.provider === "daytona" && typeof reference.providerLeaseId === "string" + && reference.providerLeaseId.length > 0 && lease.providerLeaseId === reference.providerLeaseId + && typeof reference.remoteCwd === "string" && reference.remoteCwd.startsWith("/") + && reference.remoteCwd === rec(lease.metadata).remoteCwd, "copy-back must bind the exact run and provider lease"); + return { kind: "product_copyback", reference, lease: { id: lease.id, companyId, environmentId, heartbeatRunId: run.id, + provider: lease.provider, providerLeaseId: lease.providerLeaseId, remoteCwd: reference.remoteCwd }, + guestObservation: false, hostSnapshotRecomputed: false }; +} + +/** Only the common public projection is authority here. Native raw arguments, + * 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; + events: unknown[]; seed: unknown; workspaceBytes: Buffer; attachments: unknown[]; + attachmentId: string; downloadedBytes: Buffer; activity: unknown[]; +}) { + const c = piFileContract(input.nonce), run = input.run, seed = rec(input.seed); + const workspaceProvenance = input.environment === "daytona" + ? gradePiCopyback(run, rec(input.lease), input.companyId, input.environmentId) + : { kind: "independent_local_workspace" }; + requireEvidence(run.id && run.companyId === input.companyId && run.agentId === input.agentId + && run.runtimeMode === "native" && run.status === "succeeded", "actual native run identity"); + requireEvidence(seed.schema === "paperclip.e2e.pi-file-seed.v1" && seed.filename === c.filename + && seed.beforeSha256 === sha(c.before) && seed.byteSize === Buffer.byteLength(c.before), "independent pre-edit seed"); + requireEvidence(input.workspaceBytes.equals(Buffer.from(c.after)), "independent final workspace bytes"); + requireEvidence(input.events.length > 0 && input.events.length <= 20_000, "bounded run events"); + const rows = input.events.map(rec).filter(row => rec(row.payload).prpEvent !== undefined); + const seen = new Set(), seqs = new Set(), sourceSeqs = new Map(); + const canonical = rows.map(row => { + const e = rec(rec(row.payload).prpEvent); + requireEvidence(row.companyId === input.companyId && row.runId === run.id && e.runId === run.id + && isValidNativePrpEnvelope(e, row.protocolSchemaVersion) && row.eventType === e.eventType + && 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) }; + }); + const tools = canonical.filter(x => x.e.eventType.startsWith("tool.execution.") && x.p.transport === "builtin"); + const edits = tools.filter(x => x.e.eventType === "tool.execution.completed" && x.p.operation === "edit"); + requireEvidence(edits.length === 1, "one native edit required; final-only writes cannot qualify"); + const edit = edits[0]!; + requireEvidence(edit.p.name === "edit" && edit.p.target === c.filename, "exact native edit and relative target required"); + function lifecycle(end: typeof edit) { + const matches = tools.filter(x => x.p.executionId === end.p.executionId); + const starts = matches.filter(x => x.e.eventType === "tool.execution.started"); + requireEvidence(typeof end.p.executionId === "string" && end.p.executionId.length > 0 + && end.p.schema === "paperclip.tool.execution.v1" && end.p.status === "completed" + && end.e.sourceKind === "runner" && end.e.sourceInstanceId === run.runnerInstanceId + && end.e.normalizedSessionId === run.nativeSessionId && typeof end.e.turnId === "string" && end.e.turnId.length > 0 + && typeof end.e.normalizedSessionId === "string" && end.e.normalizedSessionId.length > 0 + && 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.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" + && ["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 === "bash"); + requireEvidence(validations.length === 1, "one observed provider validation execution"); + 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]!; + requireEvidence(a.companyId === input.companyId && a.issueId === input.issueId && a.originatingRunId === run.id + && a.createdByAgentId === input.agentId && a.originalFilename === c.filename && a.contentType === "text/plain" + && a.byteSize === c.byteSize && a.sha256 === c.sha256, "attachment bytes and immutable run attribution"); + requireEvidence(input.downloadedBytes.equals(Buffer.from(c.after)), "actual public download bytes"); + const receipts = Object.values(rec(rec(run.resultJson).semanticToolReceipts)).map(rec) + .filter(r => r.operationId === "register_deliverable" && rec(r.input).filename === c.filename); + requireEvidence(receipts.length === 1, "one authenticated semantic registration"); + const receipt = receipts[0]!, args = rec(receipt.input), result = rec(receipt.result); + requireEvidence(args.contentRef === c.filename && args.contentType === "text/plain" && args.byteSize === c.byteSize + && args.sha256 === c.sha256 && result.disposition === "applied" && result.commandId === `deliverable-prepared:${a.id}` + && Array.isArray(result.entityRefs) && result.entityRefs[0] === a.id, "registered artifact receipt must match downloaded attachment"); + const publications = input.activity.map(rec).filter(row => row.action === "issue.attachment_added" + && rec(row.details).attachmentId === a.id); + requireEvidence(publications.length === 1 && publications[0]!.companyId === input.companyId + && publications[0]!.runId === run.id && publications[0]!.actorId === input.agentId + && publications[0]!.entityId === input.issueId && rec(publications[0]!.details).source === "paperclip_runner_protocol", + "publication activity must belong to this run and task"); + return { + schema: "paperclip.e2e.pi-file-evidence.v1", passed: true, workspaceProvenance, + runId: run.id, turnId: edit.e.turnId, nativeEditExecutionId: edit.p.executionId, + 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, + 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: ["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."], + }; +} + +export async function collectPiFileEvidence(input: { + page: Page; api: RunnerApi; nonce: string; companyId: string; issueId: string; agentId: string; + run: Row; events: unknown[]; seed: unknown; workspace: string; + environment: "local" | "daytona"; environmentId: string; + evidence(data: unknown): Promise; +}) { + const c = piFileContract(input.nonce); + let run = input.run, lease: Row | undefined; + if (input.environment === "daytona") { + run = await input.api.get(`/api/heartbeat-runs/${input.run.id}`); + requireEvidence(run.id === input.run.id, "public copy-back run readback changed identity"); + const leaseId = rec(rec(run.runnerProfileJson).nativeWorkspaceSync).leaseId; + requireEvidence(typeof leaseId === "string" && /^[A-Za-z0-9_-]{1,128}$/u.test(leaseId), "copy-back lease reference missing"); + lease = await input.api.get(`/api/environment-leases/${leaseId}`); + } + const attachments = await input.api.get(`/api/issues/${input.issueId}/attachments`); + const selected = attachments.filter(a => a.originalFilename === c.filename); + requireEvidence(selected.length === 1 && /^[a-f0-9-]{36}$/u.test(selected[0]!.id), "exact published file selection"); + requireEvidence(selected[0]!.byteSize === c.byteSize && selected[0]!.sha256 === c.sha256 + && selected[0]!.contentType === "text/plain", "bounded attachment metadata before download"); + const attachmentId = selected[0]!.id; + const contentPath = `/api/attachments/${attachmentId}/content`; + // Exercise the real task surface and the authenticated public download route. + await input.page.locator(`a[href*="${contentPath}"]`).first().waitFor({ state: "visible", timeout: 15_000 }); + const response = await input.api.request.get(contentPath); + requireEvidence(response.ok(), "public attachment download failed"); + const downloadedBytes = await response.body(); + requireEvidence(downloadedBytes.length <= 4096, "bounded text attachment"); + const workspaceBytes = await readFile(join(input.workspace, c.filename)); + const activity = await input.api.get(`/api/issues/${input.issueId}/activity`); + await input.evidence({ schema: "paperclip.e2e.pi-file-observation.v1", attachments, attachmentId, activity, + workspaceBytes: workspaceBytes.toString("base64"), downloadedBytes: downloadedBytes.toString("base64"), + encoding: "base64", expectedSha256: c.sha256, copybackReference: rec(run.runnerProfileJson).nativeWorkspaceSync, + copybackLease: lease && { id: lease.id, companyId: lease.companyId, environmentId: lease.environmentId, + heartbeatRunId: lease.heartbeatRunId, provider: lease.provider, providerLeaseId: lease.providerLeaseId, remoteCwd: rec(lease.metadata).remoteCwd } }); + return gradePiFileEvidence({ ...input, run, lease, attachments, attachmentId, downloadedBytes, workspaceBytes, activity }); +} diff --git a/tests/runner-e2e/pi-native-cases.test.ts b/tests/runner-e2e/pi-native-cases.test.ts new file mode 100644 index 0000000000..03de12ad23 --- /dev/null +++ b/tests/runner-e2e/pi-native-cases.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +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); + const cells = runnerMatrix.filter(row => row.suite.id === suite.id); + expect(cells).toHaveLength(10); + expect(cells.every(row => row.profile.qualificationCandidate === "pi")).toBe(true); + expect(cells.filter(row => row.environment.id === "local").map(row => [row.task.id, row.task.expectedRunCount])).toEqual([ + ["native-questions", 1], ["agent-files-fresh-run", 2], ["restrictive-denial", 1], ["human-permission-denial", 1], + ["native-pending-controller-restart", 1], + ]); + expect(cells.filter(row => row.environment.id === "daytona").map(row => [row.task.id, row.task.expectedRunCount])).toEqual([ + ["native-questions", 1], ["agent-files-fresh-run", 2], ["human-permission-denial", 1], + ["native-pending-controller-restart", 1], ["native-pending-provider-death", 1], + ]); + expect(cells.reduce((sum, row) => sum + row.task.expectedRunCount, 0)).toBe(12); + expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(row => row.suite.id === suite.id)).toBe(false); + expect(runnerMatrix.filter(row => row.suite.id === "extended-harnesses")).toHaveLength(30); + expect(piNativeTasks[0]!.buildPrompt("fixture")).toContain("paperclip_native_question"); + expect(piNativeTasks[1]!.buildPrompt("fixture")).toContain("AGENT_HOME"); + }); + + 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(buildRunnerE2EProcessEnvironment(source, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); + const remote = runnerMatrix.find(row => row.suite.id === "pi-native" && row.environment.id === "daytona")!; + 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 } }, + { ...cell, suite: { ...cell.suite, id: "implicit" } }, + { ...cell, profile: { ...cell.profile, qualificationCandidate: "copilot" as const } }, + ]) expect(() => buildRunnerE2EProcessEnvironment(source, [changed])).toThrow("explicit provider qualification suite"); + }); + + it("grades all four actual typed results and fails missing, stale or invented answers", () => { + const valid = [{ status: "answered", optionId: "blue" }, { status: "negative_or_cancelled", confirmed: false }, { status: "answered", value: "hidden-name" }, { status: "answered", value: "hidden-draft\nsecond" }]; + expect(gradePiNativeAnswers(valid, "hidden-name", "hidden-draft\nsecond")).toBe(true); + for (const actual of [undefined, [], valid.slice(0, 3), [...valid, valid[0]], valid.map((row, i) => i === 1 ? { status: "answered", confirmed: true } : row), valid.map((row, i) => i === 2 ? { ...row, value: "guessed-name" } : row), valid.map((row, i) => i === 0 ? { ...row, extra: "invented" } : row)]) { + expect(gradePiNativeAnswers(actual, "hidden-name", "hidden-draft\nsecond")).toBe(false); + } + }); + + it("requires exactly one native cross-root policy receipt when public paths are suppressed", () => { + const event = { eventType: "tool.execution.completed", payload: { prpEvent: { payload: { schema: "paperclip.tool.execution.v1", transport: "builtin", operation: "edit", name: "write", status: "failed", target: null, executionId: "native-1", output: "Pi tool path is outside its assigned workspace and agent files" } } } }; + expect(hasPiCrossRootDenial([event])).toBe(true); + expect(hasPiCrossRootDenial([event, event])).toBe(false); + for (const patch of [{ output: "model claims denied" }, { executionId: "" }, { status: "completed" }, { transport: "mcp" }, { target: "other.txt" }]) { + expect(hasPiCrossRootDenial([{ ...event, payload: { prpEvent: { payload: { ...event.payload.prpEvent.payload, ...patch } } } }])).toBe(false); + } + }); + + it("requires a correlated failed native edit, never a model claim or an unexecuted intent", () => { + const event = { eventType: "tool.execution.completed", payload: { prpEvent: { payload: { schema: "paperclip.tool.execution.v1", operation: "edit", status: "failed", name: "write", target: "pi-denied.txt" } } } }; + expect(hasFailedPiWrite([event], "pi-denied.txt")).toBe(true); + for (const events of [[], [{ eventType: "assistant.message", text: "Write pi-denied.txt failed" }], [{ ...event, eventType: "tool.execution.started" }], [{ ...event, payload: { prpEvent: { payload: { ...event.payload.prpEvent.payload, status: "completed" } } } }]]) expect(hasFailedPiWrite(events, "pi-denied.txt")).toBe(false); + expect(hasFailedPiWrite([event], "other.txt")).toBe(false); + }); +}); diff --git a/tests/runner-e2e/pi-native-cases.ts b/tests/runner-e2e/pi-native-cases.ts new file mode 100644 index 0000000000..a58ac8c543 --- /dev/null +++ b/tests/runner-e2e/pi-native-cases.ts @@ -0,0 +1,133 @@ +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], + ["restrictive-denial", "Restrictive native write denial prevents a file effect", 1], + ["human-permission-denial", "Browser denial prevents a native write through process retirement", 1], + ["native-pending-controller-restart", "Pending native question survives controller restart in the same live run", 1], + ["native-pending-provider-death", "Lost Pi provider expires its original unanswered native question", 1], +].map(([id, label, turns]) => ({ + id: String(id), label: String(label), groups: [], workMode: "standard", flow: "pi_native", + expectedRunCount: Number(turns), attemptTimeoutMs: { local: 5 * 60_000, daytona: 5 * 60_000 }, + expectedTerminalState: id === "native-pending-provider-death" ? { issue: "blocked", run: "failed" } : { issue: "done", run: "succeeded" }, + buildTitle: nonce => `Pi ${id} ${nonce}`, + buildVisibleMarker: nonce => `PI-NATIVE-${id}-${nonce}`, + buildPrompt: nonce => piNativePrompt(String(id), nonce), + buildMatchers: () => [], // The flow grades durable state and independent bytes. +})); + +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 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 { + if (id === "native-pending-provider-death") return [ + 'Call paperclip_native_question exactly once with {method:"input",title:"Pi native provider death",placeholder:"Answer"}. Wait for the real answer; never guess it or substitute semantic request_human_input.', + "Only after receiving the actual answer, use native write to save its exact tool-result object as JSON to pi-provider-death-answer.json in the task workspace. Never write that file while the question is unanswered. This is an internal verification file, not a deliverable.", + piNativeFinish(`PI-NATIVE-${id}-${nonce}`), + ].join("\n"); + if (id === "native-pending-controller-restart") return [ + 'Call paperclip_native_question exactly once with {method:"input",title:"Pi native restart",placeholder:"Answer"}. Wait for the real answer; never guess it or substitute semantic request_human_input.', + "Use native write to save the exact returned tool-result object as JSON to pi-native-restart-answer.json in the task workspace. Do not change or infer its fields. This is an internal verification file, not a deliverable.", + piNativeFinish(`PI-NATIVE-${id}-${nonce}`), + ].join("\n"); + if (id === "native-questions") return [ + "Use paperclip_native_question exactly four times, sequentially, waiting for each answer. Do not substitute semantic request_human_input.", + 'First: {method:"select",title:"Pi native color",options:[{id:"blue",label:"Blue"},{id:"red",label:"Red"}]}.', + 'Second: {method:"confirm",title:"Pi native confirmation",message:"Continue this fixture?"}.', + 'Third: {method:"input",title:"Pi native name",placeholder:"Name"}.', + 'Fourth: {method:"editor",title:"Pi native draft",prefill:"Old draft"}.', + "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 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.", + piNativeFinish(`PI-NATIVE-${id}-${nonce}`), + ].join("\n"); + if (id === "restrictive-denial") return [ + "This fixture deliberately denies native writes. Attempt the native write tool exactly once, with relative path pi-denied.txt and content forbidden. Do not use bash, an API, another path, or a retry to work around the denial.", + "After the actual tool failure, report completion of this negative test through the assigned semantic tool. A failed native write is the expected outcome, not a reason to repeat it.", + piNativeFinish(`PI-NATIVE-${id}-${nonce}`), + ].join("\n"); + throw new Error(`Unknown Pi native fixture ${id}`); +} + +export function gradePiNativeAnswers(actual: unknown, name: string, draft: string): boolean { + const expected = [{ status: "answered", optionId: "blue" }, { status: "negative_or_cancelled", confirmed: false }, { status: "answered", value: name }, { status: "answered", value: draft }]; + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return actual.every((value, index) => value && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === Object.keys(expected[index]!).length + && Object.entries(expected[index]!).every(([key, wanted]) => value[key] === wanted)); +} + +export function hasFailedPiWrite(events: readonly Record[], path: string): boolean { + return events.some(row => { + const payload = row.payload?.prpEvent?.payload; + return row.eventType === "tool.execution.completed" && payload?.schema === "paperclip.tool.execution.v1" + && payload.operation === "edit" && payload.status === "failed" + && payload.target === path; + }); +} + +/** Outside paths are intentionally omitted from public display locations. */ +export function hasPiCrossRootDenial(events: readonly Record[]): boolean { + const denied = events.filter(row => { + const payload = row.payload?.prpEvent?.payload; + return row.eventType === "tool.execution.completed" && payload?.schema === "paperclip.tool.execution.v1" + && payload.transport === "builtin" && payload.operation === "edit" && payload.name === "write" + && payload.status === "failed" && payload.target === null + && typeof payload.executionId === "string" && payload.executionId.length > 0 + && typeof payload.output === "string" && payload.output.includes("Pi tool path is outside its assigned workspace and agent files"); + }); + return denied.length === 1; +} diff --git a/tests/runner-e2e/pi-native-evidence.test.ts b/tests/runner-e2e/pi-native-evidence.test.ts new file mode 100644 index 0000000000..836763e132 --- /dev/null +++ b/tests/runner-e2e/pi-native-evidence.test.ts @@ -0,0 +1,160 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { piNativeTasks } from "./pi-native-cases.js"; +import { packageEvidence } from "./evidence.js"; +import { runPiNativeFlow } from "./pi-native-flow.js"; +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(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), + toBeVisible: async () => expect(actual, message).toEqual({ visible: true }), + }), +})); + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); + +type FlowInput = Parameters[0]; +async function fixture(options: { failComments?: boolean; remote?: boolean; incompleteRemote?: boolean } = {}) { + const root = await mkdtemp(join(tmpdir(), "pi-native-evidence-")); roots.push(root); + const privateDir = join(root, "private"); const uploadDir = join(root, "upload"); + 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: "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> = []; + const interactions = () => headers.slice(0, Math.min(4, answered + 1)).map((header, index) => ({ + id: `question-${index}`, kind: "ask_user_questions", status: index < answered ? "answered" : "pending", + sourceRunId: run.id, continuationPolicy: "none", + payload: { runtimeRequestId: `native-${index}`, questionSet: { questions: [{ header }] } }, + })); + const secret = "private-fixture-sentinel-value"; + const comments = [{ id: "comment-fixture", body: `Completed ${secret}` }]; + // A full first page requires the real collector to retain a second page. + const eventRows = Array.from({ length: 1001 }, (_, index) => ({ seq: index + 1, eventType: "fixture" })); + const paths: string[] = []; + const api = { + post: async () => ({ name: "Pi fixture project" }), + get: async (path: string) => { + paths.push(path); + if (path === "/api/companies/company-fixture/issues?limit=100") return [issue]; + if (path === `/api/issues/${issue.id}`) return issue; + if (path === "/api/companies/company-fixture/heartbeat-runs?limit=100") return [run]; + if (path === `/api/heartbeat-runs/${run.id}`) return run; + if (path === `/api/issues/${issue.id}/interactions`) return interactions(); + if (path === `/api/issues/${issue.id}/comments`) { + if (options.failComments) throw new Error("fixture comments unavailable"); + return comments; + } + if (path.startsWith(`/api/heartbeat-runs/${run.id}/events?`)) { + const afterSeq = Number(new URL(path, "http://fixture.invalid").searchParams.get("afterSeq")); + return eventRows.filter(row => row.seq > afterSeq).slice(0, 1000); + } + throw new Error(`Unexpected fixture API path ${path}`); + }, + }; + const page = { + goto: async () => {}, reload: async () => {}, + getByTestId: (id: string) => id === "issue-detail-header" + ? { getByRole: () => ({ visible: true }) } + : { last: () => ({ locator: () => ({ first: () => ({ fill: async (value: string) => { typed.push(value); } }) }) }) }, + getByRole: (role: string) => ({ last: () => ({ click: async () => { + if (role !== "button") return; + answered += 1; + if (answered === 4) { answerProof = JSON.stringify([ + { status: "answered", optionId: "blue" }, { status: "negative_or_cancelled", confirmed: false }, + { status: "answered", value: typed[0] }, { status: "answered", value: typed[1] }, + ]); await writeFile(join(workspacePath, "pi-native-answers.json"), options.remote ? "WRONG HOST COPYBACK" : answerProof); } + } }) }), + }; + const evidence = async (name: string, data: unknown) => { await writeFile(join(snapshots, name), JSON.stringify(sanitizeJson(data, [secret]))); }; + const capture = vi.fn(async (_id: string, _label: string, file: string) => { await writeFile(join(privateDir, file), "fixture screenshot boundary"); }); + const remoteRoot = { pid: 10, ppid: 1, startTicks: "100", bootId: "boot" }; + const remoteSnapshot = { observedAtMs: 2, complete: !options.incompleteRemote, workspace: {}, targets: {}, watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: true, root: remoteRoot, journal: [remoteRoot], live: [] } }; + const remoteFixture = { binding: { runId: run.id }, remoteCwd: "/owned/remote", outsideTarget: null, + snapshot: async () => remoteSnapshot, + finish: async () => { remoteFinished = true; return remoteSnapshot; }, + readFile: async (path: string) => { expect(remoteFinished).toBe(true); expect(path).toBe("pi-native-answers.json"); return Buffer.from(answerProof); }, + close: vi.fn(async () => {}), + }; + const input = { + page: page as unknown as FlowInput["page"], api: api as unknown as FlowInput["api"], + 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: { + prompt: () => "Read the bootstrap file only", + bindAndRelease: async (value: { targets: string[]; actionPrompt(fixture: any): Promise | string }) => { + expect(value.targets).toEqual(["pi-native-answers.json"]); const prompt = await value.actionPrompt(remoteFixture); expect(prompt).toContain("paperclip_native_question"); return remoteFixture; + }, + } } : {}), + }; + for (const [file, content] of [["result.json", "{}"], ["snapshots/fixtures.json", "{}"], ["junit.xml", ""], ["html-report/index.html", "fixture report"]]) await writeFile(join(privateDir, file!), content!); + const blob = join(privateDir, "blob-report"); await writeFile(join(blob, "fixture.txt"), "unit fixture"); + execFileSync("zip", ["-q", "report.zip", "fixture.txt"], { cwd: blob }); + return { input: input as FlowInput, privateDir, uploadDir, snapshots, paths, capture, issue, run, secret, cleanupAssertions }; +} + +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(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([]); + const raw = await readFile(join(f.uploadDir, "snapshots/api-state.json"), "utf8"); + expect(raw).not.toContain(f.secret); + const state = JSON.parse(raw); + expect(state.issue).toEqual(f.issue); expect(state.runs).toEqual([f.run]); expect(state.run).toEqual(f.run); + expect(state.comments).toHaveLength(1); expect(state.comments[0].id).toBe("comment-fixture"); + expect(state.interactions).toHaveLength(4); expect(state.interactions.every((row: { status: string }) => row.status === "answered")).toBe(true); + expect(state.runEventsByRun[0].runId).toBe(f.run.id); + expect(state.runEventsByRun[0].events).toHaveLength(1001); + expect(state.runEventsByRun[0].events.at(-1).seq).toBe(1001); + expect(f.paths).toContain(`/api/heartbeat-runs/${f.run.id}/events?afterSeq=1000&limit=1000`); + + // The original failure remains a failure: Pi-specific evidence cannot + // substitute for the mandatory generic terminal API snapshot. + await rm(join(f.snapshots, "api-state.json")); + const incomplete = await packageEvidence({ privateDir: f.privateDir, uploadDir: f.uploadDir, secrets: [f.secret], expectPassScreenshot: true }); + expect(incomplete.missing).toEqual(["snapshots/api-state.json"]); + }); + + it("fails capture when terminal API evidence is unavailable, retaining behavioral checks without declaring success", async () => { + 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(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 }); + expect(packaged.missing).toEqual(["final-state.png", "snapshots/api-state.json"]); + }); +}); + + +it("uses sealed remote answer bytes rather than host copyback and requires independent retirement", async () => { + const f = await fixture({ remote: true }); const result = await runPiNativeFlow(f.input); + expect(result.checks.every(check => check.passed)).toBe(true); expect(f.cleanupAssertions).toHaveLength(1); await f.cleanupAssertions[0]!(); + const retained = JSON.parse(await readFile(join(f.snapshots, "pi-remote-1-questions-final.json"), "utf8")); expect(retained.snapshot.processes.live).toEqual([]); +}); +it("cannot replace missing remote retirement proof with a succeeded Product run", async () => { + const f = await fixture({ remote: true, incompleteRemote: true }); + await expect(runPiNativeFlow(f.input)).rejects.toThrow("retirement is unproven"); + await expect(f.cleanupAssertions[0]!()).rejects.toThrow("retirement proof incomplete"); +}); diff --git a/tests/runner-e2e/pi-native-evidence.ts b/tests/runner-e2e/pi-native-evidence.ts new file mode 100644 index 0000000000..58874cd106 --- /dev/null +++ b/tests/runner-e2e/pi-native-evidence.ts @@ -0,0 +1,58 @@ +import { hasAcpxNativeOrigin } from "./acpx-native-origin.js"; + +/** Public durable permission/tool evidence. Independent filesystem and process + * proofs remain required; assistant text can never satisfy this oracle. */ +type Row = Record; +const record = (value: unknown): Row => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {}; +export interface PiDeniedPermission { runId: string; turnId: string; requestId: string; toolCallId: string; target: string } +export function piPermissionRequests(rows: readonly unknown[], runId: string): Array<{ row: Row; event: Row; request: Row }> { + return rows.map(record).flatMap(row => { + const event = record(record(row.payload).prpEvent), request = record(record(event.payload).request), origin = record(request.origin); + if (row.eventType !== "runtime_request.created" || event.eventType !== row.eventType || row.runId !== runId || event.runId !== runId + || row.protocolSchemaVersion !== 1 || event.schemaVersion !== 1 || event.schema !== "paperclip.prp.event.v1" || event.sourceKind !== "runner" + || (!Number.isSafeInteger(row.seq) || row.seq < 1) || typeof event.turnId !== "string" || !event.turnId || request.turnId !== event.turnId + || request.type !== "permission" || request.status !== "pending" || !hasAcpxNativeOrigin(origin, "pi", "session/request_permission") + || typeof request.requestId !== "string" || !request.requestId || typeof request.details?.toolCallId !== "string" || !request.details.toolCallId + || !Array.isArray(request.choices) || !request.choices.some((choice: unknown) => record(choice).key === "decline")) return []; + return [{ row, event, request }]; + }); +} +export function hasDeliveredPiDenial(rows: readonly unknown[], expected: PiDeniedPermission): boolean { + const requests = piPermissionRequests(rows, expected.runId).filter(value => value.request.requestId === expected.requestId); + if (requests.length !== 1) return false; + const created = requests[0]!; + if (created.event.turnId !== expected.turnId || created.request.details.toolCallId !== expected.toolCallId) return false; + const events = rows.map(record).map(row => ({ row, event: record(record(row.payload).prpEvent) })) + .filter(({ row, event }) => row.runId === expected.runId && event.runId === expected.runId && event.turnId === expected.turnId + && row.eventType === event.eventType && row.protocolSchemaVersion === 1 && event.schemaVersion === 1 + && event.schema === "paperclip.prp.event.v1" && event.sourceKind === "runner" && Number.isSafeInteger(row.seq) && row.seq >= 1); + const resolutions = events.filter(({ event }) => ["runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled"].includes(event.eventType) && event.payload?.requestId === expected.requestId); + if (resolutions.length !== 1 || resolutions[0]!.event.eventType !== "runtime_request.resolved" || resolutions[0]!.event.payload.action !== "decline" + || resolutions[0]!.event.payload.turnId !== expected.turnId || resolutions[0]!.row.seq <= created.row.seq) return false; + const completed = events.filter(({ event }) => event.eventType === "tool.execution.completed" && event.payload?.transport === "builtin" && event.payload?.operation === "edit"); + return completed.length === 1 && completed.every(({ row, event }) => row.seq > created.row.seq && event.payload.schema === "paperclip.tool.execution.v1" + && event.payload.executionId === expected.toolCallId && event.payload.name === "write" && event.payload.status === "failed" + && event.payload.target === expected.target && typeof event.payload.output === "string" && event.payload.output.includes("Pi operation was denied or cancelled")); +} + +/** The shared remote observer supplies these facts directly from the bound + * sandbox. Missing watcher/process coverage is never treated as an absent file. */ +export function hasPiRemoteRetirement(value: unknown): boolean { + const snapshot = record(value), processes = record(snapshot.processes), root = record(processes.root); + return snapshot.complete === true && record(snapshot.watcher).complete === true && Number.isFinite(snapshot.observedAtMs) + && processes.captured === true && Number.isSafeInteger(root.pid) && root.pid > 1 + && typeof root.startTicks === "string" && root.startTicks.length > 0 && typeof root.bootId === "string" && root.bootId.length > 0 + && Array.isArray(processes.journal) && processes.journal.length > 0 + && processes.journal.some((entry: Row) => entry.pid === root.pid && entry.startTicks === root.startTicks && entry.bootId === root.bootId) + && Array.isArray(processes.live) && processes.live.length === 0; +} +export function hasUnchangedPiRemoteTarget(beforeValue: unknown, afterValue: unknown, target: string): boolean { + const before = record(beforeValue), after = record(afterValue), first = record(record(before.targets)[target]), last = record(record(after.targets)[target]); + return before.complete === true && hasPiRemoteRetirement(after) && before.observedAtMs <= after.observedAtMs + && first.complete === true && last.complete === true && record(after.watcher).complete === true + && first.mutationCount === 0 && last.mutationCount === 0 + && typeof first.absent === "boolean" && first.absent === last.absent && first.sha256 === last.sha256 + && (first.absent ? first.sha256 === null : typeof first.sha256 === "string" && /^sha256:[a-f0-9]{64}$/.test(first.sha256)) + && record(first.parent).dev !== undefined && record(first.parent).ino !== undefined + && first.parent.dev === record(last.parent).dev && first.parent.ino === record(last.parent).ino; +} diff --git a/tests/runner-e2e/pi-native-flow.ts b/tests/runner-e2e/pi-native-flow.ts new file mode 100644 index 0000000000..7ff3d95ec8 --- /dev/null +++ b/tests/runner-e2e/pi-native-flow.ts @@ -0,0 +1,284 @@ +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"; +import { lstat, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { collectRunEvents } from "./run-observations.js"; +import { createTaskThroughUi } from "./user-actions.js"; +import { 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 { 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"; + +export interface PiRemoteBootstrap { + prompt(nonce: string): string; + bindAndRelease(input: { issueId: string; runId: string; targets: string[]; crossRoot?: { initialText: string }; actionPrompt(fixture: RemoteNativeFixture): string | Promise }): Promise; +} + +type Row = Record; +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(preserveRunner?: RestartRunnerIdentity): Promise; + observe(issue: Row, runs: Row[]): void; + capture(id: string, label: string, file: string): Promise; + evidence(name: string, data: unknown): Promise; + remoteBootstrap?: PiRemoteBootstrap; + registerCleanupAssertion?(assertion: () => Promise): void; +}) { + const { page, api, fixtures, execution, nonce } = input; + const remote = execution.environment.id === "daytona"; + 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"); + 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; + } + async function readWorkspace(path: string) { + return remote ? (await currentRemote!.readFile(path)).toString("utf8") : await readFile(join(input.workspacePath, path), "utf8"); + } + const checks: Check[] = []; let issue: Row = {}; let runs: Row[] = []; + const project = await api.post(`/api/companies/${fixtures.company.id}/projects`, { + name: `Pi native workspace ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, + workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true }, + }); + const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); expect(passed, detail).toBe(true); }; + const events = (runId: string) => collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runId}/events?afterSeq=${afterSeq}&limit=${limit}`)); + const absent = async (path: string) => { try { await lstat(path); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; } }; + const load = async () => { + issue = await api.get(`/api/issues/${issue.id}`); + const listed = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); + runs = await Promise.all(listed.map(run => api.get(`/api/heartbeat-runs/${run.id}`))); + runs.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))); input.observe(issue, runs); + const interactions = await api.get(`/api/issues/${issue.id}/interactions`); + return { issue, runs, interactions }; + }; + const rejectFailure = (state: Awaited>) => state.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status)) ? "Pi provider run failed" : undefined; + 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"); })(); + 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 }); + const run = state.runs.find(value => !previous.has(value.id))!; + currentRemote = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: run.id, targets: options.targets ?? [], crossRoot: options.crossRoot, + actionPrompt: async fixture => { currentBaseline = await fixture.snapshot("before-action"); await input.evidence(`pi-remote-${remoteSequence}-armed.json`, { binding: fixture.binding, baseline: currentBaseline }); return typeof prompt === "string" ? prompt : prompt(fixture); } }); + 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(); } + }); + } + } + async function settle(count: number) { + const result = await pollUntil({ label: "Pi native task completion", deadlineAt: input.deadlineAt, load, + accept: state => state.issue.status === "done" && state.runs.length === count && state.runs.every(run => run.status === "succeeded") && !state.interactions.some(row => row.status === "pending"), + reject: state => rejectFailure(state) ?? (state.runs.length > count ? "Extra Pi provider run" : undefined) }); + check(`native-runs-${count}`, result.runs.every(run => run.runtimeMode === "native"), "Every accounted run used the native runtime"); + await page.reload(); await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: Done)", exact: true })).toBeVisible(); + return result; + } + try { + if (execution.task.id === "native-pending-provider-death") { + if (!remote) throw new Error("Exact Pi-child fault is available only on owned Daytona Linux"); + await create(execution.task.buildTitle(nonce), execution.task.buildPrompt(nonce), { targets: [PI_DEATH_MARKER] }); + checks.push(...await runPiPendingProviderDeath({ page, api, companyId: fixtures.company.id, deadlineAt: input.deadlineAt, + fixture: currentRemote!, load, events, capture: input.capture, evidence: input.evidence })); + const originalRunId = runs[0]!.id; + input.registerCleanupAssertion!(async () => { + const state = await load(); + const passed = state.issue.status === "blocked" && state.runs.length === 1 && state.runs[0]?.id === originalRunId && state.runs[0]?.status === "failed" + && state.interactions.every(card => card.status === "expired" || (card.status === "pending" && card.continuationPolicy === "wake_assignee")); + await input.evidence("pi-provider-death-final-no-replay.json", { ...state, passed }); + if (!passed) throw new Error("Provider loss replayed a run or consumed the unanswered fallback during cleanup"); + return [{ id: "death-no-replay-through-cleanup", passed, detail: "Original failed run remains the only run through cleanup" }]; + }); + } else if (execution.task.id === "native-pending-controller-restart") { + 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, + 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")); + }, + })); + } else if (execution.task.id === "native-questions") { + const name = `name-${randomBytes(8).toString("hex")}`; const draft = `draft-${randomBytes(8).toString("hex")}\nSecond line`; + await create(execution.task.buildTitle(nonce), execution.task.buildPrompt(nonce), { targets: ["pi-native-answers.json"] }); + const seen = new Set(); let runId: string | undefined; + const forms = [{ title: "Pi native color", label: "Blue" }, { title: "Pi native confirmation", label: "No" }, { title: "Pi native name", value: name }, { title: "Pi native draft", value: draft }]; + for (const [index, form] of forms.entries()) { + const state = await pollUntil({ label: form.title, deadlineAt: input.deadlineAt, load, reject: rejectFailure, + accept: state => state.interactions.some(row => row.kind === "ask_user_questions" && row.status === "pending" && row.payload?.runtimeRequestId && row.payload?.questionSet?.questions?.[0]?.header === form.title) }); + const pending = state.interactions.filter(row => row.status === "pending"); + check(`single-pending-${index}`, pending.length === 1 && state.runs.length === 1, "One durable native question belongs to one live provider run"); + const card = pending[0]!; runId ??= card.sourceRunId; + check(`native-binding-${index}`, card.sourceRunId === runId && card.continuationPolicy === "none" && !seen.has(card.id), "Question retains its source run and distinct durable identity"); seen.add(card.id); + await input.evidence(`pi-native-question-${index}-pending.json`, { issue, runs, interaction: card }); + await page.reload(); // Reconnect the browser while preserving the live native promise. + const afterReload = (await load()).interactions.find(row => row.id === card.id); + check(`reconnect-${index}`, afterReload?.status === "pending" && afterReload.payload.runtimeRequestId === card.payload.runtimeRequestId, "Reload preserved the same pending runtime request"); + await input.capture(`pi-question-${index}`, form.title, `pi-question-${index}.png`); + if (form.label) await page.getByRole("radio", { name: form.label, exact: true }).last().click(); + else await page.getByTestId("question-text-answer-composer").last().locator('[contenteditable="true"],textarea').first().fill(form.value!); + await page.getByRole("button", { name: card.payload?.questionSet?.submitLabel ?? "Submit answers", exact: true }).last().click(); + const resolved = await pollUntil({ label: "durable native answer", deadlineAt: input.deadlineAt, load, reject: rejectFailure, accept: state => state.interactions.some(row => row.id === card.id && row.status === "answered") }); + await input.evidence(`pi-native-question-${index}-answered.json`, resolved.interactions.find(row => row.id === card.id)); + } + const final = await settle(1); + if (remote) await finishRemote("questions-final"); + const proof = JSON.parse(await readWorkspace("pi-native-answers.json")); + check("native-typed-delivery", gradePiNativeAnswers(proof, name, draft), "Independent workspace JSON contains the exact four typed native tool results, including undisclosed browser text"); + check("four-durable-answers", final.interactions.length === 4 && final.interactions.every(row => row.status === "answered" && row.sourceRunId === runId), "All four saved questions were answered in the original provider run"); + 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) => 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); + 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.restart(); + await create(`Pi read persisted memory ${nonce}`, [ + `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", 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"); + 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}`); + const configured = await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "approve-reads", lifecycleMode: "per_turn", timeoutSec: 120 } }); + check("human-denial-policy", configured.adapterConfig.acpxPermissionMode === "approve-reads" && configured.adapterConfig.lifecycleMode === "per_turn", "Native write requires a browser permission decision in an owned per-turn process"); + const localTarget = remote ? null : await createDeniedTargetFixture(input.workspacePath, "pi-human-denied.txt"); + const target = localTarget?.targetRelativePath ?? "pi-human-denied.txt", path = join(input.workspacePath, target); + const watcher = localTarget?.watcher ?? null, observer = remote ? null : observeRunProcesses(); + let processError = false, processAuthority: string | undefined; + const observe = () => { + const run = runs[0]; const authority = run?.processPid ? { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id } : undefined; + if (authority) { const key = JSON.stringify(authority); if (processAuthority && processAuthority !== key) processError = true; processAuthority ??= key; } + return observer ? observer.sample(authority) : { captured: false, live: [] as number[], journal: [] }; + }; + let processes = observe(); + const timer = remote ? undefined : setInterval(() => { try { processes = observe(); } catch { processError = true; } }, 250); + if (!remote) input.registerCleanupAssertion(async () => { + try { + await load(); processes = observe(); + if (processes.captured && processes.live.length) processes = await pollUntil({ label: "Pi denied-write provider retirement", deadlineAt: Date.now() + 5000, load: async () => observe(), accept: value => value.live.length === 0 }); + const fileAbsent = await absent(path), journal = watcher!.finish(); + const passed = runs.length === 1 && runs[0]!.status === "succeeded" && processes.captured && processes.live.length === 0 && !processError && fileAbsent && journal.complete && journal.targetMutationCount === 0; + await input.evidence("pi-human-denial-retirement.json", { processes, processError, fileAbsent, journal, passed }); + if (!passed) throw new Error("Pi human denial lacks independent no-effect and provider-retirement proof"); + return [{ id: "human-denial-through-retirement", passed, detail: "Browser-denied target stayed absent through exact owned provider-process retirement" }]; + } finally { clearInterval(timer); await input.evidence("pi-human-denial-cleanup-attempt.json", { target, processes, processError, journal: watcher!.finish() }); } + }); + 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(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 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: 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); + check("human-native-denial-delivered", hasDeliveredPiDenial(runEvents, identity), "Public durable delivery and same native write failure prove actual rejection"); + check("human-target-terminal-absent", remote ? hasUnchangedPiRemoteTarget(currentBaseline, await finishRemote("human-denial-final"), target) : await absent(path), "The target remains absent after negative-test completion"); + await input.evidence("pi-human-denial.json", { identity, final, events: runEvents }); + } else if (execution.task.id === "restrictive-denial") { + const agent = await api.get(`/api/agents/${fixtures.agent.id}`); + await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "deny-all" } }); + const deniedPath = join(input.workspacePath, "pi-denied.txt"); + check("denied-file-initially-absent", await absent(deniedPath), "The isolated denied file did not already exist"); + await create(execution.task.buildTitle(nonce), execution.task.buildPrompt(nonce)); + await settle(1); const runEvents = await events(runs[0]!.id); + check("restrictive-native-denial", await absent(deniedPath) && hasFailedPiWrite(runEvents, "pi-denied.txt"), "A correlated failed native edit and independent absent file prove restrictive denial"); + await input.evidence("pi-restrictive-denial.json", { permissionMode: "deny-all", fileAbsent: true, run: runs[0], events: runEvents }); + } else throw new Error(`Unknown Pi native flow ${execution.task.id}`); + // The specialized flow bypasses the standard task collector. Retain its + // required terminal API snapshot before declaring the fixture complete. + const final = await load(); + const comments = await api.get(`/api/issues/${issue.id}/comments`); + const runEventsByRun = await Promise.all(runs.map(async run => ({ runId: run.id, events: await events(run.id) }))); + await input.evidence("api-state.json", { + ...final, run: runs.at(-1), comments, checks, runEventsByRun, + }); + await input.capture("final-state", "Pi native fixture verified", "final-state.png"); + return { issue, runs, checks }; + } finally { await input.evidence("pi-native-checks.json", { issue, runs, checks }); } +} diff --git a/tests/runner-e2e/pi-native-human-flow.test.ts b/tests/runner-e2e/pi-native-human-flow.test.ts new file mode 100644 index 0000000000..a6b452b69a --- /dev/null +++ b/tests/runner-e2e/pi-native-human-flow.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +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; 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, + createDeniedTargetFixture: async (workspace: string, name: string) => { + const fixture = await actual.createDeniedTargetFixture(workspace, name); proof.target = fixture.targetRelativePath; + return { ...fixture, watcher: { finish: () => ({ ...fixture.watcher.finish(), complete: !proof.incomplete && fixture.watcher.finish().complete, targetMutationCount: proof.mutation ? 1 : 0 }) } }; + }, + observeRunProcesses: () => ({ sample: () => ({ captured: true, live: proof.live ? [123] : [], journal: [] }) }), +}; }); +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, 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: "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" })] : [])]; + const api = { + post: async () => ({ name: "Pi fixture project" }), patch: async (_path: string, value: any) => value, + get: async (path: string) => { + if (path === "/api/agents/agent") return { adapterConfig: {} }; + if (path.endsWith("/issues?limit=100")) return [issue()]; + if (path === "/api/issues/issue") return issue(); + if (path.endsWith("/heartbeat-runs?limit=100")) return [run()]; + if (path === "/api/heartbeat-runs/run") return run(); + if (path.endsWith("/interactions") || path.endsWith("/comments")) return []; + if (path.includes("/events?")) return events(); + throw new Error(`Unexpected fixture API ${path}`); + }, + }; + 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; }), + 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 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); + if (mutation || incomplete) await expect(cleanup[0]!()).rejects.toThrow("no-effect"); + else expect((await cleanup[0]!())[0].passed).toBe(true); + expect(saved.get(remote ? "pi-remote-1-retirement.json" : "pi-human-denial-retirement.json").passed).toBe(!mutation && !incomplete); + } finally { await rm(workspacePath, { recursive: true, force: true }); } +} +it("drives actual browser-denial flow and independent retirement proof", async () => exercise(false)); +it("rejects an observed create/delete even when final target is absent", async () => exercise(true)); + +it("proves browser denial against remote watcher and retirement without trusting a host file", async () => exercise(false, true)); + +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-permission.test.ts b/tests/runner-e2e/pi-native-permission.test.ts new file mode 100644 index 0000000000..0f1be0a531 --- /dev/null +++ b/tests/runner-e2e/pi-native-permission.test.ts @@ -0,0 +1,70 @@ +import { expect, it } from "vitest"; +import { hasDeliveredPiDenial, piPermissionRequests } from "./pi-native-evidence.js"; +const identity = { runId: "run", turnId: "turn", requestId: "permission", toolCallId: "pi-tool-1", target: "pi-human-denied.txt" }; +function evidence(adapter = "acpx-runtime") { + 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, + } } }); + return [wrap("runtime_request.created", 1, { request: { requestId: "permission", turnId: "turn", type: "permission", status: "pending", details: { toolCallId: "pi-tool-1" }, + origin: { adapter, provider: "pi", method: "session/request_permission" }, choices: [{ key: "decline" }] } }), + wrap("runtime_request.resolved", 2, { requestId: "permission", turnId: "turn", action: "decline" }), + wrap("tool.execution.completed", 3, { schema: "paperclip.tool.execution.v1", executionId: "pi-tool-1", transport: "builtin", operation: "edit", name: "write", status: "failed", target: identity.target, output: "Pi operation was denied or cancelled" })]; +} +it("requires the actual correlated durable browser denial and native tool failure", () => { + expect(piPermissionRequests(evidence(), "run")).toHaveLength(1); + expect(hasDeliveredPiDenial(evidence(), identity)).toBe(true); + for (const rows of [[], evidence().slice(1), evidence().slice(0, 2), [...evidence(), evidence()[2]!]]) expect(hasDeliveredPiDenial(rows, identity)).toBe(false); +}); +it("rejects foreign identities, approvals, expiration, write success and policy-only failure", () => { + for (const key of ["runId", "turnId", "requestId", "toolCallId", "target"]) expect(hasDeliveredPiDenial(evidence(), { ...identity, [key]: "other" })).toBe(false); + for (const [index, patch] of [[1, { action: "accept" }], [2, { status: "completed" }], [2, { output: "outside its assigned workspace" }], [2, { executionId: "other" }], [2, { target: "other.txt" }]] as const) { + const rows = evidence(); Object.assign(rows[index]!.payload.prpEvent.payload as object, patch); expect(hasDeliveredPiDenial(rows, identity)).toBe(false); + } + for (const eventType of ["runtime_request.expired", "runtime_request.cancelled"]) { + const rows = evidence(); rows[1]!.eventType = eventType; rows[1]!.payload.prpEvent.eventType = eventType; + expect(hasDeliveredPiDenial(rows, identity)).toBe(false); + } + const malformed = evidence(); (malformed[0]!.payload.prpEvent.payload as any).request.choices = [null, {}]; + expect(piPermissionRequests(malformed, "run")).toEqual([]); + const stale = evidence(); stale[1]!.seq = 0; expect(hasDeliveredPiDenial(stale, identity)).toBe(false); + const wrong = evidence(); wrong[2]!.payload.prpEvent.sourceKind = "agent"; expect(hasDeliveredPiDenial(wrong, identity)).toBe(false); +}); +it("does not misorder native notifications that arrive before the public delivery receipt is persisted", () => { + const rows = evidence(); rows[1]!.seq = 3; rows[2]!.seq = 2; + // Both native failed invocation and post-write delivery receipt are required; + // asynchronous persistence order is not treated as native execution order. + expect(hasDeliveredPiDenial(rows, identity)).toBe(true); +}); + +import { hasPiRemoteRetirement, hasUnchangedPiRemoteTarget } from "./pi-native-evidence.js"; +it("remote denial requires complete watching, stable parent and real process retirement before lease deletion", () => { + const root = { pid: 12, startTicks: "100", bootId: "boot" }; + const before = { observedAtMs: 1, complete: true, targets: { denied: { absent: true, sha256: null, parent: { dev: "1", ino: "2" }, mutationCount: 0, complete: true } } }; + const after = { ...structuredClone(before), observedAtMs: 2, watcher: { complete: true }, processes: { captured: true, root, journal: [root], live: [] as number[] } }; + expect(hasUnchangedPiRemoteTarget(before, after, "denied")).toBe(true); + for (const broken of [{ ...after, complete: false }, { ...after, processes: { ...after.processes, live: [12] } }, { ...after, processes: { ...after.processes, captured: false } }, { ...after, processes: { ...after.processes, journal: [] } }, { ...after, watcher: { complete: false } }]) expect(hasUnchangedPiRemoteTarget(before, broken, "denied")).toBe(false); + for (const change of [{ mutationCount: 1 }, { absent: false }, { parent: { dev: "1", ino: "3" } }, { complete: false }]) { + expect(hasUnchangedPiRemoteTarget(before, { ...after, targets: { denied: { ...after.targets.denied, ...change } } }, "denied")).toBe(false); + } + expect(hasPiRemoteRetirement({ sandboxDeleted: true })).toBe(false); + expect(hasPiRemoteRetirement({ ...after, watcher: { complete: false } })).toBe(false); + const existing = structuredClone(before); existing.targets.denied.absent = false; (existing.targets.denied as any).sha256 = `sha256:${"a".repeat(64)}`; + expect(hasUnchangedPiRemoteTarget(existing, { ...after, targets: existing.targets }, "denied")).toBe(true); +}); + +it.each(["acpx-runtime", "acpx-runtime-sidecar"])("requires exact Pi permission origin and identity via %s", adapter => { + expect(piPermissionRequests(evidence(adapter), "run")).toHaveLength(1); + expect(hasDeliveredPiDenial(evidence(adapter), identity)).toBe(true); + for (const patch of [{ adapter: "unknown" }, { adapter: "acpx-runtime-sidecar-unknown" }, { adapter: "semantic" }, { provider: "cursor" }, { method: "request_human_input" }]) { + const rows = evidence(adapter); + Object.assign((rows[0]!.payload.prpEvent.payload as any).request.origin, patch); + expect(piPermissionRequests(rows, "run")).toHaveLength(0); + expect(hasDeliveredPiDenial(rows, identity)).toBe(false); + } + for (const key of ["runId", "turnId", "requestId", "toolCallId", "target"]) { + expect(hasDeliveredPiDenial(evidence(adapter), { ...identity, [key]: "foreign" })).toBe(false); + } + const rows = evidence(adapter); + expect(hasDeliveredPiDenial([...rows, rows[0]!], identity)).toBe(false); + expect(hasDeliveredPiDenial([...rows, rows[1]!], identity)).toBe(false); +}); diff --git a/tests/runner-e2e/pi-native-provider-death-flow.test.ts b/tests/runner-e2e/pi-native-provider-death-flow.test.ts new file mode 100644 index 0000000000..487e60f15a --- /dev/null +++ b/tests/runner-e2e/pi-native-provider-death-flow.test.ts @@ -0,0 +1,116 @@ +import { expect, it, vi } from "vitest"; +import { gradePiProviderDeath, runPiPendingProviderDeath, PI_DEATH_MARKER } from "./pi-native-provider-death-flow.js"; +import { classifyFailure } from "./failure-classifier.js"; +import { observePiPendingNativeInput } from "./pi-native-restart-flow.js"; + +type Row = Record; +vi.mock("@playwright/test", () => ({ expect: (locator: { count(): number }) => ({ + toHaveCount: async (expected: number) => expect(locator.count()).toBe(expected), + toBeVisible: async () => expect(locator.count()).toBe(1), +}) })); +function fixture(fallback = true) { + const question = { schema: "paperclip.question_set.v1", questions: [{ id: "answer", header: "Pi native provider death", prompt: "Answer", answerMode: "text", required: true }] }; + const request = { schema: "paperclip.runtime_request.v2", requestKind: "runtime", type: "input", status: "pending", requestId: "request", turnId: "turn", itemId: "request", input: question, + origin: { adapter: "acpx-runtime-sidecar", provider: "pi", method: "elicitation/create" } }; + const wrap = (eventType: string, sourceSeq: number, payload: Row): Row => ({ companyId: "company", runId: "run", seq: sourceSeq, protocolSchemaVersion: 1, eventType, + payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", eventType, runId: "run", turnId: "turn", itemId: "request", + 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], + interactions: [{ id: "original", companyId: "company", issueId: "issue", kind: "ask_user_questions", sourceRunId: "run", status: "pending", result: null, + continuationPolicy: "none", resolverPolicy: "human_only", idempotencyKey: "paperclip-runner-question:run:request", payload: { runtimeRequestId: "request", questionSet: question } } as Row] }; + const events = [wrap("turn.started", 1, {}), wrap("runtime_request.created", 2, { request })]; + const pending = observePiPendingNativeInput(state, events, "company", "Pi native provider death"); + const before = structuredClone({ state, events }); + state.runs[0]!.status = "failed"; state.issue.status = "blocked"; state.interactions[0]!.status = "expired"; + events.push(wrap("runtime_request.expired", 3, { requestId: "request", requestKind: "runtime", requestType: "input", turnId: "turn", itemId: "request", reason: "provider_process_lost", replayAllowed: false, request }), + wrap("turn.failed", 4, { code: "provider_transport_lost" })); + if (fallback) state.interactions.push({ ...structuredClone(state.interactions[0]!), id: "fallback", status: "pending", continuationPolicy: "wake_assignee", idempotencyKey: "runtime-input-durable:v1:run:request" }); + state.interactions[0]!.result = { version: 1, answers: [], ...(fallback ? { expirationReason: "superseded_by_newer_interaction", supersededByInteractionId: "fallback" } : {}) }; + const grade = () => gradePiProviderDeath(state, events, pending, question); + return { state, events, pending, question, wrap, grade, before }; +} +it.each([false, true])("accepts production loss with distinct fallback=%s without claiming restoration", fallback => { + expect(fixture(fallback).grade()).toEqual({ originalCardExpired: true, replayAllowed: false, originalProviderRestored: false, fallbackInteractionId: fallback ? "fallback" : null }); +}); +it.each([ + ["wrong issue disposition", (f: ReturnType) => { f.state.issue.status = "in_progress"; }], + ["completed issue", (f: ReturnType) => { f.state.issue.status = "done"; }], + ["answered expired card", (f: ReturnType) => { f.state.interactions[0]!.result.answers = [{ questionId: "answer", otherText: "stale" }]; }], + ["missing expired result", (f: ReturnType) => { f.state.interactions[0]!.result = null; }], + ["foreign supersession", (f: ReturnType) => { f.state.interactions[0]!.result.supersededByInteractionId = "other"; }], + ["missing expiry", (f: ReturnType) => { f.events.splice(2, 1); }], + ["model-only claim", f => { f.events[2] = { eventType: "assistant.message", payload: { text: "The callback expired" } }; }], + ["controller injected expiry", f => { f.events[2]!.payload.prpEvent.sourceKind = "control_plane"; }], + ["cancelled instead of lost", f => { f.events[2]!.eventType = f.events[2]!.payload.prpEvent.eventType = "runtime_request.cancelled"; }], + ["resolved instead of lost", f => { f.events[2]!.eventType = f.events[2]!.payload.prpEvent.eventType = "runtime_request.resolved"; }], + ["normal durable handoff", f => { f.events[2]!.payload.prpEvent.payload.reason = "durable_handoff"; }], + ["replay allowed", f => { f.events[2]!.payload.prpEvent.payload.replayAllowed = true; }], + ["foreign request", f => { f.events[2]!.payload.prpEvent.payload.requestId = "other"; }], + ["foreign item", f => { f.events[2]!.payload.prpEvent.payload.itemId = "other"; }], + ["missing canonical request", f => { delete f.events[2]!.payload.prpEvent.payload.request; }], + ["replaced canonical question", f => { f.events[2]!.payload.prpEvent.payload.request = { ...f.events[2]!.payload.prpEvent.payload.request, input: {} }; }], + ["duplicate expiry", f => { f.events.push(f.wrap("runtime_request.expired", 5, f.events[2]!.payload.prpEvent.payload)); }], + ["new native turn", f => { f.events.push(f.wrap("turn.started", 5, {})); }], + ["successful provider", f => { f.state.runs[0]!.status = "succeeded"; }], + ["Stop cancellation", f => { f.state.runs[0]!.resultJson = { nativeCancellation: {} }; }], + ["worker failure without turn failure", f => { f.events.pop(); }], + ["completed turn", f => { f.events[3]!.eventType = f.events[3]!.payload.prpEvent.eventType = "turn.completed"; }], + ["terminal before expiry", f => { f.events[3]!.seq = 0; }], + ["second run", f => { f.state.runs.push({ ...f.state.runs[0], id: "other" }); }], + ["restored session", f => { f.state.runs[0]!.nativeSessionId = "restored"; }], + ["foreign company", f => { f.events[2]!.companyId = "foreign"; }], + ["foreign source", f => { f.events[2]!.payload.prpEvent.sourceInstanceId = "foreign"; }], + ["original still pending", f => { f.state.interactions[0]!.status = "pending"; }], + ["answered original", f => { f.state.interactions[0]!.status = "answered"; }], + ["rebound original card", f => { f.state.interactions[0]!.sourceRunId = "other"; }], + ["duplicate fallback", f => { f.state.interactions.push(structuredClone(f.state.interactions[1]!)); }], + ["fallback masquerading as callback", f => { f.state.interactions[1]!.continuationPolicy = "none"; }], + ["answered fallback", f => { f.state.interactions[1]!.status = "answered"; }], + ["foreign fallback", f => { f.state.interactions[1]!.idempotencyKey = "foreign"; }], +] satisfies Array<[string, (f: ReturnType) => void]>)("rejects %s", (_name, mutate) => { + const f = fixture(); mutate(f); expect(f.grade).toThrow(); +}); + +async function journey(staleStatus = 409, markerEffect = false) { + const f = fixture(), evidence = new Map(), posted: string[] = []; + let lost = false, signals = 0, retired = false; + Object.assign(f.before.state.runs[0]!, { contextSnapshot: { executionWorkspaceId: "workspace", paperclipEnvironment: { leaseId: "lease" } }, + runnerProfileJson: { nativeExecutionInput: { binding: { executionWorkspaceId: "workspace" } } } }); + const root = { pid: 21, startTicks: "100", bootId: "boot" }; + const composer: any = { count: () => 1, filter: () => composer }; + const page: any = { getByTestId: (id: string) => id === "issue-detail-header" ? { getByRole: (_role: string, options: Row) => { + expect(options).toEqual({ name: "Change status (current: Blocked", exact: false }); return { count: () => 1 }; + } } : composer, reload: async () => { expect(retired).toBe(true); }, + locator: (selector: string) => { expect(selector).toBe("#interaction-original"); return { count: () => 1, locator: () => ({ count: () => 0 }) }; } }; + const remoteFixture: any = { binding: { companyId: "company", runId: "run", leaseId: "lease" }, baseline: { scope: { observedPrpEnvironmentLeaseId: "workspace" } }, + terminatePiProvider: async (lease: string) => { expect(lease).toBe("workspace"); expect(lost).toBe(false); signals++; lost = true; return { targetKind: "pi_native_child", signalled: true }; }, + finish: async () => { expect(lost).toBe(true); retired = true; return { complete: true, observedAtMs: 1, watcher: { complete: true }, + processes: { captured: true, root, journal: [root], live: [] }, targets: { [PI_DEATH_MARKER]: { absent: !markerEffect, complete: true, mutationCount: markerEffect ? 1 : 0 } } }; } }; + const result = runPiPendingProviderDeath({ page, api: { request: { post: async (path: string) => { expect(retired).toBe(true); posted.push(path); return { status: () => staleStatus }; } } } as any, + companyId: "company", deadlineAt: Date.now() + 1000, fixture: remoteFixture, + load: async () => structuredClone(lost ? f.state : f.before.state), events: async () => structuredClone(lost ? f.events : f.before.events), + capture: async () => {}, evidence: async (name, value) => { evidence.set(name, value); } }); + return { result, evidence, posted, signalCount: () => signals }; +} +it("drives one admitted fault, retirement, original-card UI and both stale public APIs in order", async () => { + const j = await journey(); expect((await j.result).every(check => check.passed)).toBe(true); + expect(j.signalCount()).toBe(1); expect(j.posted).toEqual(["/api/issues/issue/interactions/original/respond", "/api/heartbeat-runs/run/runtime-requests/request/resolve"]); + expect(j.evidence.get("pi-provider-death-result.json")).toMatchObject({ originalProviderRestored: false, fallbackInteractionId: "fallback", staleCardStatus: 409, staleRuntimeStatus: 409 }); +}); +it("does not accept a successful stale answer despite otherwise valid expiry receipts", async () => { + const j = await journey(200); await expect(j.result).rejects.toThrow("Both original durable-card"); expect(j.signalCount()).toBe(1); +}); +it("rejects a continuation file effect before sending either stale answer", async () => { + const j = await journey(409, true); await expect(j.result).rejects.toThrow("Exact owned process tree"); expect(j.posted).toEqual([]); +}); + +it("classifies deliberate-loss proof failures as candidate failures, preserving real API transport errors", () => { + const f = fixture(); f.state.runs[0]!.status = "succeeded"; + let observed: unknown; + try { f.grade(); } catch (error) { observed = error; } + expect(String(observed)).toContain("pi_provider_death_proof:"); + expect(classifyFailure(observed)).toBe("candidate_failure"); + expect(classifyFailure(new Error("GET controller returned 503 service unavailable"))).toBe("transient_infrastructure"); + expect(classifyFailure(new Error("pi_provider_death_proof: secret leak"))).toBe("secret_leak"); + expect(classifyFailure(new Error("pi_provider_death_proof: cleanup incomplete"))).toBe("cleanup_failure"); +}); diff --git a/tests/runner-e2e/pi-native-provider-death-flow.ts b/tests/runner-e2e/pi-native-provider-death-flow.ts new file mode 100644 index 0000000000..1189e0863a --- /dev/null +++ b/tests/runner-e2e/pi-native-provider-death-flow.ts @@ -0,0 +1,135 @@ +import { createHash, randomBytes } from "node:crypto"; +import { expect, type Page } from "@playwright/test"; +import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { isValidNativePrpEnvelope } from "./native-event-envelope.js"; +import { observePiPendingNativeInput, type PiRestartPending } from "./pi-native-restart-flow.js"; +import { hasPiRemoteRetirement } from "./pi-native-evidence.js"; +import type { RemoteNativeFixture, PiProviderDeathReceipt } from "./remote-native-fixtures.js"; + +type Row = Record; +type State = { issue: Row; runs: Row[]; interactions: Row[] }; +type Check = { id: string; passed: boolean; detail: string }; +const hash = (value: unknown) => createHash("sha256").update(canonicalJson(value)).digest("hex"); +const demand = (value: unknown, reason: string): void => { if (!value) throw new Error(`pi_provider_death_proof: ${reason}`); }; +export const PI_DEATH_MARKER = "pi-provider-death-answer.json"; + +/** All events are public durable production receipts, never a fault fixture's events. */ +export function gradePiProviderDeath(state: State, events: readonly Row[], pending: PiRestartPending, originalQuestion: unknown) { + demand(state.issue.id === pending.issueId && state.issue.companyId === pending.companyId && state.issue.status === "blocked" && state.runs.length === 1, "foreign issue, wrong blocked disposition or second run"); + const run = state.runs[0]!; + demand(run.id === pending.runId && run.companyId === pending.companyId && run.nativeIssueId === pending.issueId && run.runtimeMode === "native" + && run.nativeSessionId === pending.nativeSessionId && run.runnerInstanceId === pending.sourceInstanceId + && run.status === "failed" && run.resultJson?.nativeCancellation == null && run.resultJson?.startupCancellation == null, + "original provider did not fail independently of Stop"); + demand(Array.isArray(events) && events.length > 0 && events.length <= 20_000, "missing event history"); + const rows = events.filter(row => row.payload?.prpEvent).sort((a, b) => a.seq - b.seq); + const ids = new Set(), seqs = new Set(), cursors = new Map(); + for (const row of rows) { + const e = row.payload.prpEvent; + demand(row.companyId === pending.companyId && row.runId === pending.runId && row.eventType === e.eventType + && Number.isSafeInteger(row.seq) && row.seq > 0 && !seqs.has(row.seq) && isValidNativePrpEnvelope(e, row.protocolSchemaVersion) + && e.runId === pending.runId && e.normalizedSessionId === pending.nativeSessionId + && ((e.sourceKind === "runner" && e.sourceInstanceId === pending.sourceInstanceId) + || (e.sourceKind === "control_plane" && e.sourceInstanceId === `${pending.sourceInstanceId}:control`)) + && e.sourceEventId === `${e.sourceInstanceId}:${pending.runId}:${e.sourceSeq}` && !ids.has(e.sourceEventId) + && Number.isSafeInteger(e.sourceSeq) && e.sourceSeq > (cursors.get(e.sourceInstanceId) ?? 0), "foreign, duplicate or reordered event"); + ids.add(e.sourceEventId); seqs.add(row.seq); cursors.set(e.sourceInstanceId, e.sourceSeq); + if (e.turnId != null) demand(e.turnId === pending.turnId, "replacement native turn"); + } + const of = (kind: string) => rows.filter(row => row.eventType === kind); + const created = of("runtime_request.created"); + demand(created.length === 1 && hash(created[0]) === pending.createdRowSha256, "original request changed or replayed"); + const closures = rows.filter(row => ["runtime_request.expired", "runtime_request.resolved", "runtime_request.cancelled"].includes(row.eventType)); + demand(closures.length === 1 && closures[0]!.eventType === "runtime_request.expired", "missing unique production expiry"); + const expired = closures[0]!.payload.prpEvent, p = expired.payload; + demand(expired.sourceKind === "runner" && expired.sourceSeq > pending.createdSourceSeq && expired.turnId === pending.turnId + && p.requestId === pending.requestId && p.requestKind === "runtime" && p.requestType === "input" && p.turnId === pending.turnId && p.itemId === pending.itemId + && p.reason === "provider_process_lost" && p.replayAllowed === false && hash(p.request) === hash(created[0]!.payload.prpEvent.payload.request), "expiry is not the original lost Pi callback"); + const failed = of("turn.failed"); + demand(failed.length === 1 && failed[0]!.seq > closures[0]!.seq && failed[0]!.payload.prpEvent.sourceKind === "runner" + && failed[0]!.payload.prpEvent.turnId === pending.turnId && !of("turn.completed").length && !of("turn.cancelled").length + && of("turn.started").length === 1 && !of("run.result.proposed").length, "wrong terminal or replacement turn"); + const card = state.interactions.find(card => card.id === pending.interactionId); + demand(card?.companyId === pending.companyId && card.issueId === pending.issueId && card.sourceRunId === pending.runId + && card.idempotencyKey === `paperclip-runner-question:${pending.runId}:${pending.requestId}` && card.continuationPolicy === "none" + && card.status === "expired" && card.result?.version === 1 && Array.isArray(card.result.answers) && card.result.answers.length === 0 + && card.kind === "ask_user_questions" && card.resolverPolicy === "human_only" && card.payload?.runtimeRequestId === pending.requestId && hash(card.payload.questionSet) === hash(originalQuestion), "original durable card did not expire"); + const fallback = state.interactions.filter(card => card.id !== pending.interactionId); + demand(fallback.length <= 1, "duplicate fallback interaction"); + if (fallback.length) { + const next = fallback[0]!; + demand(next.id !== pending.interactionId && next.companyId === pending.companyId && next.issueId === pending.issueId && next.sourceRunId === pending.runId + && next.kind === "ask_user_questions" && next.status === "pending" && next.result == null && next.continuationPolicy === "wake_assignee" + && next.idempotencyKey === `runtime-input-durable:v1:${pending.runId}:${pending.requestId}` + && card!.result.expirationReason === "superseded_by_newer_interaction" && card!.result.supersededByInteractionId === next.id + && next.payload?.runtimeRequestId === pending.requestId && hash(next.payload.questionSet) === hash(originalQuestion), "fallback was mistaken for restored input"); + } + return { originalCardExpired: true, replayAllowed: false, originalProviderRestored: false, fallbackInteractionId: fallback[0]?.id ?? null }; +} + +export async function runPiPendingProviderDeath(input: { + page: Page; api: RunnerApi; companyId: string; deadlineAt: number; fixture: RemoteNativeFixture; + load(): Promise; events(runId: string): Promise; + capture(id: string, label: string, file: string): Promise; evidence(name: string, value: unknown): Promise; +}): Promise { + const checks: Check[] = []; + const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); demand(passed, detail); }; + let fault: PiProviderDeathReceipt | undefined; + const snapshot = async (name: string) => { + const state = await input.load(), events = state.runs.length === 1 ? await input.events(state.runs[0]!.id) : []; + await input.evidence(name, { ...state, events, fault }); return { state, events }; + }; + try { + await pollUntil({ label: "Pi native input before provider death", deadlineAt: input.deadlineAt, load: input.load, + accept: s => s.interactions.some(c => c.status === "pending"), reject: s => s.runs.length > 1 || s.runs.some(r => ["succeeded", "failed", "cancelled", "timed_out"].includes(r.status)) ? "Provider ended before fault admission" : undefined }); + const before = await snapshot("pi-provider-death-before.json"); + const pending = observePiPendingNativeInput(before.state, before.events, input.companyId, "Pi native provider death"); + const run = before.state.runs[0]!, question = before.state.interactions[0]!.payload.questionSet; + demand(input.fixture.binding.runId === pending.runId && input.fixture.binding.companyId === pending.companyId && input.fixture.terminatePiProvider, + "missing exact owned Daytona fault capability"); + // createRunnerdBackend's default PRP lease is the execution workspace ID; + // reject alternate/recovered authority instead of guessing that identity. + const workspaceId = run.contextSnapshot?.executionWorkspaceId; + demand(typeof workspaceId === "string" && workspaceId.length > 0 + && run.runnerProfileJson?.nativeExecutionInput?.binding?.executionWorkspaceId === workspaceId + && run.contextSnapshot?.paperclipEnvironment?.leaseId === input.fixture.binding.leaseId + && input.fixture.baseline.scope.observedPrpEnvironmentLeaseId === workspaceId, "native run/lease/workspace binding unavailable"); + await expect(input.page.getByTestId("question-text-answer-composer").filter({ visible: true })).toHaveCount(1); + await input.capture("pi-provider-death-pending", "Unanswered native Pi input before provider loss", "pi-provider-death-pending.png"); + // Refresh immediately before the one-shot irreversible fault. + const fresh = await snapshot("pi-provider-death-admission.json"); + check("death-original-pending-input", hash(observePiPendingNativeInput(fresh.state, fresh.events, input.companyId, "Pi native provider death")) === hash(pending), "Fault admits only the unchanged original native input"); + fault = await input.fixture.terminatePiProvider!(workspaceId); + await input.evidence("pi-provider-death-fault.json", fault); + await pollUntil({ label: "production expiry after Pi provider loss", deadlineAt: input.deadlineAt, load: input.load, + accept: s => s.issue.status === "blocked" && s.runs[0]?.status === "failed" && s.interactions.some(c => c.id === pending.interactionId && c.status === "expired"), + reject: s => s.runs.length !== 1 || s.runs.some(r => ["succeeded", "cancelled", "timed_out"].includes(r.status)) ? "Unexpected run after provider fault" : undefined }); + const retired = await input.fixture.finish(); + check("death-owned-retirement-no-effect", hasPiRemoteRetirement(retired) && retired.targets[PI_DEATH_MARKER]?.absent === true + && retired.targets[PI_DEATH_MARKER]?.complete === true && retired.targets[PI_DEATH_MARKER]?.mutationCount === 0, + "Exact owned process tree retired while the unanswered continuation marker remained absent"); + await input.evidence("pi-provider-death-retirement.json", retired); + await input.page.reload(); + const final = await snapshot("pi-provider-death-expired.json"), result = gradePiProviderDeath(final.state, final.events, pending, question); + // Failed native terminals are permanent on the first attempt; the + // controller's nativeSessionRecoveryProjection projects Blocked while this + // run still owns the task (native-session-executor.ts). + await expect(input.page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: Blocked", exact: false })).toBeVisible(); + const oldCard = input.page.locator(`#interaction-${pending.interactionId}`); + await expect(oldCard).toHaveCount(1); + await expect(oldCard.locator('textarea,[contenteditable="true"],button[type="submit"]')).toHaveCount(0); + await input.capture("pi-provider-death-expired", "Expired original input and any separate durable fallback", "pi-provider-death-expired.png"); + const answer = `STALE-${randomBytes(16).toString("hex")}`; + const original = await input.api.request.post(`/api/issues/${pending.issueId}/interactions/${pending.interactionId}/respond`, + { data: { answers: [{ questionId: pending.questionId, optionIds: [], otherText: answer }] } }); + const runtime = await input.api.request.post(`/api/heartbeat-runs/${pending.runId}/runtime-requests/${encodeURIComponent(pending.requestId)}/resolve`, + { data: { resolution: { action: "submit", response: { schema: "paperclip.question_response.v1", answers: { [pending.questionId]: { text: answer } } } } } }); + check("death-stale-original-responses-rejected", original.status() === 409 && runtime.status() === 409, "Both original durable-card and native-runtime response APIs reject the stale answer"); + const after = await snapshot("pi-provider-death-after-stale.json"); + check("death-expiry-not-restoration", hash(gradePiProviderDeath(after.state, after.events, pending, question)) === hash(result) + && hash(after.state.interactions) === hash(final.state.interactions), "Stale answers neither resolve/replay the original request nor start a new run; any fallback remains a separate unanswered interaction"); + await input.evidence("pi-provider-death-result.json", { ...result, staleCardStatus: original.status(), staleRuntimeStatus: runtime.status(), paidProviderDeath: true }); + return checks; + } finally { await input.evidence("pi-provider-death-checks.json", checks); } +} diff --git a/tests/runner-e2e/pi-native-remote-flow.test.ts b/tests/runner-e2e/pi-native-remote-flow.test.ts new file mode 100644 index 0000000000..6eeb401e64 --- /dev/null +++ b/tests/runner-e2e/pi-native-remote-flow.test.ts @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; +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, PI_NATIVE_MEMORY_PARENT_SEED_PATH, PI_NATIVE_MEMORY_PARENT_SEED_CONTENT } from "./pi-native-cases.js"; +import { runPiNativeFlow } from "./pi-native-flow.js"; +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.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, 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: "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 = { 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 (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: 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(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 () => { 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 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 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 new file mode 100644 index 0000000000..f9a6903954 --- /dev/null +++ b/tests/runner-e2e/pi-native-restart-flow.test.ts @@ -0,0 +1,209 @@ +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"; + +type Row = Record; +vi.mock("@playwright/test", () => ({ expect: (locator: { count(): number }) => ({ + toHaveCount: async (expected: number) => expect(locator.count()).toBe(expected), +}) })); + +function fixture(adapter = "acpx-runtime-sidecar") { + const questionSet = { schema: "paperclip.question_set.v1", questions: [{ id: "answer", header: "Pi native restart", prompt: "Answer", answerMode: "text", required: true }] }; + const request = { schema: "paperclip.runtime_request.v2", requestKind: "runtime", type: "input", status: "pending", requestId: "request", turnId: "turn", itemId: "request", + input: questionSet, origin: { adapter, provider: "pi", method: "elicitation/create" } }; + const wrap = (eventType: string, sourceSeq: number, payload: Row): Row => ({ + companyId: "company", runId: "run", seq: sourceSeq, protocolSchemaVersion: 1, eventType, + payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", eventType, runId: "run", turnId: "turn", itemId: "request", + 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", 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] }; + const events = [wrap("runtime_request.created", 1, { request })]; + const control = (eventType: string, sourceSeq: number, payload: Row): Row => { + const row = wrap(eventType, sourceSeq + 4, payload), event = row.payload.prpEvent; + Object.assign(event, { sourceKind: "control_plane", sourceInstanceId: "runner:control", sourceSeq, sourceEventId: `runner:control:run:${sourceSeq}` }); + delete event.itemId; + Object.assign(row, { sourceInstanceId: event.sourceInstanceId, sourceSeq, sourceEventId: event.sourceEventId }); + return row; + }; + function finish(answer = "hidden") { + state.issue.status = "done"; state.runs[0]!.status = "succeeded"; + Object.assign(state.interactions[0]!, { status: "answered", result: { answers: [{ questionId: "answer", optionIds: [], otherText: answer }] } }); + const validated = validatePrpStructuredRunResult({ summary: "Restart question answered", reportedWorkDisposition: "done", + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, evidence: [], verification: [] }); + if (!validated.ok) throw new Error("Fixture finish result must satisfy the actual production schema"); + const result = validated.result; + events.push(wrap("runtime_request.resolved", 2, { requestId: "request", turnId: "turn", itemId: "request", action: "submit", + response: { schema: "paperclip.question_response.v1", answers: { answer: { text: answer } } } }), wrap("turn.completed", 4, { status: "completed" }), + wrap("run.result.proposed", 3, structuredClone(result)), control("run.result.accepted", 1, { result: structuredClone(result) }), + control("run.terminal", 2, { schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", runTerminalState: "succeeded", reportedWorkDisposition: "done" })); + return { status: "answered", value: answer }; + } + return { state, events, request, wrap, control, finish }; +} + +it.each(["acpx-runtime", "acpx-runtime-sidecar"])("accepts mixed runner/control-plane production completion via %s", adapter => { + const f = fixture(adapter), pending = observePiRestartPending(f.state, f.events, "company"), proof = f.finish(); + 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"; }], + ["semantic origin", (f: ReturnType) => { f.request.origin.adapter = "semantic"; }], + ["internal kind instead of public schema", (f: ReturnType) => { f.request.requestKind = "elicitation"; }], + ["missing native session", (f: ReturnType) => { delete f.state.runs[0]!.nativeSessionId; }], + ["foreign request turn", (f: ReturnType) => { f.request.turnId = "other"; }], + ["foreign company", (f: ReturnType) => { f.state.interactions[0]!.companyId = "other"; }], + ["durable fallback", (f: ReturnType) => { f.state.interactions[0]!.continuationPolicy = "wake_assignee"; }], + ["wrong idempotency identity", (f: ReturnType) => { f.state.interactions[0]!.idempotencyKey = "other"; }], + ["duplicate interaction", (f: ReturnType) => { f.state.interactions.push(structuredClone(f.state.interactions[0]!)); }], + ["duplicate source receipt", (f: ReturnType) => { f.events.push(structuredClone(f.events[0]!)); }], + ["already answered", (f: ReturnType) => { f.finish(); }], +] as const)("rejects %s before restart", (_label, mutate) => { + const f = fixture(); mutate(f); expect(() => observePiRestartPending(f.state, f.events, "company")).toThrow("Pi native restart"); +}); + +it.each([ + ["replacement run", (f: ReturnType) => { f.state.runs[0]!.id = "replacement"; }], + ["additional run", (f: ReturnType) => { f.state.runs.push({ ...f.state.runs[0], id: "replayed" }); }], + ["replacement native session", (f: ReturnType) => { f.state.runs[0]!.nativeSessionId = "replacement"; }], + ["replacement producer", (f: ReturnType) => { f.state.runs[0]!.runnerInstanceId = "replacement"; }], + ["replaced interaction", (f: ReturnType) => { f.state.interactions[0]!.id = "replacement"; }], + ["missing resolution", (f: ReturnType) => { f.events.splice(1, 1); }], + ["duplicate resolution", (f: ReturnType) => { f.events.push(structuredClone(f.events[1]!)); }], + ["replayed creation", (f: ReturnType) => { f.events.push(f.wrap("runtime_request.created", 4, { request: f.request })); }], + ["cancelled callback", (f: ReturnType) => { f.events[1]!.eventType = f.events[1]!.payload.prpEvent.eventType = "runtime_request.cancelled"; }], + ["expired callback", (f: ReturnType) => { f.events[1]!.eventType = f.events[1]!.payload.prpEvent.eventType = "runtime_request.expired"; }], + ["wrong delivered answer", (f: ReturnType) => { f.events[1]!.payload.prpEvent.payload.response.answers.answer.text = "guess"; }], + ["wrong durable answer", (f: ReturnType) => { f.state.interactions[0]!.result.answers[0].otherText = "guess"; }], + ["wrong response turn", (f: ReturnType) => { f.events[1]!.payload.prpEvent.payload.turnId = "other"; }], + ["missing resolution envelope turn", (f: ReturnType) => { delete f.events[1]!.payload.prpEvent.turnId; }], + ["missing resolution item", (f: ReturnType) => { delete f.events[1]!.payload.prpEvent.itemId; }], + ["rewritten canonical question", (f: ReturnType) => { f.request.input.questions[0]!.prompt = "Replacement prompt"; }], + ["new native turn", (f: ReturnType) => { f.events[2]!.payload.prpEvent.turnId = "other"; }], + ["failed terminal payload", (f: ReturnType) => { f.events[2]!.payload.prpEvent.payload.status = "failed"; }], + ["missing terminal", (f: ReturnType) => { f.events.splice(2, 1); }], + ["duplicate terminal", (f: ReturnType) => { f.events.push(f.wrap("turn.completed", 4, {})); }], + ["missing control-plane acceptance", (f: ReturnType) => { f.events.splice(4, 1); }], + ["missing control-plane terminal", (f: ReturnType) => { f.events.pop(); }], + ["foreign control-plane source", (f: ReturnType) => { f.events[4]!.payload.prpEvent.sourceInstanceId = "other:control"; }], + ["foreign control-plane turn", (f: ReturnType) => { f.events[4]!.payload.prpEvent.turnId = "other"; }], + ["missing control-plane turn", (f: ReturnType) => { delete f.events[4]!.payload.prpEvent.turnId; }], + ["foreign control-plane session", (f: ReturnType) => { f.events[4]!.payload.prpEvent.normalizedSessionId = "other"; }], + ["mismatched durable control source", (f: ReturnType) => { f.events[4]!.sourceSeq = 5; }], + ["duplicate control receipt", (f: ReturnType) => { f.events.push(structuredClone(f.events[4]!)); }], + ["control plane impersonates a native turn", (f: ReturnType) => { f.events.push(f.control("turn.completed", 3, { status: "completed" })); }], + ["failed control terminal", (f: ReturnType) => { f.events[5]!.payload.prpEvent.payload.runTerminalState = "failed"; }], + ["unaccepted native result", (f: ReturnType) => { f.events[4]!.payload.prpEvent.payload.result.summary = "Replacement result"; }], + ["malformed accepted result", (f: ReturnType) => { delete f.events[4]!.payload.prpEvent.payload.result.schema; }], + ["runner impersonates acceptance", (f: ReturnType) => { f.events[4]!.payload.prpEvent.sourceKind = "runner"; }], +] as const)("rejects %s despite a correct workspace answer", (_label, mutate) => { + const f = fixture(), pending = observePiRestartPending(f.state, f.events, "company"), proof = f.finish(); + mutate(f); expect(() => gradePiRestartCompletion(f.state, f.events, pending, "hidden", proof)).toThrow(); +}); + +it.each([null, {}, { status: "answered", value: "guess" }, { status: "cancelled", value: "hidden" }, { status: "answered", value: "hidden", invented: true }])( + "rejects missing or wrong independent file proof %j", proof => { + const f = fixture(), pending = observePiRestartPending(f.state, f.events, "company"); f.finish(); + expect(() => gradePiRestartCompletion(f.state, f.events, pending, "hidden", proof)).toThrow(); + }, +); + +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/${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); + } }; + const page: any = { getByTestId: () => composer, getByRole: () => button, reload: async () => { checkpoints.push("reload"); }, + 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 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(["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 (!["replaced", "replaced-runner"].includes(failure ?? "")) expect(evidence.has("pi-native-restart-final.json")).toBe(true); +} +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 new file mode 100644 index 0000000000..34a34739d3 --- /dev/null +++ b/tests/runner-e2e/pi-native-restart-flow.ts @@ -0,0 +1,179 @@ +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"; +import { validatePrpStructuredRunResult } from "../../packages/paperclip-runner/src/protocol/replay-contract.js"; +import { pollUntil } from "./api.js"; +import { hasAcpxNativeOrigin } from "./acpx-native-origin.js"; +import { isValidNativePrpEnvelope } from "./native-event-envelope.js"; + +type Row = Record; +type State = { issue: Row; runs: Row[]; interactions: Row[] }; +type Check = { id: string; passed: boolean; detail: string }; +const rec = (value: unknown): Row => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : {}; +const id = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(value) && !value.includes("[REDACTED]"); +const digest = (value: unknown) => createHash("sha256").update(canonicalJson(value)).digest("hex"); +const closures = new Set(["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"]); +const terminals = new Set(["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]); +function requireProof(value: unknown, reason: string): asserts value { if (!value) throw new Error(`Pi native restart: ${reason}`); } + +export interface PiRestartPending { + companyId: string; issueId: string; runId: string; interactionId: string; requestId: string; + turnId: string; itemId: string; nativeSessionId: string; sourceInstanceId: string; questionId: string; + createdSourceSeq: number; createdRowSha256: string; interactionBindingSha256: string; +} + +function interactionBinding(card: Row) { + return { id: card.id, companyId: card.companyId, issueId: card.issueId, kind: card.kind, + sourceRunId: card.sourceRunId, idempotencyKey: card.idempotencyKey, + continuationPolicy: card.continuationPolicy, resolverPolicy: card.resolverPolicy, payload: card.payload }; +} + +function inspect(state: State, events: readonly Row[], companyId: string, header = "Pi native restart") { + requireProof(id(companyId) && id(state.issue.id) && state.issue.companyId === companyId && state.runs.length === 1 && state.interactions.length === 1, + "one company-scoped task, run and question required"); + const run = state.runs[0]!, card = state.interactions[0]!; + requireProof(id(run.id) && run.companyId === companyId && run.nativeIssueId === state.issue.id && run.runtimeMode === "native" + && id(run.nativeSessionId) && id(run.runnerInstanceId), "native run/session identity missing"); + requireProof(id(card.id) && card.companyId === companyId && card.issueId === state.issue.id && card.kind === "ask_user_questions" + && card.sourceRunId === run.id && card.continuationPolicy === "none" && card.resolverPolicy === "human_only", "question is not the native callback"); + requireProof(Array.isArray(events) && events.length > 0 && events.length <= 20_000, "bounded durable events required"); + const rows = events.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 seenRows = new Set(), seenSources = new Set(), cursors = new Map(); + for (const { row, event } of rows) { + requireProof(row.companyId === companyId && row.runId === run.id && isValidNativePrpEnvelope(event, row.protocolSchemaVersion) + && event.runId === run.id && event.eventType === row.eventType && ["runner", "control_plane"].includes(event.sourceKind) + && Number.isSafeInteger(row.seq) && row.seq > 0 && !seenRows.has(row.seq) + && id(event.sourceInstanceId) && Number.isSafeInteger(event.sourceSeq) && event.sourceSeq > (cursors.get(event.sourceInstanceId) ?? 0) + && event.sourceEventId === `${event.sourceInstanceId}:${run.id}:${event.sourceSeq}` && !seenSources.has(event.sourceEventId), + "foreign, duplicate or reordered durable event"); + // The production finalizer owns this separate, stable two-event stream. + // It cannot impersonate a native request/turn, nor can a runner accept itself. + if (event.sourceKind === "control_plane") { + requireProof(event.sourceInstanceId === `${run.runnerInstanceId}:control` && event.normalizedSessionId === run.nativeSessionId + && row.sourceInstanceId === event.sourceInstanceId && row.sourceSeq === event.sourceSeq && row.sourceEventId === event.sourceEventId + && ((event.eventType === "run.result.accepted" && event.sourceSeq === 1) || (event.eventType === "run.terminal" && event.sourceSeq === 2)), + "foreign or unexpected control-plane completion receipt"); + } else requireProof(!["run.result.accepted", "run.terminal"].includes(event.eventType), "runner cannot claim control-plane completion"); + seenRows.add(row.seq); seenSources.add(event.sourceEventId); cursors.set(event.sourceInstanceId, event.sourceSeq); + } + const created = rows.filter(x => x.event.eventType === "runtime_request.created"); + requireProof(created.length === 1, "exactly one native request creation required"); + const opening = created[0]!, request = rec(opening.event.payload?.request), questionSet = rec(request.input); + const question = Array.isArray(questionSet.questions) && questionSet.questions.length === 1 ? rec(questionSet.questions[0]) : {}; + requireProof(request.schema === "paperclip.runtime_request.v2" && request.requestKind === "runtime" && request.type === "input" && request.status === "pending" + && id(request.requestId) && id(request.itemId) && id(request.turnId) && request.turnId === opening.event.turnId && request.itemId === opening.event.itemId + && hasAcpxNativeOrigin(request.origin, "pi", "elicitation/create") && questionSet.schema === "paperclip.question_set.v1" + && 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"); + 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"); + const binding: PiRestartPending = { companyId, issueId: state.issue.id, runId: run.id, interactionId: card.id, requestId: request.requestId, + turnId: request.turnId, itemId: request.itemId, nativeSessionId: run.nativeSessionId, sourceInstanceId: run.runnerInstanceId, questionId: question.id, + createdSourceSeq: opening.event.sourceSeq, createdRowSha256: digest(opening.row), interactionBindingSha256: digest(interactionBinding(card)) }; + return { run, card, rows, binding }; +} + +/** Grade public state and PRP receipts; assistant prose is never restart proof. */ +export function observePiPendingNativeInput(state: State, events: readonly Row[], companyId: string, header: string): PiRestartPending { + const { run, card, rows, binding } = inspect(state, events, companyId, header); + requireProof(state.issue.status === "in_progress" && run.status === "running" && card.status === "pending" && card.result == null + && run.resultJson?.nativeCancellation == null && run.resultJson?.startupCancellation == null + && !rows.some(x => closures.has(x.event.eventType) || terminals.has(x.event.eventType) || x.event.sourceKind === "control_plane"), "question is no longer unanswered in a live run"); + return binding; +} + +export function observePiRestartPending(state: State, events: readonly Row[], companyId: string): PiRestartPending { + return observePiPendingNativeInput(state, events, companyId, "Pi native restart"); +} + +export function gradePiRestartCompletion(state: State, events: readonly Row[], pending: PiRestartPending, answer: string, proof: unknown): boolean { + const { run, card, rows, binding } = inspect(state, events, pending.companyId); + requireProof(digest(binding) === digest(pending), "original request/run/source identity changed"); + const resolved = rows.filter(x => closures.has(x.event.eventType)), terminal = rows.filter(x => terminals.has(x.event.eventType)); + requireProof(resolved.length === 1 && resolved[0]!.event.eventType === "runtime_request.resolved", "exactly one durable native resolution required"); + const delivered = resolved[0]!.event, response = rec(delivered.payload); + requireProof(response.requestId === pending.requestId && response.turnId === pending.turnId && response.itemId === pending.itemId + && response.action === "submit" && delivered.itemId === pending.itemId && delivered.sourceSeq > pending.createdSourceSeq + && digest(response.response) === digest({ schema: "paperclip.question_response.v1", answers: { [pending.questionId]: { text: answer } } }), + "hidden browser answer was not delivered to the original native request"); + requireProof(terminal.length === 1 && terminal[0]!.event.eventType === "turn.completed" && terminal[0]!.event.payload?.status === "completed" + && terminal[0]!.event.payload?.error == null && terminal[0]!.event.sourceSeq > delivered.sourceSeq + && state.issue.status === "done" && run.status === "succeeded" && run.resultJson?.nativeCancellation == null && run.resultJson?.startupCancellation == null, + "original native turn did not finish exactly once"); + const proposed = rows.filter(x => x.event.eventType === "run.result.proposed"), accepted = rows.filter(x => x.event.eventType === "run.result.accepted"), runTerminal = rows.filter(x => x.event.eventType === "run.terminal"); + requireProof(proposed.length === 1 && accepted.length === 1 && runTerminal.length === 1, "unique proposed and control-plane accepted completion required"); + const result = rec(accepted[0]!.event.payload?.result), validation = validatePrpStructuredRunResult(result); + requireProof(validation.ok && digest(validation.result) === digest(result) && result.reportedWorkDisposition === "done" + && digest(proposed[0]!.event.payload) === digest(result) + && proposed[0]!.row.seq > resolved[0]!.row.seq && proposed[0]!.row.seq < terminal[0]!.row.seq + && accepted[0]!.row.seq > terminal[0]!.row.seq && runTerminal[0]!.row.seq > accepted[0]!.row.seq + && digest(runTerminal[0]!.event.payload) === digest({ schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", runTerminalState: "succeeded", reportedWorkDisposition: "done" }), + "control-plane completion does not accept the original successful native result"); + requireProof(card.status === "answered" && Array.isArray(card.result?.answers) && card.result.answers.length === 1 + && card.result.answers[0].questionId === pending.questionId && card.result.answers[0].otherText === answer, "durable interaction answer missing or wrong"); + requireProof(digest(proof) === digest({ status: "answered", value: answer }), "independent workspace proof is missing or wrong"); + return true; +} + +export async function runPiPendingControllerRestart(input: { + page: Page; companyId: string; deadlineAt: number; load(): Promise; events(runId: string): 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")}`; + const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); requireProof(passed, detail); }; + const snapshot = async (name: string) => { + const state = await input.load(), events = state.runs.length === 1 ? await input.events(state.runs[0]!.id) : []; + await input.evidence(name, { ...state, events }); return { state, events }; + }; + try { + await pollUntil({ label: "Pi pending native restart question", deadlineAt: input.deadlineAt, load: input.load, + accept: state => state.interactions.some(card => card.status === "pending"), + reject: state => state.runs.length > 1 || state.runs.some(run => ["failed", "cancelled", "timed_out", "succeeded"].includes(run.status)) ? "Pi ended or created another run before the pending restart boundary" : undefined }); + const before = await snapshot("pi-native-restart-before.json"), pending = observePiRestartPending(before.state, before.events, input.companyId); + check("restart-pending-native-identity", true, "One unanswered native Pi callback belongs to the original running turn and session"); + 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"); + 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 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(); + const posted = (await submitted).postDataJSON(); + check("restart-exact-browser-answer", Array.isArray(posted?.answers) && posted.answers.length === 1 + && posted.answers[0].questionId === pending.questionId && posted.answers[0].otherText === answer, "Actual browser POST answered the retained question with previously undisclosed text"); + const final = await input.settle(), events = await input.events(pending.runId), proof = await input.readProof(); + await input.evidence("pi-native-restart-final.json", { ...final, events, pending, posted, proof }); + check("restart-native-answer-delivered-once", gradePiRestartCompletion(final, events, pending, answer, proof), + "One durable answer and one successful original native turn delivered the exact typed result after restart"); + return checks; + } finally { await input.evidence("pi-native-restart-checks.json", checks); } +} diff --git a/tests/runner-e2e/pi-provider-fault.py b/tests/runner-e2e/pi-provider-fault.py new file mode 100644 index 0000000000..56537a8b53 --- /dev/null +++ b/tests/runner-e2e/pi-provider-fault.py @@ -0,0 +1,229 @@ +"""One-shot Linux Pi-child fault. No environment reads; never targets a worker. + +The caller is the nonce-bound remote observer. Its already admitted lease and +run root are rechecked here, then pidfd pins the exact child across PID reuse. +This helper does not relax provider admission or manufacture runtime events. +""" +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import signal +import stat +import sys + +PACK = '/opt/paperclip-runner/provider-pack' +ENTRY = 'node_modules/@earendil-works/pi-coding-agent/dist/cli.js' +NODE = 'node/bin/node' +EXTENSION = 'extensions/paperclip.js' +GUARD = '.paperclip-native-module-guard.cjs' + + +def require(value, code): + if not value: + raise RuntimeError('pi_provider_fault:' + code) + + +def proc(pid, boot): + raw = Path(f'/proc/{pid}/stat').read_text() + require(raw.startswith(f'{pid} ('), 'stat_pid') + fields = raw.rsplit(') ', 1)[1].split() + require(fields[0] != 'Z', 'dead_process') + return {'pid': pid, 'ppid': int(fields[1]), 'startTicks': fields[19], 'bootId': boot} + + +def argv(pid): + raw = Path(f'/proc/{pid}/cmdline').read_bytes() + require(0 < len(raw) <= 32768, 'argv_bound') + return raw.decode().rstrip('\0').split('\0') + + +def flag(args, name): + require(args.count(name) == 1 and args.index(name) + 1 < len(args), 'flag_' + name) + return args[args.index(name) + 1] + + +def checked_file(path, maximum, sealed=False): + require(os.path.realpath(path) == path, 'file_realpath') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + try: + before = os.fstat(fd) + require(stat.S_ISREG(before.st_mode) and before.st_nlink == 1 and 0 < before.st_size <= maximum, 'file_shape') + if sealed: + require(before.st_mode & 0o222 == 0, 'file_writable') + chunks = [] + while True: + chunk = os.read(fd, min(1024 * 1024, maximum + 1)) + if not chunk: + break + chunks.append(chunk) + require(sum(map(len, chunks)) <= maximum, 'file_bound') + after, named = os.fstat(fd), os.lstat(path) + identity = lambda s: (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns, s.st_mode, s.st_nlink) + require(identity(before) == identity(after) == identity(named), 'file_changed') + return b''.join(chunks), (before.st_dev, before.st_ino) + finally: + os.close(fd) + + +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'] + boot = Path('/proc/sys/kernel/random/boot_id').read_text().strip() + require(re.fullmatch(r'[a-f0-9-]{36}', boot) and boot == expected['bootId'], 'boot_changed') + root = proc(expected['pid'], boot) + require(root == expected and os.getpgid(root['pid']) == root['pid'], 'root_changed') + root_args = argv(root['pid']) + runtime_root = binding['remoteCwd'] + '/.paperclip-runtime/paperclip-runner' + runner = runtime_root + '/bin/paperclip-runnerd' + require(root_args[0] == runner and flag(root_args, '--run-id') == binding['runId'] + 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_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) + 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] + require(len(pids) <= 4096, 'process_bound') + table = {} + for pid in pids: + try: + table[pid] = proc(pid, boot) + except (FileNotFoundError, ProcessLookupError): + continue + except RuntimeError as error: + if str(error).endswith(':dead_process'): + continue + raise + def ancestry(pid): + chain = [] + while pid != root['pid']: + require(pid in table and len(chain) < 64 and pid not in [p['pid'] for p in chain], 'ancestry') + chain.append(table[pid]); pid = table[pid]['ppid'] + return chain + [root] + matches = [] + for pid in table: + try: + chain = ancestry(pid) + except RuntimeError: + continue + parent = table[pid]['ppid'] + if parent not in table: + continue + parent_args = argv(parent) + if len(parent_args) != 4 or not parent_args[3].endswith('/distribution/pi-entry.cjs'): + 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[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') + for directory in (distribution, os.path.dirname(distribution)): + st = os.lstat(directory) + require(stat.S_ISDIR(st.st_mode) and not st.st_mode & 0o222 and os.path.realpath(directory) == directory, 'snapshot_seal') + require(os.readlink(f'/proc/{pid}/cwd') == binding['remoteCwd'], 'cwd') + identities = {} + for name in (NODE, ENTRY, EXTENSION, 'pi-entry.cjs', 'node_modules/pi-acp/dist/index.js', 'node_modules/pi-acp/dist/paperclip-runtime.js'): + content, inode = checked_file(distribution + '/' + name, 256 * 1024 * 1024, True) + require(len(content) == entries[name]['size'] and digest(content) == entries[name]['sha256'], 'snapshot_digest') + identities[name] = inode + exe = os.stat(f'/proc/{pid}/exe') + 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'], + 'entrypointSha256': 'sha256:' + entries[ENTRY]['sha256'], 'closureSha256': config['closureSha256'], + 'entrypointAttribution': 'pinned_wrapper_parent', 'originalChildArgvAvailable': False, 'observedChildTitle': 'pi'}) + require(len(matches) == 1, 'unique_pi_child') + return matches[0] + + +def terminate(config): + require(sys.platform == 'linux' and hasattr(os, 'pidfd_open') and hasattr(signal, 'pidfd_send_signal'), 'pidfd_required') + before = inspect(config) + pid = before['target']['pid'] + require(pid != os.getpid() and pid != config['root']['pid'], 'not_worker') + fd = os.pidfd_open(pid) + try: + require(inspect(config) == before, 'identity_changed_after_pidfd') + fields = dict(line.split(':', 1) for line in Path(f'/proc/self/fdinfo/{fd}').read_text().splitlines() if ':' in line) + require(int(fields.get('Pid', '-1').strip()) == pid, 'pidfd_retired') + signal.pidfd_send_signal(fd, signal.SIGKILL, None, 0) + return {'schema': 'paperclip.e2e.pi-provider-death.v1', 'binding': config['binding'], 'runtimeEnvironmentLeaseId': config['runtimeEnvironmentLeaseId'], + **before, 'signalled': True, 'signal': 'SIGKILL', 'targetKind': 'pi_native_child', 'workerSignalled': False} + finally: + os.close(fd) + + +if __name__ == '__main__': + try: + require(len(sys.argv) == 2 and len(sys.argv[1]) <= 8192, 'input_bound') + print(json.dumps(terminate(json.loads(base64.b64decode(sys.argv[1], validate=True))), separators=(',', ':'))) + except Exception: + # Never print process arguments, filesystem paths, or inherited context. + print(json.dumps({'ok': False, 'code': 'pi_provider_identity_or_signal_failed'})) + sys.exit(1) diff --git a/tests/runner-e2e/pi-provider-fault.test.py b/tests/runner-e2e/pi-provider-fault.test.py new file mode 100644 index 0000000000..281c78cbc4 --- /dev/null +++ b/tests/runner-e2e/pi-provider-fault.test.py @@ -0,0 +1,342 @@ +"""Credential-free admission tests; real pidfd calibration runs only on Linux.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace +import unittest +import select +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('pi_fault', Path(__file__).with_name('pi-provider-fault.py')) +fault = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fault) +BOOT = '12345678-1234-1234-1234-123456789abc' +ROOT = {'pid': 21, 'ppid': 1, 'startTicks': '100', 'bootId': BOOT} +PARENT = {'pid': 22, 'ppid': 21, 'startTicks': '101', 'bootId': BOOT} +TARGET = {'pid': 23, 'ppid': 22, 'startTicks': '102', 'bootId': BOOT} +DIST = '/tmp/paperclip-acpx-native-fixture/distribution' +RUNTIME = '/workspace/.paperclip-runtime/paperclip-runner' +CANCELLED = False + +def check_cancelled(): + if CANCELLED: raise KeyboardInterrupt('calibration 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)) + 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(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), + patch.object(os, 'readlink', return_value='/workspace'), patch.object(os, 'stat', side_effect=lambda p: SimpleNamespace(st_dev=1, st_ino=90 if p == '/proc/21/exe' else 100))] + for p in self.patches: p.start(); self.addCleanup(p.stop) + + def test_parent_attests_title_overwritten_child(self): + receipt = fault.inspect(self.config) + self.assertEqual(receipt['target'], TARGET) + self.assertEqual(receipt['entrypointAttribution'], 'pinned_wrapper_parent') + 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}) + + def test_wrong_title_is_not_a_generic_node_selector(self): + self.args[23] = ['node'] + with self.assertRaisesRegex(RuntimeError, 'pi_process_title'): fault.inspect(self.config) + + def test_foreign_parent_and_changed_wrapper_are_rejected(self): + self.args[22][3] = '/foreign/pi-entry.cjs' + with self.assertRaisesRegex(RuntimeError, 'unique_pi_child'): fault.inspect(self.config) + self.args[22][3] = DIST + '/pi-entry.cjs' + self.files[DIST + '/node_modules/pi-acp/dist/index.js'] = (b'changed', (1, 104)) + with self.assertRaisesRegex(RuntimeError, 'snapshot_digest'): fault.inspect(self.config) + + def test_foreign_root_reused_pid_and_wrong_cwd_are_rejected(self): + for mutation in [{'startTicks': '999'}, {'bootId': 'foreign'}, {'ppid': 99}]: + with self.subTest(mutation=mutation), self.assertRaises(RuntimeError): fault.inspect({**self.config, 'root': {**ROOT, **mutation}}) + with patch.object(os, 'readlink', return_value='/foreign'), self.assertRaisesRegex(RuntimeError, 'cwd'): fault.inspect(self.config) + + def test_ambiguous_direct_children_are_rejected(self): + self.table[24] = {**TARGET, 'pid': 24}; self.args[24] = ['pi'] + 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} + for second, fdinfo in [({'target': {**TARGET, 'startTicks': '999'}}, 'Pid:\t23\n'), (before, 'Pid:\t-1\n')]: + with patch.object(sys, 'platform', 'linux'), patch.object(fault, 'inspect', side_effect=[before, second]), \ + patch.object(os, 'pidfd_open', return_value=42, create=True), patch.object(signal, 'pidfd_send_signal', create=True) as sent, \ + patch.object(os, 'close') as closed, patch.object(Path, 'read_text', return_value=fdinfo): + with self.assertRaises(RuntimeError): fault.terminate({'root': ROOT}) + sent.assert_not_called(); closed.assert_called_once_with(42) + + def test_symlink_and_mutable_snapshot_file_fail(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp).resolve() / 'data'; path.write_text('data') + link = Path(tmp) / 'link'; link.symlink_to(path) + with self.assertRaises(RuntimeError): fault.checked_file(str(link), 100, True) + with self.assertRaises(RuntimeError): fault.checked_file(str(path), 100, True) + path.chmod(0o400) + self.assertEqual(fault.checked_file(str(path), 100, True)[0], b'data') + + @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')) + distribution = snapshot / 'distribution'; workspace = base / 'workspace'; workspace.mkdir() + runtime = workspace / '.paperclip-runtime/paperclip-runner'; (runtime / 'bin').mkdir(parents=True) + pack = base / 'pack'; (pack / 'provider-assets/pi/linux-x64').mkdir(parents=True) + root_child = None + owned_fds = [] + try: + 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) + 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 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) + 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)) + except Exception: + # Direct Popen child has not been polled/reaped, so its numeric + # identity cannot have been reused before this emergency stop. + root_child.terminate(); root_child.wait(timeout=3) + raise + check_cancelled() + deadline = time.monotonic() + 5 + while not ready.exists() and time.monotonic() < deadline: + check_cancelled(); time.sleep(.02) + 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(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) + for identity in admitted['ancestry'][:-1]: + fd = os.pidfd_open(identity['pid']) + try: + fields = dict(line.split(':', 1) for line in Path(f'/proc/self/fdinfo/{fd}').read_text().splitlines() if ':' in line) + self.assertEqual(int(fields.get('Pid', '-1').strip()), identity['pid']) + self.assertEqual(fault.proc(identity['pid'], boot), identity) + self.assertEqual(fault.inspect(config), admitted) + except BaseException: + os.close(fd); raise + owned_fds.append(fd) + check_cancelled() + check_cancelled() + receipt = fault.terminate(config) + self.assertEqual(receipt['target']['pid'], target); self.assertTrue(receipt['signalled']); self.assertEqual(select.select([owned_fds[0]], [], [], 0)[0], []) + deadline = time.monotonic() + 3 + while not exited.exists() and time.monotonic() < deadline: + check_cancelled(); time.sleep(.02) + self.assertEqual(json.loads(exited.read_text()), {'code': None, 'signal': 'SIGKILL'}) + finally: + try: + if root_child is not None and owned_fds: + # A pidfd never retargets a recycled numeric process group. + # Before readiness the synthetic root owns a SIGTERM cascade. + try: signal.pidfd_send_signal(owned_fds[0], signal.SIGTERM, None, 0) + except ProcessLookupError: pass + try: root_child.wait(timeout=3) + except subprocess.TimeoutExpired: + for fd in reversed(owned_fds): + try: signal.pidfd_send_signal(fd, signal.SIGKILL, None, 0) + except ProcessLookupError: pass + root_child.wait(timeout=3) + # Also retire individually pinned descendants if root exited + # unexpectedly. Readable pidfds identify already-dead children. + for fd in reversed(owned_fds[1:]): + if not select.select([fd], [], [], 0)[0]: + try: signal.pidfd_send_signal(fd, signal.SIGKILL, None, 0) + except ProcessLookupError: pass + self.assertTrue(select.select([fd], [], [], 3)[0], 'owned descendant must retire') + finally: + for fd in owned_fds: os.close(fd) + for directory, dirs, files in os.walk(snapshot): os.chmod(directory, 0o700) + shutil.rmtree(snapshot); shutil.rmtree(base) + + +if __name__ == '__main__': + def cancelled(_signum, _frame): + # Flag only: never interrupt Popen or pidfd ownership publication. + global CANCELLED + CANCELLED = True + signal.signal(signal.SIGTERM, cancelled) + signal.signal(signal.SIGINT, cancelled) + unittest.main() diff --git a/tests/runner-e2e/pi-provider-fault.test.ts b/tests/runner-e2e/pi-provider-fault.test.ts new file mode 100644 index 0000000000..4c059c2cf5 --- /dev/null +++ b/tests/runner-e2e/pi-provider-fault.test.ts @@ -0,0 +1,22 @@ +import { spawnSync } from "node:child_process"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +it("calibrates the exact Pi fault helper with metadata negatives and native Linux pidfd", () => { + // Linux exercises a real, title-changing, test-owned Node child. macOS runs + // every metadata negative and explicitly skips that Linux-only process test. + // Never inherit provider credentials or an ambient Python module path. + const result = spawnSync("/usr/bin/python3", ["-B", fileURLToPath(new URL("./pi-provider-fault.test.py", import.meta.url))], { + env: { PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, PYTHONDONTWRITEBYTECODE: "1" }, + cwd: dirname(fileURLToPath(import.meta.url)), timeout: 25_000, killSignal: "SIGTERM", maxBuffer: 64 * 1024, + encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + }); + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("Ran 21 tests"); + if (process.platform === "linux") expect(result.stderr).not.toContain("skipped"); + 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 88d1c16bca..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> = []; @@ -601,6 +607,7 @@ for (const execution of executions) { let runtimeLeases: EnvironmentLeaseRecord[] = []; let matcherResults: MatcherResult[] = []; let downloadedResponseProof: Awaited> | undefined; + let piFileSeed: Awaited> | undefined; const completionQuality: CompletionQualityRecord[] = []; const completionEvidence = async (name: string, data: unknown) => { await writeSanitizedJson(snapshotsDir, name, data, secrets); @@ -877,6 +884,11 @@ for (const execution of executions) { }); expect(experimental.enableNativeRunner).toBe(true); + if (execution.suite.id === "extended-harnesses" && execution.profile.qualificationCandidate === "pi" && execution.task.id === "file-edit-validate") { + piFileSeed = await seedPiFile(workspacePath, nonce); + await writeSanitizedJson(snapshotsDir, "pi-file-seed.json", piFileSeed, secrets); + } + if (execution.suite.id === "daytona-git-streaming") { await setupGitStreamingWorkspace(workspacePath); } @@ -903,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, @@ -1012,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, @@ -1040,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, @@ -2196,6 +2238,15 @@ for (const execution of executions) { await writeSanitizedJson(snapshotsDir, "api-response-pagination.json", paging, secrets); if (!paging.passed) invariantFailures.push(`Bounded response paging was not proven: ${paging.failure}`); } + if (piFileSeed) { + const proof = await collectPiFileEvidence({ page, api, nonce, companyId: fixtures.company.id, + issueId: issue.id, agentId: fixtures.agent.id, run: finalRun, + events: runEventsByRun.find(entry => entry.runId === finalRun.id)?.events ?? [], + seed: piFileSeed, workspace: workspacePath, + environment: execution.environment.id === "daytona" ? "daytona" : "local", environmentId: fixtures.environment.id, + evidence: data => writeSanitizedJson(snapshotsDir, "pi-file-observation.json", data, secrets) }); + await writeSanitizedJson(snapshotsDir, "pi-file-evidence.json", proof, secrets); + } if (execution.suite.id === "task-titles") { const titleEvidence = gradeTaskTitle({ initial: titleCreation?.issue, submitted: titleCreation?.submitted, 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..eb5be53b44 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,16 @@ 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, inspectOrDot?: ((container: HTMLElement) => void) | boolean): Promise { + const openAiDotEnabled = inspectOrDot === true; + const inspect = typeof inspectOrDot === "function" ? inspectOrDot : undefined; return renderMarkup( , expand?: string, op openAiDotEnabled={openAiDotEnabled} /> , - expand, + expand, inspect, ); } @@ -96,7 +99,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 +108,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 c0e7b6f985..6147d5f89c 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -232,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"); @@ -273,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") === "pi" && ( + + + + )} {runnerManaged && runnerProvider === "claude_managed" && ( <> { 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.test.ts b/ui/src/adapters/paperclip-runner/index.test.ts index e4c7354826..df5fdfd9f5 100644 --- a/ui/src/adapters/paperclip-runner/index.test.ts +++ b/ui/src/adapters/paperclip-runner/index.test.ts @@ -1,32 +1,93 @@ import { describe, expect, it } from "vitest"; import { paperclipRunnerUIAdapter } from "./index"; +import { createPiProfileExtensionAdapter, PI_NOTICE_METHOD } from "../../../../packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter"; +import { transcriptToTaskChatItems } from "../../components/task-chat/transcript-adapter"; + +const piFailure = (overrides: Record = {}, eventOverrides: Record = {}) => ({ + type: "paperclip.prp.event", + event: { + runId: "run-1", turnId: "turn-1", normalizedSessionId: "session-1", + eventType: "provider.notice.recorded", + payload: { + schema: "paperclip.provider.notice.v1", noticeId: "notice-1", category: "pi.runtime_failure", + severity: "error", summary: "Pi process exited with code 4", scope: "session", + recoverable: false, userActionable: false, + details: [{ name: "source.method", value: PI_NOTICE_METHOD }, + { name: "source.nativeEvent", value: "runtime_failure" }, { name: "source.sessionId", value: "session-1" }, + { name: "reason", value: "native_process_exited" }], + ...overrides, + }, + ...eventOverrides, + }, +}); describe("paperclip runner transcript projection", () => { - it("omits unrelated provider diagnostics and legacy notices from streaming chat", () => { + it("keeps both pinned Pi failure facts while rendering one consecutive diagnostic", async () => { + const adapter = createPiProfileExtensionAdapter({ workspacePath: "/fixture", sessionId: "session-1", turnId: "turn-1" }); + const notice = { sessionId: "session-1", category: "runtime_failure", severity: "error", + summary: "Pi process exited with code 4", details: { reason: "native_process_exited" } }; + const raw = [...await adapter.notification(PI_NOTICE_METHOD, notice), + ...await adapter.notification(PI_NOTICE_METHOD, structuredClone(notice))]; + expect(raw).toHaveLength(2); + expect(raw[0]!.payload.noticeId).not.toBe(raw[1]!.payload.noticeId); + const before = structuredClone(raw); const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; - const event = (eventType: string, payload: Record) => parse(JSON.stringify({ - type: "paperclip.prp.event", - event: { eventType, payload }, - }), "2026-10-02T12:00:00.000Z"); - const legacyNotice = { - schema: "paperclip.provider.notice.v1", - severity: "warning", - category: "warning", - summary: "ignored unrelated provider information", - userActionable: true, - }; - expect(event("harness.diagnostic", { code: "codex_unrelated_information" })).toEqual([]); - expect(event("provider.notice.recorded", legacyNotice)).toEqual([]); - expect(event("provider.notice.recorded", { ...legacyNotice, summary: "Repository is not trusted" })) - .toEqual([expect.objectContaining({ family: "provider_notice" })]); - expect(event("provider.notice.recorded", { ...legacyNotice, severity: "error" })) - .toEqual([expect.objectContaining({ family: "provider_notice" })]); - expect(event("provider.notice.recorded", { ...legacyNotice, category: "configWarning" })) - .toEqual([expect.objectContaining({ family: "provider_notice" })]); - expect(event("harness.diagnostic", { code: "provider_identity_failure", message: "Thread mismatch" })) - .toEqual([expect.objectContaining({ kind: "system", text: "Runner: Thread mismatch" })]); - expect(event("item.completed", { kind: "agentMessage", channel: "final", text: "Here is the answer." })) - .toEqual([expect.objectContaining({ kind: "assistant", text: "Here is the answer." })]); + const entries = raw.flatMap(event => parse(JSON.stringify({ type: "paperclip.prp.event", event: { + runId: "run-1", normalizedSessionId: "session-1", turnId: "turn-1", ...event, + } }), "2026-10-03T07:00:00.000Z")); + expect(entries).toHaveLength(1); + expect(transcriptToTaskChatItems(entries, { runId: "run-1", agentName: "Pi", running: false })) + .toEqual([expect.objectContaining({ kind: "protocol", family: "provider_notice", + details: expect.arrayContaining([{ label: "Severity", value: "error", mono: false }]) })]); + expect(raw).toEqual(before); + }); + + it("preserves different failure details, severity, authority and run/turn/session scope", () => { + const changed = [ + piFailure({ summary: "Provider request failed" }), + piFailure({ severity: "warning" }), + piFailure({ recoverable: true }), + piFailure({ userActionable: true }), + piFailure({ details: piFailure().event.payload.details.map(detail => detail.name === "reason" + ? { ...detail, value: "provider_http_429" } : detail) }), + piFailure({ details: piFailure().event.payload.details.map(detail => detail.name === "source.sessionId" + ? { ...detail, value: "session-2" } : detail) }), + piFailure({}, { runId: "run-2" }), + piFailure({}, { turnId: "turn-2" }), + piFailure({}, { normalizedSessionId: "session-2" }), + piFailure({}, { runId: undefined }), + piFailure({}, { turnId: undefined }), + ]; + for (const event of changed) { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + expect(parse(JSON.stringify(piFailure()), "first")).toHaveLength(1); + expect(parse(JSON.stringify(event), "second")).toHaveLength(1); + } + }); + + it("starts a new diagnostic after retry activity, another event, or parser reset", () => { + const parser = paperclipRunnerUIAdapter.createStdoutParser!(); + const parse = (event: unknown) => parser.parseLine(JSON.stringify(event), "2026-10-03T07:00:00.000Z"); + expect(parse(piFailure())).toHaveLength(1); + expect(parse(piFailure({ noticeId: "notice-2" }))).toEqual([]); + for (const intervening of [piFailure({ category: "pi.auto_retry_start" }), + piFailure({}, { eventType: "item.delta", payload: { kind: "agentMessage", text: "Retrying" } }), + { type: "other" }]) { + parse(intervening); + expect(parse(piFailure())).toHaveLength(1); + } + parser.reset(); + expect(parse(piFailure())).toHaveLength(1); + }); + + it("never coalesces other providers or unscoped/unsupported notices", () => { + for (const event of [piFailure({ category: "other.runtime_failure" }), + piFailure({ details: [] }), piFailure({}, { normalizedSessionId: undefined }), + piFailure({}, { runId: undefined }), piFailure({}, { turnId: undefined })]) { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + expect(parse(JSON.stringify(event), "first")).toHaveLength(1); + expect(parse(JSON.stringify(event), "second")).toHaveLength(1); + } }); it("renders committed PRP semantic tool items with the existing chat parts", () => { diff --git a/ui/src/adapters/paperclip-runner/index.ts b/ui/src/adapters/paperclip-runner/index.ts index ad1f8ef6c5..d7ebfbb2cb 100644 --- a/ui/src/adapters/paperclip-runner/index.ts +++ b/ui/src/adapters/paperclip-runner/index.ts @@ -14,6 +14,7 @@ interface PaperclipRunnerParserState { itemChannels: Map; structuredFinalItemIds: Set; runtimeRequests: Map>; + previousPiRuntimeFailure: string | null; } function itemChannel(payload: JsonRecord): "progress" | "final" | "summary" | "detail" | "unknown" { @@ -665,6 +666,22 @@ function semanticToolEntries(eventType: string, payload: JsonRecord, ts: string) }]; } +function piRuntimeFailureKey(event: JsonRecord, payload: JsonRecord): string | null { + // The frozen Pi projection emits both process-exit and prompt-rejection + // facts. Coalesce their identical consecutive display rows, retaining the + // original PRP log and requiring the complete run/turn/session binding. + if (event.eventType !== "provider.notice.recorded" + || payload.schema !== "paperclip.provider.notice.v1" + || payload.category !== "pi.runtime_failure" + || !text(event.runId) || !text(event.turnId) || !text(event.normalizedSessionId)) return null; + const details = Array.isArray(payload.details) ? payload.details.map(record) : []; + if (!details.some(detail => detail.name === "source.method" && detail.value === "paperclip/pi_notice") + || !details.some(detail => detail.name === "source.nativeEvent" && detail.value === "runtime_failure") + || !details.some(detail => detail.name === "source.sessionId" && text(detail.value))) return null; + const { noticeId: _noticeId, ...notice } = payload; + return JSON.stringify([event.runId, event.turnId, event.normalizedSessionId, notice]); +} + function parsePrpEvent( event: JsonRecord, ts: string, @@ -673,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" @@ -770,6 +790,7 @@ function createParserState(): PaperclipRunnerParserState { itemChannels: new Map(), structuredFinalItemIds: new Set(), runtimeRequests: new Map(), + previousPiRuntimeFailure: null, }; } @@ -778,12 +799,18 @@ function parsePaperclipRunnerLine(line: string, ts: string, state: PaperclipRunn try { parsed = JSON.parse(line); } catch { + state.previousPiRuntimeFailure = null; return parseCodexStdoutLine(line, ts); } const envelope = record(parsed); - if (envelope.type !== "paperclip.prp.event") return parseCodexStdoutLine(line, ts); + if (envelope.type !== "paperclip.prp.event") { + state.previousPiRuntimeFailure = null; + return parseCodexStdoutLine(line, ts); + } const event = record(envelope.event); - return Object.keys(event).length > 0 ? parsePrpEvent(event, ts, state) : []; + if (Object.keys(event).length > 0) return parsePrpEvent(event, ts, state); + state.previousPiRuntimeFailure = null; + return []; } export function parsePaperclipRunnerStdoutLine(line: string, ts: string): TranscriptEntry[] { diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index b7f33fcd1b..2dd3d1ef26 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -63,6 +63,15 @@ const connectionIntentsApiMocks = vi.hoisted(() => ({ decline: vi.fn(), })); +const routerMocks = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), + readSearchParams: vi.fn(), + setSearchParams: vi.fn(), + navigate: vi.fn(), + setBreadcrumbs: vi.fn(), + pushToast: vi.fn(), +})); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; async function act(callback: () => void | Promise) { @@ -80,13 +89,38 @@ async function act(callback: () => void | Promise) { } vi.mock("@/lib/router", () => ({ + useParams: () => ({}), + useSearchParams: () => { + routerMocks.readSearchParams(); + return [routerMocks.searchParams, routerMocks.setSearchParams]; + }, + useNavigate: () => routerMocks.navigate, Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => ( {children} ), })); +vi.mock("@/context/CompanyContext", async (importOriginal) => ({ + ...await importOriginal(), + useCompany: () => ({ selectedCompanyId: issueThreadInteractionFixtureMeta.companyId, selectedCompany: null }), +})); +vi.mock("@/context/BreadcrumbContext", async (importOriginal) => ({ + ...await importOriginal(), + useBreadcrumbs: () => ({ setBreadcrumbs: routerMocks.setBreadcrumbs }), +})); +vi.mock("@/context/ToastContext", async (importOriginal) => ({ + ...await importOriginal(), + useToast: () => ({ pushToast: routerMocks.pushToast }), +})); + vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: connectionIntentsApiMocks })); +vi.mock("./task-chat/TaskChatRichInput", () => ({ + TaskChatRichInput: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( +