diff --git a/.dockerignore b/.dockerignore index 3fca173d3a0..23085f962f9 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 c1e2377a391..dcfbfc6df40 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 b259714bb75..4ceba5dd646 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/scripts/tests/release-runner-cache.test.mjs b/.github/scripts/tests/release-runner-cache.test.mjs index 96bba1f3250..b6f478122e6 100644 --- a/.github/scripts/tests/release-runner-cache.test.mjs +++ b/.github/scripts/tests/release-runner-cache.test.mjs @@ -38,14 +38,19 @@ test("parallel lanes cover check:all exactly once and never bypass verification" const scripts = JSON.parse(readFileSync(new URL("../../../packages/paperclip-runner/package.json", import.meta.url))).scripts; const checks = [...runner.matchAll(/^ checks: (.+)$/gm)].flatMap(([, value]) => value.split(" ")); assert.deepEqual(checks, scripts["check:all"].split(" && ").map((command) => command.replace(/^pnpm run /, ""))); - assert.deepEqual([...runner.matchAll(/^ - lane: (.+)$/gm)].map(([, value]) => value), ["protocol", "rust"]); + assert.deepEqual([...runner.matchAll(/^ - lane: (.+)$/gm)].map(([, value]) => value), ["protocol", "rust", "server-integration"]); assert.match(runner, /fail-fast: false/); assert.doesNotMatch(runner, /max-parallel: 1|^ needs:|continue-on-error:/m); const verify = runner.split(" - name: Verify Paperclip Runner\n")[1].split(" - name: Warm debug")[0]; assert.match(verify, /RUNNER_CHECKS: \$\{\{ matrix.checks \}\}/); assert.match(verify, /set -euo pipefail/); assert.match(verify, /for check in \$RUNNER_CHECKS; do\s+pnpm --filter @paperclipai\/paperclip-runner "\$check"\s+done/); - assert.doesNotMatch(verify, /if:|cache-hit/); + const verifyIf = verify.match(/^\s*if: \$\{\{ (.+) \}\}$/m)?.[1]; + assert.equal(verifyIf, "matrix.lane != 'server-integration'"); + for (const lane of ["protocol", "rust", "server-integration"]) { + assert.equal(runInNewContext(verifyIf, { matrix: { lane } }), lane !== "server-integration"); + } + assert.doesNotMatch(verify, /cache-hit/); assert.doesNotMatch(runner, /id-token: write|packages: write|secrets: inherit/); }); @@ -57,7 +62,7 @@ test("only the trusted Rust lane writes, and warms both build profiles before sa assert.match(warm, /run: pnpm --filter @paperclipai\/paperclip-runner build:rust/); const sha = "a".repeat(40); const base = { repository: "paperclipai/paperclip", event_name: "push", ref: "refs/heads/master", sha }; - for (const lane of ["protocol", "rust"]) { + for (const lane of ["protocol", "rust", "server-integration"]) { for (const [overrides, ref, trusted] of [ [{}, sha, true], [{ event_name: "pull_request", ref: "refs/pull/1/merge" }, sha, false], diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index 39d148a7925..1da502befda 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -7,20 +7,24 @@ on: description: "Branch in this repository to build; resolved to one immutable commit before checkout" type: string required: false + build_eval_image: + description: "Build an immutable qualification image as an inspectable OCI artifact without publication" + type: boolean + default: 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 +102,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 @@ -136,7 +141,11 @@ jobs: package-manager-cache: false - name: Resolve source dependencies without lifecycle scripts run: | - pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + if [[ "${{ inputs.build_eval_image || inputs.publish_eval_image }}" == true ]]; then + pnpm install --frozen-lockfile --ignore-scripts + else + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + fi mkdir -p remote-verification sha256sum pnpm-lock.yaml > remote-verification/lock.sha256 git rev-parse HEAD > remote-verification/source.txt @@ -147,31 +156,49 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build checksum-verified Grok provider image - if: inputs.publish_eval_image + - name: Prepare hosted image build capacity + if: inputs.build_eval_image || inputs.publish_eval_image + run: | + # These preinstalled SDKs are absent from the image build context. + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc + df -h . + test "$(df --output=avail -BG . | tail -1 | tr -dc '0-9')" -ge 35 + - name: Build checksum-verified qualification image + if: inputs.build_eval_image || inputs.publish_eval_image env: SOURCE_SHA: ${{ needs.authorize_manual.outputs.target_sha }} run: | set -euo pipefail image="ghcr.io/paperclipai/paperclip-daytona-runner:qualification-${SOURCE_SHA}-${GITHUB_RUN_ID}" lock_sha="$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" + content_id="$(node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/daytona-image-content.ts)" + [[ "$content_id" =~ ^[0-9a-f]{64}$ ]] + printf '%s\n' "$content_id" > remote-verification/content-id.txt + output_args=(--output type=oci,dest=remote-verification/image.oci.tar) + if [[ "${{ inputs.publish_eval_image }}" == true ]]; then + output_args=(--push) + fi docker buildx build --platform linux/amd64 \ --file docker/daytona-runner/Dockerfile \ --build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=$SOURCE_SHA" \ - --build-arg "PAPERCLIP_RUNNER_CONTENT_ID=qualification-$SOURCE_SHA" \ + --build-arg "PAPERCLIP_RUNNER_CONTENT_ID=$content_id" \ --build-arg "PAPERCLIP_RUNNER_LOCK_SHA256=$lock_sha" \ --cache-from type=registry,ref=ghcr.io/paperclipai/paperclip-daytona-runner:e2e-buildcache-amd64 \ - --tag "$image" --metadata-file remote-verification/image.json --push . + --tag "$image" --metadata-file remote-verification/image.json "${output_args[@]}" . digest="$(jq -r '."containerimage.digest"' remote-verification/image.json)" [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] immutable="${image%:*}@$digest" - docker logout ghcr.io - docker buildx imagetools inspect "$immutable" >/dev/null + if [[ "${{ inputs.publish_eval_image }}" == true ]]; then + docker logout ghcr.io + docker buildx imagetools inspect "$immutable" >/dev/null + else + sha256sum remote-verification/image.oci.tar > remote-verification/image-archive.sha256 + fi echo "$immutable" > remote-verification/image.txt - echo "Image: $immutable" >> "$GITHUB_STEP_SUMMARY" - - name: Verify repository on EC2 + echo "Image: $immutable; published: ${{ inputs.publish_eval_image }}" >> "$GITHUB_STEP_SUMMARY" + - 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 +220,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: | @@ -210,4 +239,6 @@ jobs: with: name: grok-remote-verification-${{ github.run_id }} path: remote-verification/ + compression-level: 0 + retention-days: 1 retention-days: 7 diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index b87e5115b9c..9b1d175b370 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -384,7 +384,7 @@ jobs: echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT" - name: Restore pnpm store (read only) - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -609,7 +609,7 @@ jobs: echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT" - name: Restore pnpm store (read only) - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -767,7 +767,7 @@ jobs: echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT" - name: Restore pnpm store (read only) - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -905,7 +905,7 @@ jobs: echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT" - name: Restore pnpm store (read only) - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -1078,7 +1078,7 @@ jobs: echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT" - name: Restore pnpm store (read only) - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -1137,7 +1137,7 @@ jobs: echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT" - name: Restore pnpm store (read only) - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -1327,7 +1327,7 @@ jobs: echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT" - name: Restore pnpm store (read only) - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 76e5cd28bb3..492b7e62b47 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -107,45 +107,46 @@ jobs: matrix: include: # Split the long chat file by collected test locations, and balance - # the remaining server files across ten runners. Normal PR/local - # invocations retain their complete general-server group. - - group: general-server-without-chat + # the remaining server files across ten runners. Rust-backed server + # suites run in the cached Runner lane below. Local invocations retain + # their complete general-server group. + - group: general-server-without-chat-or-native-runner group_label: server (1/10) shard_index: 0 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (2/10) shard_index: 1 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (3/10) shard_index: 2 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (4/10) shard_index: 3 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (5/10) shard_index: 4 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (6/10) shard_index: 5 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (7/10) shard_index: 6 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (8/10) shard_index: 7 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (9/10) shard_index: 8 shard_count: 10 - - group: general-server-without-chat + - group: general-server-without-chat-or-native-runner group_label: server (10/10) shard_index: 9 shard_count: 10 @@ -297,6 +298,7 @@ jobs: checks: check:eval-kernel check:protocol - lane: rust checks: check:runner check:api-authority + - lane: server-integration steps: - name: Checkout repository @@ -373,14 +375,28 @@ jobs: # dependencies; never restore installed executables from cargo/bin. cache-workspace-crates: false cache-bin: false - # Both lanes restore the existing dependency cache. Only the Rust + # All lanes restore the existing dependency cache. Only the Rust # lane saves it, after warming both release and debug dependencies. save-if: ${{ matrix.lane == 'rust' && github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }} - name: Install dependencies run: pnpm install --no-frozen-lockfile + - name: Build native server test binaries + if: ${{ matrix.lane == 'server-integration' }} + timeout-minutes: 10 + working-directory: packages/paperclip-runner + run: | + set -euo pipefail + pnpm build:rust + cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd --bin fake-codex-app-server + + - name: Run native server integration suites + if: ${{ matrix.lane == 'server-integration' }} + run: pnpm test:run:general -- --group general-server-native-runner + - name: Verify Paperclip Runner + if: ${{ matrix.lane != 'server-integration' }} env: RUNNER_CHECKS: ${{ matrix.checks }} run: | diff --git a/.gitignore b/.gitignore index 870bf56d8b2..84a602b84a0 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ test-results/ # Copilot assets are generated from pinned platform packages during installation. /packages/paperclip-runner/provider-assets/ /server/provider-assets/ + +# Local native Runner test/session state +.paperclip-runner-prp/ diff --git a/Dockerfile b/Dockerfile index 1db10902f67..2f4e30ffdf0 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 00000000000..eeed2d26202 --- /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 6592bf7d6ac..e4e57e09bfa 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 5afdc54be8e..ee9b1afdc46 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 022b19d5b78..cf3450cfc2d 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/DEVELOPING.md b/doc/DEVELOPING.md index 53ea15d19ac..318fe321025 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -174,6 +174,16 @@ checkout, including a non-primary checkout. Changing projects clears the previous worktree choice. Switching from reuse to a new worktree or the project workspace restores the project default checkout. Task creation uses the selected company and has no separate heading or settings control. + +Task execution-policy controls apply the shared schema defaults when stages or +participants are omitted. An invalid policy shows an unavailable notice and +disables policy edits while leaving other task properties usable. Refresh to +retry. Optional browser error monitoring reports only a fixed field category, +once per mounted control; it never includes the policy or task identifiers. +Edits in the reviewer and monitor controls retain an explicitly configured +review-round limit when the last reviewer or monitor is removed, so adding a +reviewer later uses the saved limit. + Fresh tasks start with an empty request and the last task assignee chosen in that company, including a human. If that assignee is unavailable, the CEO is the default, or the first eligible agent when the company has no CEO. Explicit launch @@ -1226,6 +1236,51 @@ all service instances share in-flight materialization. Existing public helpers and the workspace validation error class remain available from `heartbeat.ts`. Keep workspace policy changes separate from scheduling and run execution changes. +Run preparation is in `server/src/services/heartbeat/run-preparation.ts`. It owns +issue and wake context, responsible-user resolution, routine environment snapshots, +skill mentions, adapter environment configuration, and MCP/tool access setup. +`createHeartbeatRunPreparation(db)` binds the context loaders to a service's database +without doing database work during construction. `heartbeat.ts` composes queue +dispatch with cancellation and execution, and re-exports the existing +public helpers and configuration-incomplete error class. Keep preparation policy +changes in this module and its tests. + +Run retrieval and session state are in `server/src/services/heartbeat/run-state.ts`. +It owns bounded run projections, database encoding checks, task session reads and +writes, explicit resumes, session compaction, and usage/billing helpers. +`createHeartbeatRunState(db)` binds these operations without doing database work +during construction. The encoding-check cache belongs to each factory instance. +`heartbeat.ts` keeps run execution, session-goal recovery, cost accounting writes, +and status transitions, and re-exports the existing public helpers. Keep session +policy changes separate from run orchestration changes. + +Retry scheduling is in `server/src/services/heartbeat/retries.ts`. It owns bounded +retry schedules, connection and workspace contention deferrals, shared-workspace +holder checks, due retry promotion, and retry-now requests. `createHeartbeatRetries` +binds these operations to the service database and explicit lifecycle callbacks +without doing work during construction. `heartbeat.ts` supplies status writes, +run events, issue-lock release, plan-resume reporting, and worktree cutoffs. It +re-exports the existing retry helpers and workspace-busy error class. Keep retry +policy changes separate from this extraction. + +Restart recovery and lease cleanup are in `server/src/services/heartbeat/recovery.ts`. +It owns hot-restart snapshots and adoption, native restart recovery, shutdown +draining, orphaned-run reaping, and active/pending-cleanup lease sweeps. +`createHeartbeatRecovery` binds the service database and explicit lifecycle +callbacks without starting work. The service supplies its shutdown flag callback +and shared execution sets so separate service instances keep the same ownership +and shutdown barriers. Cleanup single-flight state stays at module scope. +Keep recovery policy changes separate from retry scheduling and execution changes. + +Queue admission and wakeup dispatch are in `server/src/services/heartbeat/queue.ts`. +It owns wake coalescing and batching, queued-run claims, daily heartbeat caps, +concurrency and priority checks, timer admission, and native status wake intents. +`createHeartbeatQueue` binds these operations without querying or starting work. +The service supplies lifecycle effects, execution callbacks, worktree gates, and +its process-wide execution and wakeup promise sets. Forwarding callbacks preserve +construction order for retry and recovery services. Keep queue policy changes +separate from this extraction and from adapter execution changes. + ## Wake Context Delivery Built-in adapters deliver wake context through the run prompt, including structured @@ -1641,6 +1696,9 @@ Environment overrides: - `PAPERCLIP_DB_BACKUP_ENABLED=true|false` - `PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES=` +- `PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED=1` enables verified final backups + for owned idle sleep, with restart catch-up. Off by default; see + [idle sleep safety](idle-sleep-safety.md) for the hosting and storage contract. - `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=` - `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path` - `PAPERCLIP_DB_BACKUP_MAX_AGE_HOURS=` controls the `/api/health` diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index df952bf5250..f9f5bb5e0d9 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1115,6 +1115,7 @@ Core authorization follows these rules: - A user may set inbox-agent policy to `disabled` or `allowlist`. Policy restrictions override the default-open path, and low-trust agents are denied. - An agent targeting any user other than its resolved responsible user requires either a materialized target-user policy that permits that agent (`open` or matching `allowlist`) or an explicit `inbox:manage` grant. The implicit default-open policy for a missing row remains responsible-user-only, so it never becomes a blanket cross-user grant. Grants may be unscoped or constrained by `scope.userIds` and act as administrative overrides, including over a disabled target-user policy. - Archive and unarchive operations are company-scoped, reversible, and activity logged with actor, agent, run, target user, target-resolution source, and policy mode. +- Concurrent archives keep the newest archive time and its actor attribution. An earlier request must not undo an archive written by human completion while it waited. Unarchive removes that state so a later archive starts fresh. - New qualifying issue activity may invalidate an archive so the item resurfaces; archival is not a substitute for resolving or closing work. - Viewing an issue may update its per-user read receipt, but read receipts alone do not enroll the issue in Mine. Mine participation begins with a user-authored comment, issue creation/assignment, or another audited user mutation; explicit product actions such as manually running a routine may record an audited inbox touch. diff --git a/doc/agent-files.md b/doc/agent-files.md index f037cb25515..d0fce516eb9 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 00000000000..ceddfb1b7d8 --- /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 00000000000..7876c5cba45 --- /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 54467eaa033..c1334de585b 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 00000000000..6c628f84035 --- /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 00000000000..c7eb43bbf28 --- /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 00000000000..bfee2d157da --- /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 00000000000..9cc691344bc --- /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/cloud-build-readiness.md b/doc/cloud-build-readiness.md index df143f11eca..18611b833b0 100644 --- a/doc/cloud-build-readiness.md +++ b/doc/cloud-build-readiness.md @@ -33,9 +33,14 @@ test jobs on standard runners. Measure queue time to assess the timing gain. Release verification spreads the general server suites across ten standard hosted runners, with the long chat suite split separately across three jobs. Each server -job still runs one test worker. The partition covers every suite exactly once; -normal PR and local test groups keep their existing shape. More jobs increase -concurrent runner demand, so compare queue time as well as test duration. +job still runs one test worker. The three Rust-backed server suites run in a +separate Runner lane with the shared dependency cache. That lane builds both +debug and release test binaries in a visible, bounded step before Vitest starts; +the suites still check Cargo freshness and run against the current source. A +cache miss builds from source. Native test failures block source verification. +The partition covers every suite exactly once; normal PR and local test groups +keep their existing shape. More jobs increase concurrent runner demand, so compare +queue time as well as test duration. All release verification installs, including the Runner scorer and chaos evals, allow pnpm to refresh an outdated lockfile. Contributor PRs leave lockfile updates diff --git a/doc/connections/CONNECTOR-PERMISSION-AUDIT.md b/doc/connections/CONNECTOR-PERMISSION-AUDIT.md index 57f72332831..5f0844ae68b 100644 --- a/doc/connections/CONNECTOR-PERMISSION-AUDIT.md +++ b/doc/connections/CONNECTOR-PERMISSION-AUDIT.md @@ -9,7 +9,8 @@ separate contracts and were not changed. Methods reviewed after this audit carry their own `reviewedAt` date in the same ledger; the counts above are not restated. Later additions: [Neon](./NEON.md) (`mcp-oauth`, `mcp-api-key`; 2026-10-02), [Superagent](./SUPERAGENT.md) -(`mcp-api-key`; 2026-10-06). +(`mcp-api-key`; 2026-10-06), [Gauge](./GAUGE.md) +(`mcp-oauth`, `mcp-api-key`; 2026-10-08). ## Shared credential failure diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 6c5f7913b2f..c04bed4648c 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -317,6 +317,15 @@ only `customer` must not silently fall through to generic DCR. A method containing `dcr` permits the automatic CIMD/DCR tiers. Deployment-preconfigured credentials still take precedence when present. +A reviewed remote OAuth method may set `oauthClientRegistration: "dcr"` when +live provider evidence shows that its advertised CIMD route fails while DCR +works. Airtable uses this compatibility option. It requires `dcr` in +`ownershipModes`; a deployment-preconfigured client still wins, and a missing +registration endpoint fails with `oauth_dcr_not_supported`. Existing CIMD +bindings are re-registered through DCR when the method changes. Other methods +keep the normal resolution order. This option does not relax issuer, resource, +redirect URI, PKCE, or registration-response validation. + ### Credential source is not OAuth client ownership `ownershipModes` says who owns the OAuth client registration. It does not say @@ -353,6 +362,16 @@ connection, grant, catalog, profile, policy, and run state; resolves the needed secret version; projects only the reviewed headers/arguments; calls the provider; and writes redacted audit evidence. +For a reviewed remote MCP method that accepts discovery before initialization +but requires an initialized session for calls, set +`defaults.mcpSessionRequired: true`. Tavily uses this compatibility preference. +Setup persists it for new connections; discovery and execution also resolve the +curated method default for retained connections without the config flag. The +existing session cache stays scoped to the connection, grant, actor, endpoint, +and effective credential headers. Other methods retain their existing discovery fallback +and direct-call behavior. Initialize before dispatch; never retry `tools/call` +automatically after a provider error, since the first call may have mutated data. + ## Secret Storage And Lifecycle Connection credentials use the same secrets architecture as agent, project, @@ -770,7 +789,10 @@ remove it there, and increment its version when changing the default. There is no central instruction-template registry. See [Connection instructions](./CONNECTION-INSTRUCTIONS.md) for the runtime contract. -1. Add or update the provider in `scripts/ingest-app-definitions.mjs`. +1. Add or update the provider in `scripts/ingest-app-definitions.mjs` or its + reviewed source inputs at `scripts/app-definition-overrides/.json`. + Overrides use the complete AppDefinition shape and pass through the same + branding, permission-review, instruction-template, and validation pipeline. 2. Update `packages/shared/src/self-serve-mcp-research.json` when it belongs to that program. 3. Add branding provenance and assets first; generation fails closed when @@ -1719,10 +1741,16 @@ supports public clients (`token_endpoint_auth_method: "none"` plus PKCE S256) need **no pre-provisioned OAuth app at all**. At first connect the broker registers a client on the fly and stores it on the connection: -- Registration request: `client_name` `Paperclip ()`, +- Registration request: `client_name` `Paperclip `, `redirect_uris` = the instance's own callback, `grant_types` `["authorization_code", "refresh_token"]`, `response_types` `["code"]`, `token_endpoint_auth_method` `"none"`. +- Authorization servers may validate `client_name` more narrowly than the + human-readable RFC field suggests. Paperclip replaces punctuation in the + callback host with hyphens (for example, `localhost:3105` becomes + `localhost-3105`), preserving a recognizable instance label while meeting + Calendly's documented rule that names contain only alphanumeric characters, + hyphens, and spaces ([Calendly MCP DCR requirements](https://developer.calendly.com/docs/mcp/calendly-mcp-server)). - The issued `client_id` is persisted in the connection's OAuth config and any issued `client_secret` becomes a `company_secrets` ref. The registered client is **reused** for every later authorize/refresh on that connection — diff --git a/doc/connections/GAUGE.md b/doc/connections/GAUGE.md new file mode 100644 index 00000000000..39a2d89447b --- /dev/null +++ b/doc/connections/GAUGE.md @@ -0,0 +1,189 @@ +# Gauge + +Updated: 2026-10-08. Status: catalog definition reviewed against official +documentation and live provider metadata; live account qualification +outstanding. + +Gauge (`withgauge.com`) measures how AI answer engines mention and cite a +brand, combines that with SEO, analytics and ads data, and runs a content +pipeline that can publish to a connected CMS. It appears in Apps and uses +Paperclip's shared remote-MCP connection, vault, catalog, grants, policies, +gateway, and audit trail. It is a resource connection, not Paperclip sign-in. +No plugin, database migration, or provider-specific runtime code is required. + +Paperclip connects to Gauge's hosted MCP server at +`https://app.withgauge.com/mcp` (Streamable HTTP) with two methods: + +- `mcp-oauth`: browser sign-in through Gauge's dynamic client registration. + The user chooses one Gauge organization on the consent screen. +- `mcp-api-key`: an organization API key stored as a Paperclip secret and + sent as an `Authorization: Bearer ` header. + +This curated connection is the polished route: it provides branding, method +guidance, and a publish warning. The same URL can also be connected from +**Connect your own MCP server**. See +[Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md). + +## Service involvement + +Gauge hosts the MCP resource and the authorization server. Paperclip stores +the OAuth tokens or the key as secret references, discovers tools with +`tools/list`, and sends the credential on every governed call. No +Paperclip-operated vendor relay is involved; cloud and self-hosted instances +use the same path. + +## Endpoints + +| Purpose | URL | +| --- | --- | +| MCP resource | `https://app.withgauge.com/mcp` | +| Protected-resource metadata | `https://app.withgauge.com/.well-known/oauth-protected-resource` | +| Authorization-server metadata | `https://app.withgauge.com/.well-known/oauth-authorization-server` | +| Authorize | `https://app.withgauge.com/mcp/oauth/authorize` | +| Token | `https://app.withgauge.com/mcp/oauth/token` | +| Dynamic client registration | `https://app.withgauge.com/mcp/oauth/register` | + +The authorization server supports `authorization_code` and `refresh_token`, +PKCE `S256`, and public clients (`token_endpoint_auth_method: none`). It +advertises the scopes `mcp`, `openid`, `profile`, `email` and +`organizations`, and rejects unknown scopes with HTTP 400. + +## Scopes + +Paperclip requests only `mcp`, the scope that grants MCP access. The identity +scopes are not needed: Gauge selects the organization on its consent screen, +not through a scope. This is recorded as an explicit review in +[`tool-method-permission-reviews.json`](./tool-method-permission-reviews.json). + +## Administrator setup + +Browser sign-in: + +1. In Paperclip, open **Apps → Browse → Gauge** and click + **Continue to sign in**. +2. Sign in to Gauge, choose the organization, and approve access. + +API key: + +1. In Gauge, open **Settings → Integrations → API Keys** and click + **Generate API Key**. Use a separate key for Paperclip so it can be deleted + independently. +2. In Paperclip, open **Apps → Browse → Gauge**, open **Advanced**, choose + **Use an API key**, paste the key, and click **Connect**. + +After either method, set publish actions to **Ask first** or **Off** on the +connection's **Permissions** screen if agents run unattended. + +To disconnect, remove the connection in Paperclip. For an API key, also +delete the key in Gauge under **Settings → Integrations → API Keys**, which +revokes every connection that uses it. + +## Capabilities and policy + +Gauge documents about 25 tools in these groups: + +- AI visibility and citations: mentions, citations, answers, visibility and + performance trends, and the sources and pages behind them. +- Topics and prompts: browse tracked prompts and answers; create, generate, + move and tag prompts; bulk keyword updates, which replace each prompt's + full keyword list. +- SEO and keyword reporting: organic rankings, volume, difficulty, and the + keywords a domain or page ranks for. +- GA4, Search Console and PostHog reports, where those integrations are + connected in Gauge. +- Google Ads and OpenAI Ads reporting. +- Content pipeline: brief, research, outline, write, review, images, and + publish to a connected CMS as a draft, to staging, or live. +- Memory files and saved skills: list, read, create and edit. +- Action Center: list, create, assign, set due dates and update status. +- Ask Gauge: natural-language questions answered from the organization's + data. + +Tool names were not captured during this review because it needed a signed-in +account. The generic `classifyRisk` rules apply: `publish`, `create`, +`update`, `set` and similar verbs are writes, and `delete` or `remove` is +destructive. Bulk keyword replacement and live CMS publishing are the +highest-impact actions. The method warnings and the API-key helper tell +operators to put publish actions behind Ask first. If live discovery shows +mutations with read-like names, add a reviewed `gauge` rule to +`classifyRisk`, as Superagent has. + +## Transport and auth + +- Transport: `mcp_remote`, Streamable HTTP (SSE is also documented). +- OAuth: DCR, public client, PKCE, scope `mcp`. Connects as the approving + user, limited to the chosen organization. +- API key: `Authorization: Bearer `. Keys are bound to one + organization, are not scoped, and carry no user identity. Requests are + attributed to the organization. Paperclip cannot narrow an issued key. + +## Resource filters + +None. Gauge documents no query or header options that narrow the hosted +server. Each connection is already limited to one organization; narrow further +with per-action policies. + +## Manifest + +| Field | Value | +| --- | --- | +| Slug | `gauge` | +| Category | `analytics` | +| Methods | `mcp-oauth` (`dcr`, S3, scope `mcp`); `mcp-api-key` (`customer`, S3) | +| Server URL | `https://app.withgauge.com/mcp` | +| Credential | `authorization`, password, required | +| Key placement | header `Authorization`, prefix `Bearer ` | +| Console links | docs `https://docs.withgauge.com/help/mcp` | + +The definition is generated from the `gauge` row in +`packages/shared/src/self-serve-mcp-research.json` and the `gauge` branch of +`specialMethodsFor` in `scripts/ingest-app-definitions.mjs`. + +## Wizard path + +`/apps/connect?source=gauge` defaults to **Sign in with Gauge**. Both methods +share one `write` capability profile, so there is no capability picker, but +the access step shows its description, including the publish warning, before +sign-in. Under +**Advanced**, **Use an API key** shows the key field; **Connect** stays +disabled until a key is entered. A successful sign-in or key check and tool +discovery lead to the connection's Permissions screen. + +## Governance defaults + +- Default profile and bindings: the standard connection profile; every + discovered action starts Allowed under the current product default. +- Policies: operators narrow publish and bulk-edit tools to Ask first or Off + on the Permissions screen. +- Risk: the generic `classifyRisk` rules. +- Quarantine rules: the shared defaults; no Gauge-specific exceptions. + +## Brand provenance + +Gauge's site ships only a 32×32 favicon, below the catalog's 128px minimum. +The same orange "G" gauge mark is the avatar of Gauge's official GitHub +organization (`withgauge`, whose profile links `https://withgauge.com/`), +published as a 460×460 transparent PNG. That file is used unchanged in both +themes. + +| File | Source | SHA-256 | +| --- | --- | --- | +| `ui/public/brands/apps/gauge.png` (460×460) | `https://avatars.githubusercontent.com/u/213101673?v=4&s=512` | `bf1f63a5d30b7f6af1064b74f5f66bf5ea6454d6036a068bf8b0c926fa3f37a6` | + +## Validation hook + +- Environment: definition review on 2026-10-08 against `origin/master`; no + Gauge account was used. +- Metadata probe: an unauthenticated `initialize` on `/mcp` returned HTTP 401 + with `resource_metadata`; both `.well-known` documents returned the + endpoints and scopes above; dynamic client registration succeeded for a + loopback redirect; the authorize endpoint accepted `scope=mcp` (redirect to + Gauge sign-in) and rejected an unknown scope with HTTP 400. +- Deterministic tests: manifest shape, store visibility, artwork, URL + recognition, reviewed scope, bearer-header placement, and the connect + form's sign-in default, the publish warning before sign-in, and API-key + gating. +- Connect evidence, catalog evidence, allowed read, governed write, denied + case, revoke, audit: not run. Both methods still need the full live + lifecycle with a Gauge organization before this connection is considered + qualified. diff --git a/doc/connections/README.md b/doc/connections/README.md index 5f5b4d09741..829ff427b52 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md). Long-term memory: [Experimental memory connectors](./MEMORY.md). Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), -[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), +[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and [Enterpret](./ENTERPRET.md). Optional credential custody: [Vercel Connect](./VERCEL-CONNECT.md). diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index f92216c57bf..88e88ec310e 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -23,8 +23,9 @@ "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server", "https://mcp.airtable.com/.well-known/oauth-protected-resource" ], - "reviewedAt": "2026-09-30", - "liveProof": "not-run" + "reviewedAt": "2026-10-08", + "liveProof": "DCR/HTTPS OAuth completed for one selected base only with read/write records, comments and base structure. Bounded base-name search passed in 0.6s. 46 actions (24 Read/22 Write); all 22 Write Off persisted after reload. No record data read or writes.", + "evidenceLimit": "An initial consent-flow review issue was disclosed before the completed grant; qualification stayed limited to bounded reads. No provider writes or actual agents ran. Token-refresh and actual runtime behavior remain unqualified." }, { "app": "api-key-generic", @@ -39,7 +40,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Actions depend on the operator-supplied API and key; grant read/write on the required resources at the provider. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", + "keyPermissions": "Actions depend on the operator-supplied API and key; grant read/write on the required resources at the provider. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions.", "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." }, { @@ -140,7 +141,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Create/manage links in authorized groups. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Create/manage links in authorized groups. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "box", @@ -208,7 +209,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Create/stop browser sessions and run browser tasks under the API key. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Create/stop browser sessions and run browser tasks under the API key. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "candid", @@ -282,7 +283,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "cloudinary", @@ -338,7 +339,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Modify documents/tables permitted by the account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Modify documents/tables permitted by the account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "cognee", @@ -353,7 +354,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Add/process knowledge and manage authorized datasets. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Add/process knowledge and manage authorized datasets. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "composio", @@ -505,7 +506,41 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Use an API key for an account with write access to the meetings your agents need to share, rename, move or turn into soundbites. Paperclip cannot increase the key\u2019s permissions." + "keyPermissions": "Use an API key for an account with write access to the meetings your agents need to share, rename, move or turn into soundbites. Paperclip cannot increase the key’s permissions." + }, + { + "app": "gauge", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "mcp" + ], + "capability": "write", + "supportedActions": "Read AI visibility, mentions, citations, answers and competitor data; browse and curate tracked topics and prompts (create, generate, move, tag, and replace keyword lists); pull SEO keyword, GA4, Search Console, PostHog and Google Ads/OpenAI Ads reports; run the content pipeline from brief to article and publish to a connected CMS as a draft, to staging or live; read and edit organization memory files and skills; create, assign and update Action Center items; ask Ask Gauge questions. Access is limited to the organization chosen during consent.", + "evidence": [ + "https://docs.withgauge.com/help/mcp", + "https://app.withgauge.com/.well-known/oauth-protected-resource", + "https://app.withgauge.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-08", + "liveProof": "not-run" + }, + { + "app": "gauge", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Read AI visibility, mentions, citations, answers and competitor data; browse and curate tracked topics and prompts (create, generate, move, tag, and replace keyword lists); pull SEO keyword, GA4, Search Console, PostHog and Google Ads/OpenAI Ads reports; run the content pipeline from brief to article and publish to a connected CMS as a draft, to staging or live; read and edit organization memory files and skills; create, assign and update Action Center items; ask Ask Gauge questions.", + "evidence": [ + "https://docs.withgauge.com/help/mcp", + "https://app.withgauge.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-10-08", + "liveProof": "not-run", + "keyPermissions": "Gauge API keys are not scoped: a key reaches every MCP tool in the organization that created it, including publishing to a connected CMS, and requests are attributed to the organization rather than a user. Generate a separate key for Paperclip so it can be deleted independently, and set publish actions to Ask first before agents run unattended. Paperclip cannot narrow an existing key." }, { "app": "github", @@ -536,7 +571,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", + "keyPermissions": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions.", "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." }, { @@ -1232,7 +1267,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Launch/manage browsers, profiles, apps and browser automation. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Launch/manage browsers, profiles, apps and browser automation. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "linear", @@ -1248,9 +1283,9 @@ "evidence": [ "https://mcp.linear.app/.well-known/oauth-protected-resource" ], - "reviewedAt": "2026-09-30", - "liveProof": "not-run", - "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + "reviewedAt": "2026-10-08", + "liveProof": "User-approved read/write; selected workspace returned one team in 0.3s. IDs omitted. Catalog refresh and reload retained 68 actions; all 30 write actions Off. No provider writes or actual agent runs.", + "evidenceLimit": "Browser tester account read only; provider writes, refresh-token expiry, and actual agent-adapter execution remain unqualified. Any agent company installation; UI did not allow narrowing. No actual agent ran." }, { "app": "local-falcon", @@ -1284,7 +1319,7 @@ "https://www.make.com/.well-known/oauth-authorization-server/mcp" ], "reviewedAt": "2026-09-30", - "liveProof": "not-run", + "liveProof": "2026-10-08: real Paperclip browser Test dialog environment_get returned authenticated organization/team context. Refresh/reload retained 133 actions; all 59 writes set Off and retained after reload. Company-wide installation in isolated test company, no provider write or actual adapter run. Account values excluded.", "providerDefaultReason": "Make documents selecting scopes and scenarios during its hosted connection flow. Management tools require a paid plan. Do not replace that resource selection with invented MCP client scopes." }, { @@ -1321,7 +1356,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Add/update/delete memories under the API key account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Add/update/delete memories under the API key account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "miro", @@ -1436,7 +1471,7 @@ ], "reviewedAt": "2026-10-02", "liveProof": "not-run", - "keyPermissions": "Project, branch, compute, snapshot, SQL and schema changes within the key\u2019s reach. A project-scoped key limits access to one project with Editor rights; personal and organization keys reach every project they can access. Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Project, branch, compute, snapshot, SQL and schema changes within the key’s reach. A project-scoped key limits access to one project with Editor rights; personal and organization keys reach every project they can access. Paperclip cannot increase an existing key’s permissions." }, { "app": "notion", @@ -1518,7 +1553,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "pagerduty", @@ -1533,7 +1568,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "planetscale", @@ -1585,9 +1620,10 @@ "https://posthog.com/docs/model-context-protocol", "https://mcp.posthog.com/.well-known/oauth-protected-resource" ], - "reviewedAt": "2026-09-30", - "liveProof": "not-run", - "providerDefaultReason": "PostHog documents the no-scope MCP setup as read/write, with optional readonly and feature filters. The authorization server advertises account administration unrelated to many tools; retain its MCP consent defaults and the existing explicit filters." + "reviewedAt": "2026-10-08", + "liveProof": "Provider read-only preset; active Paperclip project only. Authenticated profile returned in 0.7s; identity values omitted. Catalog refresh and reload retained 380 actions; all 7 write actions Off. No provider writes or actual agent runs.", + "providerDefaultReason": "PostHog documents the no-scope MCP setup as read/write, with optional readonly and feature filters. The authorization server advertises account administration unrelated to many tools; retain its MCP consent defaults and the existing explicit filters.", + "evidenceLimit": "Browser tester account read only; provider writes, refresh-token expiry, and actual agent-adapter execution remain unqualified. Any agent company installation; UI did not allow narrowing. No actual agent ran." }, { "app": "posthog", @@ -1603,7 +1639,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "postman", @@ -1671,7 +1707,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "postman", @@ -1688,7 +1724,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "postman", @@ -1705,7 +1741,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "railway", @@ -1759,7 +1795,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", + "keyPermissions": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions.", "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." }, { @@ -1811,7 +1847,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "sentry", @@ -1958,7 +1994,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "supabase", @@ -2003,7 +2039,7 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run", - "keyPermissions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "keyPermissions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." }, { "app": "superagent", @@ -2263,6 +2299,251 @@ ], "reviewedAt": "2026-09-30", "liveProof": "not-run" + }, + { + "app": "tavily", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "offline_access" + ], + "capability": "read", + "supportedActions": "Run public web search and extract content from public pages using the account’s Tavily API key and credit allowance. These are read/search operations; no provider business-record write action is exposed.", + "evidence": [ + "https://docs.tavily.com/documentation/mcp", + "https://mcp.tavily.com/.well-known/oauth-protected-resource/mcp", + "https://mcp.tavily.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-08", + "liveProof": "2026-10-08: HTTPS OAuth connected. After the initialized-session fix, query-only public search returned five results in 1.7s. Refresh/reload retained five Read/zero Write actions. No grant expansion or writes.", + "evidenceLimit": "Two earlier searches returned HTTP 400; successful call after proactive initialization supports but does not prove cause. Provider writes, refresh-token expiry and actual agent runs unqualified. Any agent company installation in isolated test company; no actual agents ran." + }, + { + "app": "calendly", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "mcp:scheduling:read", + "mcp:scheduling:write" + ], + "capability": "write", + "supportedActions": "Read event types and scheduling availability and call supported scheduling write tools under the authorized account; the official MCP documents the same scheduling read/write scope family as the public API.", + "evidence": [ + "https://developer.calendly.com/docs/mcp/calendly-mcp-server", + "https://mcp.calendly.com/.well-known/oauth-protected-resource", + "https://calendly.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-08", + "liveProof": "HTTPS DCR/S256 scheduling read/write grant succeeded on retry; provider profile verified the connected account. lookup_current_user passed in 2.5s. 36 actions (25 Read/11 Write); all 11 writes Off persisted after reload.", + "evidenceLimit": "Account read only; scheduling/provider writes, expiry refresh, and actual agent runtime unqualified. Any agent installation in isolated test company; no agents ran." + }, + { + "app": "exa", + "method": "public-search", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "read", + "supportedActions": "Public web search and webpage retrieval. Public MCP initialize and tools/list observed only web_search_exa and web_fetch_exa, both marked readOnly/idempotent; account-gated agent/advanced tools are excluded.", + "evidence": [ + "https://exa.ai/docs/get-started/exa-mcp", + "https://exa.ai/mcp", + "https://mcp.exa.ai/mcp" + ], + "reviewedAt": "2026-10-08", + "liveProof": "2026-10-08: Paperclip browser Test dialog web_search_exa returned a public repository matching the query in 2.0s; connection persisted after reload and action refresh passed. Public read only; no writes or agent-adapter run." + }, + { + "app": "firecrawl", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "firecrawl:global", + "offline_access" + ], + "capability": "write", + "supportedActions": "Full account-authorized Firecrawl MCP surface; web reads plus provider jobs such as crawl/research, which may consume team credits.", + "evidence": [ + "https://docs.firecrawl.dev/mcp-server", + "https://www.firecrawl.dev/.well-known/oauth-authorization-server", + "https://mcp.firecrawl.dev/.well-known/oauth-protected-resource/v2/mcp-oauth" + ], + "reviewedAt": "2026-10-08", + "liveProof": "not-run" + }, + { + "app": "firecrawl", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Bearer API key may expose full Firecrawl tool surface, including crawl/research jobs billed to the selected team.", + "evidence": [ + "https://docs.firecrawl.dev/mcp-server" + ], + "reviewedAt": "2026-10-08", + "liveProof": "not-run", + "keyPermissions": "Create an API key for the intended Firecrawl team with access to the search, scrape, parse, and any job tools you intend to use. Jobs can consume credits; Paperclip cannot increase an existing key’s permissions." + }, + { + "app": "firecrawl", + "method": "keyless", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "read", + "supportedActions": "Keyless Firecrawl exposes firecrawl_search, firecrawl_scrape, and firecrawl_parse; public catalog initialization confirmed those three. These retrieve/search content and process supplied files; keyless usage is limited.", + "evidence": [ + "https://docs.firecrawl.dev/mcp-server", + "https://mcp.firecrawl.dev/v2/mcp" + ], + "reviewedAt": "2026-10-08", + "liveProof": "2026-10-08: Paperclip browser Test dialog firecrawl_scrape returned public page metadata and HTTP 200 (0.6s); connection persisted after reload; action refresh passed. Public read only, no writes or agent-adapter run." + }, + { + "app": "parallel-search", + "method": "public-search", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "read", + "supportedActions": "Public Parallel Search MCP initialize and tools/list observed only web_search and web_fetch; both tool annotations marked read-only/idempotent. Task MCP is a separate endpoint/product.", + "evidence": [ + "https://docs.parallel.ai/integrations/mcp/search-mcp", + "https://parallel.ai/articles/add-free-web-search-to-coding-agent", + "https://search.parallel.ai/mcp" + ], + "reviewedAt": "2026-10-08", + "liveProof": "2026-10-08: Paperclip browser Test dialog web_search returned 10 public results in 0.8s; connection persisted after reload and action refresh passed. Public read only; no writes or agent-adapter run." + }, + { + "app": "windsor-ai", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "create", + "delete", + "offline_access" + ], + "capability": "write", + "supportedActions": "The MCP resource explicitly challenges for create and delete even to initialize/list tools. Read tools query selected connected sources; provider-level delete can expose destructive actions on supported sources. Keep write/destructive actions Off for read qualification. Search Console source authorization is separately read-only.", + "evidence": [ + "https://mcp.windsor.ai/", + "https://mcp.windsor.ai/.well-known/oauth-protected-resource", + "https://mcp.windsor.ai/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-08", + "liveProof": "User-approved create/delete/offline_access MCP scopes; separately approved read-only Search Console source, the selected Search Console property only. 54 field definitions; bounded last_7d request returned 5 daily rows dated 2026-10-01 through 2026-10-05 in 1.5s; metric values omitted. Catalog refresh and reload retained 19 actions; all 5 write actions Off. No provider writes or actual agent runs.", + "evidenceLimit": "Browser tester account read only; provider writes, refresh-token expiry, and actual agent-adapter execution remain unqualified. Just agents I pick; no agents selected." + }, + { + "app": "windsor-ai", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Bearer key accesses Windsor MCP; actions depend on source connector/account and may include advertising or commerce changes.", + "evidence": [ + "https://mcp.windsor.ai/" + ], + "reviewedAt": "2026-10-08", + "liveProof": "not-run", + "keyPermissions": "Use an account key for the intended Windsor.ai organization and connect only source accounts needed for the task. Review write tools and source-specific effects; Paperclip cannot increase key permissions." + }, + { + "app": "gsc-wizard", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "mcp:read", + "mcp:write" + ], + "capability": "write", + "supportedActions": "Read GSC/Bing Webmaster/GA4 and related property data, plus provider-advertised tools for URL submission and other write actions; exact property/action needs catalog review.", + "evidence": [ + "https://www.gscwizard.com/claude-search-console-mcp.html", + "https://mcp.gscwizard.com/.well-known/oauth-protected-resource", + "https://tool.gscwizard.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-08", + "liveProof": "HTTPS MCP connection with 112 actions. Account metadata, property list and seven-day performance read for the selected Search Console property passed; all 32 writes Off persisted after reload.", + "evidenceLimit": "Google identity screen showed name/profile/email only. Worker resumed to already Connected; Google completion and underlying GSC API scopes not independently verified. Paid/trial metadata provenance unknown; no payment action. No writes or agents ran." + }, + { + "app": "gsc-wizard", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Bearer key may be read-only or read/write depending on key creation; docs cite read analytics, URL inspection, and URL submission tools.", + "evidence": [ + "https://www.gscwizard.com/claude-search-console-mcp.html", + "https://mcp.gscwizard.com/" + ], + "reviewedAt": "2026-10-08", + "liveProof": "not-run", + "keyPermissions": "At key creation, choose read-only or read/write according to the approved task. Read/write keys can reach URL-submission and related provider actions; Paperclip cannot increase an existing key’s permissions." + }, + { + "app": "airtable", + "method": "mcp-pat", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Read/update records; creation and editing in allowed bases; token scopes, base resources, roles, and organization allowlisting apply.", + "evidence": [ + "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server", + "https://support.airtable.com/articles/9934989703-creating-personal-access-tokens" + ], + "keyPermissions": "Choose provider scopes and resource bases for intended actions; token and user/base role permissions both restrict access.", + "providerDefaultReason": "The exact required scope set depends on tools and selected base resources; do not infer a broader fixed scope list.", + "reviewedAt": "2026-10-08", + "evidenceLevel": "provider-documentation-reviewed", + "liveProof": "not-run" + }, + { + "app": "linear", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Create/update issues, projects, comments and other authorized workspace objects; key can be restricted to Read, Write, Admin, Create issues, Create comments and teams.", + "evidence": [ + "https://linear.app/docs/mcp", + "https://linear.app/docs/api-and-webhooks" + ], + "keyPermissions": "Choose intended permissions and teams when creating the personal API key. Read-only is supported with Read permission only.", + "reviewedAt": "2026-10-08", + "evidenceLevel": "provider-documentation-reviewed", + "liveProof": "not-run" + }, + { + "app": "make", + "method": "mcp-api-token", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Run selected scenarios as MCP tools; token scope and scenario access control determine availability.", + "evidence": [ + "https://developers.make.com/mcp-server/connect-using-mcp-token", + "https://developers.make.com/mcp-server/connect-using-mcp-token/usage-with-cursor" + ], + "keyPermissions": "Provider-issued Make token. Include documented mcp:use and only additional scopes required by selected scenarios; exact scopes are user selected.", + "reviewedAt": "2026-10-08", + "evidenceLevel": "provider-documentation-reviewed", + "liveProof": "not-run" } ] } diff --git a/doc/connections/verified-mcp-brand-provenance-2026-10-08.json b/doc/connections/verified-mcp-brand-provenance-2026-10-08.json new file mode 100644 index 00000000000..6e3e157298a --- /dev/null +++ b/doc/connections/verified-mcp-brand-provenance-2026-10-08.json @@ -0,0 +1,73 @@ +{ + "retrievedAt": "2026-10-08", + "providers": [ + { + "slug": "tavily", + "provider": "Tavily", + "file": "ui/public/brands/apps/tavily.png", + "sourceUrl": "https://www.tavily.com/apple-icon.png?apple-icon.2rsjznomrsf77.png", + "linkedFrom": "https://www.tavily.com/", + "retrievedAt": "2026-10-08", + "sha256": "7f8dd7b74386f30b061ddfe57e58217d6d32f9318e3f95fdf4130db6a22e60dc", + "note": "Official Tavily touch icon; retained without modification." + }, + { + "slug": "calendly", + "provider": "Calendly", + "file": "ui/public/brands/apps/calendly.png", + "sourceUrl": "https://calendly.com/media/favicon/apple-touch-icon.png", + "linkedFrom": "https://calendly.com/", + "retrievedAt": "2026-10-08", + "sha256": "96a66579d19466967a6326c6d5bab7195fd8f48dace937e84753674282e75246", + "note": "Official Calendly touch icon; retained without modification." + }, + { + "slug": "exa", + "provider": "Exa", + "catalogVisible": true, + "localAsset": "/brands/apps/exa.svg", + "sourceUrl": "https://exa.ai/assets/Exa%20Brand%20Assets.zip", + "sourceContext": "Exa Brand Assets/Logo/Icons/Icon Square.svg", + "sha256": "1bd417f607768b78e848fd49459a028b1e01e45dd26a455d3292dba4f684a290" + }, + { + "slug": "firecrawl", + "provider": "Firecrawl", + "catalogVisible": true, + "localAsset": "/brands/apps/firecrawl.svg", + "sourceUrl": "https://www.firecrawl.dev/brand/firecrawl-wordmark.svg", + "sourceContext": "https://www.firecrawl.dev/press-brand", + "sha256": "7edcff63dfe51f3129d039110786da574a8610ee0ed5998666f4663f3d744ae9" + }, + { + "slug": "parallel-search", + "provider": "Parallel Search", + "catalogVisible": true, + "localAsset": "/brands/apps/parallel.svg", + "sourceUrl": "https://assets.parallel.ai/dark-parallel-avatar-270.svg", + "sourceContext": "https://search.parallel.ai/mcp (MCP initialize icons)", + "sha256": "b02498a0d8a1047d95affc7c09e92d54e560469a45610fa4514f17e460cf695a", + "darkAsset": "/brands/apps/parallel-dark.svg", + "darkSourceUrl": "https://assets.parallel.ai/white-parallel-avatar-270.svg", + "darkSha256": "fc7f2c8b12bad544e8428ebd14d64792a6c03444533255983668af3109e676d1" + }, + { + "slug": "windsor-ai", + "provider": "Windsor.ai", + "catalogVisible": true, + "localAsset": "/brands/apps/windsor-ai.png", + "sourceUrl": "https://windsor.ai/wp-content/uploads/2020/02/favicon-310-300x300.png", + "sourceContext": "https://windsor.ai/", + "sha256": "0fd54d0fe1df834bbf86e1f738d7bdf1a26a89da9fc4e7d3b7c61e2ab2d81c57" + }, + { + "slug": "gsc-wizard", + "provider": "GSC Wizard", + "catalogVisible": true, + "localAsset": "/brands/apps/gsc-wizard.png", + "sourceUrl": "https://www.gscwizard.com/wizard-logo-icon-256_circle.png", + "sourceContext": "https://www.gscwizard.com/", + "sha256": "001469a9236ced832f82bcea0b72a9a9a7ff26cab3f58473431b9b2be66c5a19" + } + ] +} diff --git a/doc/connections/verified-mcp-qualification-2026-10-08.json b/doc/connections/verified-mcp-qualification-2026-10-08.json new file mode 100644 index 00000000000..424169d61b9 --- /dev/null +++ b/doc/connections/verified-mcp-qualification-2026-10-08.json @@ -0,0 +1,373 @@ +{ + "as_of": "2026-10-08", + "scope": "Eleven providers with successful bounded Paperclip browser action tests; seven new catalog definitions and four existing providers.", + "live_read_passes": 11, + "provider_write_calls": 0, + "actual_agent_runs": 0, + "providers": [ + { + "provider": "Airtable", + "slug": "airtable", + "live_read": "passed-account-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.airtable.com/mcp" + }, + { + "key": "mcp-pat", + "transport": "mcp_remote", + "auth": "api_key", + "server_url": "https://mcp.airtable.com/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "grant": "the selected test base only (one base/one workspace); records including delete, comments, fields/tables read/write.", + "tool": "Search for an Airtable base", + "result": "Bounded query the selected test base returned that base in 0.6s; ID omitted, no record data read.", + "catalog_actions": 46, + "read_actions": 24, + "write_actions": 22, + "write_policy": "All 22 write actions Off; persisted after reload", + "human_audience": "Just me", + "agent_installation_audience": "Any agent company installation; UI did not allow narrowing.", + "actual_adapter_run": false + } + }, + { + "provider": "Linear", + "slug": "linear", + "live_read": "passed-account-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.linear.app/mcp" + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "server_url": "https://mcp.linear.app/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "tool": "List teams", + "result": "One workspace team returned in 0.3s; IDs omitted.", + "catalog_actions": 68, + "read_actions": 38, + "write_actions": 30, + "grant": "User-approved read/write; selected workspace selected.", + "agent_installation_audience": "Any agent company installation; UI did not allow narrowing. No actual agent ran.", + "human_audience": "Just me", + "write_policy": "All write actions Off; persisted after reload", + "reload": "persisted", + "actions_refresh": "passed", + "actual_adapter_run": false + } + }, + { + "provider": "Make", + "slug": "make", + "live_read": "passed-account-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.make.com" + }, + { + "key": "mcp-api-token", + "transport": "mcp_remote", + "auth": "api_key", + "server_url_template": "https://{zone}/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "actor_label": "Connector Tester", + "tool": "environment_get", + "result": "Organization/team context returned; account values omitted.", + "reload": "persisted", + "actions_refresh": "passed", + "catalog_actions": 133, + "write_actions": 59, + "write_policy": "Off; retained after reload", + "agent_installation_audience": "Company-wide in isolated test company; UI does not permit narrowing while globally installed.", + "actual_adapter_run": false + } + }, + { + "provider": "Exa", + "slug": "exa", + "live_read": "passed-public-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "public-search", + "transport": "mcp_remote", + "auth": "none", + "server_url": "https://mcp.exa.ai/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "actor_label": "Connector Tester", + "query": "Official Paperclip public project repository", + "expected_url": "https://github.com/paperclipai/paperclip", + "reload": "persisted", + "actions_refresh": "passed", + "latency_seconds": 2.0, + "tool": "web_search_exa" + } + }, + { + "provider": "Firecrawl", + "slug": "firecrawl", + "live_read": "passed-public-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.firecrawl.dev/v2/mcp-oauth" + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "server_url": "https://mcp.firecrawl.dev/v2/mcp" + }, + { + "key": "keyless", + "transport": "mcp_remote", + "auth": "none", + "server_url": "https://mcp.firecrawl.dev/v2/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "actor_label": "Connector Tester", + "tool": "firecrawl_scrape", + "url": "https://paperclip.ing", + "expected": "Paperclip public site markdown; title and sourceURL; HTTP 200", + "latency_seconds": 0.6, + "reload": "persisted", + "actions_refresh": "passed" + } + }, + { + "provider": "PostHog", + "slug": "posthog", + "live_read": "passed-account-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.posthog.com/mcp" + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "server_url": "https://mcp.posthog.com/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "tool": "Get user (@me)", + "result": "Authenticated profile returned in 0.7s; identity values omitted.", + "catalog_actions": 380, + "read_actions": 373, + "write_actions": 7, + "grant": "Provider read-only preset; active Paperclip project only.", + "agent_installation_audience": "Any agent company installation; UI did not allow narrowing. No actual agent ran.", + "human_audience": "Just me", + "write_policy": "All write actions Off; persisted after reload", + "reload": "persisted", + "actions_refresh": "passed", + "actual_adapter_run": false + } + }, + { + "provider": "Tavily", + "slug": "tavily", + "live_read": "passed-account-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.tavily.com/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "account_identity": "the verified user verified on provider Settings", + "catalog_actions": 5, + "read_actions": 5, + "write_actions": 0, + "actions_refresh": "passed", + "reload": "persisted", + "actual_adapter_run": false, + "read_failures": 2, + "http_status": 400, + "tool": "Search", + "result": "Query-only public web search returned five rows in 1.7s, including official paperclipai/paperclip GitHub result after proactive MCP initialization.", + "human_audience": "Just me", + "agent_installation_audience": "Any agent company installation; UI did not allow narrowing. No actual agent ran.", + "session_initialization": "Curated true default; refreshed actions before one new call.", + "diagnostic_limit": "Earlier two calls returned HTTP 400; later pass supports but does not prove the missing-session hypothesis." + } + }, + { + "provider": "Parallel Search", + "slug": "parallel-search", + "live_read": "passed-public-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "public-search", + "transport": "mcp_remote", + "auth": "none", + "server_url": "https://search.parallel.ai/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "actor_label": "Connector Tester", + "query": "Official Paperclip public project repository", + "expected_url": "https://github.com/paperclipai/paperclip", + "reload": "persisted", + "actions_refresh": "passed", + "latency_seconds": 0.8, + "tool": "web_search" + } + }, + { + "provider": "Calendly", + "slug": "calendly", + "live_read": "passed-account-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.calendly.com" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "grant": "Same HTTPS Paperclip client, Calendly MCP resource, mcp:scheduling:read and mcp:scheduling:write", + "tool": "lookup_current_user", + "result": "Succeeded in 2.5s; profile details omitted", + "catalog_actions": 36, + "read_actions": 25, + "write_actions": 11, + "write_policy": "All 11 write/destructive actions Off; persisted after reload", + "human_audience": "Just me", + "agent_installation_audience": "Any agent; narrowing disabled for installation", + "authorization_retry": "Initial generic failure; same grant succeeded on retry", + "actual_adapter_run": false + } + }, + { + "provider": "Windsor.ai", + "slug": "windsor-ai", + "live_read": "passed-account-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.windsor.ai/" + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "server_url": "https://mcp.windsor.ai/" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "tool": "Get Fields (searchconsole), Get Data", + "result": "54 field definitions; bounded last_7d request returned 5 daily rows dated 2026-10-01 through 2026-10-05 in 1.5s; metric values omitted.", + "catalog_actions": 19, + "read_actions": 14, + "write_actions": 5, + "grant": "User-approved create/delete/offline_access MCP scopes; separately approved read-only Search Console source, the selected Search Console property only.", + "agent_installation_audience": "Just agents I pick; no agents selected.", + "human_audience": "Just me", + "write_policy": "All write actions Off; persisted after reload", + "reload": "persisted", + "actions_refresh": "passed", + "actual_adapter_run": false + } + }, + { + "provider": "GSC Wizard", + "slug": "gsc-wizard", + "live_read": "passed-source-tool-test", + "live_write": "not-run", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "server_url": "https://mcp.gscwizard.com/mcp" + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "server_url": "https://mcp.gscwizard.com/mcp" + } + ], + "proof": { + "surface": "Paperclip browser action Test dialog", + "mcp_scopes": [ + "mcp:read", + "mcp:write" + ], + "google_identity_scopes_observed": "Name/profile picture/email only", + "google_data_scopes": "Not independently verified; MCP scopes are not Google scopes", + "tools": [ + "Get account info", + "List Search Console properties", + "Get site performance summary" + ], + "result": "Account read succeeded in 0.6s; property list returned only the selected Search Console property, readable/activated/siteOwner; bounded seven-day performance summary succeeded in 1.1s through settled 2026-10-06. Metrics omitted.", + "catalog_actions": 112, + "write_actions": 32, + "write_policy": "All 32 write/destructive actions Off; persisted after reload", + "human_audience": "Just me", + "agent_installation_audience": "Any agent; narrowing disabled for installation", + "provider_access_metadata": "level paid with trial end 7 days after connectedAt; provisioning provenance unknown; no payment action", + "actual_adapter_run": false + } + } + ], + "limits": [ + "Live proof covers the recorded auth method only. Other documented methods are not independently live-qualified.", + "Provider writes, expiry refresh, and real agent-adapter sessions are unqualified.", + "GSC Wizard underlying Google data scopes and identity-flow completion provenance were not independently verified; no purchase was performed.", + "Tests ran against isolated qualification state; credentials, codes, provider response data, and private account identifiers are omitted." + ], + "repository_checks": "Full repository typecheck and build passed locally. Focused catalog, generator, OAuth/session/callback, and UI checks were run; latest CI results and any local-suite limitations are recorded in https://github.com/paperclipai/paperclip/pull/15621." +} diff --git a/doc/connections/verified-mcp-qualification-2026-10-08.md b/doc/connections/verified-mcp-qualification-2026-10-08.md new file mode 100644 index 00000000000..48119d73bf0 --- /dev/null +++ b/doc/connections/verified-mcp-qualification-2026-10-08.md @@ -0,0 +1,64 @@ +# Verified MCP connectors — 2026-10-08 + +This change includes eleven providers with successful bounded reads through +Paperclip's real browser action tester. Seven are new catalog entries: Calendly, +Exa, Firecrawl, GSC Wizard, Parallel Search, Tavily, and Windsor.ai. Airtable, +Linear, Make, and PostHog already have catalog entries. AgentMail uses the +existing full integration; this change adds no AgentMail method or channel. + +| Provider | Live read | Catalog and reload proof | Write policy during qualification | +|---|---|---|---| +| Airtable | Bounded base-name search, 0.6s | 46 actions; persisted after reload | All 22 writes Off; grant limited to one test base | +| Calendly | Current user, 2.5s | 36 actions; persisted after reload | All 11 writes Off | +| Exa | Public search found the official repository | Reload and refresh passed | No write call | +| Firecrawl | Public site scrape, HTTP 200, 0.6s | Connected with three actions; reload/refresh passed | No write call | +| GSC Wizard | Account, property list, seven-day performance summary, 1.1s | 112 actions; persisted after reload | All 32 writes Off; one selected property read | +| Linear | List teams, 0.3s | 68 actions; persisted after reload | All 30 writes Off | +| Make | Organization/team metadata | 133 actions; persisted after reload | All 59 writes Off | +| Parallel Search | Public search found the official repository | Reload and refresh passed | No write call | +| PostHog | Current user, 0.7s | 380 actions; persisted after reload | All seven writes Off; provider read-only preset | +| Tavily | Public search returned five results, 1.7s | Five read actions; persisted after reload | No write actions discovered | +| Windsor.ai | Source fields and bounded five-day Search Console data, 1.5s | 19 actions; persisted after reload | All five writes Off; Google source read-only | + +The catalog keeps documented read/write capabilities. The test policy above +records the qualification settings; it does not change normal catalog defaults +or prove provider write behavior. Alternative documented auth methods were not +independently live-tested. No provider writes or actual agent sessions ran. +Several test-company installations report Any agent because scoped-agent +selection is unavailable; no agent runtime was started. Windsor used no agents. + +Airtable requires reviewed DCR registration because its advertised CIMD flow +failed live. Calendly requires a compatible client name. Tavily's search passed +after proactive MCP initialization; two earlier HTTP 400 failures remain +undiagnosed. Calls are never automatically replayed. Firecrawl exposed an +optional nested-object form validation issue, which is fixed in the shared form. +The setup UI now displays the callback supplied by the server for HTTPS hosts. + +GSC Wizard's resumed tab was already Connected before the worker continued. +The Google identity screen showed name/profile/email only; underlying Google +Search Console scopes were not independently verified. Provider metadata reports +paid/trial access of unknown provisioning provenance; no payment was made. +Airtable's worker submitted an expanded grant before revised action-time review. +The exact access and error were disclosed, and the user directed continuation +with the requested one-base grant. This procedural error is retained in the +qualification record; no subsequent grant expansion or provider write occurred. + +[Machine-readable proof](verified-mcp-qualification-2026-10-08.json) and +[brand provenance](verified-mcp-brand-provenance-2026-10-08.json) record the scope. +Private account identifiers, credentials, OAuth state/codes, and returned metrics +are omitted. The original research and unfinished providers are preserved on a +separate local checkpoint branch and are outside this PR. + +Repository validation is recorded in the PR description after checks complete. +Browser proof establishes these reads, not expiry refresh, provider writes, or +execution through an actual agent adapter. + +## PR UI retest + +The PR checkout UI was previewed on a separate loopback port against the existing, authorized local qualification server. This retest covers frontend behavior; it is not a new live proof of the revised server OAuth recovery path. Calendly appeared connected in the catalog and its setup screen displayed before authorization. The Firecrawl scrape tester succeeded after expanding More options, entering only the public `https://paperclip.ing` URL, and leaving nested optional inputs untouched. No new provider authorization or agent heartbeat was started. + +- [Calendly catalog](verified-mcp-qualification-2026-10-08/calendly-catalog.jpg) +- [Calendly setup before authorization](verified-mcp-qualification-2026-10-08/calendly-setup.jpg) +- [Firecrawl successful read](verified-mcp-qualification-2026-10-08/firecrawl-read.jpg) + +OAuth recovery now refreshes credentials after an upstream 401 and returns `oauth_refreshed_retry_required` without replaying the tool call. The caller must retry explicitly; that invocation creates a session with refreshed credentials when required. The regression test verifies single dispatch on each invocation and fresh session/protocol headers on explicit retry. This behavior also applies to the existing managed and Vercel OAuth gateway paths. diff --git a/doc/connections/verified-mcp-qualification-2026-10-08/calendly-catalog.jpg b/doc/connections/verified-mcp-qualification-2026-10-08/calendly-catalog.jpg new file mode 100644 index 00000000000..878e3b5d3ee Binary files /dev/null and b/doc/connections/verified-mcp-qualification-2026-10-08/calendly-catalog.jpg differ diff --git a/doc/connections/verified-mcp-qualification-2026-10-08/calendly-setup.jpg b/doc/connections/verified-mcp-qualification-2026-10-08/calendly-setup.jpg new file mode 100644 index 00000000000..8a7e71e736f Binary files /dev/null and b/doc/connections/verified-mcp-qualification-2026-10-08/calendly-setup.jpg differ diff --git a/doc/connections/verified-mcp-qualification-2026-10-08/firecrawl-read.jpg b/doc/connections/verified-mcp-qualification-2026-10-08/firecrawl-read.jpg new file mode 100644 index 00000000000..a36dd7f758e Binary files /dev/null and b/doc/connections/verified-mcp-qualification-2026-10-08/firecrawl-read.jpg differ diff --git a/doc/evals.md b/doc/evals.md index 06037daea6e..97b17733496 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 @@ -391,6 +403,11 @@ The 26 native `first-task` cells exercise onboarding before native selection becomes the UI default. Live results and semantic answer reviews must accompany any qualification claim; catalog presence alone is not a pass. +The explicit-only [native question/resume qualification](../tests/runner-e2e/README.md#task-continuation) +separates a completed two-answer user journey from semantic-tool documentation +qualification. It verifies the exact provider-pause or semantic-response-wake +binding for each answer and preserves prior grades when the definition changes. + ## Lifecycle behavior baseline The credential-free [lifecycle baseline](../tests/lifecycle-baseline/README.md) @@ -423,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/idle-sleep-safety.md b/doc/idle-sleep-safety.md index 61f0977f044..71bd5a549e2 100644 --- a/doc/idle-sleep-safety.md +++ b/doc/idle-sleep-safety.md @@ -52,11 +52,50 @@ counted after readiness until its writes settle. Scheduler work already in flight remains counted until its promise settles. Idle holds pause new scheduler admissions. Database backup promises remain -counted through success or failure. Configured periodic backups also block -sleep until a host owns a durable wake schedule for them. A generation counter invalidates a +counted through success or failure. Configured periodic backups block sleep by +default. The opt-in checkpoint mode below preserves a final backup instead of +running the timer while asleep. A generation counter invalidates a scan if tracked work both starts and finishes during inspection. Reports remain unknown until startup recovery and HTTP tracking are installed. +### Backup checkpoints for idle instances + +`PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED=1` opts an instance with automatic +backups enabled into this mode. Keep its database and persistent backup volume +attached across sleep. All writers must use the instance's admission protocol; +this mode cannot cover independent writers to the same database. +On managed instances, checkpoint-enabled safety reads require a verified signed +Cloud control actor. Tenant instance-admin elevation does not grant platform +backup authority. Self-hosted instance administrators retain operator access. + +Each owned safety read first checks local work, plugin drains and durable work. +Known local blockers return before opening a database transaction. If every +other check passes, the server takes a fresh backup with the existing backup +engine and the retention policy from Instance Settings. It reads the full gzip +archive, checks its trailer and nonempty content, and syncs the archive and +directory before returning `none`. The final pre-stop revalidation takes another +fresh backup. Reads can therefore take longer; the host must bound its request +and still leave enough lease time for a confirmed provider stop. + +There is no cached backup exemption. Failed, concurrent or untracked backups, +other work during the scan, changed owners and expired holds cannot authorize +sleep. Client timeouts do not clear the backup's in-flight token. Slow backups +may retain an awake instance until a later sleep attempt. + +A durable `.idle-backup-wake-required` marker is written before the dump. +No recurring duplicate archives are produced while the instance is asleep. +The last pre-sleep archive and existing history stay on persistent storage; +no retention pruning runs during sleep. On restart, the server attempts a fresh +backup before enabling idle eligibility, then resumes the normal interval. +Only a verified new backup clears the marker. Failure keeps it for the next +scheduled retry. Disabling automatic backups explicitly still disables this +startup backup. Disabling only checkpoint mode restores the sleep blocker and +continues to honor an existing restart marker. + +This does not replace filesystem backups, encryption-key recovery or off-provider +disaster recovery. Validate restoration of a staging checkpoint before enabling +the flag on customer instances. + The report inspects accounting and sandbox-cleanup spools directly. Any entry, including malformed JSON, temporary files and failed write probes, blocks sleep. Read errors and invalid spool paths return unknown. Recovery readers that skip diff --git a/doc/observability.md b/doc/observability.md index 2114c16eb79..e8d6cbb2151 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -443,14 +443,60 @@ evidence, unknown binding reasons, secret-provider failures, ambiguous missing secret-definition lookups remain reportable. This filter does not change task recovery, credentials, or execution policy. +AI account selection also stays local when the selection service proves a +missing responsible user, personal default, or selected connection; an +incompatible selection; or an account that needs reconnection. The service marks +the original error, and setup preserves its closed `selectionFailure` reason. +The same failed/preparing/setup/bootstrap checks apply, and resumed native runs +with persisted provider input remain reportable. The generic +`ai_connection_unavailable` wrapper alone is insufficient: database, credential +provider, controller, and unclassified errors still report. Failed runs, blocked +tasks, and account-repair actions remain unchanged. + A workspace policy conflict also stays local when the resolver proves that an explicit `local_path` or `non_git_path` project workspace has no repository URL, Git confirms the selected directory is not a repository, and the task requests a Git worktree. The run must fail during setup before provider work starts. Its existing validation error, blocked task and board recovery action remain. -Git command failures, permissions, corrupt repositories, failed materialization, -fallback paths, and generic missing or unrestorable workspaces remain reportable; -the error text or `workspace_validation_failed` code alone never suppresses them. +Local Git command failures, permissions, corrupt repositories, fallback paths, +and generic missing or unrestorable workspaces remain reportable. The error text +or `workspace_validation_failed` code alone never suppresses them. + +Recognized external connection failures also stay in the local run result instead +of producing application-error events in Sentry. Their producer records a closed +`connectionFailure` descriptor: schema version, provider, operation, and reason. +It contains no URL, host, path, credential, or remote response. This classification +does not change the run's failed state, task recovery action, retry policy, or +whether remote work may have been accepted. + +- Managed remote Git clones classify known authentication, repository access, + invalid URL, DNS (including machine-specific SSH aliases), connection, TLS, + rate-limit, and upstream failures from the actual clone subprocess. Setup must + fail before provider work starts. Every failed workspace candidate must have + proven connection evidence; a mixed or unknown failure remains reportable. + Local/file clones, credential-provider exceptions, local checkout or copy + failures, process signals, and repository corruption remain reportable. Remote + fetch failures already return workspace warnings; their classification is not + transferred to a later failure resolving a local base ref. +- Hermes gateway configuration validation and the initial create-run request use + exact config, HTTP, or structured transport evidence. Missing configuration, + rejected credentials, unavailable endpoints, rate limits, and upstream outages + remain visible to the run owner. Unknown protocol/parser errors, missing run + identifiers, untyped terminal failures, and timeouts after accepted work remain + reportable. Stream/poll errors retain their existing retry and logging behavior. + +Expected pre-provider waits for a shared project workspace, a credential rotation +lock, or an exhausted AI connection pool carry a server-owned expected +cancellation receipt. They retain existing retry and lock behavior without a +Sentry cancellation event. A later failed retry still reports its actual error; +the wait code alone is not sufficient to suppress an event. + +MCP tool discovery records recognized outbound HTTP/transport failures through +server-only provenance. These failures preserve the response status, stored +connection health, audit record, local context, and first-party telemetry while +skipping only Sentry. A proven missing OAuth scope returns an actionable 422 +response with its reconnect guidance. Unknown responses, parsing errors, local +callbacks, database errors, and unproven network-looking messages still report. The `run_failure` context also includes the recorded process `exitCode` and `signal`, so a generic adapter error can still distinguish a nonzero exit from @@ -615,6 +661,24 @@ The envelope excludes provider messages, response bodies, names, paths, and credentials. The existing RPC error message and code remain unchanged, as do restore classification, retries, and source retention. Older workers without the envelope still report `unknown` when no structured cause is available. +For native transfers, optional `workspaceRestoreTransferStep` identifies +`sandbox_access`, `sandbox_guard`, `file_download`, `file_finalize`, +`archive_create`, `archive_download`, `archive_validate`, or `archive_extract`. +`workspaceRestoreTransferFailureKind` distinguishes a producer-confirmed +`command_failed`, `download_failed`, `download_missing`, `unsafe_archive`, +`listing_timeout`, `listing_entry_limit`, `listing_byte_limit`, +`listing_line_limit`, or `listing_stderr_limit`. These labels are fixed values, +not classifications inferred from provider message text. Missing labels remain +absent. A listing deadline records `ETIMEDOUT`; sandbox and listing commands +retain bounded nonzero exit codes even when their error text is wrapped. +`workspaceRestoreRpcCode` retains only known numeric plugin RPC error codes from +a typed error at the host's sync-out boundary. In particular, RPC `TIMEOUT` +(`-32003`) is separate from the model execution's `timedOut` value; successful +model execution does not exclude a later transfer timeout. Transfer evidence is +isolated per outbound request and per restore settlement, then independently +revalidated before persistence and Sentry projection. These fields only accompany +`workspace_transfer` or `asset_restore` diagnostics and do not authorize cleanup +or retries. Older workers remain compatible. For `git_integration`, optional `workspaceRestoreGitCommand` identifies the fixed command family: `rev_parse`, `symbolic_ref`, `merge_base`, `merge_tree`, `commit_tree`, `update_ref`, or `log`. `workspaceRestoreGitFailureKind` is diff --git a/doc/openai-dot-runner.md b/doc/openai-dot-runner.md index 358dd0de041..6f8afba36f4 100644 --- a/doc/openai-dot-runner.md +++ b/doc/openai-dot-runner.md @@ -48,6 +48,16 @@ controller deployments are not qualified. The feature is off by default. Only the operator's one-use pairing code is displayed. OAuth tokens and callback signing secrets stay on the server and never enter the Runner descriptor, task prompt or saved adapter config. Pairing codes expire after 15 minutes. +Once paired, the connection has no Paperclip inactivity expiry, including existing +valid connections upgraded by migration `0319_heavy_captain_midlands.sql`. Access tokens +still last 15 minutes and are renewed with rotating, non-expiring refresh tokens; +revocation, replay detection, and current company/agent permissions still apply. +This does not control any independent OpenAI-side connection policy. + +In **Invite an external agent → Dot**, the prompt preview closes automatically +as soon as Paperclip observes the connection. The footer shows **Connecting…** +while Dot subscribes, then **Confirming connection…** during the event check. +After the round trip succeeds, **Done** closes the invitation dialog. The dedicated connection uses the merged MCP gateway's PKCE browser and device flows, including verified client metadata documents and organization hints. @@ -333,12 +343,13 @@ Mailbox `follow_up` entries reference new comments on an accepted assignment. Re ## Tool inventory -The top-level MCP catalog contains these 15 transport and lifecycle tools: +The top-level MCP catalog contains these 16 transport, lifecycle, and profile tools: `paperclip_dot_capabilities`, `paperclip_dot_request_turn`, `paperclip_dot_tasks`, `paperclip_dot_request_work`, `paperclip_dot_pair`, `paperclip_dot_inbox`, `paperclip_dot_read`, `paperclip_dot_accept`, `paperclip_dot_tool`, `paperclip_dot_progress`, `paperclip_dot_finish`, `paperclip_dot_operation_status`, -`paperclip_dot_confirm_event`, `paperclip_dot_renew`, `paperclip_dot_control_ack`. +`paperclip_dot_confirm_event`, `paperclip_dot_renew`, `paperclip_dot_control_ack`, +`paperclip_dot_set_avatar`. After accepting work, use `paperclip_dot_tool` with a name from the assignment's actual catalog. Availability depends on work mode, permissions, assigned apps, @@ -387,3 +398,28 @@ A plugin upgraded during a running Dot conversation can retain an old top-level tool catalog. Refresh its tools in ChatGPT plugin settings and reattach it. Inspect the real exposed actions before claiming new idle or lease actions are available. The assignment catalog is read on each new assignment. + +## Cloud direction and external-agent invitation UX + +The [2026-10-08 cloud Runner and invitation decision](plans/2026-10-08-cloud-dot-runner-and-external-invitations.md) keeps remote agents on the new Runner infrastructure. In addition to shared assignment lifecycle handling, this preserves a sandbox boundary for future tools that may access Paperclip workspaces. The first cloud version gives Dot no Paperclip workspace file or command tools; Dot works on its own computer. + +The self-hosted entry is **New Agent → Invite an external agent → Dot / Hermes / Other**. Dot receives a copyable setup prompt and live connection checks, with readiness requiring a confirmed event round trip. The same components have Storybook journeys with fixture data. Cloud execution remains disabled pending qualification. Hermes continues to use the existing external-agent invitation prompt. + +## Agent avatar + +After pairing, Dot can call `paperclip_dot_set_avatar` without an active assignment or completed event test. It changes only the Paperclip agent bound to that live connection. It requires the Dot and Assistant connections experimental flags, an active operator membership, and a non-revoked grant and binding. + +Input is `{ "imageBase64": "" }`. PNG, JPEG and WebP are supported, up to 512 KiB and 16 megapixels; animations and SVG are rejected. Paperclip re-encodes the image as a metadata-free PNG at most 512 pixels on either side. Sending the same image again is safe and does not create another asset. Send `{ "imageBase64": null }` to restore the existing Paperclip character and palette. + +The same capability is available to agents via `PUT /api/companies/:companyId/agents/:agentId/avatar` with the same body and normal agent bearer authentication. An agent can update only itself; board users need the same `agent_config:update` permission as other agent configuration changes. Task bridge and skill test credentials cannot change avatars. Returned `appearance.customAvatarAssetId` and `avatarUrl` propagate through existing agent views. The image is a company-scoped private asset served through authenticated `/api/assets/:assetId/content`, not a public image URL for third-party embeds. Previous assets remain available for configuration history. Company exports retain the preset character and palette and warn that uploaded avatar assets must be uploaded again after import; private asset IDs are never portable. Activity records contain asset metadata, never image bytes. + +The setup prompt asks Dot to upload its own current image only if it can obtain it. We have not verified a supported OpenAI avatar-export API. Avatar availability must never block pairing; Dot may call the tool later. This capability does not add workspace access or change Runner assignment execution. + + +### Invite from the agent picker + +In a self-hosted instance, enable **OpenAI Dot** and **Assistant connections (MCP)**, then choose **New Agent → Invite an external agent → Dot**. Copy the setup prompt into your Dot. Paperclip creates a scoped Runner agent and watches connection, event subscription, and a harmless event round trip. If your company requires hire approval, approve the agent before copying its pairing prompt. The test event is sent automatically after the callback is verified; Retry test event remains available if confirmation times out. + +Reopening setup resumes the operator's unfinished invitation. Pairing codes are not stored in browser persistence. After refreshing or when an open prompt expires, setup automatically prepares a fresh prompt to copy. It first checks current connection state and replaces only the pending capability, without revoking an established connection. A failed renewal offers a retry rather than looping. If another browser window replaces the prompt, the current window asks before replacing it again. Hermes and Other continue to use the ordinary external-agent invitation flow. + +Cloud Dot execution is still gated. The external launcher hooks do not yet enable managed cloud execution or qualify tenant MCP routing. See [the cloud Runner plan](plans/2026-10-08-cloud-dot-runner-and-external-invitations.md). 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 00000000000..b7b6485ffb6 --- /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/doc/plans/2026-10-03-copilot-production-readiness.md b/doc/plans/2026-10-03-copilot-production-readiness.md index cfc07c4bd3d..e66ed7a3b6a 100644 --- a/doc/plans/2026-10-03-copilot-production-readiness.md +++ b/doc/plans/2026-10-03-copilot-production-readiness.md @@ -1,27 +1,42 @@ # GitHub Copilot production readiness -Current release checklist, updated 2026-10-08 (America/Chicago). +Current release checklist, updated 2026-10-09 (America/Chicago). -## Current release gate: v33 +## Current shipping gate: cloud build and v34 qualification -The frozen application source is `024e1422f9c7b43ff1b083e7d8a25e1969dc4acf`; native artifacts come from `941fbcb664a885eea713e732edc10ac133c62238`. Copilot **1.0.88**, exact **gpt-5.6-luna**, profile **v33**, and `sha256:482c4e997b4690c7e4a5375dd035560aaad69000f8f5291904d76f8c3a88284a` identify this campaign. Master baseline is `0ac194450a48a407450921a16c3ef8684dcb85ca`. Earlier results below remain historical. +Frozen **v33 qualification is complete**: all **20/20 Product cells**, all **7/7 protocol cases** (46 assertions), and both ordinary installed local and Daytona canaries pass. These results retain their exact source, fixture, runtime, model and image identities. They do not qualify different runtime bytes. -The reviewed server follow-up is `50d54d78e552869cae154d88ade68063191a547e`. It preserves explicitly selected remote environments under managed-only policy and resolves forced Kubernetes before Copilot admission. All 63 focused regressions, server typecheck and server build pass. It changes neither qualified runtime/profile/image inputs nor the campaign's local and Daytona execution policies. Its ordinary installed local canary now passes separately; existing live results retain their measured source revision. The normal user path connects a personal saved token, selects the exact model, saves and reopens configuration, passes metadata Test without a prompt, completes one task, downloads the exact 30-byte artifact, and retires all owned processes without qualification overrides. +The current candidate incorporates master `57e977be72f37dcf31acff64b3e5dcbb64a92cd0`. It preserves mainline Pi/Cursor admission and retains the later Copilot permission, receipt, token and recovery repairs. Copilot remains **1.0.88**, with exact **gpt-5.6-luna** selected for qualification. Copilot **v34** is `sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed`. The changed bound sources require fresh qualification; v33 remains historical release evidence. -The denial-fixture follow-up is `dc5ebe3f92d405e284b096f5add797864bb0f74e`. It retains a terminal first observed while saving the pre-Stop receipt, then checks again for an earlier Stop before dispatch. All 82 settlement regressions and Product typecheck pass. Case definitions and grading assertions are unchanged; native source, profile and image content identity are unchanged. Earlier live results retain their measured fixture/source revision. - -| Current gate | Result | +| Current gate | State | |---|---| -| Runner protocol | **7/7 pass**, 46 assertions, context-before-action included, cleanup confirmed | -| Product local | **10/10 pass**, including attached command, denial, pending-permission Stop, provider death, warm continuity and all five core workflows; saved-token grants and exact profile/model verified | -| Product Daytona | **0/10 current passes**; exact v33 publication approval pending; attached-command cell runs first | -| Platform install/startup | Actual installed ARM64, x64 under Rosetta, and Linux x64 under Docker emulation initialize and exit cleanly; native Daytona still pending | -| Authenticated metadata | Packaged runtime discovers 19 models including the exact qualification model; no prompt | -| Ordinary user path | Fresh normal package installation, saved-token setup, metadata Test, exact task completion/download and cleanup pass locally on reviewed source without qualification overrides; native Daytona remains pending | -| Current image | Credential scan covered 225,852 files with zero selected credential matches. Tag `copilot-qualification-22fd2a8ae3414eee317e`; OCI index `sha256:65fc4f30c15b3d744534ce3d7240dfbf3d3ff081da6d51180bd89f2802bd6e56`; not published | -| CI / review / shipping | Runtime and regression heads passed CI and fresh 5/5 review. Setup has fresh 5/5 review. Qualification requires fresh CI/review after the denial-fixture repair; pending trusted-runner selection has now completed. Separate code-owner approval and shipped canary remain required | +| Current-master integration | Merge conflicts resolved; migration collision uses additive `0320_copilot_defaults.sql`; historical identities retained separately | +| Build and contracts | Full `pnpm build`, recursive typecheck, shared/database builds and token gates pass | +| Focused regressions | 224 transport/recovery cases and 123 saved-token connection cases pass; installer contract and remote fixture checks pass; remaining full-suite validation is pending | +| Cloud image | Build-only OCI artifact mode retains the exact frozen lock, content ID and digest before publication; build, scan and publication pending | +| Live qualification | Freeze source, v34 profile, model, provider pack, case definitions and image; run exact 20 Product cells and seven protocol cases; verify both ordinary installed canaries | +| Shipping | Fresh current-head CI/review, separate code-owner approval, normal merge and shipped-build canary pending | -No automatic behavior retries occurred. Retained failures include the v32 permission-target defect, pre-provider Vite startup failures, and the private eval admission mismatch. The first ordinary canary completed but wrote Markdown escape backslashes, producing 34 bytes instead of 30; it remains failed. One changed definition gave the same expected contents as exact hexadecimal bytes and passed. Original grades remain unchanged. The reconciled allowance has **$35.86 remaining** after these canaries; provider USD stays unknown and attributed to GitHub. No budget or billing settings were reset. The current machine-readable checklist is [2026-10-08-copilot-qualification-v33.json](2026-10-08-copilot-qualification-v33.json). +No unchanged CI rerun remains available for the old v33 run `37829278179`: attempt 2 interrupted server shard 6 after a runner shutdown signal, without an observed assertion failure. The integrated candidate receives fresh CI. Future Docker images build in the cloud. No v34 live result or production rollout is claimed yet. + +## Completed frozen v33 qualification + +The original application source is `024e1422f9c7b43ff1b083e7d8a25e1969dc4acf`; reviewed application execution is `50d54d78e552869cae154d88ade68063191a547e`; native artifacts are `941fbcb664a885eea713e732edc10ac133c62238`. Daytona's reviewed controller/fixture source is `5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638`, including fixture repair `dc5ebe3f92d405e284b096f5add797864bb0f74e`. Source equivalence proofs retain the selected policies and case definitions; no original grade was relabeled. Copilot **1.0.88**, exact **gpt-5.6-luna**, profile **v33**, and `sha256:482c4e997b4690c7e4a5375dd035560aaad69000f8f5291904d76f8c3a88284a` identify the native campaign. + +| Gate | Result | +|---|---| +| Runner protocol | **7/7 pass**, 46 assertions, context-before-action and cleanup included | +| Product local | **10/10 pass**, saved-token grants and exact profile/model verified | +| Product Daytona | **10/10 pass**, native process/sandbox retirement independently verified | +| Platform install/startup | Actual installed ARM64, x64 under Rosetta and Linux x64 under Docker emulation pass; native Linux runs pass on Daytona | +| Ordinary user paths | Both fresh normal installed local and Daytona paths pass without qualification overrides: saved personal token, exact model, save/reopen, metadata-only Test, one task/final response, accepted normalized receipt and downloaded artifact | +| Ordinary remote cleanup | Exact 32 downloaded/synced bytes; native commands exited before completion; all **42** owned sandboxes independently absent. Two intentional setup/runtime-test archives were deleted after verifying exact owned identities | +| Image publication | Approved, published and anonymously verified. Tag `copilot-qualification-22fd2a8ae3414eee317e`; index `sha256:65fc4f30c15b3d744534ce3d7240dfbf3d3ff081da6d51180bd89f2802bd6e56`; Linux manifest `sha256:9606c290372884ee74438408e8115088320979c8614a3dbd9d77be2b548a896e`; scan found zero selected credentials across 225,852 files | +| Shipping | Pending current-master reconciliation/new-build qualification, CI/review, code-owner approval, normal merge and shipped canary | + +No automatic behavior retries occurred. The first Daytona attached-command attempt rejected a controller pack before native provider start: extraction omitted seven empty Cursor directories. Image files and Copilot assets matched. Restoring only the verified empty directories fixed the prerequisite; production offline pack validation and the explicit second attempt passed. The original failure remains. Other retained failures include v32 target aliasing, pre-provider startup/admission failures and the first ordinary local file's Markdown escape bytes. The changed hexadecimal local definition passed without relabeling that failure. + +Authenticated account reconciliation leaves **$70.50** within the existing approved window, with no active paid hold. Historical result mismatches and missing-closure compensation keep their full contingencies; infrastructure holds remain. Provider per-run USD stays unknown and GitHub-attributed. No billing controls or budget ceiling changed. The machine-readable record is [2026-10-08-copilot-qualification-v33.json](2026-10-08-copilot-qualification-v33.json). ## Historical v32 release attempt diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md new file mode 100644 index 00000000000..d28e7960eaf --- /dev/null +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -0,0 +1,96 @@ +# Runner defaults: staged delivery + +Updated 2026-10-08. + +## Outcome and ownership + +Users choose a harness. New agents use Paperclip Runner where the harness and +execution target are qualified. Existing agents keep their recorded runner. +The task owns implementation, verification, review fixes, and reviewable PRs. +Merging and deployment require separate authorization. + +The original implementation is preserved on `codex/default-agent-runner` at +`171d841295808ca185a258aaeb10b3dee68bb2b9`. Its public review is +[PR #15422](https://github.com/paperclipai/paperclip/pull/15422). +The user approved splitting that work into four useful steps: + +1. Packaged runtime reliability and Codex prerequisites, without changing defaults. +2. Codex defaults across creation, onboarding, hiring, imports, and the UI. +3. Claude defaults and authentication. +4. The remaining supported harness defaults and provider-specific fixes. + +## Current slice + +Canonical branch: `codex/runner-packaging-prerequisites`. +Frozen master base: `1881894973a2b25838d8abed9bd8aeebc3af4441`. +Canonical PR: [#15555](https://github.com/paperclipai/paperclip/pull/15555). +Its [checks](https://github.com/paperclipai/paperclip/pull/15555/checks) and +[commits](https://github.com/paperclipai/paperclip/pull/15555/commits) record the +final candidate. Hosted results and review disposition belong to that exact +candidate; earlier green checks do not qualify a later revision. + +The user requested a smaller PR on 2026-10-08. This slice now covers Codex +executable resolution, native-free npm packaging, the installed JavaScript +dependency graph, and the sandbox resource lookup required by that npm layout. +It retains focused tests and extends the existing public npm consumer verifier. +It does not add a workflow, change Docker or Git installation, change shared +login HOME or working-directory behavior, or transfer runner release assets. +Agent defaults, saved runner choices, UI, schema, and provider qualification stay +unchanged. + +The broader prerequisite implementation is preserved on the pushed branch +`codex/runner-packaging-full-snapshot` at +`6f3060beaa842ffcee21af058370d3cab5f571e9`. Its Git staging, extra login isolation, +release pipeline, Docker materialization, and unrelated test-fixture changes +remain outside this PR. Release and platform packaging must be qualified in a +separate slice before enabling new-agent defaults. + +### Retained behavior + +Ordinary native Codex startup, direct evals, and browser login prefer the +installed dependency. They accept usable older or newer CLI versions. If the +dependency is absent, use the selected host's PATH. Explicit commands and recorded +sessions retain precedence. Real protocol/login failures remain actionable; +there is no silent runner fallback. Release pins and the separate ACPX artifact +qualification stay unchanged. Linux ARM64 keeps its existing legacy login path. + +Final review found a relative PATH regression in the retained resolver. Resolve +relative and empty PATH entries against the selected provider working directory; +retain installed-dependency preference. Extend the existing fresh-process fixture +and qualify the resulting commit separately from the green `cb5948e` candidate. + +Paperclip npm tarballs retain the patched JavaScript graph and strip Codex native +payloads. The published server declares official optional host packages. npm +installs them for the consumer's OS and architecture. This avoids collisions with +the legacy adapter's separate Codex version. The server owns the bridge dependency +used by its vendored runner. Sandbox reads remain confined to the selected native +vendor resources; package identities and path containment still apply. + +Browser login keeps master's provider-specific credential directories, ambient +HOME, and working directory. Only executable selection and safe errors change. + +## Evidence and gates + +| Gate | Status | Evidence | +| --- | --- | --- | +| Preserve original and broader work | Passed | Pushed branches and revisions above | +| Narrow scope against current master | Passed | Installed Codex/npm scope only; no workflow, Docker, Git installer, or unrelated fixture diff | +| Focused packaging and installed-consumer controls | Passed | 24 packaging tests with actual offline npm tarballs; 9 existing consumer assertion tests | +| Login controls after narrowing | Passed | 13 existing tests; HOME/config-directory/spawn semantics match frozen master | +| Codex selection, protocol, sandbox, and ACPX integrity | Passed locally | 21 selection/security/transport/eval tests; 4 npm layout integrity controls; runner TypeScript no-emit | +| Full checks and fresh review | Pending for relative PATH fix | `cb5948e` passed all 47 CI jobs and its clean Linux npm consumer; qualify the final fix with new CI and fresh 5/5 review | +| Previous broad candidate | Historical | 47 ordinary CI jobs passed at `6f3060b`; not proof for the narrowed candidate | +| Existing old CLI protocol proof | Historical | Official Codex 0.156.1 completed startup handshake; scope must be matched to retained sources | +| All-harness live onboarding and cloud qualification | Deferred | Later defaults slices; authentication probes and CI cannot close live journeys | + +Use Node 24 and one worker for focused local checks. Keep heavy builds in hosted +CI. Do not build Docker images or Rust locally. Do not author a lockfile change. +No provider-backed runs or new disposable environments are needed for this +prerequisite slice. Incremental provider spend for narrowing is $0. The original +$250 ceiling and cleanup obligations remain for later live qualification. +Existing credentials and user previews remain untouched. + +Next action: commit and push the narrowed candidate, rerun its checks and fresh +review, then update the PR and existing evidence report. Start the Codex-default +slice after the user chooses to proceed. Merging and deployment remain outside +this task. diff --git a/doc/plans/2026-10-08-cloud-dot-runner-and-external-invitations.md b/doc/plans/2026-10-08-cloud-dot-runner-and-external-invitations.md new file mode 100644 index 00000000000..fc7d8b42667 --- /dev/null +++ b/doc/plans/2026-10-08-cloud-dot-runner-and-external-invitations.md @@ -0,0 +1,90 @@ +# Cloud Dot runners and external-agent invitations + +Date: 2026-10-08 + +Status: the live self-hosted invitation flow is implemented. The shared square picker and animated dialog now create/resume scoped invitations, preserve company approval, watch server state, and send the event test automatically. Dot driver launcher/checkpoint hooks are qualified with a real Rust process. **Cloud execution remains disabled:** the existing managed-environment and Cloud MCP guards are unchanged pending sandbox and tenant-ingress qualification. + +## Decision: remote agents use the new Runner infrastructure + +Keep Dot and future remote-agent drivers within the new Rust Runner infrastructure. A remote agent can do its thinking and computer work elsewhere while Paperclip still owns admission, assignment lifecycle, tool authorization, receipts, progress, cancellation, and completion through the same Runner contract used by other agents. + +There is also a deliberate future benefit: **we may give remote agents tools that access a Paperclip workspace. Those tools require sandbox isolation.** Keeping remote agents on the Runner infrastructure gives those future tools an established execution boundary, workspace lifecycle, and authorization path. We should not need to invent a second execution system when a remote agent gains workspace capabilities. + +Where the Runner runs and which tools the agent receives are independent decisions. Running a Runner in a sandbox does not itself grant Dot filesystem or shell access. The tool catalog and per-run grants determine that access. + +For the first cloud implementation: + +- Dot uses its own OpenAI-hosted computer. Do not expose Paperclip workspace file or command tools to it. +- Start one ordinary managed Runner execution job for an admitted assignment, using the configured cloud sandbox provider (for example, Kubernetes or Daytona). Reuse the job for that assignment's messages and tool calls. +- Do not start a new sandbox for each message or tool call. Do not require an idle, always-running sandbox just to receive Dot traffic. +- Do not run agent-provided commands on the Paperclip control-plane host. Future workspace tools execute inside the assignment's managed sandbox and require explicit capability grants. +- Keep public MCP, OAuth, subscription handling, and durable mailbox state on the control plane. These remain reachable while no Runner job is active. + +```mermaid +flowchart LR + User[Operator assigns work] --> CP[Paperclip control plane] + CP -->|admit assignment| Job[Managed sandbox: Rust Runner] + Job -->|offer, progress, receipts| CP + CP -->|MCP Events notification| Dot[Dot on OpenAI's computer] + Dot -->|MCP inbox and tool calls| CP + CP -->|authorized assignment operations| Job + Job -.->|future explicitly granted tools| Workspace[Isolated Paperclip workspace] +``` + +The control plane authenticates inbound Dot requests even when no assignment exists. Idle reads and connection checks need no sandbox. A request to start work creates a visible task and passes normal admission before launching a job. During an assignment, the control plane routes operations to its owning Runner through the durable bridge. Progress and completion return through normal Runner bookkeeping. After completion or cancellation, tear down the job according to the managed execution policy; keep the binding and mailbox. + +This preserves one lifecycle without promising that every provider performs identical work. For Dot, much of the driver coordinates a remote service. For a local coding agent, the driver also supervises a local process. Workspace access can be added later without weakening the boundary between the control plane and execution. + +## Invitation walkthrough + +Entry: **New agent → Invite an external agent → Dot / Hermes / Other**. + +The picker uses square cards matching the harness picker, with the Dot and Hermes brand marks. The shared dialog animates content-height changes without scaling text and respects reduced-motion preferences. The Dot option is governed by the standalone Dot experimental setting and its required Assistant connections (MCP) dependency in the live controller. Experimental infrastructure prerequisites belong in settings, not as a list of implementation details in this invitation. + +1. Choose **Dot**. Paperclip creates a company/agent-scoped invitation and a short-lived pairing prompt. +2. Show **Copy setup prompt**, using the shared `AgentSetupPrompt` component with the Dot mark. Tell the user to send the whole prompt to their Dot in ChatGPT. The preview remains inspectable and offers selectable text if clipboard access fails. +3. Dot follows the prompt to install or reuse the private plugin, complete scoped OAuth consent using the one-use pairing code, read its inbox, and subscribe to updates. Retain the exact user-approved plugin-creation consent wording from the existing prompt. The pairing code must remain transient and must not be logged, committed, or stored in browser persistence. +4. Paperclip watches the connection and updates three checks in place: + - **Connected to Paperclip** — the scoped OAuth/binding connection is established. + - **Task updates enabled** — the event subscription/callback is verified. + - **Test event confirmed** — Paperclip sent a readiness challenge through the event path and the paired Dot explicitly confirmed it through MCP. +5. Show **Your Dot is connected** and **Done** only after the round trip is confirmed. A successful copy, OAuth connection, or subscription alone is insufficient. + +The event delivery worker automatically issues the readiness test once the subscription is verified. It must be idempotent across refreshes/reconnects, correlate confirmation to the current binding and challenge, and never mark a replacement binding ready using an old confirmation. Use server events or bounded polling with reconnect/revalidation. UI timers are not evidence of success. + +When the event test times out, preserve completed checks and offer **Retry test event**. When the pairing code expires, automatically prepare a fresh prompt, invalidate the old prompt, and make the new one available to copy. When watching is interrupted, state that updates are paused and offer **Reconnect updates**; do not imply Dot itself disconnected. Returning to the picker must preserve a pending invitation rather than silently create duplicate agents or codes. Closing the dialog must not revoke an established connection. + +The managed invitation authorizes only the selected agent in the selected company; it does not grant operator access. OAuth scope preview and consent remain part of connecting. This flow should not require the operator to manipulate Dot's computer or manually run an event test. + +## Hermes and Other + +Hermes is a named choice with its existing brand mark. **No new Hermes protocol, tools, transport, authentication, or setup behavior is part of this work.** Hermes and Other use the same existing `buildAgentOnboardingPrompt` output, including its Hermes Gateway guidance and existing board approval/key-claim sequence. They do not show Dot's MCP event checks or claim readiness from a copied prompt. + +## Reviewable Storybook surface + +Folder: `ui/storybook/stories/external-agent-invite/`. + +- **Onboarding / External agent invitation / Journeys**: entry, picker, copy handoff, individual watched states, success, timeout/retry, expiration, interrupted updates, Hermes, Other, mobile, light theme, and long company names. +- **Onboarding / External agent invitation / Components**: picker and each controlled connection-check state. +- Interaction stories exercise copy → watched progression → success, retry without discarding completed checks, and measured intermediate heights while the modal expands and contracts. + +The shared presentation lives in `ui/src/components/new-agent/ExternalAgentInviteContent.tsx`. Storybook alone supplies timers and fixture credentials at `.example` URLs. It uses the live Dot prompt unchanged, including automatic readiness testing. The prompt optionally asks Dot to upload its own avatar using `paperclip_dot_set_avatar`; avatar availability does not block pairing. Hermes and Other reuse the production invitation builder without modifications. + +Run `pnpm --filter @paperclipai/ui storybook`, then open the journey group. Copying in the interactive Dot story advances simulated checks; the fixed-state stories remain still for inspection. These stories illustrate the live UX with simulated connection evidence; they do not prove a live cloud pairing. + +## Remaining implementation and qualification + +The live invitation controller, owner-scoped pending-invitation lookup, atomic creation, pending-code replacement, automatic event test, feature gating, and watcher are implemented. Active run controllers poll durable operation rows under their current controller generation and lease, so an operation reserved by another HTTP replica reaches its owning Runner. Idle-initiated work already uses normal admission. + +Before shipping cloud, connect Dot's qualified launcher hooks to managed job dispatch and qualify the dedicated tenant MCP ingress. Keep the present cloud restrictions until those paths are proven. The optional external launcher uses target-owned provider checkpoints and refuses recovery when identity or state is missing; it does not invent a replacement Dot thread. Preserve company isolation, approval rules, budget stops, assignment authority, and mutation receipts. Qualify real OAuth installation, event round trip, assignment execution, unsolicited Dot work, restart/reconnect, cancellation, and expired/revoked credentials against a real sandbox provider. No cloud-ready claim follows from this Storybook pass. + + +## Implemented invitation semantics + +- `POST /api/companies/:companyId/dot-invitations` finds or creates one unfinished Dot invitation for the signed-in operator. Creation holds a company row lock and commits the agent, membership, grants, approval (when required), and audit entries atomically. The fixed preset disables workspace/attachment access and periodic wakes; normal on-demand admission remains available. +- `GET` on that path returns the operator's unfinished invitation without a pairing secret. The dialog's existing binding query reports pairing/test expiry and agent approval state. +- Pairing codes stay in component memory and out of React Query's mutation results. After refresh, the UI checks current connection state and automatically creates a fresh prompt for an unfinished pairing. Expiry while the dialog is open also renews automatically; failures offer a retry without an automatic retry loop. Replacing a prompt requires the exact pending binding ID and its issuing operator; a concurrent completed OAuth connection is not revoked. +- The delivery worker creates one readiness challenge after callback verification under the binding row lock. Refreshing, reconnecting, or repeating a worker tick does not replace that challenge. An expired test needs an explicit retry. A revoked generation cannot confirm it. +- Hermes and Other share the existing external-agent invitation API and unchanged onboarding prompt. Dot is no longer a separate card in the harness picker. + +Verification: a disposable authenticated local instance exercised the natural New Agent entry, Dot prompt copy, Back, Hermes, refresh/resume, and pending-prompt replacement. Signed callback verification, nonce confirmation, scoped OAuth, actual Rust execution, duplicate receipts, and recovery have automated coverage. No real OpenAI Dot or managed cloud sandbox was connected during this implementation pass. diff --git a/doc/plans/2026-10-08-copilot-qualification-v33.json b/doc/plans/2026-10-08-copilot-qualification-v33.json index 1fc230ed850..8ba5fdcf701 100644 --- a/doc/plans/2026-10-08-copilot-qualification-v33.json +++ b/doc/plans/2026-10-08-copilot-qualification-v33.json @@ -1,6 +1,6 @@ { "schema": "paperclip.copilot.production-qualification/v33", - "updatedAt": "2026-10-08T19:04:43.055832+00:00", + "updatedAt": "2026-10-09T15:05:45.536425+00:00", "qualifiedApplicationSource": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", "reviewedApplicationSource": "50d54d78e552869cae154d88ade68063191a547e", "runtimeArtifactSource": "941fbcb664a885eea713e732edc10ac133c62238", @@ -103,7 +103,7 @@ "product": { "localPassed": 10, "localSelected": 10, - "daytonaPassed": 0, + "daytonaPassed": 10, "daytonaSelected": 10, "cells": [ { @@ -115,10 +115,14 @@ }, { "selector": "native-active-stop.runner-acpx-copilot.daytona.pending-permission-stop", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:c97343873c4f6acd42d16c1f9a0142da2bcc005d32406e68311eddb482da695c", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-pending-stop-01", + "automaticRetries": 0 }, { "selector": "copilot-provider-death.runner-acpx-copilot.local.pending-permission-provider-death", @@ -129,10 +133,14 @@ }, { "selector": "copilot-provider-death.runner-acpx-copilot.daytona.pending-permission-provider-death", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:583d2f1de67364578476203bdebc77d92257613ba6b46fc126668feb194997d1", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-provider-death-01", + "automaticRetries": 0 }, { "selector": "copilot-protection.runner-acpx-copilot.local.native-permission-deny-write", @@ -150,17 +158,25 @@ }, { "selector": "copilot-protection.runner-acpx-copilot.daytona.native-permission-deny-write", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:c80efe711eb5b410edc840d4f3cda13d4f1d2a362d3c7525f8e82806ca0d8c7d", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-denied-write-01", + "automaticRetries": 0 }, { "selector": "copilot-protection.runner-acpx-copilot.daytona.attached-async-settlement", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:8ef732bb5d2823952cfeda7f1c8311d7de8f41ba0804299eec548332d8d16da5", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-attached-02", + "automaticRetries": 0 }, { "selector": "rich-acp-warm-continuity.runner-acpx-copilot.local.warm-three-turn", @@ -171,10 +187,14 @@ }, { "selector": "rich-acp-warm-continuity.runner-acpx-copilot.daytona.warm-three-turn", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:0feab84de96215589db7b847fb915ed96b4246c48a25220cfbdfb8fe972e06c4", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-warm-continuity-01", + "automaticRetries": 0 }, { "selector": "extended-harnesses.runner-acpx-copilot.local.hello-complete", @@ -213,38 +233,58 @@ }, { "selector": "extended-harnesses.runner-acpx-copilot.daytona.hello-complete", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:95758a04d9bce33ff2f230235770020ca12147d62de1616347118b696839dd8c", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-hello-01", + "automaticRetries": 0 }, { "selector": "extended-harnesses.runner-acpx-copilot.daytona.question-resume-complete", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:2d83e99f42cd641192899de8ac5385b31b9e47bab63d53cc76e66113f78725fd", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-question-01", + "automaticRetries": 0 }, { "selector": "extended-harnesses.runner-acpx-copilot.daytona.plan-approve-complete", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:5f8148676b0282a08f386c86cdd01bc40eb8141a75e0c8ba5964ce5c9776fbf7", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-plan-01", + "automaticRetries": 0 }, { "selector": "extended-harnesses.runner-acpx-copilot.daytona.structured-question-restart-resume", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:3d5004d1cb4ae4e947d4c025fb5b22398e932ea87b68d207d553341fb01f3595", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-controller-restart-01", + "automaticRetries": 0 }, { "selector": "extended-harnesses.runner-acpx-copilot.daytona.file-edit-validate", - "status": "pending", - "sourceRevision": null, - "resultSha256": null, - "cleanupConfirmed": null + "status": "passed", + "sourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "resultSha256": "sha256:0d80f636006d75ad1f7e371556a5207f403e4ac83ca0d73515e1f5abe0ca074e", + "cleanupConfirmed": true, + "applicationExecutableSourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "originalQualifiedApplicationSourceRevision": "024e1422f9c7b43ff1b083e7d8a25e1969dc4acf", + "attemptLabel": "copilot-v33-final-daytona-file-edit-01", + "automaticRetries": 0 } ] }, @@ -253,14 +293,17 @@ "indexDigest": "sha256:65fc4f30c15b3d744534ce3d7240dfbf3d3ff081da6d51180bd89f2802bd6e56", "knownCredentialMatches": 0, "filesScanned": 225852, - "published": false, - "exactApprovalPending": true, - "reviewedServerDeltaChangesImage": false + "published": true, + "exactApprovalPending": false, + "reviewedServerDeltaChangesImage": false, + "linuxManifestDigest": "sha256:9606c290372884ee74438408e8115088320979c8614a3dbd9d77be2b548a896e", + "anonymousPublicRetrievalVerified": true, + "publicationProofSha256": "sha256:1a3f274fad5f764e2c92c7f1116871d70e0a735801dad564fe5ad49c620aed95" }, "installedPlatformSmokes": { "darwin-arm64": "passed", "darwin-x64": "passed under Rosetta", - "linux-x64": "passed under Docker emulation; native Daytona qualification pending" + "linux-x64": "installed smoke passed under Docker emulation; ten native Daytona cells and normal installed canary passed" }, "reviewedSetupDelta": { "source": "50d54d78e552869cae154d88ade68063191a547e", @@ -281,7 +324,7 @@ }, "canaries": { "ordinaryLocal": "passed on reviewed source", - "ordinaryDaytona": "pending", + "ordinaryDaytona": "passed on reviewed source", "ordinaryLocalEvidence": { "sourceRevision": "50d54d78e552869cae154d88ade68063191a547e", "definition": "Exact hexadecimal bytes; same expected file and final response", @@ -293,14 +336,43 @@ "cleanupConfirmed": true, "qualificationOverrides": [], "previousFailureRetained": true + }, + "ordinaryDaytonaEvidence": { + "sourceRevision": "50d54d78e552869cae154d88ade68063191a547e", + "qualificationSourceRevision": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "runtimeSourceRevision": "941fbcb664a885eea713e732edc10ac133c62238", + "profileVersion": 33, + "profileDigest": "sha256:482c4e997b4690c7e4a5375dd035560aaad69000f8f5291904d76f8c3a88284a", + "model": "gpt-5.6-luna", + "artifactBytes": 32, + "acceptedCompletionEvidence": true, + "finalResponseCount": 1, + "qualificationOverrides": [], + "nativeUsd": null, + "image": "ghcr.io/paperclipai/paperclip-daytona-runner@sha256:9606c290372884ee74438408e8115088320979c8614a3dbd9d77be2b548a896e", + "cleanup": { + "allOwnedSandboxesAbsent": true, + "ownedTaskSandboxAbsent": true, + "manualTestArchiveIntentionallyRetainedThenDeleted": true, + "loginDiscoverySandboxesAbsent": true, + "nativeRecordClosed": true, + "pendingRuntimeRequests": 0, + "providerExitUnconfirmed": false, + "ownedNativeControllerProcessesRemaining": [], + "liveRuns": 0 + }, + "proofSha256": "sha256:d12671b343c66437e389aa03dc7a785dca58cabb617b46566e10a7c74ed97b6e", + "receiptReceiverAcknowledged": true, + "metadataTestNoModelPrompt": true } }, "budget": { - "remainingConservativeAllowanceUsd": 35.86, + "remainingConservativeAllowanceUsd": 70.5, "nativeUsd": null, "billingEntity": "github", "budgetReset": false, - "historicalAttemptsRetained": true + "historicalAttemptsRetained": true, + "aggregateAccountReconciliationRefreshedAt": "2026-10-09T15:03:43.221645+00:00" }, "retainedFailedAttempts": [ { @@ -323,12 +395,17 @@ "cause": "Provider copied Markdown underscore escapes into a 34-byte file instead of the requested 30 bytes", "runtimeRepair": null, "definitionFollowup": "One changed definition specified exact hexadecimal bytes; expected file and final response unchanged; follow-up passed; original failure and cleanup retained" + }, + { + "attempt": "v33 native Daytona attached-command attempt 1", + "cause": "Controller docker-cp extraction omitted seven empty Cursor asset directories, so full provider-pack tree validation rejected before native provider start. Image files and Copilot tree matched.", + "runtimeRepair": null, + "artifactRepair": "Restore only the seven verified empty directories from the exact image. Production offline pack validator passed; explicit attached-command attempt 2 passed. Image/profile/source unchanged; original failure retained." } ], "releaseGates": [ - "Exact v33 public image publication approval", - "Ten exact native Daytona Product cells", - "Ordinary installed Daytona canary on reviewed source", + "Resolve current master 57e977be72f37dcf31acff64b3e5dcbb64a92cd0 conflicts, preserving mainline Pi/Cursor and Copilot fixes", + "Bind changed runtime source to a new Copilot profile; cloud build and qualification required for changed bytes", "Current-head CI and fresh clean review", "Separate code-owner approval, normal merge and shipped canary" ], @@ -354,5 +431,21 @@ "tests/runner-e2e/copilot-protection-flow.ts": "sha256:b396a29ed3f40c398591d6687798a31234c24f94753c5c048b831a84b7fef8c7", "tests/runner-e2e/copilot-protection-settlement.test.ts": "sha256:bdbe4bb254ddfdfd0983a164e81a6b9e33c6f79c14d653ef2446a4f6c9f61a83" } + }, + "reviewedQualificationControllerSource": "5f9b5e2043c4ec8e5cad667eaa4afbb09f1a9638", + "frozenV33QualificationComplete": true, + "currentMainlineShippingBlocker": { + "master": "57e977be72f37dcf31acff64b3e5dcbb64a92cd0", + "runtimePrMergeState": "DIRTY", + "qualificationCI": { + "runId": 37829278179, + "attempt": 2, + "serverShard": 6, + "testStep": "cancelled after runner shutdown signal", + "assertionFailureObserved": false, + "unchangedRerunsUsed": 1, + "allGreen": false + }, + "v33EvidenceRelabeledAsNewBuild": false } } diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index a3728720e1c..4030c0104d9 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -25,6 +25,38 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i building custom versions. - `ctx.assets` is not supported in the current runtime. +## Idle sleep + +An enabled worker blocks automatic idle sleep unless it implements +`onIdleDrain(signal): Promise<"none" | "present" | "unknown">`. This is a live +runtime handshake, not a manifest or version exemption. Older SDKs remain +blockers. Manual shutdown keeps its existing behavior. + +The host checks the exact owned, expiring task-drain hold. It closes new worker +admission and checks outstanding RPCs in both directions and live terminal +routes. The SDK also counts accepted handlers and notifications until they +actually finish. Caller timeouts do not count as completion. An unconfirmed +write, missing worker, crash, invalid reply, or expired hold prevents sleep. + +The hook must return `none` only when plugin-owned timers, sockets, detached +operations, and cleanup are settled and cannot start work until `signal` +aborts. Do not cancel useful work to make a worker idle. Return `present` while +work remains and `unknown` when completion cannot be proved. The SDK closes +ordinary RPC admission while the hook runs and until the matching hold releases +or expires. Release aborts the signal; expiry does so without another request. +Hooks that suspend autonomous work must arrange its safe resumption on abort. + +The handshake does not exempt database work. Enabled jobs, deliveries, +schedules, external ingress, and other persisted work still block sleep until +they have their own safe wake contract. An installed plugin without a matching +live worker receipt also blocks sleep. + +Daytona currently opts in only before its worker has contacted the provider. +After provider access it remains a blocker for that process lifetime, even if +the visible operation finishes. Its timeout and terminal cleanup paths need +stronger completion receipts before that restriction can be relaxed. Restarting +does not bypass the separate persisted lease and recovery checks. + ## Durable resource lifecycle inbox Plugins with `events.subscribe` can read durable, company-scoped resource hooks diff --git a/doc/public-mcp.md b/doc/public-mcp.md index 87a66486427..fe266ebe0a4 100644 --- a/doc/public-mcp.md +++ b/doc/public-mcp.md @@ -205,8 +205,13 @@ Dynamic registration uses public clients, exact registered HTTPS redirect URIs (or HTTP loopback), authorization code flow, S256 PKCE and exact resource binding to the endpoint. Authorization requests expire in ten minutes; codes expire one minute after consent and are single-use. Access tokens expire in -fifteen minutes. `offline_access` issues a thirty-day rotating refresh token; -replaying a consumed refresh token revokes its entire grant. Tokens and codes +fifteen minutes. `offline_access` issues a thirty-day rotating refresh token for +personal assistant connections. Dedicated Dot agent connections instead receive +rotating refresh tokens without an inactivity expiry: they stay connected until +revoked or their agent/operator loses access. Migration `0319_heavy_captain_midlands.sql` +removes the old expiry from valid, unused Dot refresh tokens on unrevoked grants. +Expired credentials remain expired. +Replaying a consumed refresh token still revokes its entire grant. Tokens and codes are hashed at rest. `paperclip:read` is required; `paperclip:write` adds only task work and attachment mutations on direct connections. The `paperclip:configure` scope covers allowed configuration operations. One **Write all of your Paperclip diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index b1a301d4c85..27ade493783 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=84e60b0170df23ec9026e98068501a9e52d4fc76693c25f8db5d1ef6ef429c94 # 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 d2b9ceaa2b8..d37129f05ed 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 9abfd933c9f..b46b2476f1c 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/connection-failure.test.ts b/packages/adapter-utils/src/connection-failure.test.ts new file mode 100644 index 00000000000..602e01fd9ec --- /dev/null +++ b/packages/adapter-utils/src/connection-failure.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { readConnectionFailure } from "./connection-failure.js"; + +describe("readConnectionFailure", () => { + const git = { schemaVersion: 1, provider: "git", operation: "clone", reason: "authentication_failed" }; + const gateway = { schemaVersion: 1, provider: "hermes_gateway", operation: "create_run", reason: "connection_refused" }; + const mcp = { schemaVersion: 1, provider: "mcp_http", operation: "discover_tools", reason: "connection_refused" }; + + it.each([ + "authentication_failed", "repository_unavailable", "invalid_remote", "dns_failure", "connection_refused", + "network_unreachable", "connection_reset", "connection_timeout", "tls_failure", "rate_limited", "remote_unavailable", + ])("accepts the Git clone reason %s", (reason) => { + const input = { ...git, reason }; + expect(readConnectionFailure(input)).toEqual(input); + }); + + it.each(["endpoint_missing", "endpoint_invalid", "insecure_transport", "credentials_missing"])( + "accepts the gateway configuration reason %s", (reason) => { + const input = { ...gateway, operation: "configuration", reason }; + expect(readConnectionFailure(input)).toEqual(input); + }, + ); + + it.each([ + "authentication_failed", "endpoint_not_found", "rate_limited", "remote_unavailable", "connection_refused", + "dns_failure", "network_unreachable", "connection_reset", "connection_timeout", "tls_failure", + ])("accepts the gateway request reason %s", (reason) => { + const input = { ...gateway, reason }; + expect(readConnectionFailure(input)).toEqual(input); + }); + + it.each([ + "authentication_failed", "endpoint_not_found", "rate_limited", "remote_unavailable", "connection_refused", + "dns_failure", "network_unreachable", "connection_reset", "connection_timeout", "tls_failure", + ])("accepts the MCP tool discovery request reason %s", (reason) => { + const input = { ...mcp, reason }; + expect(readConnectionFailure(input)).toEqual(input); + }); + + it.each([ + { ...git, operation: "fetch" }, + { ...git, operation: "create_run" }, + { ...git, reason: "endpoint_not_found" }, + { ...gateway, operation: "clone" }, + { ...gateway, operation: "poll_status" }, + { ...gateway, operation: "stop_run" }, + { ...gateway, operation: "configuration" }, + { ...gateway, reason: "credentials_missing" }, + { ...gateway, reason: "repository_unavailable" }, + { ...gateway, reason: "timeout" }, + { ...gateway, reason: "unknown" }, + { ...gateway, provider: "other_gateway" }, + { ...gateway, provider: "hermes_local" }, + { ...mcp, operation: "create_run" }, + { ...mcp, operation: "clone" }, + { ...mcp, operation: "configuration" }, + { ...mcp, operation: "call_tool" }, + { ...mcp, reason: "credentials_missing" }, + { ...mcp, reason: "repository_unavailable" }, + { ...gateway, operation: "discover_tools" }, + { ...git, operation: "discover_tools" }, + ])("rejects an unsupported provider/operation/reason combination: %j", (input) => { + expect(readConnectionFailure(input)).toBeNull(); + }); + + it.each([ + undefined, null, false, 1, "connection_refused", [], [gateway], {}, + { ...gateway, schemaVersion: 0 }, { ...gateway, schemaVersion: 2 }, { ...gateway, schemaVersion: "1" }, + { ...gateway, schemaVersion: null }, { ...gateway, schemaVersion: undefined }, + { ...gateway, provider: null }, { ...gateway, provider: ["hermes_gateway"] }, + { ...gateway, operation: null }, { ...gateway, operation: 1 }, + { ...gateway, reason: null }, { ...gateway, reason: 1 }, { ...gateway, reason: ["connection_refused"] }, + { ...gateway, reason: { code: "ECONNREFUSED" } }, + { provider: "hermes_gateway", operation: "create_run", reason: "connection_refused" }, + { ...gateway, reason: "connection_refused " }, { ...gateway, reason: "CONNECTION_REFUSED" }, + ])("rejects unknown versions or malformed values: %j", (input) => { + expect(readConnectionFailure(input)).toBeNull(); + }); + + it.each([ + { url: "https://private.example.test" }, + { account: "private-account" }, + { token: "synthetic-secret" }, + { remoteRunAccepted: false }, + { providerWorkStarted: false }, + { details: { message: "private diagnostics" } }, + { extra: undefined }, + ])("rejects extra metadata instead of broadening the bounded marker: %j", (extra) => { + expect(readConnectionFailure({ ...gateway, ...extra })).toBeNull(); + }); + + it("rejects unknown own fields even when a prototype supplies a valid descriptor", () => { + const input = Object.assign(Object.create(gateway), { a: 1, b: 2, c: 3, d: 4 }); + expect(readConnectionFailure(input)).toBeNull(); + }); + + it("returns an independent bounded value without mutating its producer's input", () => { + const input = Object.freeze({ ...gateway }); + const output = readConnectionFailure(input); + expect(output).toEqual(gateway); + expect(output).not.toBe(input); + expect(input).toEqual(gateway); + expect(Object.keys(output ?? {}).sort()).toEqual(["operation", "provider", "reason", "schemaVersion"]); + }); +}); diff --git a/packages/adapter-utils/src/connection-failure.ts b/packages/adapter-utils/src/connection-failure.ts new file mode 100644 index 00000000000..a5a3e5895d2 --- /dev/null +++ b/packages/adapter-utils/src/connection-failure.ts @@ -0,0 +1,58 @@ +/** + * A bounded producer-owned explanation of an external connection failure. + * This changes error reporting only: it never proves remote acceptance, + * authorizes a retry, or turns a failed run into a success. + */ +const gitReasons = [ + "authentication_failed", "repository_unavailable", "invalid_remote", + "dns_failure", "connection_refused", "network_unreachable", "connection_reset", + "connection_timeout", "tls_failure", "rate_limited", "remote_unavailable", +] as const; +const hermesConfigurationReasons = [ + "endpoint_missing", "endpoint_invalid", "insecure_transport", "credentials_missing", +] as const; +const hermesRequestReasons = [ + "authentication_failed", "endpoint_not_found", "rate_limited", "remote_unavailable", + "connection_refused", "dns_failure", "network_unreachable", "connection_reset", + "connection_timeout", "tls_failure", +] as const; + +export type GitConnectionFailure = { + schemaVersion: 1; + provider: "git"; + operation: "clone"; + reason: typeof gitReasons[number]; +}; + +export type ConnectionFailure = GitConnectionFailure | { + schemaVersion: 1; + provider: "hermes_gateway"; +} & ({ + operation: "configuration"; + reason: typeof hermesConfigurationReasons[number]; +} | { + operation: "create_run"; + reason: typeof hermesRequestReasons[number]; +}) | { + schemaVersion: 1; + provider: "mcp_http"; + operation: "discover_tools"; + reason: typeof hermesRequestReasons[number]; +}; + +/** Unknown versions, combinations and extra payload fields remain reportable. */ +export function readConnectionFailure(value: unknown): ConnectionFailure | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const keys = Object.keys(record); + if (keys.length !== 4 || !keys.every((key) => ["schemaVersion", "provider", "operation", "reason"].includes(key)) || + record.schemaVersion !== 1 || typeof record.reason !== "string") return null; + let reasons: readonly string[]; + if (record.provider === "git" && record.operation === "clone") reasons = gitReasons; + else if (record.provider === "hermes_gateway" && record.operation === "configuration") reasons = hermesConfigurationReasons; + else if (record.provider === "hermes_gateway" && record.operation === "create_run") reasons = hermesRequestReasons; + else if (record.provider === "mcp_http" && record.operation === "discover_tools") reasons = hermesRequestReasons; + else return null; + if (!reasons.includes(record.reason)) return null; + return { schemaVersion: 1, provider: record.provider, operation: record.operation, reason: record.reason } as ConnectionFailure; +} diff --git a/packages/adapter-utils/src/directory-merge-lock.test.ts b/packages/adapter-utils/src/directory-merge-lock.test.ts index a2b71aac55d..b10ad9189bf 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 5d89d97d3f5..46a4df7d6d5 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 42a05cbb895..c70c02d7b91 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 2dab7d4af0f..7259d65451b 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 3240fdf4786..44012753f78 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 077f5336436..31bc6acc4b2 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 86315864ff2..f6ef0405dae 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: true, 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 9ea60e5147e..c059b4535b6 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/adapter-utils/src/workspace-restore-diagnostics.test.ts b/packages/adapter-utils/src/workspace-restore-diagnostics.test.ts index 39e82e19297..936b68e302c 100644 --- a/packages/adapter-utils/src/workspace-restore-diagnostics.test.ts +++ b/packages/adapter-utils/src/workspace-restore-diagnostics.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { classifyWorkspaceRestoreFailure } from "./workspace-restore-merge.js"; import { - getWorkspaceRestoreDiagnostic, preserveWorkspaceRestoreErrorDiagnostic, recordWorkspaceRestoreDiagnostic, + withWorkspaceRestoreDiagnosticCapture, getWorkspaceRestoreDiagnostic, preserveWorkspaceRestoreErrorDiagnostic, recordWorkspaceRestoreDiagnostic, sanitizeWorkspaceRestoreDiagnostic, withWorkspaceRestoreDiagnostics, withWorkspaceRestoreStep, withWorkspaceRestoreGitCommand, type WorkspaceRestoreDiagnostic, } from "./workspace-restore-diagnostics.js"; @@ -297,3 +297,33 @@ describe("Git integration diagnostic privacy and attribution", () => { }); }); }); + + +it("isolates wrapped transfer evidence across settlements reusing a frozen error", async () => { + const shared = Object.freeze(new Error("private-message")); + let resume!: () => void; + const gate = new Promise(resolve => { resume = resolve; }); + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + const first = withWorkspaceRestoreDiagnosticCapture(async () => { + await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => { + preserveWorkspaceRestoreErrorDiagnostic(shared, {}, { transferStep: "archive_create" }); + started(); await gate; throw shared; + }))).rejects.toBe(shared); + return getWorkspaceRestoreDiagnostic(shared); + }); + await ready; + const second = await withWorkspaceRestoreDiagnosticCapture(async () => { + await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => { + throw preserveWorkspaceRestoreErrorDiagnostic(shared, {}, { transferStep: "archive_download" }); + }))).rejects.toBe(shared); + return getWorkspaceRestoreDiagnostic(shared); + }); + resume(); + expect((await first)?.transferStep).toBe("archive_create"); + expect(second?.transferStep).toBe("archive_download"); + await withWorkspaceRestoreDiagnosticCapture(async () => { + await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => { throw shared; }))).rejects.toBe(shared); + expect(getWorkspaceRestoreDiagnostic(shared)?.transferStep).toBeUndefined(); + }); +}); diff --git a/packages/adapter-utils/src/workspace-restore-diagnostics.ts b/packages/adapter-utils/src/workspace-restore-diagnostics.ts index afb8d659c96..8ac378374f8 100644 --- a/packages/adapter-utils/src/workspace-restore-diagnostics.ts +++ b/packages/adapter-utils/src/workspace-restore-diagnostics.ts @@ -15,6 +15,18 @@ const GIT_FAILURE_KINDS = new Set([ "merge_conflict", "invalid_object", "ref_conflict", "permission_denied", "unknown", ] as const); type GitFailureKind = typeof GIT_FAILURE_KINDS extends Set ? T : never; +const TRANSFER_STEPS = new Set([ + "sandbox_access", "sandbox_guard", "file_download", "file_finalize", + "archive_create", "archive_download", "archive_validate", "archive_extract", +] as const); +type TransferStep = typeof TRANSFER_STEPS extends Set ? T : never; +const TRANSFER_FAILURE_KINDS = new Set([ + "command_failed", "download_failed", "download_missing", "unsafe_archive", + "listing_timeout", "listing_entry_limit", "listing_byte_limit", "listing_line_limit", "listing_stderr_limit", +] as const); +type TransferFailureKind = typeof TRANSFER_FAILURE_KINDS extends Set ? T : never; +// Deliberately revalidate the known plugin RPC constants at the persistence boundary. +const RPC_CODES = new Set([-32000, -32001, -32002, -32003, -32004, -32005, -32006, -32099]); export interface WorkspaceRestoreDiagnostic { phase: RestorePhase; step?: WorkspaceRestoreStep; @@ -23,13 +35,16 @@ export interface WorkspaceRestoreDiagnostic { exitCode?: number; gitCommand?: WorkspaceRestoreGitCommand; gitFailureKind?: GitFailureKind; + transferStep?: TransferStep; + transferFailureKind?: TransferFailureKind; + rpcCode?: number; } const ERROR_CODES = new Set([ "ENOENT", "EACCES", "EPERM", "ENOSPC", "EIO", "EXDEV", "ENOTDIR", "EISDIR", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE", "ENOTFOUND", "EAI_AGAIN", "ABORT_ERR", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", ]); -type ErrorDiagnostic = Pick; +type ErrorDiagnostic = Pick; interface DiagnosticScope { active: boolean; sequence: number; @@ -44,11 +59,14 @@ const activeDiagnostic = new AsyncLocalStorage(); // Never attach a raw cause to an error just to retain a numeric Git exit code. const wrappedDiagnostics = new WeakMap(); const restoreDiagnostics = new WeakMap(); -const diagnosticCapture = new AsyncLocalStorage>(); +const diagnosticCapture = new AsyncLocalStorage<{ + restores: WeakMap; + wrapped: WeakMap; +}>(); /** Keep each settlement's diagnostic receipt isolated across asynchronous work. */ export function withWorkspaceRestoreDiagnosticCapture(operation: () => Promise): Promise { - return diagnosticCapture.run(new WeakMap(), operation); + return diagnosticCapture.run({ restores: new WeakMap(), wrapped: new WeakMap() }, operation); } function readField(value: Record, key: string): unknown { @@ -108,7 +126,10 @@ function diagnostic(error: unknown): ErrorDiagnostic { // SDKs wrap transport errors in a cause. Bound traversal, including cycles. for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) { const value = current as Record; - const saved = wrappedDiagnostics.get(value); + const saved = (diagnosticCapture.getStore()?.wrapped ?? wrappedDiagnostics).get(value); + result.transferStep ??= saved?.transferStep; + result.transferFailureKind ??= saved?.transferFailureKind; + result.rpcCode ??= saved?.rpcCode; const code = saved?.errorCode !== "unknown" && saved?.errorCode !== undefined ? saved.errorCode : readField(value, "code"); if (result.errorCode === "unknown" && typeof code === "string" && ERROR_CODES.has(code)) { @@ -129,14 +150,27 @@ function diagnostic(error: unknown): ErrorDiagnostic { } /** Retain bounded fields across an existing error wrapper, without a raw cause. */ -export function preserveWorkspaceRestoreErrorDiagnostic(wrapper: T, source: unknown): T { - wrappedDiagnostics.set(wrapper, diagnostic(source)); +export function preserveWorkspaceRestoreErrorDiagnostic(wrapper: T, source: unknown, transferEvidence?: unknown): T { + (diagnosticCapture.getStore()?.wrapped ?? wrappedDiagnostics).set(wrapper, { ...diagnostic(source), ...sanitizeTransferEvidence(transferEvidence) }); const scope = activeDiagnostic.getStore(); const failure = scope?.active ? scope.failures.get(source) : undefined; if (failure) scope!.failures.set(wrapper, failure); return wrapper; } +function sanitizeTransferEvidence(value: unknown): Pick { + if (!value || typeof value !== "object") return {}; + const record = value as Record; + const step = readField(record, "transferStep"); + const kind = readField(record, "transferFailureKind"); + const rpcCode = readField(record, "rpcCode"); + return { + ...(typeof step === "string" && TRANSFER_STEPS.has(step as TransferStep) ? { transferStep: step as TransferStep } : {}), + ...(typeof kind === "string" && TRANSFER_FAILURE_KINDS.has(kind as TransferFailureKind) ? { transferFailureKind: kind as TransferFailureKind } : {}), + ...(typeof rpcCode === "number" && RPC_CODES.has(rpcCode) ? { rpcCode } : {}), + }; +} + /** Decode persisted adapter metadata; never copy arbitrary properties or text. */ export function sanitizeWorkspaceRestoreDiagnostic(value: unknown): WorkspaceRestoreDiagnostic | undefined { if (!value || typeof value !== "object") return undefined; @@ -153,6 +187,7 @@ export function sanitizeWorkspaceRestoreDiagnostic(value: unknown): WorkspaceRes && typeof gitCommand === "string" && GIT_COMMANDS.has(gitCommand as WorkspaceRestoreGitCommand); return { phase, + ...(step === "workspace_transfer" || step === "asset_restore" ? sanitizeTransferEvidence(record) : {}), ...(typeof step === "string" && RESTORE_STEPS.has(step as WorkspaceRestoreStep) ? { step: step as WorkspaceRestoreStep } : {}), errorCode: typeof code === "string" && ERROR_CODES.has(code) ? code : "unknown", @@ -168,14 +203,14 @@ export function sanitizeWorkspaceRestoreDiagnostic(value: unknown): WorkspaceRes export function getWorkspaceRestoreDiagnostic(error: unknown): WorkspaceRestoreDiagnostic | undefined { return error && typeof error === "object" - ? sanitizeWorkspaceRestoreDiagnostic((diagnosticCapture.getStore() ?? restoreDiagnostics).get(error)) : undefined; + ? sanitizeWorkspaceRestoreDiagnostic((diagnosticCapture.getStore()?.restores ?? restoreDiagnostics).get(error)) : undefined; } /** Preserve the scheduler-selected task's snapshot when errors share identity. */ export function recordWorkspaceRestoreDiagnostic(error: unknown, diagnostic: WorkspaceRestoreDiagnostic | undefined): void { const safe = sanitizeWorkspaceRestoreDiagnostic(diagnostic); if (error && typeof error === "object") { - const receipts = diagnosticCapture.getStore() ?? restoreDiagnostics; + const receipts = diagnosticCapture.getStore()?.restores ?? restoreDiagnostics; if (safe) receipts.set(error, safe); else receipts.delete(error); } @@ -229,10 +264,10 @@ export async function withWorkspaceRestoreDiagnostics( } catch (error) { const failure = scope.failures.get(error); const step = failure?.step; - const fields = { phase, ...(step ? { step } : {}), ...diagnostic(error), + const fields = sanitizeWorkspaceRestoreDiagnostic({ phase, ...(step ? { step } : {}), ...diagnostic(error), ...(phase === "workspace" && step === "git_integration" && failure?.gitCommand ? { gitCommand: failure.gitCommand, gitFailureKind: failure.gitFailureKind } : {}), - }; + })!; recordWorkspaceRestoreDiagnostic(error, fields); if (parent?.active && step) parent.failures.set(error, { ...failure, sequence: ++parent.sequence, step }); try { 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 20419fb5a35..bd72bbee414 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -242,16 +242,7 @@ describe("buildPaperclipRunnerConfig", () => { expect(config).not.toHaveProperty("acpxAgent"); }); - it.each(["pi"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => { - expect(() => buildPaperclipRunnerConfig(makeValues({ - adapterType: "paperclip_runner", - model: "explicit-provider-model", - adapterSchemaValues: { - provider: "acpx", - acpxAgent, - }, - }))).toThrow(/is not enabled for production/); - }); + it.each(["agent", "plan", "ask"])("preserves Cursor's explicit model and %s mode", (mode) => { expect(buildPaperclipRunnerConfig(makeValues({ @@ -360,3 +351,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 803bc9a727c..be765d748b2 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 { expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false, errorCode: "hermes_gateway_connect_failed", errorFamily: "transient_upstream", retryNotBefore: null }); expect(result.executionRecovery).toBeUndefined(); - expect(result.resultJson).toBeUndefined(); + expect(result.resultJson).toEqual({ connectionFailure: { + schemaVersion: 1, provider: "hermes_gateway", operation: "create_run", reason: "connection_refused", + } }); expect(ctx.onDispatch).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock.mock.calls[0]?.[1]?.redirect).toBeUndefined(); @@ -738,6 +740,189 @@ describe("execute", () => { }); }); +describe("gateway connection failure reporting", () => { + const config = { apiBaseUrl: "https://gateway.example.test", apiKey: "gateway-private-key" }; + const marker = (operation: string, reason: string) => ({ + schemaVersion: 1, provider: "hermes_gateway", operation, reason, + }); + + it.each([ + [{}, "api_base_url_missing", "endpoint_missing"], + [{ apiBaseUrl: "not a URL" }, "api_base_url_invalid", "endpoint_invalid"], + [{ apiBaseUrl: "ftp://gateway.example.test" }, "api_base_url_invalid", "endpoint_invalid"], + [{ apiBaseUrl: "http://gateway.example.test" }, "plain_http_remote_denied", "insecure_transport"], + [{ apiBaseUrl: config.apiBaseUrl }, "api_key_missing", "credentials_missing"], + ])("classifies configuration failure %s without dispatch", async (settings, code, reason) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const ctx = makeCtx(settings); + ctx.onDispatch = vi.fn(); + const result = await execute(ctx); + expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false, + errorCode: `hermes_gateway_${code}`, resultJson: { connectionFailure: marker("configuration", reason) } }); + expect(result.errorMessage).toBeTruthy(); + expect(ctx.onDispatch).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [401, "auth_failed", "authentication_failed", null], + [403, "auth_failed", "authentication_failed", null], + [404, "runs_unsupported", "endpoint_not_found", null], + [429, "rate_limited", "rate_limited", "transient_upstream"], + [500, "upstream_error", "remote_unavailable", "transient_upstream"], + [503, "upstream_error", "remote_unavailable", "transient_upstream"], + [599, "upstream_error", "remote_unavailable", "transient_upstream"], + ])("classifies HTTP %i without changing retry or dispatch behavior", async (status, code, reason, errorFamily) => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ detail: "remote failure" }), { + status, headers: { "retry-after": "7" }, + })); + vi.stubGlobal("fetch", fetchMock); + const ctx = makeCtx(config); + ctx.onDispatch = vi.fn(); + const result = await execute(ctx); + expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false, + errorCode: `hermes_gateway_${code}`, errorFamily, retryNotBefore: "7", + errorMeta: { status, body: { detail: "remote failure" } }, + resultJson: { connectionFailure: marker("create_run", reason) } }); + expect(result.errorMessage).toContain(`Hermes gateway HTTP ${status}`); + expect(result.executionRecovery).toBeUndefined(); + expect(ctx.onDispatch).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(Object.keys(result.resultJson ?? {})).toEqual(["connectionFailure"]); + }); + + it.each([ + ["ECONNREFUSED", "connection_refused"], ["ENOTFOUND", "dns_failure"], ["EAI_AGAIN", "dns_failure"], + ["EHOSTUNREACH", "network_unreachable"], ["ENETUNREACH", "network_unreachable"], + ["ECONNRESET", "connection_reset"], ["EPIPE", "connection_reset"], ["UND_ERR_SOCKET", "connection_reset"], + ["ETIMEDOUT", "connection_timeout"], ["UND_ERR_CONNECT_TIMEOUT", "connection_timeout"], + ["UND_ERR_HEADERS_TIMEOUT", "connection_timeout"], ["UND_ERR_BODY_TIMEOUT", "connection_timeout"], + ["CERT_HAS_EXPIRED", "tls_failure"], ["CERT_NOT_YET_VALID", "tls_failure"], + ["DEPTH_ZERO_SELF_SIGNED_CERT", "tls_failure"], ["SELF_SIGNED_CERT_IN_CHAIN", "tls_failure"], + ["UNABLE_TO_VERIFY_LEAF_SIGNATURE", "tls_failure"], ["UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "tls_failure"], + ["ERR_TLS_CERT_ALTNAME_INVALID", "tls_failure"], + ])("classifies structured fetch cause %s", async (code, reason) => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("fetch failed", { cause: Object.assign(new Error("external transport failed"), { code }) }); + })); + const result = await execute(makeCtx(config)); + expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false, + errorCode: "hermes_gateway_connect_failed", errorFamily: "transient_upstream", retryNotBefore: null, + resultJson: { connectionFailure: marker("create_run", reason) } }); + expect(result.errorMessage).toContain(code); + expect(result.executionRecovery).toBeUndefined(); + }); + + it("classifies a network failure reading the create response without rewriting its error", async () => { + const response = new Response("unread"); + vi.spyOn(response, "text").mockRejectedValue(new TypeError("terminated", { cause: { code: "UND_ERR_SOCKET" } })); + vi.stubGlobal("fetch", vi.fn(async () => response)); + const result = await execute(makeCtx(config)); + expect(result).toMatchObject({ exitCode: 1, signal: null, timedOut: false, + errorCode: "hermes_gateway_protocol_error", errorFamily: null, errorMessage: "terminated", + resultJson: { connectionFailure: marker("create_run", "connection_reset") } }); + expect(result.executionRecovery).toBeUndefined(); + }); + + it("requires every aggregate member to prove the same external failure", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("fetch failed", { cause: { + code: "ECONNREFUSED", errors: [{ code: "ECONNREFUSED" }, { code: "ECONNREFUSED" }], + } }); })); + const result = await execute(makeCtx(config)); + expect(result.resultJson).toEqual({ connectionFailure: marker("create_run", "connection_refused") }); + }); + + it.each([ + undefined, { message: "ECONNREFUSED" }, { code: "ERR_INTERNAL_ASSERTION" }, { code: "toString" }, + { code: "ECONNREFUSED", errors: [] }, { code: "ECONNREFUSED", errors: "invalid" }, + { code: "ECONNREFUSED", errors: Array.from({ length: 9 }, () => ({ code: "ECONNREFUSED" })) }, + { code: "ECONNREFUSED", errors: [{ code: "ECONNREFUSED" }, { code: "ETIMEDOUT" }] }, + { code: "ECONNREFUSED", errors: [{ code: "ECONNREFUSED" }, new TypeError("internal bug")] }, + { code: "ERR_INTERNAL_ASSERTION", errors: [{ code: "ECONNREFUSED" }] }, + { code: "ECONNRESET", name: "AbortError" }, + ])("keeps unknown, mixed, or internal transport causes reportable: %j", async (cause) => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("ECONNREFUSED", { cause }); })); + const result = await execute(makeCtx(config)); + expect(result.errorCode).toBe("hermes_gateway_connect_failed"); + expect(result.resultJson).toBeUndefined(); + }); + + it("keeps abort failures reportable even if their cause looks like a transport error", async () => { + const aborted = Object.assign(new Error("cancelled", { cause: { code: "ECONNRESET" } }), { name: "AbortError" }); + vi.stubGlobal("fetch", vi.fn(async () => { throw aborted; })); + const result = await execute(makeCtx(config)); + expect(result.resultJson).toBeUndefined(); + expect(result.errorCode).toBe("hermes_gateway_connect_failed"); + }); + + it.each([400, 409, 422])("does not trust HTTP %i body claims about connection failures", async (status) => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + error: "ECONNREFUSED", connectionFailure: marker("create_run", "connection_refused"), + }), { status }))); + const result = await execute(makeCtx(config)); + expect(result.errorCode).toBe("hermes_gateway_protocol_error"); + expect(result.resultJson).toBeUndefined(); + }); + + it.each(["not json", JSON.stringify({ status: "running", connectionFailure: marker("create_run", "connection_refused") })])( + "keeps malformed successful create responses reportable", async (body) => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(body))); + const result = await execute(makeCtx(config)); + expect(result.errorCode).toBe("hermes_gateway_protocol_error"); + expect(result.resultJson).toBeUndefined(); + }, + ); + + it("does not classify a local response reader TypeError", async () => { + const response = new Response("unread"); + vi.spyOn(response, "text").mockRejectedValue(new TypeError("cannot read property of undefined")); + vi.stubGlobal("fetch", vi.fn(async () => response)); + const result = await execute(makeCtx(config)); + expect(result.errorCode).toBe("hermes_gateway_protocol_error"); + expect(result.resultJson).toBeUndefined(); + }); + + it("does not trust a callback error with forged gateway codes or markers", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const ctx = makeCtx(config); + ctx.onDispatch = () => { throw Object.assign(new Error("callback bug"), { + code: "hermes_gateway_auth_failed", status: 401, cause: { code: "ECONNREFUSED" }, + connectionFailure: marker("create_run", "authentication_failed"), + }); }; + const result = await execute(ctx); + expect(result.resultJson).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(["completed", "failed", "cancelled"])("does not classify remote terminal %s from text or a forged marker", (status) => { + const result = mapFinalResultForTest({ + terminal: { runId: "run-remote", status, payload: { + error: "ECONNREFUSED", code: "ECONNREFUSED", + connectionFailure: marker("create_run", "connection_refused"), + } }, outputChunks: [], sessionKey: null, strategy: "none", + }); + expect(result.resultJson?.connectionFailure).toBeUndefined(); + expect(result.errorCode).toBe(status === "completed" ? undefined : status === "failed" ? "hermes_gateway_run_failed" : "hermes_gateway_cancelled"); + }); + + it("retains an ambiguous accepted-run timeout after stream, poll, and stop connection failures", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith("/v1/runs")) return new Response(JSON.stringify({ run_id: "run-accepted" })); + throw new TypeError("fetch failed", { cause: { code: "ECONNRESET" } }); + }); + vi.stubGlobal("fetch", fetchMock); + const ctx = makeCtx({ ...config, timeoutSec: 0.3, pollIntervalMs: 250, eventReconnectMs: 250 }); + const result = await execute(ctx); + expect(result).toMatchObject({ timedOut: true, errorCode: "hermes_gateway_timeout" }); + expect(result.resultJson?.connectionFailure).toBeUndefined(); + expect(result.resultJson?.run_id).toBe("run-accepted"); + expect(fetchMock.mock.calls.some(([input]) => String(input).endsWith("/stop"))).toBe(true); + expect(ctx.onLog).toHaveBeenCalledWith("stderr", expect.stringContaining("status poll failed")); + }); +}); + describe("testEnvironment", () => { it("fails remote plain HTTP before probing health", async () => { const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); diff --git a/packages/adapters/hermes/src/gateway/server/execute.ts b/packages/adapters/hermes/src/gateway/server/execute.ts index 6942af59fc0..e4551b1cb6d 100644 --- a/packages/adapters/hermes/src/gateway/server/execute.ts +++ b/packages/adapters/hermes/src/gateway/server/execute.ts @@ -3,6 +3,7 @@ import type { AdapterExecutionResult, UsageSummary, } from "@paperclipai/adapter-utils"; +import type { ConnectionFailure } from "@paperclipai/adapter-utils/connection-failure"; import { asNumber, asString, @@ -61,6 +62,72 @@ type ExecutionState = { type TextRedactor = (value: string) => string; +type GatewayConfigurationFailure = Extract; +type GatewayRequestFailure = Extract; + +// Only errors observed at our outbound request boundary acquire this provenance. +// A callback or remote payload cannot opt itself out of product error reporting. +const requestConnectionFailures = new WeakMap(); +const NETWORK_FAILURE_REASONS: Readonly> = { + ECONNREFUSED: "connection_refused", + ENOTFOUND: "dns_failure", + EAI_AGAIN: "dns_failure", + EHOSTUNREACH: "network_unreachable", + ENETUNREACH: "network_unreachable", + ECONNRESET: "connection_reset", + EPIPE: "connection_reset", + UND_ERR_SOCKET: "connection_reset", + ETIMEDOUT: "connection_timeout", + UND_ERR_CONNECT_TIMEOUT: "connection_timeout", + UND_ERR_HEADERS_TIMEOUT: "connection_timeout", + UND_ERR_BODY_TIMEOUT: "connection_timeout", + CERT_HAS_EXPIRED: "tls_failure", + CERT_NOT_YET_VALID: "tls_failure", + DEPTH_ZERO_SELF_SIGNED_CERT: "tls_failure", + SELF_SIGNED_CERT_IN_CHAIN: "tls_failure", + UNABLE_TO_VERIFY_LEAF_SIGNATURE: "tls_failure", + UNABLE_TO_GET_ISSUER_CERT_LOCALLY: "tls_failure", + ERR_TLS_CERT_ALTNAME_INVALID: "tls_failure", +}; + +function gatewayConfigurationFailure(reason: GatewayConfigurationFailure["reason"]): GatewayConfigurationFailure { + return { schemaVersion: 1, provider: "hermes_gateway", operation: "configuration", reason }; +} + +function networkFailureReason(value: unknown, depth = 0): GatewayRequestFailure["reason"] | null { + const cause = asRecord(value); + if (!cause || depth > 2 || cause.name === "AbortError") return null; + const reason = typeof cause.code === "string" && Object.hasOwn(NETWORK_FAILURE_REASONS, cause.code) + ? NETWORK_FAILURE_REASONS[cause.code]! + : null; + if (cause.code !== undefined && !reason) return null; + if (cause.errors !== undefined) { + if (!Array.isArray(cause.errors) || cause.errors.length === 0 || cause.errors.length > 8) return null; + const reasons = cause.errors.map((error) => networkFailureReason(error, depth + 1)); + const first = reasons[0]; + return first && reasons.every((item) => item === first) && (!reason || reason === first) ? first : null; + } + return reason; +} + +function rememberNetworkFailure(target: Error, error: unknown): void { + if (!(error instanceof Error) || error.name === "AbortError") return; + // Native fetch wraps transport errors in cause. Do not classify an arbitrary + // TypeError, its text, or remote JSON that happens to name a network error. + const reason = networkFailureReason(error.cause); + if (reason) requestConnectionFailures.set(target, { + schemaVersion: 1, provider: "hermes_gateway", operation: "create_run", reason, + }); +} + +function httpFailureReason(status: number): GatewayRequestFailure["reason"] | null { + if (status === 401 || status === 403) return "authentication_failed"; + if (status === 404) return "endpoint_not_found"; + if (status === 429) return "rate_limited"; + if (status >= 500 && status <= 599) return "remote_unavailable"; + return null; +} + const CRITICAL_HEADERS = new Set([ "authorization", "content-type", @@ -380,9 +447,16 @@ async function fetchJson(input: RequestInfo | URL, init: RequestInit): Promise { }); describeEmbeddedPostgres("runDatabaseBackup", () => { + it("preserves a post-pg_dump verification error without falling back or pruning", async () => { + const connectionString = await createTempDatabase(); + const backupDir = createTempDir("checkpoint-pg-dump-"); + const executable = path.join(backupDir, "pg-dump-fixture"); + fs.writeFileSync(executable, "#!/bin/sh\nprintf 'SELECT 1;\\n'\n", { mode: 0o700 }); + const history = path.join(backupDir, "paperclip-history.sql.gz"); + const historyBytes = gzipSync("SELECT 'history';"); + fs.writeFileSync(history, historyBytes); + fs.utimesSync(history, new Date(0), new Date(0)); + const previous = process.env.PAPERCLIP_PG_DUMP_PATH; + process.env.PAPERCLIP_PG_DUMP_PATH = executable; + const failure = new Error("fixture archive fsync failed"); + let verified = 0; + try { + await expect(runDatabaseBackup({ + connectionString, backupDir, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, + verifyBeforePrune: async archive => { + verified++; + expect(gunzipSync(fs.readFileSync(archive)).toString()).toBe("SELECT 1;\n"); + throw failure; + }, + })).rejects.toBe(failure); + expect(verified).toBe(1); + expect(fs.readFileSync(history)).toEqual(historyBytes); + expect(fs.readdirSync(backupDir).filter(name => name.endsWith(".sql.gz"))).toEqual(["paperclip-history.sql.gz"]); + } finally { + if (previous === undefined) delete process.env.PAPERCLIP_PG_DUMP_PATH; + else process.env.PAPERCLIP_PG_DUMP_PATH = previous; + } + }); + it("preserves identity generation, sequence options and progress in JavaScript backups", async () => { const source = await createTempDatabase(); const target = await createSiblingDatabase(source, "identity_restore_target"); diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index a95ac3f1be8..fdf849de266 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -6,6 +6,7 @@ import { open as openFile } from "node:fs/promises"; import { pipeline } from "node:stream/promises"; import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; +import { randomUUID } from "node:crypto"; export type BackupRetentionPolicy = { dailyDays: number; @@ -28,6 +29,8 @@ export type RunDatabaseBackupOptions = { excludeTables?: string[]; nullifyColumns?: Record; backupEngine?: "auto" | "pg_dump" | "javascript"; + /** Optional durability gate. A rejected archive must not prune history. */ + verifyBeforePrune?: (backupFile: string) => Promise; }; export type RunDatabaseBackupResult = { @@ -542,13 +545,17 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise await sql.end(); }; mkdirSync(opts.backupDir, { recursive: true }); - const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`); + // Initial and final sleep checks can run within the same second. A failed + // second checkpoint must never overwrite the first verified recovery point. + const checkpointSuffix = opts.verifyBeforePrune ? `-${randomUUID()}` : ""; + const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}${checkpointSuffix}.sql`); const backupFile = `${sqlFile}.gz`; const writer = createBufferedTextFileWriter(sqlFile); try { if (backupEngine === "pg_dump" || (backupEngine === "auto" && canUsePgDump)) { await sql`SELECT 1`; + let dumped = false; try { await closeSql(); await runPgDumpBackup({ @@ -556,14 +563,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise backupFile, connectTimeout, }); - await writer.abort(); - const sizeBytes = statSync(backupFile).size; - const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); - return { - backupFile, - sizeBytes, - prunedCount, - }; + dumped = true; } catch (error) { if (existsSync(backupFile)) { try { unlinkSync(backupFile); } catch { /* ignore */ } @@ -575,6 +575,16 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); sqlClosed = false; } + if (dumped) { + // Only an engine failure can select the JavaScript fallback. Once + // pg_dump succeeds, archive verification or persistence failures + // must retain their original error and must not reuse a closed writer. + await writer.abort(); + const sizeBytes = statSync(backupFile).size; + await opts.verifyBeforePrune?.(backupFile); + const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); + return { backupFile, sizeBytes, prunedCount }; + } } await sql`SELECT 1`; @@ -1056,6 +1066,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise unlinkSync(sqlFile); const sizeBytes = statSync(backupFile).size; + await opts.verifyBeforePrune?.(backupFile); const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); return { diff --git a/packages/db/src/migrations/0319_heavy_captain_midlands.sql b/packages/db/src/migrations/0319_heavy_captain_midlands.sql new file mode 100644 index 00000000000..14233bbf75a --- /dev/null +++ b/packages/db/src/migrations/0319_heavy_captain_midlands.sql @@ -0,0 +1,15 @@ +ALTER TABLE "mcp_oauth_tokens" ALTER COLUMN "expires_at" DROP NOT NULL; +--> statement-breakpoint +-- Active Dot connections keep working without another consent flow. Expired +-- or consumed credentials stay invalid, revoked grants stay revoked, and +-- personal assistant connections retain their expiry. +UPDATE "mcp_oauth_tokens" AS tokens +SET "expires_at" = NULL +FROM "mcp_oauth_grants" AS grants +WHERE tokens."grant_id" = grants."id" + AND tokens."kind" = 'refresh' + AND tokens."used_at" IS NULL + AND tokens."expires_at" > now() + AND grants."purpose" = 'agent' + AND grants."resource" LIKE '%/mcp/runner' + AND grants."revoked_at" IS NULL; diff --git a/packages/db/src/migrations/0319_copilot_defaults.sql b/packages/db/src/migrations/0320_copilot_defaults.sql similarity index 100% rename from packages/db/src/migrations/0319_copilot_defaults.sql rename to packages/db/src/migrations/0320_copilot_defaults.sql diff --git a/packages/db/src/migrations/meta/0319_snapshot.json b/packages/db/src/migrations/meta/0319_snapshot.json index d5e398e9c3b..8152888d270 100644 --- a/packages/db/src/migrations/meta/0319_snapshot.json +++ b/packages/db/src/migrations/meta/0319_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a780db47-713c-41f3-8c0a-37fa1c1adf59", + "id": "af0b2d33-956b-4b12-8d68-8b4e14c991b2", "prevId": "541f5e10-e2f3-44e1-b238-4d1cbfba61f2", "version": "7", "dialect": "postgresql", @@ -4266,7 +4266,7 @@ "checkConstraints": { "ai_connection_defaults_provider_check": { "name": "ai_connection_defaults_provider_check", - "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google','github')" + "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" }, "ai_connection_defaults_method_check": { "name": "ai_connection_defaults_method_check", @@ -4739,7 +4739,7 @@ "checkConstraints": { "ai_provider_defaults_provider_check": { "name": "ai_provider_defaults_provider_check", - "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google','github')" + "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" } }, "isRLSEnabled": false @@ -56254,7 +56254,7 @@ "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false }, "created_at": { "name": "created_at", @@ -56355,4 +56355,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0314_snapshot.json b/packages/db/src/migrations/meta/0320_snapshot.json similarity index 91% rename from packages/db/src/migrations/meta/0314_snapshot.json rename to packages/db/src/migrations/meta/0320_snapshot.json index 52d8ef6d49a..bcb7f0e3a26 100644 --- a/packages/db/src/migrations/meta/0314_snapshot.json +++ b/packages/db/src/migrations/meta/0320_snapshot.json @@ -1,6 +1,6 @@ { - "id": "a1aefefe-8911-4583-9c52-7c658e46e9b4", - "prevId": "adf783f2-c391-4fd5-b93a-d85e6d3c60a0", + "id": "8e8297b3-1ae2-44d5-8841-f74637ce0395", + "prevId": "af0b2d33-956b-4b12-8d68-8b4e14c991b2", "version": "7", "dialect": "postgresql", "tables": { @@ -64,37 +64,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "accounting_runtime_baselines_agent_id_agents_id_fk": { "name": "accounting_runtime_baselines_agent_id_agents_id_fk", "tableFrom": "accounting_runtime_baselines", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "accounting_runtime_baselines_company_id_companies_id_fk": { "name": "accounting_runtime_baselines_company_id_companies_id_fk", "tableFrom": "accounting_runtime_baselines", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -200,9 +200,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "billing_invoice_lines_company_idx": { "name": "billing_invoice_lines_company_idx", @@ -221,50 +221,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "billing_invoice_lines_company_id_companies_id_fk": { "name": "billing_invoice_lines_company_id_companies_id_fk", "tableFrom": "billing_invoice_lines", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "billing_invoice_lines_invoice_id_billing_invoices_id_fk": { "name": "billing_invoice_lines_invoice_id_billing_invoices_id_fk", "tableFrom": "billing_invoice_lines", + "tableTo": "billing_invoices", "columnsFrom": [ "invoice_id" ], - "tableTo": "billing_invoices", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "billing_invoice_lines_cost_event_id_cost_events_id_fk": { "name": "billing_invoice_lines_cost_event_id_cost_events_id_fk", "tableFrom": "billing_invoice_lines", + "tableTo": "cost_events", "columnsFrom": [ "cost_event_id" ], - "tableTo": "cost_events", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -361,24 +361,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "billing_invoices_company_id_companies_id_fk": { "name": "billing_invoices_company_id_companies_id_fk", "tableFrom": "billing_invoices", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -408,13 +408,19 @@ "name": "run_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false + }, + "decision_invocation_id": { + "name": "decision_invocation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false }, "agent_id": { "name": "agent_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false }, "project_id": { "name": "project_id", @@ -456,6 +462,27 @@ } }, "indexes": { + "budget_reservations_decision_idx": { + "name": "budget_reservations_decision_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "decision_invocation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "budget_reservations_run_idx": { "name": "budget_reservations_run_idx", "columns": [ @@ -473,9 +500,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "budget_reservations_active_idx": { "name": "budget_reservations_active_idx", @@ -500,57 +527,61 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"budget_reservations\".\"state\" = 'held'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "budget_reservations_company_id_companies_id_fk": { "name": "budget_reservations_company_id_companies_id_fk", "tableFrom": "budget_reservations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "budget_reservations_run_id_heartbeat_runs_id_fk": { "name": "budget_reservations_run_id_heartbeat_runs_id_fk", "tableFrom": "budget_reservations", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "budget_reservations_agent_id_agents_id_fk": { "name": "budget_reservations_agent_id_agents_id_fk", "tableFrom": "budget_reservations", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": { + "budget_reservations_source_check": { + "name": "budget_reservations_source_check", + "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int = 1" + }, "budget_reservations_state_check": { "name": "budget_reservations_state_check", "value": "\"budget_reservations\".\"state\" in ('held', 'settled', 'released')" @@ -673,9 +704,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_adjustments_event_idx": { "name": "cost_adjustments_event_idx", @@ -700,9 +731,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_adjustments_invoice_line_idx": { "name": "cost_adjustments_invoice_line_idx", @@ -721,50 +752,50 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "cost_adjustments_company_id_companies_id_fk": { "name": "cost_adjustments_company_id_companies_id_fk", "tableFrom": "cost_adjustments", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "cost_adjustments_cost_event_id_cost_events_id_fk": { "name": "cost_adjustments_cost_event_id_cost_events_id_fk", "tableFrom": "cost_adjustments", + "tableTo": "cost_events", "columnsFrom": [ "cost_event_id" ], - "tableTo": "cost_events", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk": { "name": "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk", "tableFrom": "cost_adjustments", + "tableTo": "billing_invoice_lines", "columnsFrom": [ "invoice_line_id" ], - "tableTo": "billing_invoice_lines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -876,24 +907,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "provider_billing_snapshots_company_id_companies_id_fk": { "name": "provider_billing_snapshots_company_id_companies_id_fk", "tableFrom": "provider_billing_snapshots", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1003,9 +1034,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "run_usage_receipts_run_latest_idx": { "name": "run_usage_receipts_run_latest_idx", @@ -1030,37 +1061,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "run_usage_receipts_company_id_companies_id_fk": { "name": "run_usage_receipts_company_id_companies_id_fk", "tableFrom": "run_usage_receipts", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "run_usage_receipts_run_id_heartbeat_runs_id_fk": { "name": "run_usage_receipts_run_id_heartbeat_runs_id_fk", "tableFrom": "run_usage_receipts", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1172,9 +1203,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "activity_log_company_agent_created_idx": { "name": "activity_log_company_agent_created_idx", @@ -1199,9 +1230,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "activity_log_company_responsible_user_created_idx": { "name": "activity_log_company_responsible_user_created_idx", @@ -1226,9 +1257,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "activity_log_run_id_idx": { "name": "activity_log_run_id_idx", @@ -1241,9 +1272,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "activity_log_entity_type_id_idx": { "name": "activity_log_entity_type_id_idx", @@ -1262,50 +1293,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "activity_log_company_id_companies_id_fk": { "name": "activity_log_company_id_companies_id_fk", "tableFrom": "activity_log", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "activity_log_agent_id_agents_id_fk": { "name": "activity_log_agent_id_agents_id_fk", "tableFrom": "activity_log", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "activity_log_run_id_heartbeat_runs_id_fk": { "name": "activity_log_run_id_heartbeat_runs_id_fk", "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1461,9 +1492,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "adapter_auth_sessions_company_owner_adapter_active_uq": { "name": "adapter_auth_sessions_company_owner_adapter_active_uq", @@ -1488,10 +1519,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"adapter_auth_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "adapter_auth_sessions_public_session_id_uq": { "name": "adapter_auth_sessions_public_session_id_uq", @@ -1504,9 +1535,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "adapter_auth_sessions_environment_idx": { "name": "adapter_auth_sessions_environment_idx", @@ -1519,9 +1550,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "adapter_auth_sessions_expires_idx": { "name": "adapter_auth_sessions_expires_idx", @@ -1534,9 +1565,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "adapter_auth_sessions_provider_lease_idx": { "name": "adapter_auth_sessions_provider_lease_idx", @@ -1549,37 +1580,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "adapter_auth_sessions_company_id_companies_id_fk": { "name": "adapter_auth_sessions_company_id_companies_id_fk", "tableFrom": "adapter_auth_sessions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "adapter_auth_sessions_environment_id_environments_id_fk": { "name": "adapter_auth_sessions_environment_id_environments_id_fk", "tableFrom": "adapter_auth_sessions", + "tableTo": "environments", "columnsFrom": [ "environment_id" ], - "tableTo": "environments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1667,9 +1698,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_api_keys_company_agent_idx": { "name": "agent_api_keys_company_agent_idx", @@ -1688,37 +1719,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_api_keys_agent_id_agents_id_fk": { "name": "agent_api_keys_agent_id_agents_id_fk", "tableFrom": "agent_api_keys", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agent_api_keys_company_id_companies_id_fk": { "name": "agent_api_keys_company_id_companies_id_fk", "tableFrom": "agent_api_keys", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1818,9 +1849,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_commentary_company_kind_created_idx": { "name": "agent_commentary_company_kind_created_idx", @@ -1845,63 +1876,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_commentary_company_id_companies_id_fk": { "name": "agent_commentary_company_id_companies_id_fk", "tableFrom": "agent_commentary", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "agent_commentary_agent_id_agents_id_fk": { "name": "agent_commentary_agent_id_agents_id_fk", "tableFrom": "agent_commentary", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "agent_commentary_run_id_heartbeat_runs_id_fk": { "name": "agent_commentary_run_id_heartbeat_runs_id_fk", "tableFrom": "agent_commentary", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "agent_commentary_issue_id_issues_id_fk": { "name": "agent_commentary_issue_id_issues_id_fk", "tableFrom": "agent_commentary", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -2014,9 +2045,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_config_revisions_agent_created_idx": { "name": "agent_config_revisions_agent_created_idx", @@ -2035,50 +2066,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_config_revisions_company_id_companies_id_fk": { "name": "agent_config_revisions_company_id_companies_id_fk", "tableFrom": "agent_config_revisions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agent_config_revisions_agent_id_agents_id_fk": { "name": "agent_config_revisions_agent_id_agents_id_fk", "tableFrom": "agent_config_revisions", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "agent_config_revisions_created_by_agent_id_agents_id_fk": { "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", "tableFrom": "agent_config_revisions", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -2140,17 +2171,17 @@ "agent_identity_keys_owner_fk": { "name": "agent_identity_keys_owner_fk", "tableFrom": "agent_identity_keys", + "tableTo": "agents", "columnsFrom": [ "company_id", "agent_id" ], - "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -2205,33 +2236,33 @@ "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk": { "name": "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk", "tableFrom": "agent_instruction_heads", + "tableTo": "agent_instruction_revisions", "columnsFrom": [ "company_id", "agent_id", "entry_file", "revision_id" ], - "tableTo": "agent_instruction_revisions", "columnsTo": [ "company_id", "agent_id", "entry_file", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "agent_instruction_heads_identity_uq": { "name": "agent_instruction_heads_identity_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "agent_id", "entry_file" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -2377,39 +2408,39 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk": { "name": "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk", "tableFrom": "agent_instruction_revisions", + "tableTo": "agents", "columnsFrom": [ "company_id", "agent_id" ], - "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "agent_instruction_revisions_identity_uq": { "name": "agent_instruction_revisions_identity_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "agent_id", "entry_file", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -2569,9 +2600,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_instruction_copies_agent_idx": { "name": "agent_instruction_copies_agent_idx", @@ -2596,39 +2627,39 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk": { "name": "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk", "tableFrom": "agent_instruction_working_copies", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk": { "name": "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk", "tableFrom": "agent_instruction_working_copies", + "tableTo": "agents", "columnsFrom": [ "company_id", "agent_id" ], - "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -2712,9 +2743,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_memberships_company_user_starred_idx": { "name": "agent_memberships_company_user_starred_idx", @@ -2739,9 +2770,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_memberships_agent_idx": { "name": "agent_memberships_agent_idx", @@ -2754,9 +2785,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_memberships_company_user_agent_uq": { "name": "agent_memberships_company_user_agent_uq", @@ -2781,37 +2812,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_memberships_company_id_companies_id_fk": { "name": "agent_memberships_company_id_companies_id_fk", "tableFrom": "agent_memberships", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "agent_memberships_agent_id_agents_id_fk": { "name": "agent_memberships_agent_id_agents_id_fk", "tableFrom": "agent_memberships", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -2934,9 +2965,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_runtime_state_company_updated_idx": { "name": "agent_runtime_state_company_updated_idx", @@ -2955,37 +2986,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_runtime_state_agent_id_agents_id_fk": { "name": "agent_runtime_state_agent_id_agents_id_fk", "tableFrom": "agent_runtime_state", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agent_runtime_state_company_id_companies_id_fk": { "name": "agent_runtime_state_company_id_companies_id_fk", "tableFrom": "agent_runtime_state", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -3099,9 +3130,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_session_goal_actions_company_status_created_idx": { "name": "agent_session_goal_actions_company_status_created_idx", @@ -3126,9 +3157,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_session_goal_actions_session_created_idx": { "name": "agent_session_goal_actions_session_created_idx", @@ -3147,37 +3178,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_session_goal_actions_company_id_companies_id_fk": { "name": "agent_session_goal_actions_company_id_companies_id_fk", "tableFrom": "agent_session_goal_actions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agent_session_goal_actions_session_id_agent_task_sessions_id_fk": { "name": "agent_session_goal_actions_session_id_agent_task_sessions_id_fk", "tableFrom": "agent_session_goal_actions", + "tableTo": "agent_task_sessions", "columnsFrom": [ "session_id" ], - "tableTo": "agent_task_sessions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -3339,9 +3370,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_task_sessions_company_agent_updated_idx": { "name": "agent_task_sessions_company_agent_updated_idx", @@ -3366,9 +3397,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_task_sessions_company_task_updated_idx": { "name": "agent_task_sessions_company_task_updated_idx", @@ -3393,50 +3424,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_task_sessions_company_id_companies_id_fk": { "name": "agent_task_sessions_company_id_companies_id_fk", "tableFrom": "agent_task_sessions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agent_task_sessions_agent_id_agents_id_fk": { "name": "agent_task_sessions_agent_id_agents_id_fk", "tableFrom": "agent_task_sessions", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "last_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -3594,9 +3625,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_wakeup_requests_company_requested_idx": { "name": "agent_wakeup_requests_company_requested_idx", @@ -3615,9 +3646,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_wakeup_requests_agent_requested_idx": { "name": "agent_wakeup_requests_agent_requested_idx", @@ -3636,9 +3667,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agent_wakeup_requests_review_path_recovery_idempotency_uq": { "name": "agent_wakeup_requests_review_path_recovery_idempotency_uq", @@ -3657,10 +3688,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_review_path_lost:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "agent_wakeup_requests_disposition_repair_idempotency_uq": { "name": "agent_wakeup_requests_disposition_repair_idempotency_uq", @@ -3679,10 +3710,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_disposition_repair:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "agent_wakeup_requests_question_response_delivery_idempotency_uq": { "name": "agent_wakeup_requests_question_response_delivery_idempotency_uq", @@ -3701,10 +3732,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "(\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'question-response:%' OR \"agent_wakeup_requests\".\"idempotency_key\" LIKE 'interaction:%') AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "agent_wakeup_requests_connection_intent_delivery_idempotency_uq": { "name": "agent_wakeup_requests_connection_intent_delivery_idempotency_uq", @@ -3723,10 +3754,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'connection-intent:%' AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "agent_wakeup_requests_tool_action_delivery_uq": { "name": "agent_wakeup_requests_tool_action_delivery_uq", @@ -3745,10 +3776,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'tool-action-response:%' AND \"agent_wakeup_requests\".\"status\" NOT IN ('skipped', 'failed', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "agent_wakeup_requests_chat_completion_uq": { "name": "agent_wakeup_requests_chat_completion_uq", @@ -3767,10 +3798,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'chat-completion:%'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "agent_wakeup_requests_company_payload_issue_idx": { "name": "agent_wakeup_requests_company_payload_issue_idx", @@ -3783,43 +3814,43 @@ }, { "expression": "(\"payload\" ->> 'issueId')", - "isExpression": true, "asc": true, + "isExpression": true, "nulls": "last" } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agent_wakeup_requests_company_id_companies_id_fk": { "name": "agent_wakeup_requests_company_id_companies_id_fk", "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agent_wakeup_requests_agent_id_agents_id_fk": { "name": "agent_wakeup_requests_agent_id_agents_id_fk", "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -4012,9 +4043,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agents_company_reports_to_idx": { "name": "agents_company_reports_to_idx", @@ -4033,9 +4064,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "agents_company_default_environment_idx": { "name": "agents_company_default_environment_idx", @@ -4054,61 +4085,61 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "agents_company_id_companies_id_fk": { "name": "agents_company_id_companies_id_fk", "tableFrom": "agents", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agents_reports_to_agents_id_fk": { "name": "agents_reports_to_agents_id_fk", "tableFrom": "agents", + "tableTo": "agents", "columnsFrom": [ "reports_to" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "agents_default_environment_id_environments_id_fk": { "name": "agents_default_environment_id_environments_id_fk", "tableFrom": "agents", + "tableTo": "environments", "columnsFrom": [ "default_environment_id" ], - "tableTo": "environments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "agents_company_id_uq": { "name": "agents_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -4194,39 +4225,39 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "ai_connection_defaults_company_id_companies_id_fk": { "name": "ai_connection_defaults_company_id_companies_id_fk", "tableFrom": "ai_connection_defaults", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_defaults_company_grant_fk": { "name": "ai_connection_defaults_company_grant_fk", "tableFrom": "ai_connection_defaults", + "tableTo": "connection_grants", "columnsFrom": [ "company_id", "grant_id" ], - "tableTo": "connection_grants", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -4235,7 +4266,7 @@ "checkConstraints": { "ai_connection_defaults_provider_check": { "name": "ai_connection_defaults_provider_check", - "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" + "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google','github')" }, "ai_connection_defaults_method_check": { "name": "ai_connection_defaults_method_check", @@ -4299,54 +4330,54 @@ "ai_connection_pools_id_tool_connections_id_fk": { "name": "ai_connection_pools_id_tool_connections_id_fk", "tableFrom": "ai_connection_pools", + "tableTo": "tool_connections", "columnsFrom": [ "id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_pools_company_id_companies_id_fk": { "name": "ai_connection_pools_company_id_companies_id_fk", "tableFrom": "ai_connection_pools", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_pools_company_connection_fk": { "name": "ai_connection_pools_company_connection_fk", "tableFrom": "ai_connection_pools", + "tableTo": "tool_connections", "columnsFrom": [ "company_id", "id" ], - "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "ai_connection_pools_company_id_uq": { "name": "ai_connection_pools_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -4388,43 +4419,43 @@ "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk": { "name": "ai_connection_router_cursors_pool_id_ai_connection_pools_id_fk", "tableFrom": "ai_connection_router_cursors", + "tableTo": "ai_connection_pools", "columnsFrom": [ "pool_id" ], - "tableTo": "ai_connection_pools", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_router_cursors_company_id_companies_id_fk": { "name": "ai_connection_router_cursors_company_id_companies_id_fk", "tableFrom": "ai_connection_router_cursors", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_router_cursors_company_pool_fk": { "name": "ai_connection_router_cursors_company_pool_fk", "tableFrom": "ai_connection_router_cursors", + "tableTo": "ai_connection_pools", "columnsFrom": [ "company_id", "pool_id" ], - "tableTo": "ai_connection_pools", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -4518,80 +4549,80 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "ai_connection_task_pins_company_id_companies_id_fk": { "name": "ai_connection_task_pins_company_id_companies_id_fk", "tableFrom": "ai_connection_task_pins", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk": { "name": "ai_connection_task_pins_pool_id_ai_connection_pools_id_fk", "tableFrom": "ai_connection_task_pins", + "tableTo": "ai_connection_pools", "columnsFrom": [ "pool_id" ], - "tableTo": "ai_connection_pools", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_task_pins_agent_id_agents_id_fk": { "name": "ai_connection_task_pins_agent_id_agents_id_fk", "tableFrom": "ai_connection_task_pins", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_task_pins_company_pool_fk": { "name": "ai_connection_task_pins_company_pool_fk", "tableFrom": "ai_connection_task_pins", + "tableTo": "ai_connection_pools", "columnsFrom": [ "company_id", "pool_id" ], - "tableTo": "ai_connection_pools", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_connection_task_pins_company_agent_fk": { "name": "ai_connection_task_pins_company_agent_fk", "tableFrom": "ai_connection_task_pins", + "tableTo": "agents", "columnsFrom": [ "company_id", "agent_id" ], - "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -4667,39 +4698,39 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "ai_provider_defaults_company_id_companies_id_fk": { "name": "ai_provider_defaults_company_id_companies_id_fk", "tableFrom": "ai_provider_defaults", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "ai_provider_defaults_company_grant_fk": { "name": "ai_provider_defaults_company_grant_fk", "tableFrom": "ai_provider_defaults", + "tableTo": "connection_grants", "columnsFrom": [ "company_id", "grant_id" ], - "tableTo": "connection_grants", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -4708,7 +4739,7 @@ "checkConstraints": { "ai_provider_defaults_provider_check": { "name": "ai_provider_defaults_provider_check", - "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" + "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google','github')" } }, "isRLSEnabled": false @@ -4840,9 +4871,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "approval_comments_approval_idx": { "name": "approval_comments_approval_idx", @@ -4855,9 +4886,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "approval_comments_approval_created_idx": { "name": "approval_comments_approval_created_idx", @@ -4876,50 +4907,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "approval_comments_company_id_companies_id_fk": { "name": "approval_comments_company_id_companies_id_fk", "tableFrom": "approval_comments", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "approval_comments_approval_id_approvals_id_fk": { "name": "approval_comments_approval_id_approvals_id_fk", "tableFrom": "approval_comments", + "tableTo": "approvals", "columnsFrom": [ "approval_id" ], - "tableTo": "approvals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "approval_comments_author_agent_id_agents_id_fk": { "name": "approval_comments_author_agent_id_agents_id_fk", "tableFrom": "approval_comments", + "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -5033,37 +5064,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "approvals_company_id_companies_id_fk": { "name": "approvals_company_id_companies_id_fk", "tableFrom": "approvals", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "approvals_requested_by_agent_id_agents_id_fk": { "name": "approvals_requested_by_agent_id_agents_id_fk", "tableFrom": "approvals", + "tableTo": "agents", "columnsFrom": [ "requested_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -5170,9 +5201,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "assets_company_provider_idx": { "name": "assets_company_provider_idx", @@ -5191,9 +5222,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "assets_company_object_key_uq": { "name": "assets_company_object_key_uq", @@ -5212,37 +5243,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "assets_company_id_companies_id_fk": { "name": "assets_company_id_companies_id_fk", "tableFrom": "assets", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "assets_created_by_agent_id_agents_id_fk": { "name": "assets_created_by_agent_id_agents_id_fk", "tableFrom": "assets", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -5358,24 +5389,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -5442,15 +5473,15 @@ "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -5631,9 +5662,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "board_api_keys_user_idx": { "name": "board_api_keys_user_idx", @@ -5646,24 +5677,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "board_api_keys_user_id_user_id_fk": { "name": "board_api_keys_user_id_user_id_fk", "tableFrom": "board_api_keys", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -5734,9 +5765,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "browser_use_browsers_session_idx": { "name": "browser_use_browsers_session_idx", @@ -5749,37 +5780,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "browser_use_browsers_company_id_companies_id_fk": { "name": "browser_use_browsers_company_id_companies_id_fk", "tableFrom": "browser_use_browsers", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "browser_use_browsers_session_id_browser_use_sessions_id_fk": { "name": "browser_use_browsers_session_id_browser_use_sessions_id_fk", "tableFrom": "browser_use_browsers", + "tableTo": "browser_use_sessions", "columnsFrom": [ "session_id" ], - "tableTo": "browser_use_sessions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -5901,9 +5932,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "browser_use_runs_provider_uq": { "name": "browser_use_runs_provider_uq", @@ -5916,9 +5947,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "browser_use_runs_session_idx": { "name": "browser_use_runs_session_idx", @@ -5931,50 +5962,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "browser_use_runs_company_id_companies_id_fk": { "name": "browser_use_runs_company_id_companies_id_fk", "tableFrom": "browser_use_runs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "browser_use_runs_session_id_browser_use_sessions_id_fk": { "name": "browser_use_runs_session_id_browser_use_sessions_id_fk", "tableFrom": "browser_use_runs", + "tableTo": "browser_use_sessions", "columnsFrom": [ "session_id" ], - "tableTo": "browser_use_sessions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "browser_use_runs_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "browser_use_runs_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "browser_use_runs", + "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -6107,9 +6138,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "browser_use_sessions_provider_uq": { "name": "browser_use_sessions_provider_uq", @@ -6122,76 +6153,76 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "browser_use_sessions_company_id_companies_id_fk": { "name": "browser_use_sessions_company_id_companies_id_fk", "tableFrom": "browser_use_sessions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "browser_use_sessions_issue_id_issues_id_fk": { "name": "browser_use_sessions_issue_id_issues_id_fk", "tableFrom": "browser_use_sessions", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "browser_use_sessions_agent_id_agents_id_fk": { "name": "browser_use_sessions_agent_id_agents_id_fk", "tableFrom": "browser_use_sessions", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "browser_use_sessions_connection_id_tool_connections_id_fk": { "name": "browser_use_sessions_connection_id_tool_connections_id_fk", "tableFrom": "browser_use_sessions", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "browser_use_sessions_grant_id_connection_grants_id_fk": { "name": "browser_use_sessions_grant_id_connection_grants_id_fk", "tableFrom": "browser_use_sessions", + "tableTo": "connection_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "connection_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -6235,28 +6266,28 @@ "browser_use_settings_grant_id_connection_grants_id_fk": { "name": "browser_use_settings_grant_id_connection_grants_id_fk", "tableFrom": "browser_use_settings", + "tableTo": "connection_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "connection_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "browser_use_settings_company_id_companies_id_fk": { "name": "browser_use_settings_company_id_companies_id_fk", "tableFrom": "browser_use_settings", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -6394,9 +6425,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "budget_incidents_company_scope_idx": { "name": "budget_incidents_company_scope_idx", @@ -6427,9 +6458,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "budget_incidents_policy_window_threshold_idx": { "name": "budget_incidents_policy_window_threshold_idx", @@ -6454,51 +6485,51 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"budget_incidents\".\"status\" = 'open'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "budget_incidents_company_id_companies_id_fk": { "name": "budget_incidents_company_id_companies_id_fk", "tableFrom": "budget_incidents", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "budget_incidents_policy_id_budget_policies_id_fk": { "name": "budget_incidents_policy_id_budget_policies_id_fk", "tableFrom": "budget_incidents", + "tableTo": "budget_policies", "columnsFrom": [ "policy_id" ], - "tableTo": "budget_policies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "budget_incidents_approval_id_approvals_id_fk": { "name": "budget_incidents_approval_id_approvals_id_fk", "tableFrom": "budget_incidents", + "tableTo": "approvals", "columnsFrom": [ "approval_id" ], - "tableTo": "approvals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -6669,9 +6700,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "budget_policies_company_window_idx": { "name": "budget_policies_company_window_idx", @@ -6696,9 +6727,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "budget_policies_company_scope_metric_unique_idx": { "name": "budget_policies_company_scope_metric_unique_idx", @@ -6735,24 +6766,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "budget_policies_company_id_companies_id_fk": { "name": "budget_policies_company_id_companies_id_fk", "tableFrom": "budget_policies", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -6853,9 +6884,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "built_in_managed_resources_resource_idx": { "name": "built_in_managed_resources_resource_idx", @@ -6874,9 +6905,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "built_in_managed_resources_company_bundle_resource_uq": { "name": "built_in_managed_resources_company_bundle_resource_uq", @@ -6907,24 +6938,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "built_in_managed_resources_company_id_companies_id_fk": { "name": "built_in_managed_resources_company_id_companies_id_fk", "tableFrom": "built_in_managed_resources", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -6995,9 +7026,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "case_attachments_asset_uq": { "name": "case_attachments_asset_uq", @@ -7010,50 +7041,50 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "case_attachments_company_id_companies_id_fk": { "name": "case_attachments_company_id_companies_id_fk", "tableFrom": "case_attachments", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_attachments_case_id_cases_id_fk": { "name": "case_attachments_case_id_cases_id_fk", "tableFrom": "case_attachments", + "tableTo": "cases", "columnsFrom": [ "case_id" ], - "tableTo": "cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_attachments_asset_id_assets_id_fk": { "name": "case_attachments_asset_id_assets_id_fk", "tableFrom": "case_attachments", + "tableTo": "assets", "columnsFrom": [ "asset_id" ], - "tableTo": "assets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -7136,9 +7167,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "case_documents_document_uq": { "name": "case_documents_document_uq", @@ -7151,9 +7182,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "case_documents_company_case_updated_idx": { "name": "case_documents_company_case_updated_idx", @@ -7178,50 +7209,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "case_documents_company_id_companies_id_fk": { "name": "case_documents_company_id_companies_id_fk", "tableFrom": "case_documents", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_documents_case_id_cases_id_fk": { "name": "case_documents_case_id_cases_id_fk", "tableFrom": "case_documents", + "tableTo": "cases", "columnsFrom": [ "case_id" ], - "tableTo": "cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_documents_document_id_documents_id_fk": { "name": "case_documents_document_id_documents_id_fk", "tableFrom": "case_documents", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -7323,9 +7354,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "case_events_company_case_idx": { "name": "case_events_company_case_idx", @@ -7344,50 +7375,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "case_events_company_id_companies_id_fk": { "name": "case_events_company_id_companies_id_fk", "tableFrom": "case_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_events_case_id_cases_id_fk": { "name": "case_events_case_id_cases_id_fk", "tableFrom": "case_events", + "tableTo": "cases", "columnsFrom": [ "case_id" ], - "tableTo": "cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_events_actor_agent_id_agents_id_fk": { "name": "case_events_actor_agent_id_agents_id_fk", "tableFrom": "case_events", + "tableTo": "agents", "columnsFrom": [ "actor_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -7479,9 +7510,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "case_issue_links_company_case_idx": { "name": "case_issue_links_company_case_idx", @@ -7500,9 +7531,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "case_issue_links_issue_idx": { "name": "case_issue_links_issue_idx", @@ -7515,50 +7546,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "case_issue_links_company_id_companies_id_fk": { "name": "case_issue_links_company_id_companies_id_fk", "tableFrom": "case_issue_links", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_issue_links_case_id_cases_id_fk": { "name": "case_issue_links_case_id_cases_id_fk", "tableFrom": "case_issue_links", + "tableTo": "cases", "columnsFrom": [ "case_id" ], - "tableTo": "cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_issue_links_issue_id_issues_id_fk": { "name": "case_issue_links_issue_id_issues_id_fk", "tableFrom": "case_issue_links", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -7634,9 +7665,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "case_labels_company_case_idx": { "name": "case_labels_company_case_idx", @@ -7655,9 +7686,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "case_labels_label_idx": { "name": "case_labels_label_idx", @@ -7670,50 +7701,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "case_labels_company_id_companies_id_fk": { "name": "case_labels_company_id_companies_id_fk", "tableFrom": "case_labels", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_labels_case_id_cases_id_fk": { "name": "case_labels_case_id_cases_id_fk", "tableFrom": "case_labels", + "tableTo": "cases", "columnsFrom": [ "case_id" ], - "tableTo": "cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "case_labels_label_id_labels_id_fk": { "name": "case_labels_label_id_labels_id_fk", "tableFrom": "case_labels", + "tableTo": "labels", "columnsFrom": [ "label_id" ], - "tableTo": "labels", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -7852,9 +7883,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cases_identifier_uq": { "name": "cases_identifier_uq", @@ -7867,9 +7898,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cases_company_type_key_uq": { "name": "cases_company_type_key_uq", @@ -7894,9 +7925,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cases_company_status_idx": { "name": "cases_company_status_idx", @@ -7915,9 +7946,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cases_company_type_idx": { "name": "cases_company_type_idx", @@ -7936,9 +7967,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cases_company_project_idx": { "name": "cases_company_project_idx", @@ -7957,9 +7988,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cases_parent_idx": { "name": "cases_parent_idx", @@ -7972,9 +8003,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cases_title_search_idx": { "name": "cases_title_search_idx", @@ -7988,9 +8019,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} }, "cases_identifier_search_idx": { "name": "cases_identifier_search_idx", @@ -8004,9 +8035,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} }, "cases_summary_search_idx": { "name": "cases_summary_search_idx", @@ -8020,63 +8051,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} } }, "foreignKeys": { "cases_company_id_companies_id_fk": { "name": "cases_company_id_companies_id_fk", "tableFrom": "cases", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "cases_project_id_projects_id_fk": { "name": "cases_project_id_projects_id_fk", "tableFrom": "cases", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "cases_parent_case_id_cases_id_fk": { "name": "cases_parent_case_id_cases_id_fk", "tableFrom": "cases", + "tableTo": "cases", "columnsFrom": [ "parent_case_id" ], - "tableTo": "cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "cases_created_by_agent_id_agents_id_fk": { "name": "cases_created_by_agent_id_agents_id_fk", "tableFrom": "cases", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -8196,97 +8227,97 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_actions_company_id_companies_id_fk": { "name": "chat_actions_company_id_companies_id_fk", "tableFrom": "chat_actions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_actions_delivery_id_chat_deliveries_id_fk": { "name": "chat_actions_delivery_id_chat_deliveries_id_fk", "tableFrom": "chat_actions", + "tableTo": "chat_deliveries", "columnsFrom": [ "delivery_id" ], - "tableTo": "chat_deliveries", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_actions_company_delivery_fk": { "name": "chat_actions_company_delivery_fk", "tableFrom": "chat_actions", + "tableTo": "chat_deliveries", "columnsFrom": [ "company_id", "delivery_id" ], - "tableTo": "chat_deliveries", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_actions_company_conversation_fk": { "name": "chat_actions_company_conversation_fk", "tableFrom": "chat_actions", + "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], - "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_actions_company_principal_fk": { "name": "chat_actions_company_principal_fk", "tableFrom": "chat_actions", + "tableTo": "chat_external_principals", "columnsFrom": [ "company_id", "principal_id" ], - "tableTo": "chat_external_principals", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_actions_company_endpoint_fk": { "name": "chat_actions_company_endpoint_fk", "tableFrom": "chat_actions", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -8384,54 +8415,54 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_agent_routes_company_id_companies_id_fk": { "name": "chat_agent_routes_company_id_companies_id_fk", "tableFrom": "chat_agent_routes", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_agent_routes_company_source_fk": { "name": "chat_agent_routes_company_source_fk", "tableFrom": "chat_agent_routes", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "source_endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_agent_routes_company_destination_fk": { "name": "chat_agent_routes_company_destination_fk", "tableFrom": "chat_agent_routes", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "destination_endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -8571,9 +8602,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_conversations_thread_uq": { "name": "chat_conversations_thread_uq", @@ -8604,106 +8635,106 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_conversations_company_id_companies_id_fk": { "name": "chat_conversations_company_id_companies_id_fk", "tableFrom": "chat_conversations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_conversations_resource_id_chat_endpoint_resources_id_fk": { "name": "chat_conversations_resource_id_chat_endpoint_resources_id_fk", "tableFrom": "chat_conversations", + "tableTo": "chat_endpoint_resources", "columnsFrom": [ "resource_id" ], - "tableTo": "chat_endpoint_resources", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_conversations_issue_id_issues_id_fk": { "name": "chat_conversations_issue_id_issues_id_fk", "tableFrom": "chat_conversations", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" }, "chat_conversations_company_issue_fk": { "name": "chat_conversations_company_issue_fk", "tableFrom": "chat_conversations", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_conversations_company_endpoint_fk": { "name": "chat_conversations_company_endpoint_fk", "tableFrom": "chat_conversations", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_conversations_company_resource_fk": { "name": "chat_conversations_company_resource_fk", "tableFrom": "chat_conversations", + "tableTo": "chat_endpoint_resources", "columnsFrom": [ "company_id", "resource_id" ], - "tableTo": "chat_endpoint_resources", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_conversations_company_id_uq": { "name": "chat_conversations_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -8846,9 +8877,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_deliveries_event_uq": { "name": "chat_deliveries_event_uq", @@ -8867,9 +8898,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_deliveries_dedupe_uq": { "name": "chat_deliveries_dedupe_uq", @@ -8888,106 +8919,106 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_deliveries_company_id_companies_id_fk": { "name": "chat_deliveries_company_id_companies_id_fk", "tableFrom": "chat_deliveries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_deliveries_conversation_id_chat_conversations_id_fk": { "name": "chat_deliveries_conversation_id_chat_conversations_id_fk", "tableFrom": "chat_deliveries", + "tableTo": "chat_conversations", "columnsFrom": [ "conversation_id" ], - "tableTo": "chat_conversations", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_deliveries_principal_id_chat_external_principals_id_fk": { "name": "chat_deliveries_principal_id_chat_external_principals_id_fk", "tableFrom": "chat_deliveries", + "tableTo": "chat_external_principals", "columnsFrom": [ "principal_id" ], - "tableTo": "chat_external_principals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_deliveries_company_endpoint_fk": { "name": "chat_deliveries_company_endpoint_fk", "tableFrom": "chat_deliveries", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_deliveries_company_conversation_fk": { "name": "chat_deliveries_company_conversation_fk", "tableFrom": "chat_deliveries", + "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], - "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_deliveries_company_principal_fk": { "name": "chat_deliveries_company_principal_fk", "tableFrom": "chat_deliveries", + "tableTo": "chat_external_principals", "columnsFrom": [ "company_id", "principal_id" ], - "tableTo": "chat_external_principals", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_deliveries_company_id_uq": { "name": "chat_deliveries_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -9073,9 +9104,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_endpoint_leases_expiry_idx": { "name": "chat_endpoint_leases_expiry_idx", @@ -9088,39 +9119,39 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_endpoint_leases_company_id_companies_id_fk": { "name": "chat_endpoint_leases_company_id_companies_id_fk", "tableFrom": "chat_endpoint_leases", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_endpoint_leases_company_endpoint_fk": { "name": "chat_endpoint_leases_company_endpoint_fk", "tableFrom": "chat_endpoint_leases", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -9242,9 +9273,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_endpoint_resources_external_uq": { "name": "chat_endpoint_resources_external_uq", @@ -9269,50 +9300,50 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_endpoint_resources_company_id_companies_id_fk": { "name": "chat_endpoint_resources_company_id_companies_id_fk", "tableFrom": "chat_endpoint_resources", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_endpoint_resources_company_endpoint_fk": { "name": "chat_endpoint_resources_company_endpoint_fk", "tableFrom": "chat_endpoint_resources", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_endpoint_resources_company_id_uq": { "name": "chat_endpoint_resources_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -9547,9 +9578,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_endpoints_agent_idx": { "name": "chat_endpoints_agent_idx", @@ -9568,9 +9599,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_endpoints_status_idx": { "name": "chat_endpoints_status_idx", @@ -9589,9 +9620,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_endpoints_public_id_uq": { "name": "chat_endpoints_public_id_uq", @@ -9604,9 +9635,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_endpoints_agentmail_inbox_uq": { "name": "chat_endpoints_agentmail_inbox_uq", @@ -9619,10 +9650,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"chat_endpoints\".\"provider\" = 'agentmail' and \"chat_endpoints\".\"status\" != 'archived' and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "chat_endpoints_connection_uq": { "name": "chat_endpoints_connection_uq", @@ -9635,9 +9666,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_endpoints_live_bot_external_uq": { "name": "chat_endpoints_live_bot_external_uq", @@ -9662,10 +9693,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"provider_account_id\" is not null\n and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "chat_endpoints_photon_number_uq": { "name": "chat_endpoints_photon_number_uq", @@ -9678,10 +9709,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"chat_endpoints\".\"provider\" = 'imessage-photon' and \"chat_endpoints\".\"status\" <> 'archived' and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "chat_endpoints_live_discord_bot_external_uq": { "name": "chat_endpoints_live_discord_bot_external_uq", @@ -9700,10 +9731,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"chat_endpoints\".\"provider\" = 'discord'\n and \"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "chat_endpoints_live_global_app_bot_external_uq": { "name": "chat_endpoints_live_global_app_bot_external_uq", @@ -9722,10 +9753,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"chat_endpoints\".\"provider\" in ('github', 'microsoft-teams')\n and \"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"bot_external_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "chat_endpoints_live_bot_username_uq": { "name": "chat_endpoints_live_bot_username_uq", @@ -9750,79 +9781,79 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"chat_endpoints\".\"status\" in ('verifying', 'active', 'paused', 'attention')\n and \"chat_endpoints\".\"provider_account_id\" is not null\n and \"chat_endpoints\".\"bot_username\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "chat_endpoints_company_id_companies_id_fk": { "name": "chat_endpoints_company_id_companies_id_fk", "tableFrom": "chat_endpoints", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_endpoints_assigned_agent_id_agents_id_fk": { "name": "chat_endpoints_assigned_agent_id_agents_id_fk", "tableFrom": "chat_endpoints", + "tableTo": "agents", "columnsFrom": [ "assigned_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" }, "chat_endpoints_company_agent_fk": { "name": "chat_endpoints_company_agent_fk", "tableFrom": "chat_endpoints", + "tableTo": "agents", "columnsFrom": [ "company_id", "assigned_agent_id" ], - "tableTo": "agents", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_endpoints_company_connection_fk": { "name": "chat_endpoints_company_connection_fk", "tableFrom": "chat_endpoints", + "tableTo": "tool_connections", "columnsFrom": [ "company_id", "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_endpoints_company_id_uq": { "name": "chat_endpoints_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -9958,9 +9989,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_external_principals_external_uq": { "name": "chat_external_principals_external_uq", @@ -9991,35 +10022,35 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_external_principals_company_id_companies_id_fk": { "name": "chat_external_principals_company_id_companies_id_fk", "tableFrom": "chat_external_principals", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_external_principals_company_id_uq": { "name": "chat_external_principals_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -10134,9 +10165,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_identity_links_endpoint_principal_uq": { "name": "chat_identity_links_endpoint_principal_uq", @@ -10155,54 +10186,54 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_identity_links_company_id_companies_id_fk": { "name": "chat_identity_links_company_id_companies_id_fk", "tableFrom": "chat_identity_links", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_identity_links_company_endpoint_fk": { "name": "chat_identity_links_company_endpoint_fk", "tableFrom": "chat_identity_links", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_identity_links_company_principal_fk": { "name": "chat_identity_links_company_principal_fk", "tableFrom": "chat_identity_links", + "tableTo": "chat_external_principals", "columnsFrom": [ "company_id", "principal_id" ], - "tableTo": "chat_external_principals", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -10307,138 +10338,138 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_message_links_company_id_companies_id_fk": { "name": "chat_message_links_company_id_companies_id_fk", "tableFrom": "chat_message_links", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_message_links_delivery_id_chat_deliveries_id_fk": { "name": "chat_message_links_delivery_id_chat_deliveries_id_fk", "tableFrom": "chat_message_links", + "tableTo": "chat_deliveries", "columnsFrom": [ "delivery_id" ], - "tableTo": "chat_deliveries", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_message_links_publication_id_chat_publications_id_fk": { "name": "chat_message_links_publication_id_chat_publications_id_fk", "tableFrom": "chat_message_links", + "tableTo": "chat_publications", "columnsFrom": [ "publication_id" ], - "tableTo": "chat_publications", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_message_links_comment_id_issue_comments_id_fk": { "name": "chat_message_links_comment_id_issue_comments_id_fk", "tableFrom": "chat_message_links", + "tableTo": "issue_comments", "columnsFrom": [ "comment_id" ], - "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_message_links_company_endpoint_fk": { "name": "chat_message_links_company_endpoint_fk", "tableFrom": "chat_message_links", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_message_links_company_delivery_fk": { "name": "chat_message_links_company_delivery_fk", "tableFrom": "chat_message_links", + "tableTo": "chat_deliveries", "columnsFrom": [ "company_id", "delivery_id" ], - "tableTo": "chat_deliveries", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_message_links_company_publication_fk": { "name": "chat_message_links_company_publication_fk", "tableFrom": "chat_message_links", + "tableTo": "chat_publications", "columnsFrom": [ "company_id", "publication_id" ], - "tableTo": "chat_publications", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_message_links_company_comment_fk": { "name": "chat_message_links_company_comment_fk", "tableFrom": "chat_message_links", + "tableTo": "issue_comments", "columnsFrom": [ "company_id", "comment_id" ], - "tableTo": "issue_comments", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_message_links_company_conversation_fk": { "name": "chat_message_links_company_conversation_fk", "tableFrom": "chat_message_links", + "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], - "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -10582,9 +10613,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_publications_work_idx": { "name": "chat_publications_work_idx", @@ -10603,9 +10634,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_publications_idempotency_uq": { "name": "chat_publications_idempotency_uq", @@ -10624,110 +10655,110 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_publications_company_id_companies_id_fk": { "name": "chat_publications_company_id_companies_id_fk", "tableFrom": "chat_publications", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_publications_issue_id_issues_id_fk": { "name": "chat_publications_issue_id_issues_id_fk", "tableFrom": "chat_publications", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" }, "chat_publications_comment_id_issue_comments_id_fk": { "name": "chat_publications_comment_id_issue_comments_id_fk", "tableFrom": "chat_publications", + "tableTo": "issue_comments", "columnsFrom": [ "comment_id" ], - "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_publications_company_issue_fk": { "name": "chat_publications_company_issue_fk", "tableFrom": "chat_publications", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_publications_company_comment_fk": { "name": "chat_publications_company_comment_fk", "tableFrom": "chat_publications", + "tableTo": "issue_comments", "columnsFrom": [ "company_id", "comment_id" ], - "tableTo": "issue_comments", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "chat_publications_company_endpoint_fk": { "name": "chat_publications_company_endpoint_fk", "tableFrom": "chat_publications", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_publications_company_conversation_fk": { "name": "chat_publications_company_conversation_fk", "tableFrom": "chat_publications", + "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], - "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -10822,9 +10853,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_sdk_state_expiry_idx": { "name": "chat_sdk_state_expiry_idx", @@ -10837,39 +10868,39 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_sdk_state_company_id_companies_id_fk": { "name": "chat_sdk_state_company_id_companies_id_fk", "tableFrom": "chat_sdk_state", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_sdk_state_company_endpoint_fk": { "name": "chat_sdk_state_company_endpoint_fk", "tableFrom": "chat_sdk_state", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -10972,9 +11003,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_completion_deliveries_pending_idx": { "name": "chat_completion_deliveries_pending_idx", @@ -10993,63 +11024,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_completion_deliveries_company_id_companies_id_fk": { "name": "chat_completion_deliveries_company_id_companies_id_fk", "tableFrom": "chat_completion_deliveries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_completion_deliveries_task_id_chat_task_handoffs_task_id_fk": { "name": "chat_completion_deliveries_task_id_chat_task_handoffs_task_id_fk", "tableFrom": "chat_completion_deliveries", + "tableTo": "chat_task_handoffs", "columnsFrom": [ "task_id" ], - "tableTo": "chat_task_handoffs", "columnsTo": [ "task_id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_completion_deliveries_target_run_id_heartbeat_runs_id_fk": { "name": "chat_completion_deliveries_target_run_id_heartbeat_runs_id_fk", "tableFrom": "chat_completion_deliveries", + "tableTo": "heartbeat_runs", "columnsFrom": [ "target_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_completion_deliveries_response_comment_id_issue_comments_id_fk": { "name": "chat_completion_deliveries_response_comment_id_issue_comments_id_fk", "tableFrom": "chat_completion_deliveries", + "tableTo": "issue_comments", "columnsFrom": [ "response_comment_id" ], - "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -11118,63 +11149,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_task_handoffs_task_id_issues_id_fk": { "name": "chat_task_handoffs_task_id_issues_id_fk", "tableFrom": "chat_task_handoffs", + "tableTo": "issues", "columnsFrom": [ "task_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_task_handoffs_company_id_companies_id_fk": { "name": "chat_task_handoffs_company_id_companies_id_fk", "tableFrom": "chat_task_handoffs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_task_handoffs_conversation_id_issues_id_fk": { "name": "chat_task_handoffs_conversation_id_issues_id_fk", "tableFrom": "chat_task_handoffs", + "tableTo": "issues", "columnsFrom": [ "conversation_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_task_handoffs_agent_id_agents_id_fk": { "name": "chat_task_handoffs_agent_id_agents_id_fk", "tableFrom": "chat_task_handoffs", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -11287,17 +11318,17 @@ "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_github_configurations", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -11391,9 +11422,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_github_registration_endpoint_idx": { "name": "chat_github_registration_endpoint_idx", @@ -11412,26 +11443,26 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_github_registrations", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -11611,9 +11642,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_github_reviews_pull_idx": { "name": "chat_github_reviews_pull_idx", @@ -11644,9 +11675,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_github_reviews_task_idx": { "name": "chat_github_reviews_task_idx", @@ -11671,65 +11702,65 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_github_reviews_run_id_heartbeat_runs_id_fk": { "name": "chat_github_reviews_run_id_heartbeat_runs_id_fk", "tableFrom": "chat_github_reviews", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_github_reviews", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk": { "name": "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk", "tableFrom": "chat_github_reviews", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "chat_github_reviews_company_id_uq": { "name": "chat_github_reviews_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -11745,6 +11776,139 @@ }, "isRLSEnabled": false }, + "public.chat_slack_registrations": { + "name": "chat_slack_registrations", + "schema": "", + "columns": { + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "manifest_hash": { + "name": "manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "callback_uri": { + "name": "callback_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_ids": { + "name": "secret_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_slack_registrations", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_slack_registration_status_check": { + "name": "chat_slack_registration_status_check", + "value": "\"chat_slack_registrations\".\"status\" in ('creating', 'uncertain', 'failed', 'install', 'credentials_saved', 'configured', 'removed')" + } + }, + "isRLSEnabled": false + }, "public.chat_teams_file_transfers": { "name": "chat_teams_file_transfers", "schema": "", @@ -11995,9 +12159,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_teams_file_transfers_token_uq": { "name": "chat_teams_file_transfers_token_uq", @@ -12016,9 +12180,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "chat_teams_file_transfers_work_idx": { "name": "chat_teams_file_transfers_work_idx", @@ -12043,97 +12207,97 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "chat_teams_file_transfers_company_id_companies_id_fk": { "name": "chat_teams_file_transfers_company_id_companies_id_fk", "tableFrom": "chat_teams_file_transfers", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_teams_file_transfers_issue_id_issues_id_fk": { "name": "chat_teams_file_transfers_issue_id_issues_id_fk", "tableFrom": "chat_teams_file_transfers", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" }, "chat_teams_file_transfers_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_teams_file_transfers_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_teams_file_transfers", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_teams_file_transfers_company_id_publication_id_chat_publications_company_id_id_fk": { "name": "chat_teams_file_transfers_company_id_publication_id_chat_publications_company_id_id_fk", "tableFrom": "chat_teams_file_transfers", + "tableTo": "chat_publications", "columnsFrom": [ "company_id", "publication_id" ], - "tableTo": "chat_publications", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_teams_file_transfers_company_id_conversation_id_chat_conversations_company_id_id_fk": { "name": "chat_teams_file_transfers_company_id_conversation_id_chat_conversations_company_id_id_fk", "tableFrom": "chat_teams_file_transfers", + "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], - "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "chat_teams_file_transfers_company_id_principal_id_chat_external_principals_company_id_id_fk": { "name": "chat_teams_file_transfers_company_id_principal_id_chat_external_principals_company_id_id_fk", "tableFrom": "chat_teams_file_transfers", + "tableTo": "chat_external_principals", "columnsFrom": [ "company_id", "principal_id" ], - "tableTo": "chat_external_principals", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -12270,9 +12434,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cli_auth_challenges_approved_by_idx": { "name": "cli_auth_challenges_approved_by_idx", @@ -12285,9 +12449,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cli_auth_challenges_requested_company_idx": { "name": "cli_auth_challenges_requested_company_idx", @@ -12300,50 +12464,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "cli_auth_challenges_requested_company_id_companies_id_fk": { "name": "cli_auth_challenges_requested_company_id_companies_id_fk", "tableFrom": "cli_auth_challenges", + "tableTo": "companies", "columnsFrom": [ "requested_company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "cli_auth_challenges_approved_by_user_id_user_id_fk": { "name": "cli_auth_challenges_approved_by_user_id_user_id_fk", "tableFrom": "cli_auth_challenges", + "tableTo": "user", "columnsFrom": [ "approved_by_user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk": { "name": "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk", "tableFrom": "cli_auth_challenges", + "tableTo": "board_api_keys", "columnsFrom": [ "board_api_key_id" ], - "tableTo": "board_api_keys", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -12500,9 +12664,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": {}, @@ -12562,9 +12726,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_logos_asset_uq": { "name": "company_logos_asset_uq", @@ -12577,37 +12741,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_logos_company_id_companies_id_fk": { "name": "company_logos_company_id_companies_id_fk", "tableFrom": "company_logos", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_logos_asset_id_assets_id_fk": { "name": "company_logos_asset_id_assets_id_fk", "tableFrom": "company_logos", + "tableTo": "assets", "columnsFrom": [ "asset_id" ], - "tableTo": "assets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -12697,9 +12861,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_memberships_principal_status_idx": { "name": "company_memberships_principal_status_idx", @@ -12724,9 +12888,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_memberships_company_status_idx": { "name": "company_memberships_company_status_idx", @@ -12745,24 +12909,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_memberships_company_id_companies_id_fk": { "name": "company_memberships_company_id_companies_id_fk", "tableFrom": "company_memberships", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -12876,63 +13040,63 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_onboarding_seeds_company_id_companies_id_fk": { "name": "company_onboarding_seeds_company_id_companies_id_fk", "tableFrom": "company_onboarding_seeds", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_onboarding_seeds_goal_id_goals_id_fk": { "name": "company_onboarding_seeds_goal_id_goals_id_fk", "tableFrom": "company_onboarding_seeds", + "tableTo": "goals", "columnsFrom": [ "goal_id" ], - "tableTo": "goals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_onboarding_seeds_agent_id_agents_id_fk": { "name": "company_onboarding_seeds_agent_id_agents_id_fk", "tableFrom": "company_onboarding_seeds", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_onboarding_seeds_issue_id_issues_id_fk": { "name": "company_onboarding_seeds_issue_id_issues_id_fk", "tableFrom": "company_onboarding_seeds", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -13042,9 +13206,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_bindings_secret_idx": { "name": "company_secret_bindings_secret_idx", @@ -13057,9 +13221,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_bindings_target_idx": { "name": "company_secret_bindings_target_idx", @@ -13084,9 +13248,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_bindings_target_path_uq": { "name": "company_secret_bindings_target_path_uq", @@ -13117,37 +13281,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_secret_bindings_company_id_companies_id_fk": { "name": "company_secret_bindings_company_id_companies_id_fk", "tableFrom": "company_secret_bindings", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "company_secret_bindings_secret_id_company_secrets_id_fk": { "name": "company_secret_bindings_secret_id_company_secrets_id_fk", "tableFrom": "company_secret_bindings", + "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], - "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -13382,9 +13546,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_proposals_proposer_status_idx": { "name": "company_secret_proposals_proposer_status_idx", @@ -13403,9 +13567,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_proposals_expiry_idx": { "name": "company_secret_proposals_expiry_idx", @@ -13424,9 +13588,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_proposals_secret_proposal_idx": { "name": "company_secret_proposals_secret_proposal_idx", @@ -13439,9 +13603,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_proposals_interaction_idx": { "name": "company_secret_proposals_interaction_idx", @@ -13454,128 +13618,128 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_secret_proposals_company_id_companies_id_fk": { "name": "company_secret_proposals_company_id_companies_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "company_secret_proposals_secret_id_company_secrets_id_fk": { "name": "company_secret_proposals_secret_id_company_secrets_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], - "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk": { "name": "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "company_secret_proposals", "columnsFrom": [ "secret_proposal_id" ], - "tableTo": "company_secret_proposals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_secret_proposals_target_id_agents_id_fk": { "name": "company_secret_proposals_target_id_agents_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "agents", "columnsFrom": [ "target_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_secret_proposals_proposed_by_agent_id_agents_id_fk": { "name": "company_secret_proposals_proposed_by_agent_id_agents_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "agents", "columnsFrom": [ "proposed_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_secret_proposals_origin_issue_id_issues_id_fk": { "name": "company_secret_proposals_origin_issue_id_issues_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "issues", "columnsFrom": [ "origin_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk": { "name": "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "heartbeat_runs", "columnsFrom": [ "origin_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_secret_proposals_interaction_id_issue_thread_interactions_id_fk": { "name": "company_secret_proposals_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], - "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_secret_proposals_created_secret_id_company_secrets_id_fk": { "name": "company_secret_proposals_created_secret_id_company_secrets_id_fk", "tableFrom": "company_secret_proposals", + "tableTo": "company_secrets", "columnsFrom": [ "created_secret_id" ], - "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -13720,9 +13884,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_provider_configs_company_provider_idx": { "name": "company_secret_provider_configs_company_provider_idx", @@ -13741,9 +13905,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_provider_configs_default_uq": { "name": "company_secret_provider_configs_default_uq", @@ -13762,38 +13926,38 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"company_secret_provider_configs\".\"is_default\" = true", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "company_secret_provider_configs_company_id_companies_id_fk": { "name": "company_secret_provider_configs_company_id_companies_id_fk", "tableFrom": "company_secret_provider_configs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_secret_provider_configs_created_by_agent_id_agents_id_fk": { "name": "company_secret_provider_configs_created_by_agent_id_agents_id_fk", "tableFrom": "company_secret_provider_configs", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -13906,9 +14070,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_versions_value_sha256_idx": { "name": "company_secret_versions_value_sha256_idx", @@ -13921,9 +14085,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_versions_fingerprint_idx": { "name": "company_secret_versions_fingerprint_idx", @@ -13936,9 +14100,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secret_versions_secret_version_uq": { "name": "company_secret_versions_secret_version_uq", @@ -13957,37 +14121,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_secret_versions_secret_id_company_secrets_id_fk": { "name": "company_secret_versions_secret_id_company_secrets_id_fk", "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], - "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_secret_versions_created_by_agent_id_agents_id_fk": { "name": "company_secret_versions_created_by_agent_id_agents_id_fk", "tableFrom": "company_secret_versions", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -14160,9 +14324,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secrets_company_scope_idx": { "name": "company_secrets_company_scope_idx", @@ -14181,9 +14345,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secrets_company_owner_idx": { "name": "company_secrets_company_owner_idx", @@ -14202,9 +14366,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secrets_user_definition_owner_idx": { "name": "company_secrets_user_definition_owner_idx", @@ -14229,9 +14393,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secrets_company_provider_idx": { "name": "company_secrets_company_provider_idx", @@ -14250,9 +14414,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secrets_provider_config_idx": { "name": "company_secrets_provider_config_idx", @@ -14265,9 +14429,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_secrets_company_name_uq": { "name": "company_secrets_company_name_uq", @@ -14286,10 +14450,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "company_secrets_company_key_uq": { "name": "company_secrets_company_key_uq", @@ -14308,10 +14472,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "company_secrets_user_definition_owner_uq": { "name": "company_secrets_user_definition_owner_uq", @@ -14336,64 +14500,64 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"company_secrets\".\"scope\" = 'user' and \"company_secrets\".\"deleted_at\" is null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "company_secrets_company_id_companies_id_fk": { "name": "company_secrets_company_id_companies_id_fk", "tableFrom": "company_secrets", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk": { "name": "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk", "tableFrom": "company_secrets", + "tableTo": "user_secret_definitions", "columnsFrom": [ "user_secret_definition_id" ], - "tableTo": "user_secret_definitions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_secrets_provider_config_id_company_secret_provider_configs_id_fk": { "name": "company_secrets_provider_config_id_company_secret_provider_configs_id_fk", "tableFrom": "company_secrets", + "tableTo": "company_secret_provider_configs", "columnsFrom": [ "provider_config_id" ], - "tableTo": "company_secret_provider_configs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_secrets_created_by_agent_id_agents_id_fk": { "name": "company_secrets_created_by_agent_id_agents_id_fk", "tableFrom": "company_secrets", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -14463,15 +14627,15 @@ "company_skill_policies_company_id_companies_id_fk": { "name": "company_skill_policies_company_id_companies_id_fk", "tableFrom": "company_skill_policies", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -14572,9 +14736,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_source_entries_company_skill_idx": { "name": "company_skill_source_entries_company_skill_idx", @@ -14593,50 +14757,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skill_source_entries_company_id_companies_id_fk": { "name": "company_skill_source_entries_company_id_companies_id_fk", "tableFrom": "company_skill_source_entries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_source_entries_source_id_company_skill_sources_id_fk": { "name": "company_skill_source_entries_source_id_company_skill_sources_id_fk", "tableFrom": "company_skill_source_entries", + "tableTo": "company_skill_sources", "columnsFrom": [ "source_id" ], - "tableTo": "company_skill_sources", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_source_entries_skill_id_company_skills_id_fk": { "name": "company_skill_source_entries_skill_id_company_skills_id_fk", "tableFrom": "company_skill_source_entries", + "tableTo": "company_skills", "columnsFrom": [ "skill_id" ], - "tableTo": "company_skills", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -14781,9 +14945,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_sources_identity_ref_idx": { "name": "company_skill_sources_identity_ref_idx", @@ -14808,37 +14972,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skill_sources_company_id_companies_id_fk": { "name": "company_skill_sources_company_id_companies_id_fk", "tableFrom": "company_skill_sources", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_sources_connection_id_tool_connections_id_fk": { "name": "company_skill_sources_connection_id_tool_connections_id_fk", "tableFrom": "company_skill_sources", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -14939,9 +15103,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_comments_parent_idx": { "name": "company_skill_comments_parent_idx", @@ -14954,63 +15118,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skill_comments_company_id_companies_id_fk": { "name": "company_skill_comments_company_id_companies_id_fk", "tableFrom": "company_skill_comments", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_comments_company_skill_id_company_skills_id_fk": { "name": "company_skill_comments_company_skill_id_company_skills_id_fk", "tableFrom": "company_skill_comments", + "tableTo": "company_skills", "columnsFrom": [ "company_skill_id" ], - "tableTo": "company_skills", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_comments_parent_comment_id_company_skill_comments_id_fk": { "name": "company_skill_comments_parent_comment_id_company_skill_comments_id_fk", "tableFrom": "company_skill_comments", + "tableTo": "company_skill_comments", "columnsFrom": [ "parent_comment_id" ], - "tableTo": "company_skill_comments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_skill_comments_author_agent_id_agents_id_fk": { "name": "company_skill_comments_author_agent_id_agents_id_fk", "tableFrom": "company_skill_comments", + "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -15080,9 +15244,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_stars_skill_user_idx": { "name": "company_skill_stars_skill_user_idx", @@ -15101,9 +15265,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_stars_company_skill_created_idx": { "name": "company_skill_stars_company_skill_created_idx", @@ -15128,50 +15292,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skill_stars_company_id_companies_id_fk": { "name": "company_skill_stars_company_id_companies_id_fk", "tableFrom": "company_skill_stars", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_stars_company_skill_id_company_skills_id_fk": { "name": "company_skill_stars_company_skill_id_company_skills_id_fk", "tableFrom": "company_skill_stars", + "tableTo": "company_skills", "columnsFrom": [ "company_skill_id" ], - "tableTo": "company_skills", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_stars_agent_id_agents_id_fk": { "name": "company_skill_stars_agent_id_agents_id_fk", "tableFrom": "company_skill_stars", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -15266,9 +15430,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_test_inputs_company_skill_active_idx": { "name": "company_skill_test_inputs_company_skill_active_idx", @@ -15293,37 +15457,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skill_test_inputs_company_id_companies_id_fk": { "name": "company_skill_test_inputs_company_id_companies_id_fk", "tableFrom": "company_skill_test_inputs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_test_inputs_skill_id_company_skills_id_fk": { "name": "company_skill_test_inputs_skill_id_company_skills_id_fk", "tableFrom": "company_skill_test_inputs", + "tableTo": "company_skills", "columnsFrom": [ "skill_id" ], - "tableTo": "company_skills", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -15436,50 +15600,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skill_test_run_templates_company_id_companies_id_fk": { "name": "company_skill_test_run_templates_company_id_companies_id_fk", "tableFrom": "company_skill_test_run_templates", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_test_run_templates_created_by_agent_id_agents_id_fk": { "name": "company_skill_test_run_templates_created_by_agent_id_agents_id_fk", "tableFrom": "company_skill_test_run_templates", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk": { "name": "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk", "tableFrom": "company_skill_test_run_templates", + "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -15669,9 +15833,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_test_runs_company_issue_idx": { "name": "company_skill_test_runs_company_issue_idx", @@ -15690,9 +15854,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_test_runs_company_input_created_idx": { "name": "company_skill_test_runs_company_input_created_idx", @@ -15717,9 +15881,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_test_runs_company_status_idx": { "name": "company_skill_test_runs_company_status_idx", @@ -15738,9 +15902,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_test_runs_company_harness_expires_idx": { "name": "company_skill_test_runs_company_harness_expires_idx", @@ -15759,89 +15923,89 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skill_test_runs_company_id_companies_id_fk": { "name": "company_skill_test_runs_company_id_companies_id_fk", "tableFrom": "company_skill_test_runs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_test_runs_skill_id_company_skills_id_fk": { "name": "company_skill_test_runs_skill_id_company_skills_id_fk", "tableFrom": "company_skill_test_runs", + "tableTo": "company_skills", "columnsFrom": [ "skill_id" ], - "tableTo": "company_skills", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk": { "name": "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk", "tableFrom": "company_skill_test_runs", + "tableTo": "company_skill_test_inputs", "columnsFrom": [ "input_id" ], - "tableTo": "company_skill_test_inputs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk": { "name": "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk", "tableFrom": "company_skill_test_runs", + "tableTo": "company_skill_versions", "columnsFrom": [ "skill_version_id" ], - "tableTo": "company_skill_versions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" }, "company_skill_test_runs_agent_id_agents_id_fk": { "name": "company_skill_test_runs_agent_id_agents_id_fk", "tableFrom": "company_skill_test_runs", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" }, "company_skill_test_runs_issue_id_issues_id_fk": { "name": "company_skill_test_runs_issue_id_issues_id_fk", "tableFrom": "company_skill_test_runs", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -15948,9 +16112,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skill_versions_skill_release_idx": { "name": "company_skill_versions_skill_release_idx", @@ -15969,10 +16133,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"company_skill_versions\".\"release_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "company_skill_versions_company_skill_created_idx": { "name": "company_skill_versions_company_skill_created_idx", @@ -15997,50 +16161,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skill_versions_company_id_companies_id_fk": { "name": "company_skill_versions_company_id_companies_id_fk", "tableFrom": "company_skill_versions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_versions_company_skill_id_company_skills_id_fk": { "name": "company_skill_versions_company_skill_id_company_skills_id_fk", "tableFrom": "company_skill_versions", + "tableTo": "company_skills", "columnsFrom": [ "company_skill_id" ], - "tableTo": "company_skills", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "company_skill_versions_author_agent_id_agents_id_fk": { "name": "company_skill_versions_author_agent_id_agents_id_fk", "tableFrom": "company_skill_versions", + "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -16270,9 +16434,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skills_company_name_idx": { "name": "company_skills_company_name_idx", @@ -16291,9 +16455,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skills_company_folder_idx": { "name": "company_skills_company_folder_idx", @@ -16312,9 +16476,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skills_company_categories_idx": { "name": "company_skills_company_categories_idx", @@ -16327,9 +16491,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} }, "company_skills_company_sharing_scope_idx": { "name": "company_skills_company_sharing_scope_idx", @@ -16348,9 +16512,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skills_company_current_version_idx": { "name": "company_skills_company_current_version_idx", @@ -16369,9 +16533,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_skills_company_forked_from_idx": { "name": "company_skills_company_forked_from_idx", @@ -16390,76 +16554,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_skills_company_id_companies_id_fk": { "name": "company_skills_company_id_companies_id_fk", "tableFrom": "company_skills", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "company_skills_folder_id_folders_id_fk": { "name": "company_skills_folder_id_folders_id_fk", "tableFrom": "company_skills", + "tableTo": "folders", "columnsFrom": [ "folder_id" ], - "tableTo": "folders", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_skills_forked_from_skill_id_company_skills_id_fk": { "name": "company_skills_forked_from_skill_id_company_skills_id_fk", "tableFrom": "company_skills", + "tableTo": "company_skills", "columnsFrom": [ "forked_from_skill_id" ], - "tableTo": "company_skills", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_skills_forked_from_company_id_companies_id_fk": { "name": "company_skills_forked_from_company_id_companies_id_fk", "tableFrom": "company_skills", + "tableTo": "companies", "columnsFrom": [ "forked_from_company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "company_skills_current_version_id_company_skill_versions_id_fk": { "name": "company_skills_current_version_id_company_skill_versions_id_fk", "tableFrom": "company_skills", + "tableTo": "company_skill_versions", "columnsFrom": [ "current_version_id" ], - "tableTo": "company_skill_versions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -16594,9 +16758,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_transfer_runs_idempotency_direction_idx": { "name": "company_transfer_runs_idempotency_direction_idx", @@ -16615,9 +16779,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_transfer_runs_actor_status_idx": { "name": "company_transfer_runs_actor_status_idx", @@ -16636,24 +16800,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_transfer_runs_company_id_companies_id_fk": { "name": "company_transfer_runs_company_id_companies_id_fk", "tableFrom": "company_transfer_runs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -16719,9 +16883,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_user_sidebar_preferences_user_idx": { "name": "company_user_sidebar_preferences_user_idx", @@ -16734,9 +16898,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "company_user_sidebar_preferences_company_user_uq": { "name": "company_user_sidebar_preferences_company_user_uq", @@ -16755,24 +16919,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "company_user_sidebar_preferences_company_id_companies_id_fk": { "name": "company_user_sidebar_preferences_company_id_companies_id_fk", "tableFrom": "company_user_sidebar_preferences", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -16896,9 +17060,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "completion_contracts_issue_hash_uq": { "name": "completion_contracts_issue_hash_uq", @@ -16917,68 +17081,68 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "completion_contracts_company_id_companies_id_fk": { "name": "completion_contracts_company_id_companies_id_fk", "tableFrom": "completion_contracts", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "completion_contracts_issue_company_fk": { "name": "completion_contracts_issue_company_fk", "tableFrom": "completion_contracts", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "completion_contracts_supersedes_owner_fk": { "name": "completion_contracts_supersedes_owner_fk", "tableFrom": "completion_contracts", + "tableTo": "completion_contracts", "columnsFrom": [ "company_id", "issue_id", "supersedes_contract_id" ], - "tableTo": "completion_contracts", "columnsTo": [ "company_id", "issue_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "completion_contracts_company_issue_id_uq": { "name": "completion_contracts_company_issue_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -17116,9 +17280,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_event_deliveries_company_status_idx": { "name": "connection_event_deliveries_company_status_idx", @@ -17143,24 +17307,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "connection_event_deliveries_company_id_companies_id_fk": { "name": "connection_event_deliveries_company_id_companies_id_fk", "tableFrom": "connection_event_deliveries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -17224,37 +17388,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "connection_intent_deliveries_interaction_id_issue_thread_interactions_id_fk": { "name": "connection_intent_deliveries_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "connection_intent_deliveries", + "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], - "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_intent_deliveries_company_id_companies_id_fk": { "name": "connection_intent_deliveries_company_id_companies_id_fk", "tableFrom": "connection_intent_deliveries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -17284,7 +17448,20 @@ "name": "agent_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false + }, + "usage_kind": { + "name": "usage_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false }, "issue_id": { "name": "issue_id", @@ -17438,9 +17615,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_events_provider_request_idx": { "name": "cost_events_provider_request_idx", @@ -17465,9 +17642,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_events_company_project_occurred_idx": { "name": "cost_events_company_project_occurred_idx", @@ -17492,9 +17669,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_events_unpriced_idx": { "name": "cost_events_unpriced_idx", @@ -17519,10 +17696,10 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"cost_events\".\"cost_status\" = 'unpriced' and \"cost_events\".\"billing_type\" <> 'subscription_included'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "cost_events_company_occurred_idx": { "name": "cost_events_company_occurred_idx", @@ -17541,9 +17718,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_events_company_agent_occurred_idx": { "name": "cost_events_company_agent_occurred_idx", @@ -17568,9 +17745,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_events_company_provider_occurred_idx": { "name": "cost_events_company_provider_occurred_idx", @@ -17595,9 +17772,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_events_company_biller_occurred_idx": { "name": "cost_events_company_biller_occurred_idx", @@ -17622,9 +17799,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "cost_events_company_heartbeat_run_idx": { "name": "cost_events_company_heartbeat_run_idx", @@ -17643,95 +17820,99 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "cost_events_company_id_companies_id_fk": { "name": "cost_events_company_id_companies_id_fk", "tableFrom": "cost_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "cost_events_agent_id_agents_id_fk": { "name": "cost_events_agent_id_agents_id_fk", "tableFrom": "cost_events", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "cost_events_issue_id_issues_id_fk": { "name": "cost_events_issue_id_issues_id_fk", "tableFrom": "cost_events", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "cost_events_project_id_projects_id_fk": { "name": "cost_events_project_id_projects_id_fk", "tableFrom": "cost_events", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "cost_events_goal_id_goals_id_fk": { "name": "cost_events_goal_id_goals_id_fk", "tableFrom": "cost_events", + "tableTo": "goals", "columnsFrom": [ "goal_id" ], - "tableTo": "goals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "cost_events", + "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": { + "cost_events_usage_kind_check": { + "name": "cost_events_usage_kind_check", + "value": "\"cost_events\".\"usage_kind\" = 'decision' or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" + }, "cost_events_nonnegative_amounts": { "name": "cost_events_nonnegative_amounts", "value": "\"cost_events\".\"cost_cents\" >= 0 and \"cost_events\".\"input_tokens\" >= 0 and \"cost_events\".\"cached_input_tokens\" >= 0 and \"cost_events\".\"output_tokens\" >= 0" @@ -17739,6 +17920,346 @@ }, "isRLSEnabled": false }, + "public.company_decision_models": { + "name": "company_decision_models", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_background": { + "name": "allow_background", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "company_decision_models_company_id_companies_id_fk": { + "name": "company_decision_models_company_id_companies_id_fk", + "tableFrom": "company_decision_models", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "company_decisions_configured": { + "name": "company_decisions_configured", + "value": "(\"company_decision_models\".\"connection_id\" is null) = (\"company_decision_models\".\"grant_id\" is null) and (not \"company_decision_models\".\"enabled\" or (\"company_decision_models\".\"connection_id\" is not null and \"company_decision_models\".\"provider\" is not null and \"company_decision_models\".\"model\" is not null))" + } + }, + "isRLSEnabled": false + }, + "public.decision_invocations": { + "name": "decision_invocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "identity_context_id": { + "name": "identity_context_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "question_types": { + "name": "question_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "decision_invocations_company_started_idx": { + "name": "decision_invocations_company_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_invocations_pending_idx": { + "name": "decision_invocations_pending_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"decision_invocations\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_invocations_cost_idx": { + "name": "decision_invocations_cost_idx", + "columns": [ + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_invocations_company_id_companies_id_fk": { + "name": "decision_invocations_company_id_companies_id_fk", + "tableFrom": "decision_invocations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_invocations_cost_event_id_cost_events_id_fk": { + "name": "decision_invocations_cost_event_id_cost_events_id_fk", + "tableFrom": "decision_invocations", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "decision_invocations_status_check": { + "name": "decision_invocations_status_check", + "value": "\"decision_invocations\".\"status\" in ('running', 'succeeded', 'failed', 'unknown')" + } + }, + "isRLSEnabled": false + }, "public.decision_archive_notification_outbox": { "name": "decision_archive_notification_outbox", "schema": "", @@ -17863,9 +18384,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_archive_notification_outbox_pending_idx": { "name": "decision_archive_notification_outbox_pending_idx", @@ -17884,37 +18405,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_archive_notification_outbox_company_id_companies_id_fk": { "name": "decision_archive_notification_outbox_company_id_companies_id_fk", "tableFrom": "decision_archive_notification_outbox", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_archive_notification_outbox_origin_agent_id_agents_id_fk": { "name": "decision_archive_notification_outbox_origin_agent_id_agents_id_fk", "tableFrom": "decision_archive_notification_outbox", + "tableTo": "agents", "columnsFrom": [ "origin_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -18031,9 +18552,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_queue_items_company_source_idx": { "name": "decision_queue_items_company_source_idx", @@ -18058,78 +18579,78 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_queue_items_company_id_companies_id_fk": { "name": "decision_queue_items_company_id_companies_id_fk", "tableFrom": "decision_queue_items", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_queue_items_added_by_agent_id_agents_id_fk": { "name": "decision_queue_items_added_by_agent_id_agents_id_fk", "tableFrom": "decision_queue_items", + "tableTo": "agents", "columnsFrom": [ "added_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk": { "name": "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_queue_items", + "tableTo": "heartbeat_runs", "columnsFrom": [ "added_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk": { "name": "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk", "tableFrom": "decision_queue_items", + "tableTo": "agent_api_keys", "columnsFrom": [ "added_by_agent_api_key_id" ], - "tableTo": "agent_api_keys", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_queue_items_queue_company_fk": { "name": "decision_queue_items_queue_company_fk", "tableFrom": "decision_queue_items", + "tableTo": "decision_queues", "columnsFrom": [ "queue_id", "company_id" ], - "tableTo": "decision_queues", "columnsTo": [ "id", "company_id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -18261,9 +18782,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_queues_company_updated_idx": { "name": "decision_queues_company_updated_idx", @@ -18282,74 +18803,74 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_queues_company_id_companies_id_fk": { "name": "decision_queues_company_id_companies_id_fk", "tableFrom": "decision_queues", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_queues_created_by_agent_id_agents_id_fk": { "name": "decision_queues_created_by_agent_id_agents_id_fk", "tableFrom": "decision_queues", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_queues_created_by_run_id_heartbeat_runs_id_fk": { "name": "decision_queues_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_queues", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk": { "name": "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk", "tableFrom": "decision_queues", + "tableTo": "agent_api_keys", "columnsFrom": [ "created_by_agent_api_key_id" ], - "tableTo": "agent_api_keys", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "decision_queues_id_company_uq": { "name": "decision_queues_id_company_uq", + "nullsNotDistinct": false, "columns": [ "id", "company_id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -18496,9 +19017,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_retention_company_archived_idx": { "name": "decision_retention_company_archived_idx", @@ -18517,50 +19038,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_retention_company_id_companies_id_fk": { "name": "decision_retention_company_id_companies_id_fk", "tableFrom": "decision_retention", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_retention_archived_by_agent_id_agents_id_fk": { "name": "decision_retention_archived_by_agent_id_agents_id_fk", "tableFrom": "decision_retention", + "tableTo": "agents", "columnsFrom": [ "archived_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_retention_archived_by_run_id_heartbeat_runs_id_fk": { "name": "decision_retention_archived_by_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_retention", + "tableTo": "heartbeat_runs", "columnsFrom": [ "archived_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -18703,9 +19224,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_triage_company_decide_by_idx": { "name": "decision_triage_company_decide_by_idx", @@ -18724,63 +19245,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_triage_company_id_companies_id_fk": { "name": "decision_triage_company_id_companies_id_fk", "tableFrom": "decision_triage", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_triage_set_by_agent_id_agents_id_fk": { "name": "decision_triage_set_by_agent_id_agents_id_fk", "tableFrom": "decision_triage", + "tableTo": "agents", "columnsFrom": [ "set_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_triage_set_by_run_id_heartbeat_runs_id_fk": { "name": "decision_triage_set_by_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_triage", + "tableTo": "heartbeat_runs", "columnsFrom": [ "set_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk": { "name": "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk", "tableFrom": "decision_triage", + "tableTo": "agent_api_keys", "columnsFrom": [ "set_by_agent_api_key_id" ], - "tableTo": "agent_api_keys", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -18920,9 +19441,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_triage_events_queue_created_idx": { "name": "decision_triage_events_queue_created_idx", @@ -18941,76 +19462,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_triage_events_company_id_companies_id_fk": { "name": "decision_triage_events_company_id_companies_id_fk", "tableFrom": "decision_triage_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_triage_events_queue_id_decision_queues_id_fk": { "name": "decision_triage_events_queue_id_decision_queues_id_fk", "tableFrom": "decision_triage_events", + "tableTo": "decision_queues", "columnsFrom": [ "queue_id" ], - "tableTo": "decision_queues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_triage_events_actor_agent_id_agents_id_fk": { "name": "decision_triage_events_actor_agent_id_agents_id_fk", "tableFrom": "decision_triage_events", + "tableTo": "agents", "columnsFrom": [ "actor_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_triage_events_actor_run_id_heartbeat_runs_id_fk": { "name": "decision_triage_events_actor_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_triage_events", + "tableTo": "heartbeat_runs", "columnsFrom": [ "actor_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk": { "name": "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk", "tableFrom": "decision_triage_events", + "tableTo": "agent_api_keys", "columnsFrom": [ "agent_api_key_id" ], - "tableTo": "agent_api_keys", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -19137,9 +19658,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_training_examples_issue_idx": { "name": "decision_training_examples_issue_idx", @@ -19152,9 +19673,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_training_examples_source_author_uq": { "name": "decision_training_examples_source_author_uq", @@ -19179,37 +19700,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_training_examples_company_id_companies_id_fk": { "name": "decision_training_examples_company_id_companies_id_fk", "tableFrom": "decision_training_examples", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_training_examples_issue_id_issues_id_fk": { "name": "decision_training_examples_issue_id_issues_id_fk", "tableFrom": "decision_training_examples", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -19291,63 +19812,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_bundles_company_id_companies_id_fk": { "name": "decision_bundles_company_id_companies_id_fk", "tableFrom": "decision_bundles", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_bundles_origin_agent_id_agents_id_fk": { "name": "decision_bundles_origin_agent_id_agents_id_fk", "tableFrom": "decision_bundles", + "tableTo": "agents", "columnsFrom": [ "origin_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_bundles_origin_issue_id_issues_id_fk": { "name": "decision_bundles_origin_issue_id_issues_id_fk", "tableFrom": "decision_bundles", + "tableTo": "issues", "columnsFrom": [ "origin_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_bundles_origin_run_id_heartbeat_runs_id_fk": { "name": "decision_bundles_origin_run_id_heartbeat_runs_id_fk", "tableFrom": "decision_bundles", + "tableTo": "heartbeat_runs", "columnsFrom": [ "origin_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -19441,9 +19962,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_effect_executions_target_issue_idx": { "name": "decision_effect_executions_target_issue_idx", @@ -19456,50 +19977,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_effect_executions_decision_id_decisions_id_fk": { "name": "decision_effect_executions_decision_id_decisions_id_fk", "tableFrom": "decision_effect_executions", + "tableTo": "decisions", "columnsFrom": [ "decision_id" ], - "tableTo": "decisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_effect_executions_target_issue_id_issues_id_fk": { "name": "decision_effect_executions_target_issue_id_issues_id_fk", "tableFrom": "decision_effect_executions", + "tableTo": "issues", "columnsFrom": [ "target_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decision_effect_executions_activity_log_id_activity_log_id_fk": { "name": "decision_effect_executions_activity_log_id_activity_log_id_fk", "tableFrom": "decision_effect_executions", + "tableTo": "activity_log", "columnsFrom": [ "activity_log_id" ], - "tableTo": "activity_log", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -19543,9 +20064,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decision_target_issues_issue_idx": { "name": "decision_target_issues_issue_idx", @@ -19558,50 +20079,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "decision_target_issues_decision_id_decisions_id_fk": { "name": "decision_target_issues_decision_id_decisions_id_fk", "tableFrom": "decision_target_issues", + "tableTo": "decisions", "columnsFrom": [ "decision_id" ], - "tableTo": "decisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_target_issues_issue_id_issues_id_fk": { "name": "decision_target_issues_issue_id_issues_id_fk", "tableFrom": "decision_target_issues", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "decision_target_issues_company_id_companies_id_fk": { "name": "decision_target_issues_company_id_companies_id_fk", "tableFrom": "decision_target_issues", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": { @@ -19803,9 +20324,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decisions_bundle_idx": { "name": "decisions_bundle_idx", @@ -19818,9 +20339,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decisions_origin_issue_idx": { "name": "decisions_origin_issue_idx", @@ -19833,9 +20354,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "decisions_company_idempotency_uq": { "name": "decisions_company_idempotency_uq", @@ -19854,77 +20375,77 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"decisions\".\"idempotency_key\" IS NOT NULL", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "decisions_company_id_companies_id_fk": { "name": "decisions_company_id_companies_id_fk", "tableFrom": "decisions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decisions_bundle_id_decision_bundles_id_fk": { "name": "decisions_bundle_id_decision_bundles_id_fk", "tableFrom": "decisions", + "tableTo": "decision_bundles", "columnsFrom": [ "bundle_id" ], - "tableTo": "decision_bundles", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "decisions_origin_agent_id_agents_id_fk": { "name": "decisions_origin_agent_id_agents_id_fk", "tableFrom": "decisions", + "tableTo": "agents", "columnsFrom": [ "origin_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decisions_origin_issue_id_issues_id_fk": { "name": "decisions_origin_issue_id_issues_id_fk", "tableFrom": "decisions", + "tableTo": "issues", "columnsFrom": [ "origin_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "decisions_origin_run_id_heartbeat_runs_id_fk": { "name": "decisions_origin_run_id_heartbeat_runs_id_fk", "tableFrom": "decisions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "origin_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -20048,9 +20569,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_anchor_snapshots_company_document_revision_idx": { "name": "document_annotation_anchor_snapshots_company_document_revision_idx", @@ -20075,76 +20596,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "document_annotation_anchor_snapshots_company_id_companies_id_fk": { "name": "document_annotation_anchor_snapshots_company_id_companies_id_fk", "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk": { "name": "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk", "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "document_annotation_threads", "columnsFrom": [ "thread_id" ], - "tableTo": "document_annotation_threads", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_anchor_snapshots_document_id_documents_id_fk": { "name": "document_annotation_anchor_snapshots_document_id_documents_id_fk", "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk": { "name": "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk", "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "document_revisions", "columnsFrom": [ "from_revision_id" ], - "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk": { "name": "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk", "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "document_revisions", "columnsFrom": [ "to_revision_id" ], - "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -20281,9 +20802,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_comments_company_issue_created_at_idx": { "name": "document_annotation_comments_company_issue_created_at_idx", @@ -20308,9 +20829,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_comments_company_routine_created_at_idx": { "name": "document_annotation_comments_company_routine_created_at_idx", @@ -20335,9 +20856,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_comments_company_case_created_at_idx": { "name": "document_annotation_comments_company_case_created_at_idx", @@ -20362,9 +20883,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_comments_company_document_created_at_idx": { "name": "document_annotation_comments_company_document_created_at_idx", @@ -20389,9 +20910,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_comments_issue_comment_idx": { "name": "document_annotation_comments_issue_comment_idx", @@ -20404,9 +20925,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_comments_body_search_idx": { "name": "document_annotation_comments_body_search_idx", @@ -20420,128 +20941,128 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} } }, "foreignKeys": { "document_annotation_comments_company_id_companies_id_fk": { "name": "document_annotation_comments_company_id_companies_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "document_annotation_comments_thread_id_document_annotation_threads_id_fk": { "name": "document_annotation_comments_thread_id_document_annotation_threads_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "document_annotation_threads", "columnsFrom": [ "thread_id" ], - "tableTo": "document_annotation_threads", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_comments_issue_id_issues_id_fk": { "name": "document_annotation_comments_issue_id_issues_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_comments_routine_id_routines_id_fk": { "name": "document_annotation_comments_routine_id_routines_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "routines", "columnsFrom": [ "routine_id" ], - "tableTo": "routines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_comments_case_id_cases_id_fk": { "name": "document_annotation_comments_case_id_cases_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "cases", "columnsFrom": [ "case_id" ], - "tableTo": "cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_comments_document_id_documents_id_fk": { "name": "document_annotation_comments_document_id_documents_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_comments_author_agent_id_agents_id_fk": { "name": "document_annotation_comments_author_agent_id_agents_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk": { "name": "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "document_annotation_comments_issue_comment_id_issue_comments_id_fk": { "name": "document_annotation_comments_issue_comment_id_issue_comments_id_fk", "tableFrom": "document_annotation_comments", + "tableTo": "issue_comments", "columnsFrom": [ "issue_comment_id" ], - "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -20766,9 +21287,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_threads_company_issue_status_idx": { "name": "document_annotation_threads_company_issue_status_idx", @@ -20793,9 +21314,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_threads_company_routine_status_idx": { "name": "document_annotation_threads_company_routine_status_idx", @@ -20820,9 +21341,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_threads_company_case_status_idx": { "name": "document_annotation_threads_company_case_status_idx", @@ -20847,9 +21368,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_threads_company_current_revision_open_idx": { "name": "document_annotation_threads_company_current_revision_open_idx", @@ -20880,9 +21401,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_annotation_threads_company_anchor_state_idx": { "name": "document_annotation_threads_company_anchor_state_idx", @@ -20901,128 +21422,128 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "document_annotation_threads_company_id_companies_id_fk": { "name": "document_annotation_threads_company_id_companies_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "document_annotation_threads_issue_id_issues_id_fk": { "name": "document_annotation_threads_issue_id_issues_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_threads_routine_id_routines_id_fk": { "name": "document_annotation_threads_routine_id_routines_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "routines", "columnsFrom": [ "routine_id" ], - "tableTo": "routines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_threads_case_id_cases_id_fk": { "name": "document_annotation_threads_case_id_cases_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "cases", "columnsFrom": [ "case_id" ], - "tableTo": "cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_threads_document_id_documents_id_fk": { "name": "document_annotation_threads_document_id_documents_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_annotation_threads_original_revision_id_document_revisions_id_fk": { "name": "document_annotation_threads_original_revision_id_document_revisions_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "document_revisions", "columnsFrom": [ "original_revision_id" ], - "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "document_annotation_threads_current_revision_id_document_revisions_id_fk": { "name": "document_annotation_threads_current_revision_id_document_revisions_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "document_revisions", "columnsFrom": [ "current_revision_id" ], - "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "document_annotation_threads_created_by_agent_id_agents_id_fk": { "name": "document_annotation_threads_created_by_agent_id_agents_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "document_annotation_threads_resolved_by_agent_id_agents_id_fk": { "name": "document_annotation_threads_resolved_by_agent_id_agents_id_fk", "tableFrom": "document_annotation_threads", + "tableTo": "agents", "columnsFrom": [ "resolved_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -21110,9 +21631,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_memberships_company_user_document_uq": { "name": "document_memberships_company_user_document_uq", @@ -21137,37 +21658,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "document_memberships_company_id_companies_id_fk": { "name": "document_memberships_company_id_companies_id_fk", "tableFrom": "document_memberships", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_memberships_document_id_documents_id_fk": { "name": "document_memberships_document_id_documents_id_fk", "tableFrom": "document_memberships", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -21274,9 +21795,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "document_revisions_company_document_created_idx": { "name": "document_revisions_company_document_created_idx", @@ -21301,63 +21822,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "document_revisions_company_id_companies_id_fk": { "name": "document_revisions_company_id_companies_id_fk", "tableFrom": "document_revisions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_revisions_document_id_documents_id_fk": { "name": "document_revisions_document_id_documents_id_fk", "tableFrom": "document_revisions", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "document_revisions_created_by_agent_id_agents_id_fk": { "name": "document_revisions_created_by_agent_id_agents_id_fk", "tableFrom": "document_revisions", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "document_revisions_created_by_run_id_heartbeat_runs_id_fk": { "name": "document_revisions_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "document_revisions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -21496,9 +22017,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "documents_company_created_idx": { "name": "documents_company_created_idx", @@ -21517,9 +22038,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "documents_title_search_idx": { "name": "documents_title_search_idx", @@ -21533,9 +22054,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} }, "documents_latest_body_search_idx": { "name": "documents_latest_body_search_idx", @@ -21549,63 +22070,803 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} } }, "foreignKeys": { "documents_company_id_companies_id_fk": { "name": "documents_company_id_companies_id_fk", "tableFrom": "documents", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "documents_created_by_agent_id_agents_id_fk": { "name": "documents_created_by_agent_id_agents_id_fk", "tableFrom": "documents", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "documents_updated_by_agent_id_agents_id_fk": { "name": "documents_updated_by_agent_id_agents_id_fk", "tableFrom": "documents", + "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "documents_locked_by_agent_id_agents_id_fk": { "name": "documents_locked_by_agent_id_agents_id_fk", "tableFrom": "documents", + "tableTo": "agents", "columnsFrom": [ "locked_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dot_agent_bindings": { + "name": "dot_agent_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "operator_id": { + "name": "operator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pairing'" + }, + "pairing_code_hash": { + "name": "pairing_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pairing_expires_at": { + "name": "pairing_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dot_url": { + "name": "dot_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "challenge_hash": { + "name": "challenge_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "challenge_expires_at": { + "name": "challenge_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ready_at": { + "name": "ready_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dot_bindings_active_agent_uq": { + "name": "dot_bindings_active_agent_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"dot_agent_bindings\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "dot_bindings_active_grant_uq": { + "name": "dot_bindings_active_grant_uq", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"dot_agent_bindings\".\"revoked_at\" IS NULL AND \"dot_agent_bindings\".\"grant_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "dot_bindings_pairing_code_uq": { + "name": "dot_bindings_pairing_code_uq", + "columns": [ + { + "expression": "pairing_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dot_agent_bindings_company_id_companies_id_fk": { + "name": "dot_agent_bindings_company_id_companies_id_fk", + "tableFrom": "dot_agent_bindings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dot_agent_bindings_agent_id_agents_id_fk": { + "name": "dot_agent_bindings_agent_id_agents_id_fk", + "tableFrom": "dot_agent_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dot_agent_bindings_grant_id_mcp_oauth_grants_id_fk": { + "name": "dot_agent_bindings_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "dot_agent_bindings", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dot_mailbox_items": { + "name": "dot_mailbox_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "binding_generation": { + "name": "binding_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assignment_id": { + "name": "assignment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "references": { + "name": "references", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dot_mailbox_event_uq": { + "name": "dot_mailbox_event_uq", + "columns": [ + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dot_mailbox_binding_cursor_idx": { + "name": "dot_mailbox_binding_cursor_idx", + "columns": [ + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dot_mailbox_items_company_id_companies_id_fk": { + "name": "dot_mailbox_items_company_id_companies_id_fk", + "tableFrom": "dot_mailbox_items", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dot_mailbox_items_binding_id_dot_agent_bindings_id_fk": { + "name": "dot_mailbox_items_binding_id_dot_agent_bindings_id_fk", + "tableFrom": "dot_mailbox_items", + "tableTo": "dot_agent_bindings", + "columnsFrom": [ + "binding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dot_mailbox_items_assignment_id_dot_runner_assignments_id_fk": { + "name": "dot_mailbox_items_assignment_id_dot_runner_assignments_id_fk", + "tableFrom": "dot_mailbox_items", + "tableTo": "dot_runner_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dot_runner_assignments": { + "name": "dot_runner_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "binding_generation": { + "name": "binding_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "normalized_session_id": { + "name": "normalized_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "controller_generation": { + "name": "controller_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "catalog_digest": { + "name": "catalog_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projection": { + "name": "projection", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "accept_by": { + "name": "accept_by", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dot_assignments_turn_uq": { + "name": "dot_assignments_turn_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dot_assignments_active_binding_uq": { + "name": "dot_assignments_active_binding_uq", + "columns": [ + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"dot_runner_assignments\".\"status\" IN ('offered', 'accepted')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "dot_assignments_company_idx": { + "name": "dot_assignments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dot_runner_assignments_company_id_companies_id_fk": { + "name": "dot_runner_assignments_company_id_companies_id_fk", + "tableFrom": "dot_runner_assignments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dot_runner_assignments_binding_id_dot_agent_bindings_id_fk": { + "name": "dot_runner_assignments_binding_id_dot_agent_bindings_id_fk", + "tableFrom": "dot_runner_assignments", + "tableTo": "dot_agent_bindings", + "columnsFrom": [ + "binding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dot_runner_assignments_run_id_heartbeat_runs_id_fk": { + "name": "dot_runner_assignments_run_id_heartbeat_runs_id_fk", + "tableFrom": "dot_runner_assignments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dot_runner_assignments_agent_id_agents_id_fk": { + "name": "dot_runner_assignments_agent_id_agents_id_fk", + "tableFrom": "dot_runner_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dot_runner_operations": { + "name": "dot_runner_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignment_id": { + "name": "assignment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "digest": { + "name": "digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dot_operations_request_uq": { + "name": "dot_operations_request_uq", + "columns": [ + { + "expression": "assignment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dot_runner_operations_company_id_companies_id_fk": { + "name": "dot_runner_operations_company_id_companies_id_fk", + "tableFrom": "dot_runner_operations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dot_runner_operations_assignment_id_dot_runner_assignments_id_fk": { + "name": "dot_runner_operations_assignment_id_dot_runner_assignments_id_fk", + "tableFrom": "dot_runner_operations", + "tableTo": "dot_runner_assignments", + "columnsFrom": [ + "assignment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -21672,17 +22933,17 @@ "email_endpoints_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "email_endpoints_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "email_endpoints", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -21795,9 +23056,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "email_messages_conversation_idx": { "name": "email_messages_conversation_idx", @@ -21822,41 +23083,41 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "email_messages_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "email_messages_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "email_messages", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "email_messages_company_id_conversation_id_chat_conversations_company_id_id_fk": { "name": "email_messages_company_id_conversation_id_chat_conversations_company_id_id_fk", "tableFrom": "email_messages", + "tableTo": "chat_conversations", "columnsFrom": [ "company_id", "conversation_id" ], - "tableTo": "chat_conversations", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -21942,42 +23203,42 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"email_sends\".\"outcome\" in ('queued', 'uncertain')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "email_sends_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "email_sends_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "email_sends", + "tableTo": "chat_endpoints", "columnsFrom": [ "company_id", "endpoint_id" ], - "tableTo": "chat_endpoints", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "email_sends_company_id_publication_id_chat_publications_company_id_id_fk": { "name": "email_sends_company_id_publication_id_chat_publications_company_id_id_fk", "tableFrom": "email_sends", + "tableTo": "chat_publications", "columnsFrom": [ "company_id", "publication_id" ], - "tableTo": "chat_publications", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -22132,9 +23393,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_custom_image_setup_sessions_environment_active_uq": { "name": "environment_custom_image_setup_sessions_environment_active_uq", @@ -22147,10 +23408,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"environment_custom_image_setup_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'capturing')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "environment_custom_image_setup_sessions_template_idx": { "name": "environment_custom_image_setup_sessions_template_idx", @@ -22163,9 +23424,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_custom_image_setup_sessions_promoted_template_idx": { "name": "environment_custom_image_setup_sessions_promoted_template_idx", @@ -22178,9 +23439,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_custom_image_setup_sessions_expires_idx": { "name": "environment_custom_image_setup_sessions_expires_idx", @@ -22193,9 +23454,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_custom_image_setup_sessions_provider_lease_idx": { "name": "environment_custom_image_setup_sessions_provider_lease_idx", @@ -22214,76 +23475,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "environment_custom_image_setup_sessions_environment_id_environments_id_fk": { "name": "environment_custom_image_setup_sessions_environment_id_environments_id_fk", "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "environments", "columnsFrom": [ "environment_id" ], - "tableTo": "environments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "environment_custom_image_setup_sessions_template_id_environment_custom_image_templates_id_fk": { "name": "environment_custom_image_setup_sessions_template_id_environment_custom_image_templates_id_fk", "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "environment_custom_image_templates", "columnsFrom": [ "template_id" ], - "tableTo": "environment_custom_image_templates", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "environment_custom_image_setup_sessions_promoted_template_id_environment_custom_image_templates_id_fk": { "name": "environment_custom_image_setup_sessions_promoted_template_id_environment_custom_image_templates_id_fk", "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "environment_custom_image_templates", "columnsFrom": [ "promoted_template_id" ], - "tableTo": "environment_custom_image_templates", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk": { "name": "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk", "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "environment_leases", "columnsFrom": [ "environment_lease_id" ], - "tableTo": "environment_leases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk": { "name": "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk", "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "agents", "columnsFrom": [ "started_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -22416,9 +23677,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_custom_image_templates_environment_provider_status_idx": { "name": "environment_custom_image_templates_environment_provider_status_idx", @@ -22443,9 +23704,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_custom_image_templates_environment_active_uq": { "name": "environment_custom_image_templates_environment_active_uq", @@ -22458,10 +23719,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"environment_custom_image_templates\".\"status\" = 'active'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "environment_custom_image_templates_superseded_by_idx": { "name": "environment_custom_image_templates_superseded_by_idx", @@ -22474,9 +23735,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_custom_image_templates_last_used_idx": { "name": "environment_custom_image_templates_last_used_idx", @@ -22489,50 +23750,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "environment_custom_image_templates_environment_id_environments_id_fk": { "name": "environment_custom_image_templates_environment_id_environments_id_fk", "tableFrom": "environment_custom_image_templates", + "tableTo": "environments", "columnsFrom": [ "environment_id" ], - "tableTo": "environments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "environment_custom_image_templates_created_by_agent_id_agents_id_fk": { "name": "environment_custom_image_templates_created_by_agent_id_agents_id_fk", "tableFrom": "environment_custom_image_templates", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "environment_custom_image_templates_superseded_by_template_id_environment_custom_image_templates_id_fk": { "name": "environment_custom_image_templates_superseded_by_template_id_environment_custom_image_templates_id_fk", "tableFrom": "environment_custom_image_templates", + "tableTo": "environment_custom_image_templates", "columnsFrom": [ "superseded_by_template_id" ], - "tableTo": "environment_custom_image_templates", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -22691,9 +23952,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_leases_company_execution_workspace_idx": { "name": "environment_leases_company_execution_workspace_idx", @@ -22712,9 +23973,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_leases_company_issue_idx": { "name": "environment_leases_company_issue_idx", @@ -22733,9 +23994,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_leases_heartbeat_run_idx": { "name": "environment_leases_heartbeat_run_idx", @@ -22748,9 +24009,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_leases_company_last_used_idx": { "name": "environment_leases_company_last_used_idx", @@ -22769,9 +24030,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environment_leases_provider_lease_idx": { "name": "environment_leases_provider_lease_idx", @@ -22784,76 +24045,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "environment_leases_company_id_companies_id_fk": { "name": "environment_leases_company_id_companies_id_fk", "tableFrom": "environment_leases", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "environment_leases_environment_id_environments_id_fk": { "name": "environment_leases_environment_id_environments_id_fk", "tableFrom": "environment_leases", + "tableTo": "environments", "columnsFrom": [ "environment_id" ], - "tableTo": "environments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "environment_leases_execution_workspace_id_execution_workspaces_id_fk": { "name": "environment_leases_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "environment_leases", + "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], - "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "environment_leases_issue_id_issues_id_fk": { "name": "environment_leases_issue_id_issues_id_fk", "tableFrom": "environment_leases", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "environment_leases", + "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -22946,9 +24207,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "environments_local_driver_idx": { "name": "environments_local_driver_idx", @@ -22961,10 +24222,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"environments\".\"driver\" = 'local'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "environments_managed_sandbox_idx": { "name": "environments_managed_sandbox_idx", @@ -22977,10 +24238,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"environments\".\"driver\" = 'sandbox' AND (\"environments\".\"metadata\" ->> 'managedByPaperclip')::boolean = true", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "environments_name_idx": { "name": "environments_name_idx", @@ -22993,9 +24254,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": {}, @@ -23117,9 +24378,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "execution_workspace_runtime_leases_company_owner_idx": { "name": "execution_workspace_runtime_leases_company_owner_idx", @@ -23138,9 +24399,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "execution_workspace_runtime_leases_expires_at_idx": { "name": "execution_workspace_runtime_leases_expires_at_idx", @@ -23153,86 +24414,86 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "execution_workspace_runtime_leases_company_id_companies_id_fk": { "name": "execution_workspace_runtime_leases_company_id_companies_id_fk", "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk": { "name": "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], - "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk": { "name": "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk", "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "issues", "columnsFrom": [ "owner_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk": { "name": "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk", "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "heartbeat_runs", "columnsFrom": [ "owner_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk": { "name": "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk", "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "execution_workspace_runtime_leases_execution_workspace_id_unique": { "name": "execution_workspace_runtime_leases_execution_workspace_id_unique", + "nullsNotDistinct": false, "columns": [ "execution_workspace_id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -23419,9 +24680,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "execution_workspaces_company_project_workspace_status_idx": { "name": "execution_workspaces_company_project_workspace_status_idx", @@ -23446,9 +24707,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "execution_workspaces_company_source_issue_idx": { "name": "execution_workspaces_company_source_issue_idx", @@ -23467,9 +24728,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "execution_workspaces_company_last_used_idx": { "name": "execution_workspaces_company_last_used_idx", @@ -23488,9 +24749,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "execution_workspaces_company_branch_idx": { "name": "execution_workspaces_company_branch_idx", @@ -23509,76 +24770,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "execution_workspaces_company_id_companies_id_fk": { "name": "execution_workspaces_company_id_companies_id_fk", "tableFrom": "execution_workspaces", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "execution_workspaces_project_id_projects_id_fk": { "name": "execution_workspaces_project_id_projects_id_fk", "tableFrom": "execution_workspaces", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk", "tableFrom": "execution_workspaces", + "tableTo": "project_workspaces", "columnsFrom": [ "project_workspace_id" ], - "tableTo": "project_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "execution_workspaces_source_issue_id_issues_id_fk": { "name": "execution_workspaces_source_issue_id_issues_id_fk", "tableFrom": "execution_workspaces", + "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": { "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "execution_workspaces", + "tableTo": "execution_workspaces", "columnsFrom": [ "derived_from_execution_workspace_id" ], - "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -23728,9 +24989,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "external_object_mentions_company_object_idx": { "name": "external_object_mentions_company_object_idx", @@ -23749,9 +25010,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "external_object_mentions_company_provider_idx": { "name": "external_object_mentions_company_provider_idx", @@ -23776,9 +25037,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "external_object_mentions_company_source_record_uq": { "name": "external_object_mentions_company_source_record_uq", @@ -23827,10 +25088,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"external_object_mentions\".\"source_record_id\" is not null and \"external_object_mentions\".\"canonical_identity_hash\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "external_object_mentions_company_source_null_record_uq": { "name": "external_object_mentions_company_source_null_record_uq", @@ -23873,64 +25134,64 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"external_object_mentions\".\"source_record_id\" is null and \"external_object_mentions\".\"canonical_identity_hash\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "external_object_mentions_company_id_companies_id_fk": { "name": "external_object_mentions_company_id_companies_id_fk", "tableFrom": "external_object_mentions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "external_object_mentions_source_issue_id_issues_id_fk": { "name": "external_object_mentions_source_issue_id_issues_id_fk", "tableFrom": "external_object_mentions", + "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "external_object_mentions_object_id_external_objects_id_fk": { "name": "external_object_mentions_object_id_external_objects_id_fk", "tableFrom": "external_object_mentions", + "tableTo": "external_objects", "columnsFrom": [ "object_id" ], - "tableTo": "external_objects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "external_object_mentions_created_by_plugin_id_plugins_id_fk": { "name": "external_object_mentions_created_by_plugin_id_plugins_id_fk", "tableFrom": "external_object_mentions", + "tableTo": "plugins", "columnsFrom": [ "created_by_plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -24162,9 +25423,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "external_objects_company_provider_status_idx": { "name": "external_objects_company_provider_status_idx", @@ -24189,9 +25450,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "external_objects_company_refresh_idx": { "name": "external_objects_company_refresh_idx", @@ -24210,9 +25471,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "external_objects_company_external_id_uq": { "name": "external_objects_company_external_id_uq", @@ -24243,9 +25504,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "external_objects_company_identity_uq": { "name": "external_objects_company_identity_uq", @@ -24276,37 +25537,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "external_objects_company_id_companies_id_fk": { "name": "external_objects_company_id_companies_id_fk", "tableFrom": "external_objects", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "external_objects_plugin_id_plugins_id_fk": { "name": "external_objects_plugin_id_plugins_id_fk", "tableFrom": "external_objects", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -24496,9 +25757,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "feedback_exports_company_created_idx": { "name": "feedback_exports_company_created_idx", @@ -24517,9 +25778,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "feedback_exports_company_status_idx": { "name": "feedback_exports_company_status_idx", @@ -24544,9 +25805,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "feedback_exports_company_issue_idx": { "name": "feedback_exports_company_issue_idx", @@ -24571,9 +25832,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "feedback_exports_company_project_idx": { "name": "feedback_exports_company_project_idx", @@ -24598,9 +25859,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "feedback_exports_company_author_idx": { "name": "feedback_exports_company_author_idx", @@ -24625,63 +25886,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "feedback_exports_company_id_companies_id_fk": { "name": "feedback_exports_company_id_companies_id_fk", "tableFrom": "feedback_exports", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "feedback_exports_feedback_vote_id_feedback_votes_id_fk": { "name": "feedback_exports_feedback_vote_id_feedback_votes_id_fk", "tableFrom": "feedback_exports", + "tableTo": "feedback_votes", "columnsFrom": [ "feedback_vote_id" ], - "tableTo": "feedback_votes", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "feedback_exports_issue_id_issues_id_fk": { "name": "feedback_exports_issue_id_issues_id_fk", "tableFrom": "feedback_exports", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "feedback_exports_project_id_projects_id_fk": { "name": "feedback_exports_project_id_projects_id_fk", "tableFrom": "feedback_exports", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -24801,9 +26062,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "feedback_votes_issue_target_idx": { "name": "feedback_votes_issue_target_idx", @@ -24828,9 +26089,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "feedback_votes_author_idx": { "name": "feedback_votes_author_idx", @@ -24849,9 +26110,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "feedback_votes_company_target_author_idx": { "name": "feedback_votes_company_target_author_idx", @@ -24882,37 +26143,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "feedback_votes_company_id_companies_id_fk": { "name": "feedback_votes_company_id_companies_id_fk", "tableFrom": "feedback_votes", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "feedback_votes_issue_id_issues_id_fk": { "name": "feedback_votes_issue_id_issues_id_fk", "tableFrom": "feedback_votes", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -25123,9 +26384,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "finance_events_company_occurred_idx": { "name": "finance_events_company_occurred_idx", @@ -25144,9 +26405,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "finance_events_company_biller_occurred_idx": { "name": "finance_events_company_biller_occurred_idx", @@ -25171,9 +26432,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "finance_events_company_kind_occurred_idx": { "name": "finance_events_company_kind_occurred_idx", @@ -25198,9 +26459,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "finance_events_company_direction_occurred_idx": { "name": "finance_events_company_direction_occurred_idx", @@ -25225,9 +26486,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "finance_events_company_heartbeat_run_idx": { "name": "finance_events_company_heartbeat_run_idx", @@ -25246,9 +26507,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "finance_events_company_cost_event_idx": { "name": "finance_events_company_cost_event_idx", @@ -25267,102 +26528,102 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "finance_events_company_id_companies_id_fk": { "name": "finance_events_company_id_companies_id_fk", "tableFrom": "finance_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "finance_events_agent_id_agents_id_fk": { "name": "finance_events_agent_id_agents_id_fk", "tableFrom": "finance_events", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "finance_events_issue_id_issues_id_fk": { "name": "finance_events_issue_id_issues_id_fk", "tableFrom": "finance_events", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "finance_events_project_id_projects_id_fk": { "name": "finance_events_project_id_projects_id_fk", "tableFrom": "finance_events", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "finance_events_goal_id_goals_id_fk": { "name": "finance_events_goal_id_goals_id_fk", "tableFrom": "finance_events", + "tableTo": "goals", "columnsFrom": [ "goal_id" ], - "tableTo": "goals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "finance_events", + "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "finance_events_cost_event_id_cost_events_id_fk": { "name": "finance_events_cost_event_id_cost_events_id_fk", "tableFrom": "finance_events", + "tableTo": "cost_events", "columnsFrom": [ "cost_event_id" ], - "tableTo": "cost_events", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -25476,9 +26737,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "folders_company_kind_root_slug_uq": { "name": "folders_company_kind_root_slug_uq", @@ -25503,10 +26764,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"folders\".\"parent_id\" is null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "folders_company_kind_parent_slug_uq": { "name": "folders_company_kind_parent_slug_uq", @@ -25537,10 +26798,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"folders\".\"parent_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "folders_company_kind_system_key_uq": { "name": "folders_company_kind_system_key_uq", @@ -25565,10 +26826,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"folders\".\"system_key\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "folders_company_kind_parent_position_idx": { "name": "folders_company_kind_parent_position_idx", @@ -25605,37 +26866,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "folders_company_id_companies_id_fk": { "name": "folders_company_id_companies_id_fk", "tableFrom": "folders", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "folders_parent_id_folders_id_fk": { "name": "folders_parent_id_folders_id_fk", "tableFrom": "folders", + "tableTo": "folders", "columnsFrom": [ "parent_id" ], - "tableTo": "folders", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -25726,50 +26987,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "goals_company_id_companies_id_fk": { "name": "goals_company_id_companies_id_fk", "tableFrom": "goals", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "goals_parent_id_goals_id_fk": { "name": "goals_parent_id_goals_id_fk", "tableFrom": "goals", + "tableTo": "goals", "columnsFrom": [ "parent_id" ], - "tableTo": "goals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "goals_owner_agent_id_agents_id_fk": { "name": "goals_owner_agent_id_agents_id_fk", "tableFrom": "goals", + "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -25904,9 +27165,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_run_events_run_source_event_uq": { "name": "heartbeat_run_events_run_source_event_uq", @@ -25925,10 +27186,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"heartbeat_run_events\".\"source_event_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "heartbeat_run_events_run_source_seq_uq": { "name": "heartbeat_run_events_run_source_seq_uq", @@ -25953,10 +27214,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"heartbeat_run_events\".\"source_instance_id\" is not null and \"heartbeat_run_events\".\"source_seq\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "heartbeat_run_events_company_run_idx": { "name": "heartbeat_run_events_company_run_idx", @@ -25975,9 +27236,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_run_events_company_created_idx": { "name": "heartbeat_run_events_company_created_idx", @@ -25996,50 +27257,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "heartbeat_run_events_company_id_companies_id_fk": { "name": "heartbeat_run_events_company_id_companies_id_fk", "tableFrom": "heartbeat_run_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "heartbeat_run_events_agent_id_agents_id_fk": { "name": "heartbeat_run_events_agent_id_agents_id_fk", "tableFrom": "heartbeat_run_events", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -26145,9 +27406,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_run_watchdog_decisions_company_run_snooze_idx": { "name": "heartbeat_run_watchdog_decisions_company_run_snooze_idx", @@ -26172,76 +27433,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "heartbeat_run_watchdog_decisions_company_id_companies_id_fk": { "name": "heartbeat_run_watchdog_decisions_company_id_companies_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk": { "name": "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk": { "name": "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "issues", "columnsFrom": [ "evaluation_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk": { "name": "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk": { "name": "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -26757,10 +28018,10 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"heartbeat_runs\".\"cost_accounting_pending\" = true", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "heartbeat_runs_execution_status_delivery_idx": { "name": "heartbeat_runs_execution_status_delivery_idx", @@ -26773,10 +28034,10 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"heartbeat_runs\".\"execution_status_delivery_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "heartbeat_runs_execution_control_deadline_idx": { "name": "heartbeat_runs_execution_control_deadline_idx", @@ -26789,10 +28050,10 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"heartbeat_runs\".\"execution_control_deadline_at\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "heartbeat_runs_native_replacement_predecessor_uq": { "name": "heartbeat_runs_native_replacement_predecessor_uq", @@ -26811,10 +28072,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"heartbeat_runs\".\"scheduled_retry_reason\" = 'native_safe_replacement'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "heartbeat_runs_company_agent_started_idx": { "name": "heartbeat_runs_company_agent_started_idx", @@ -26839,9 +28100,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_issue_created_idx": { "name": "heartbeat_runs_company_issue_created_idx", @@ -26866,9 +28127,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_responsible_user_idx": { "name": "heartbeat_runs_company_responsible_user_idx", @@ -26893,9 +28154,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_liveness_idx": { "name": "heartbeat_runs_company_liveness_idx", @@ -26920,9 +28181,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_status_last_output_idx": { "name": "heartbeat_runs_company_status_last_output_idx", @@ -26947,9 +28208,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_status_process_started_idx": { "name": "heartbeat_runs_company_status_process_started_idx", @@ -26974,9 +28235,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_created_at_desc_idx": { "name": "heartbeat_runs_company_created_at_desc_idx", @@ -26995,9 +28256,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_ctx_issue_created_idx": { "name": "heartbeat_runs_company_ctx_issue_created_idx", @@ -27010,8 +28271,8 @@ }, { "expression": "(\"context_snapshot\" ->> 'issueId')", - "isExpression": true, "asc": true, + "isExpression": true, "nulls": "last" }, { @@ -27022,9 +28283,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_ctx_task_created_idx": { "name": "heartbeat_runs_company_ctx_task_created_idx", @@ -27037,8 +28298,8 @@ }, { "expression": "(\"context_snapshot\" ->> 'taskId')", - "isExpression": true, "asc": true, + "isExpression": true, "nulls": "last" }, { @@ -27049,9 +28310,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "heartbeat_runs_company_ctx_taskkey_created_idx": { "name": "heartbeat_runs_company_ctx_taskkey_created_idx", @@ -27064,8 +28325,8 @@ }, { "expression": "(\"context_snapshot\" ->> 'taskKey')", - "isExpression": true, "asc": true, + "isExpression": true, "nulls": "last" }, { @@ -27076,98 +28337,98 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "heartbeat_runs_company_id_companies_id_fk": { "name": "heartbeat_runs_company_id_companies_id_fk", "tableFrom": "heartbeat_runs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "heartbeat_runs_agent_id_agents_id_fk": { "name": "heartbeat_runs_agent_id_agents_id_fk", "tableFrom": "heartbeat_runs", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "heartbeat_runs_issue_id_issues_id_fk": { "name": "heartbeat_runs_issue_id_issues_id_fk", "tableFrom": "heartbeat_runs", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", "columnsFrom": [ "wakeup_request_id" ], - "tableTo": "agent_wakeup_requests", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", "tableFrom": "heartbeat_runs", + "tableTo": "heartbeat_runs", "columnsFrom": [ "retry_of_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "heartbeat_runs_company_native_issue_id_uq": { "name": "heartbeat_runs_company_native_issue_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "native_issue_id", "id" - ], - "nullsNotDistinct": false + ] }, "heartbeat_runs_company_native_issue_contract_id_uq": { "name": "heartbeat_runs_company_native_issue_contract_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "native_issue_id", "id", "completion_contract_id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -27261,9 +28522,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "inbox_dismissals_company_item_idx": { "name": "inbox_dismissals_company_item_idx", @@ -27282,9 +28543,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "inbox_dismissals_company_user_item_idx": { "name": "inbox_dismissals_company_user_item_idx", @@ -27309,24 +28570,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "inbox_dismissals_company_id_companies_id_fk": { "name": "inbox_dismissals_company_id_companies_id_fk", "tableFrom": "inbox_dismissals", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -27396,9 +28657,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_grant_delegations_grant_agent_uq": { "name": "connection_grant_delegations_grant_agent_uq", @@ -27417,52 +28678,52 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "connection_grant_delegations_company_id_companies_id_fk": { "name": "connection_grant_delegations_company_id_companies_id_fk", "tableFrom": "connection_grant_delegations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_grant_delegations_agent_id_agents_id_fk": { "name": "connection_grant_delegations_agent_id_agents_id_fk", "tableFrom": "connection_grant_delegations", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_grant_delegations_company_grant_fk": { "name": "connection_grant_delegations_company_grant_fk", "tableFrom": "connection_grant_delegations", + "tableTo": "connection_grants", "columnsFrom": [ "company_id", "grant_id" ], - "tableTo": "connection_grants", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -27538,9 +28799,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_grant_members_grant_subject_uq": { "name": "connection_grant_members_grant_subject_uq", @@ -27565,39 +28826,39 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "connection_grant_members_company_id_companies_id_fk": { "name": "connection_grant_members_company_id_companies_id_fk", "tableFrom": "connection_grant_members", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_grant_members_company_grant_fk": { "name": "connection_grant_members_company_grant_fk", "tableFrom": "connection_grant_members", + "tableTo": "connection_grants", "columnsFrom": [ "company_id", "grant_id" ], - "tableTo": "connection_grants", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -27754,9 +29015,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_grants_subject_user_idx": { "name": "connection_grants_subject_user_idx", @@ -27775,9 +29036,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_grants_subject_agent_idx": { "name": "connection_grants_subject_agent_idx", @@ -27796,9 +29057,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_grants_user_uq": { "name": "connection_grants_user_uq", @@ -27817,9 +29078,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_grants_agent_uq": { "name": "connection_grants_agent_uq", @@ -27838,9 +29099,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_grants_default_uq": { "name": "connection_grants_default_uq", @@ -27853,90 +29114,90 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"connection_grants\".\"is_default\" = true and \"connection_grants\".\"kind\" = 'organization'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "connection_grants_company_id_companies_id_fk": { "name": "connection_grants_company_id_companies_id_fk", "tableFrom": "connection_grants", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_grants_subject_agent_id_agents_id_fk": { "name": "connection_grants_subject_agent_id_agents_id_fk", "tableFrom": "connection_grants", + "tableTo": "agents", "columnsFrom": [ "subject_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_grants_created_by_agent_id_agents_id_fk": { "name": "connection_grants_created_by_agent_id_agents_id_fk", "tableFrom": "connection_grants", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "connection_grants_revoked_by_agent_id_agents_id_fk": { "name": "connection_grants_revoked_by_agent_id_agents_id_fk", "tableFrom": "connection_grants", + "tableTo": "agents", "columnsFrom": [ "revoked_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "connection_grants_company_connection_fk": { "name": "connection_grants_company_connection_fk", "tableFrom": "connection_grants", + "tableTo": "tool_connections", "columnsFrom": [ "company_id", "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "connection_grants_company_id_uq": { "name": "connection_grants_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -28106,9 +29367,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_token_issuances_connection_created_idx": { "name": "connection_token_issuances_connection_created_idx", @@ -28133,9 +29394,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_token_issuances_agent_connection_idx": { "name": "connection_token_issuances_agent_connection_idx", @@ -28166,9 +29427,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "connection_token_issuances_run_idx": { "name": "connection_token_issuances_run_idx", @@ -28187,102 +29448,102 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "connection_token_issuances_company_id_companies_id_fk": { "name": "connection_token_issuances_company_id_companies_id_fk", "tableFrom": "connection_token_issuances", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_token_issuances_application_id_tool_applications_id_fk": { "name": "connection_token_issuances_application_id_tool_applications_id_fk", "tableFrom": "connection_token_issuances", + "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], - "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "connection_token_issuances_connection_id_tool_connections_id_fk": { "name": "connection_token_issuances_connection_id_tool_connections_id_fk", "tableFrom": "connection_token_issuances", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_token_issuances_agent_id_agents_id_fk": { "name": "connection_token_issuances_agent_id_agents_id_fk", "tableFrom": "connection_token_issuances", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "connection_token_issuances_run_id_heartbeat_runs_id_fk": { "name": "connection_token_issuances_run_id_heartbeat_runs_id_fk", "tableFrom": "connection_token_issuances", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "connection_token_issuances_issue_id_issues_id_fk": { "name": "connection_token_issuances_issue_id_issues_id_fk", "tableFrom": "connection_token_issuances", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "connection_token_issuances_project_id_projects_id_fk": { "name": "connection_token_issuances_project_id_projects_id_fk", "tableFrom": "connection_token_issuances", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -28356,24 +29617,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "instance_settings_default_environment_id_environments_id_fk": { "name": "instance_settings_default_environment_id_environments_id_fk", "tableFrom": "instance_settings", + "tableTo": "environments", "columnsFrom": [ "default_environment_id" ], - "tableTo": "environments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -28439,9 +29700,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "instance_user_roles_role_idx": { "name": "instance_user_roles_role_idx", @@ -28454,9 +29715,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": {}, @@ -28560,9 +29821,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "invites_company_invite_state_idx": { "name": "invites_company_invite_state_idx", @@ -28593,24 +29854,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "invites_company_id_companies_id_fk": { "name": "invites_company_id_companies_id_fk", "tableFrom": "invites", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -28710,9 +29971,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_access_grants_issue_idx": { "name": "issue_access_grants_issue_idx", @@ -28731,37 +29992,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_access_grants_issue_id_issues_id_fk": { "name": "issue_access_grants_issue_id_issues_id_fk", "tableFrom": "issue_access_grants", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_access_grants_granted_by_agent_id_agents_id_fk": { "name": "issue_access_grants_granted_by_agent_id_agents_id_fk", "tableFrom": "issue_access_grants", + "tableTo": "agents", "columnsFrom": [ "granted_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -28833,9 +30094,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_approvals_approval_idx": { "name": "issue_approvals_approval_idx", @@ -28848,9 +30109,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_approvals_company_idx": { "name": "issue_approvals_company_idx", @@ -28863,63 +30124,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_approvals_company_id_companies_id_fk": { "name": "issue_approvals_company_id_companies_id_fk", "tableFrom": "issue_approvals", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_approvals_issue_id_issues_id_fk": { "name": "issue_approvals_issue_id_issues_id_fk", "tableFrom": "issue_approvals", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_approvals_approval_id_approvals_id_fk": { "name": "issue_approvals_approval_id_approvals_id_fk", "tableFrom": "issue_approvals", + "tableTo": "approvals", "columnsFrom": [ "approval_id" ], - "tableTo": "approvals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_approvals_linked_by_agent_id_agents_id_fk": { "name": "issue_approvals_linked_by_agent_id_agents_id_fk", "tableFrom": "issue_approvals", + "tableTo": "agents", "columnsFrom": [ "linked_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": { @@ -29010,9 +30271,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_attachments_issue_comment_idx": { "name": "issue_attachments_issue_comment_idx", @@ -29025,9 +30286,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_attachments_originating_run_idx": { "name": "issue_attachments_originating_run_idx", @@ -29040,9 +30301,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_attachments_asset_uq": { "name": "issue_attachments_asset_uq", @@ -29055,76 +30316,76 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_attachments_company_id_companies_id_fk": { "name": "issue_attachments_company_id_companies_id_fk", "tableFrom": "issue_attachments", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_attachments_issue_id_issues_id_fk": { "name": "issue_attachments_issue_id_issues_id_fk", "tableFrom": "issue_attachments", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_attachments_asset_id_assets_id_fk": { "name": "issue_attachments_asset_id_assets_id_fk", "tableFrom": "issue_attachments", + "tableTo": "assets", "columnsFrom": [ "asset_id" ], - "tableTo": "assets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_attachments_issue_comment_id_issue_comments_id_fk": { "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", "tableFrom": "issue_attachments", + "tableTo": "issue_comments", "columnsFrom": [ "issue_comment_id" ], - "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_attachments_originating_run_id_heartbeat_runs_id_fk": { "name": "issue_attachments_originating_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_attachments", + "tableTo": "heartbeat_runs", "columnsFrom": [ "originating_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -29297,9 +30558,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_comments_company_idx": { "name": "issue_comments_company_idx", @@ -29312,9 +30573,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_comments_company_issue_created_at_idx": { "name": "issue_comments_company_issue_created_at_idx", @@ -29339,9 +30600,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_comments_company_author_issue_created_at_idx": { "name": "issue_comments_company_author_issue_created_at_idx", @@ -29372,9 +30633,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_comments_body_search_idx": { "name": "issue_comments_body_search_idx", @@ -29388,148 +30649,148 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_comments_company_id_companies_id_fk": { "name": "issue_comments_company_id_companies_id_fk", "tableFrom": "issue_comments", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_comments_issue_id_issues_id_fk": { "name": "issue_comments_issue_id_issues_id_fk", "tableFrom": "issue_comments", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_comments_author_agent_id_agents_id_fk": { "name": "issue_comments_author_agent_id_agents_id_fk", "tableFrom": "issue_comments", + "tableTo": "agents", "columnsFrom": [ "author_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_comments_on_behalf_of_user_id_user_id_fk": { "name": "issue_comments_on_behalf_of_user_id_user_id_fk", "tableFrom": "issue_comments", + "tableTo": "user", "columnsFrom": [ "on_behalf_of_user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_comments_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_comments_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_comments", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_comments_derived_author_agent_id_agents_id_fk": { "name": "issue_comments_derived_author_agent_id_agents_id_fk", "tableFrom": "issue_comments", + "tableTo": "agents", "columnsFrom": [ "derived_author_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_comments", + "tableTo": "heartbeat_runs", "columnsFrom": [ "derived_created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_comments_deleted_by_agent_id_agents_id_fk": { "name": "issue_comments_deleted_by_agent_id_agents_id_fk", "tableFrom": "issue_comments", + "tableTo": "agents", "columnsFrom": [ "deleted_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk": { "name": "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_comments", + "tableTo": "heartbeat_runs", "columnsFrom": [ "deleted_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "issue_comments_client_request_uq": { "name": "issue_comments_client_request_uq", + "nullsNotDistinct": false, "columns": [ "issue_id", "author_user_id", "client_request_id" - ], - "nullsNotDistinct": false + ] }, "issue_comments_company_id_uq": { "name": "issue_comments_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -29591,9 +30852,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_create_idempotency_keys_issue_idx": { "name": "issue_create_idempotency_keys_issue_idx", @@ -29606,9 +30867,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_create_idempotency_keys_company_created_at_idx": { "name": "issue_create_idempotency_keys_company_created_at_idx", @@ -29627,37 +30888,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_create_idempotency_keys_company_id_companies_id_fk": { "name": "issue_create_idempotency_keys_company_id_companies_id_fk", "tableFrom": "issue_create_idempotency_keys", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_create_idempotency_keys_issue_id_issues_id_fk": { "name": "issue_create_idempotency_keys_issue_id_issues_id_fk", "tableFrom": "issue_create_idempotency_keys", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -29740,9 +31001,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_documents_document_uq": { "name": "issue_documents_document_uq", @@ -29755,9 +31016,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_documents_company_issue_updated_idx": { "name": "issue_documents_company_issue_updated_idx", @@ -29782,50 +31043,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_documents_company_id_companies_id_fk": { "name": "issue_documents_company_id_companies_id_fk", "tableFrom": "issue_documents", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_documents_issue_id_issues_id_fk": { "name": "issue_documents_issue_id_issues_id_fk", "tableFrom": "issue_documents", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_documents_document_id_documents_id_fk": { "name": "issue_documents_document_id_documents_id_fk", "tableFrom": "issue_documents", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -29932,9 +31193,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_execution_decisions_stage_idx": { "name": "issue_execution_decisions_stage_idx", @@ -29959,63 +31220,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_execution_decisions_company_id_companies_id_fk": { "name": "issue_execution_decisions_company_id_companies_id_fk", "tableFrom": "issue_execution_decisions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_execution_decisions_issue_id_issues_id_fk": { "name": "issue_execution_decisions_issue_id_issues_id_fk", "tableFrom": "issue_execution_decisions", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_execution_decisions_actor_agent_id_agents_id_fk": { "name": "issue_execution_decisions_actor_agent_id_agents_id_fk", "tableFrom": "issue_execution_decisions", + "tableTo": "agents", "columnsFrom": [ "actor_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_execution_decisions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -30112,9 +31373,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_inbox_archives_company_user_idx": { "name": "issue_inbox_archives_company_user_idx", @@ -30133,9 +31394,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_inbox_archives_company_issue_user_idx": { "name": "issue_inbox_archives_company_issue_user_idx", @@ -30160,63 +31421,63 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_inbox_archives_company_id_companies_id_fk": { "name": "issue_inbox_archives_company_id_companies_id_fk", "tableFrom": "issue_inbox_archives", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_inbox_archives_issue_id_issues_id_fk": { "name": "issue_inbox_archives_issue_id_issues_id_fk", "tableFrom": "issue_inbox_archives", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_inbox_archives_archived_by_agent_id_agents_id_fk": { "name": "issue_inbox_archives_archived_by_agent_id_agents_id_fk", "tableFrom": "issue_inbox_archives", + "tableTo": "agents", "columnsFrom": [ "archived_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk": { "name": "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_inbox_archives", + "tableTo": "heartbeat_runs", "columnsFrom": [ "archived_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -30272,9 +31533,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_labels_label_idx": { "name": "issue_labels_label_idx", @@ -30287,9 +31548,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_labels_company_idx": { "name": "issue_labels_company_idx", @@ -30302,50 +31563,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_labels_issue_id_issues_id_fk": { "name": "issue_labels_issue_id_issues_id_fk", "tableFrom": "issue_labels", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_labels_label_id_labels_id_fk": { "name": "issue_labels_label_id_labels_id_fk", "tableFrom": "issue_labels", + "tableTo": "labels", "columnsFrom": [ "label_id" ], - "tableTo": "labels", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_labels_company_id_companies_id_fk": { "name": "issue_labels_company_id_companies_id_fk", "tableFrom": "issue_labels", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": { @@ -30494,9 +31755,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_plan_decompositions_active_owner_idx": { "name": "issue_plan_decompositions_active_owner_idx", @@ -30515,10 +31776,10 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"issue_plan_decompositions\".\"status\" = 'in_flight'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issue_plan_decompositions_source_revision_uq": { "name": "issue_plan_decompositions_source_revision_uq", @@ -30543,89 +31804,89 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_plan_decompositions_company_id_companies_id_fk": { "name": "issue_plan_decompositions_company_id_companies_id_fk", "tableFrom": "issue_plan_decompositions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_plan_decompositions_source_issue_id_issues_id_fk": { "name": "issue_plan_decompositions_source_issue_id_issues_id_fk", "tableFrom": "issue_plan_decompositions", + "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk": { "name": "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk", "tableFrom": "issue_plan_decompositions", + "tableTo": "document_revisions", "columnsFrom": [ "accepted_plan_revision_id" ], - "tableTo": "document_revisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk": { "name": "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "issue_plan_decompositions", + "tableTo": "issue_thread_interactions", "columnsFrom": [ "accepted_interaction_id" ], - "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_plan_decompositions_owner_agent_id_agents_id_fk": { "name": "issue_plan_decompositions_owner_agent_id_agents_id_fk", "tableFrom": "issue_plan_decompositions", + "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk": { "name": "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_plan_decompositions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "owner_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -30765,9 +32026,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_question_response_deliveries_correlation_uq": { "name": "issue_question_response_deliveries_correlation_uq", @@ -30780,9 +32041,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_question_response_deliveries_pending_idx": { "name": "issue_question_response_deliveries_pending_idx", @@ -30801,9 +32062,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_question_response_deliveries_company_issue_idx": { "name": "issue_question_response_deliveries_company_issue_idx", @@ -30828,76 +32089,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_question_response_deliveries_company_id_companies_id_fk": { "name": "issue_question_response_deliveries_company_id_companies_id_fk", "tableFrom": "issue_question_response_deliveries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_question_response_deliveries_issue_id_issues_id_fk": { "name": "issue_question_response_deliveries_issue_id_issues_id_fk", "tableFrom": "issue_question_response_deliveries", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_question_response_deliveries_interaction_id_issue_thread_interactions_id_fk": { "name": "issue_question_response_deliveries_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "issue_question_response_deliveries", + "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], - "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_question_response_deliveries_source_run_id_heartbeat_runs_id_fk": { "name": "issue_question_response_deliveries_source_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_question_response_deliveries", + "tableTo": "heartbeat_runs", "columnsFrom": [ "source_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_question_response_deliveries_target_run_id_heartbeat_runs_id_fk": { "name": "issue_question_response_deliveries_target_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_question_response_deliveries", + "tableTo": "heartbeat_runs", "columnsFrom": [ "target_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -30984,9 +32245,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_read_states_company_user_idx": { "name": "issue_read_states_company_user_idx", @@ -31005,9 +32266,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_read_states_company_issue_user_idx": { "name": "issue_read_states_company_issue_user_idx", @@ -31032,37 +32293,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_read_states_company_id_companies_id_fk": { "name": "issue_read_states_company_id_companies_id_fk", "tableFrom": "issue_read_states", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_read_states_issue_id_issues_id_fk": { "name": "issue_read_states_issue_id_issues_id_fk", "tableFrom": "issue_read_states", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -31263,9 +32524,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_recovery_actions_company_owner_status_idx": { "name": "issue_recovery_actions_company_owner_status_idx", @@ -31290,9 +32551,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_recovery_actions_company_recovery_issue_idx": { "name": "issue_recovery_actions_company_recovery_issue_idx", @@ -31311,9 +32572,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_recovery_actions_active_source_uq": { "name": "issue_recovery_actions_active_source_uq", @@ -31332,10 +32593,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issue_recovery_actions_active_fingerprint_uq": { "name": "issue_recovery_actions_active_fingerprint_uq", @@ -31366,90 +32627,90 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "issue_recovery_actions_company_id_companies_id_fk": { "name": "issue_recovery_actions_company_id_companies_id_fk", "tableFrom": "issue_recovery_actions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_recovery_actions_source_issue_id_issues_id_fk": { "name": "issue_recovery_actions_source_issue_id_issues_id_fk", "tableFrom": "issue_recovery_actions", + "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_recovery_actions_recovery_issue_id_issues_id_fk": { "name": "issue_recovery_actions_recovery_issue_id_issues_id_fk", "tableFrom": "issue_recovery_actions", + "tableTo": "issues", "columnsFrom": [ "recovery_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_recovery_actions_owner_agent_id_agents_id_fk": { "name": "issue_recovery_actions_owner_agent_id_agents_id_fk", "tableFrom": "issue_recovery_actions", + "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_recovery_actions_previous_owner_agent_id_agents_id_fk": { "name": "issue_recovery_actions_previous_owner_agent_id_agents_id_fk", "tableFrom": "issue_recovery_actions", + "tableTo": "agents", "columnsFrom": [ "previous_owner_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_recovery_actions_return_owner_agent_id_agents_id_fk": { "name": "issue_recovery_actions_return_owner_agent_id_agents_id_fk", "tableFrom": "issue_recovery_actions", + "tableTo": "agents", "columnsFrom": [ "return_owner_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -31544,9 +32805,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_reference_mentions_company_target_issue_idx": { "name": "issue_reference_mentions_company_target_issue_idx", @@ -31565,9 +32826,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_reference_mentions_company_issue_pair_idx": { "name": "issue_reference_mentions_company_issue_pair_idx", @@ -31592,9 +32853,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_reference_mentions_company_source_mention_record_uq": { "name": "issue_reference_mentions_company_source_mention_record_uq", @@ -31631,10 +32892,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issue_reference_mentions\".\"source_record_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issue_reference_mentions_company_source_mention_null_record_uq": { "name": "issue_reference_mentions_company_source_mention_null_record_uq", @@ -31665,51 +32926,51 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issue_reference_mentions\".\"source_record_id\" is null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "issue_reference_mentions_company_id_companies_id_fk": { "name": "issue_reference_mentions_company_id_companies_id_fk", "tableFrom": "issue_reference_mentions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_reference_mentions_source_issue_id_issues_id_fk": { "name": "issue_reference_mentions_source_issue_id_issues_id_fk", "tableFrom": "issue_reference_mentions", + "tableTo": "issues", "columnsFrom": [ "source_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_reference_mentions_target_issue_id_issues_id_fk": { "name": "issue_reference_mentions_target_issue_id_issues_id_fk", "tableFrom": "issue_reference_mentions", + "tableTo": "issues", "columnsFrom": [ "target_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -31798,9 +33059,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_relations_company_related_issue_idx": { "name": "issue_relations_company_related_issue_idx", @@ -31819,9 +33080,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_relations_company_type_idx": { "name": "issue_relations_company_type_idx", @@ -31840,9 +33101,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_relations_company_edge_uq": { "name": "issue_relations_company_edge_uq", @@ -31873,63 +33134,63 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_relations_company_id_companies_id_fk": { "name": "issue_relations_company_id_companies_id_fk", "tableFrom": "issue_relations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_relations_issue_id_issues_id_fk": { "name": "issue_relations_issue_id_issues_id_fk", "tableFrom": "issue_relations", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_relations_related_issue_id_issues_id_fk": { "name": "issue_relations_related_issue_id_issues_id_fk", "tableFrom": "issue_relations", + "tableTo": "issues", "columnsFrom": [ "related_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_relations_created_by_agent_id_agents_id_fk": { "name": "issue_relations_created_by_agent_id_agents_id_fk", "tableFrom": "issue_relations", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -32139,9 +33400,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_thread_interactions_company_issue_created_at_idx": { "name": "issue_thread_interactions_company_issue_created_at_idx", @@ -32166,9 +33427,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_thread_interactions_company_issue_status_idx": { "name": "issue_thread_interactions_company_issue_status_idx", @@ -32193,9 +33454,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_thread_interactions_company_issue_idempotency_uq": { "name": "issue_thread_interactions_company_issue_idempotency_uq", @@ -32220,10 +33481,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issue_thread_interactions\".\"idempotency_key\" IS NOT NULL", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issue_thread_interactions_source_comment_idx": { "name": "issue_thread_interactions_source_comment_idx", @@ -32236,9 +33497,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_thread_interactions_addressee_agent_idx": { "name": "issue_thread_interactions_addressee_agent_idx", @@ -32251,9 +33512,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_thread_interactions_addressee_user_idx": { "name": "issue_thread_interactions_addressee_user_idx", @@ -32266,115 +33527,115 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_thread_interactions_company_id_companies_id_fk": { "name": "issue_thread_interactions_company_id_companies_id_fk", "tableFrom": "issue_thread_interactions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_thread_interactions_issue_id_issues_id_fk": { "name": "issue_thread_interactions_issue_id_issues_id_fk", "tableFrom": "issue_thread_interactions", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_thread_interactions_source_comment_id_issue_comments_id_fk": { "name": "issue_thread_interactions_source_comment_id_issue_comments_id_fk", "tableFrom": "issue_thread_interactions", + "tableTo": "issue_comments", "columnsFrom": [ "source_comment_id" ], - "tableTo": "issue_comments", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk": { "name": "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_thread_interactions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "source_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_thread_interactions_created_by_agent_id_agents_id_fk": { "name": "issue_thread_interactions_created_by_agent_id_agents_id_fk", "tableFrom": "issue_thread_interactions", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_thread_interactions_addressee_agent_id_agents_id_fk": { "name": "issue_thread_interactions_addressee_agent_id_agents_id_fk", "tableFrom": "issue_thread_interactions", + "tableTo": "agents", "columnsFrom": [ "addressee_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_thread_interactions_resolved_by_agent_id_agents_id_fk": { "name": "issue_thread_interactions_resolved_by_agent_id_agents_id_fk", "tableFrom": "issue_thread_interactions", + "tableTo": "agents", "columnsFrom": [ "resolved_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk": { "name": "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_thread_interactions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "resolved_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -32506,9 +33767,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_tree_hold_members_company_issue_idx": { "name": "issue_tree_hold_members_company_issue_idx", @@ -32527,9 +33788,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_tree_hold_members_hold_depth_idx": { "name": "issue_tree_hold_members_hold_depth_idx", @@ -32548,89 +33809,89 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_tree_hold_members_company_id_companies_id_fk": { "name": "issue_tree_hold_members_company_id_companies_id_fk", "tableFrom": "issue_tree_hold_members", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk": { "name": "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk", "tableFrom": "issue_tree_hold_members", + "tableTo": "issue_tree_holds", "columnsFrom": [ "hold_id" ], - "tableTo": "issue_tree_holds", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_tree_hold_members_issue_id_issues_id_fk": { "name": "issue_tree_hold_members_issue_id_issues_id_fk", "tableFrom": "issue_tree_hold_members", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_tree_hold_members_parent_issue_id_issues_id_fk": { "name": "issue_tree_hold_members_parent_issue_id_issues_id_fk", "tableFrom": "issue_tree_hold_members", + "tableTo": "issues", "columnsFrom": [ "parent_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_tree_hold_members_assignee_agent_id_agents_id_fk": { "name": "issue_tree_hold_members_assignee_agent_id_agents_id_fk", "tableFrom": "issue_tree_hold_members", + "tableTo": "agents", "columnsFrom": [ "assignee_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk": { "name": "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_tree_hold_members", + "tableTo": "heartbeat_runs", "columnsFrom": [ "active_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -32793,9 +34054,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_tree_holds_company_status_mode_idx": { "name": "issue_tree_holds_company_status_mode_idx", @@ -32820,89 +34081,89 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_tree_holds_company_id_companies_id_fk": { "name": "issue_tree_holds_company_id_companies_id_fk", "tableFrom": "issue_tree_holds", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_tree_holds_root_issue_id_issues_id_fk": { "name": "issue_tree_holds_root_issue_id_issues_id_fk", "tableFrom": "issue_tree_holds", + "tableTo": "issues", "columnsFrom": [ "root_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_tree_holds_created_by_agent_id_agents_id_fk": { "name": "issue_tree_holds_created_by_agent_id_agents_id_fk", "tableFrom": "issue_tree_holds", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_tree_holds", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_tree_holds_released_by_agent_id_agents_id_fk": { "name": "issue_tree_holds_released_by_agent_id_agents_id_fk", "tableFrom": "issue_tree_holds", + "tableTo": "agents", "columnsFrom": [ "released_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk": { "name": "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_tree_holds", + "tableTo": "heartbeat_runs", "columnsFrom": [ "released_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -33071,9 +34332,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_watchdogs_company_status_idx": { "name": "issue_watchdogs_company_status_idx", @@ -33092,9 +34353,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_watchdogs_company_agent_idx": { "name": "issue_watchdogs_company_agent_idx", @@ -33113,9 +34374,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_watchdogs_company_watchdog_issue_uq": { "name": "issue_watchdogs_company_watchdog_issue_uq", @@ -33134,116 +34395,116 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issue_watchdogs\".\"watchdog_issue_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "issue_watchdogs_company_id_companies_id_fk": { "name": "issue_watchdogs_company_id_companies_id_fk", "tableFrom": "issue_watchdogs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_watchdogs_issue_id_issues_id_fk": { "name": "issue_watchdogs_issue_id_issues_id_fk", "tableFrom": "issue_watchdogs", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_watchdogs_watchdog_agent_id_agents_id_fk": { "name": "issue_watchdogs_watchdog_agent_id_agents_id_fk", "tableFrom": "issue_watchdogs", + "tableTo": "agents", "columnsFrom": [ "watchdog_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_watchdogs_watchdog_issue_id_issues_id_fk": { "name": "issue_watchdogs_watchdog_issue_id_issues_id_fk", "tableFrom": "issue_watchdogs", + "tableTo": "issues", "columnsFrom": [ "watchdog_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_watchdogs_created_by_agent_id_agents_id_fk": { "name": "issue_watchdogs_created_by_agent_id_agents_id_fk", "tableFrom": "issue_watchdogs", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_watchdogs_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_watchdogs_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_watchdogs", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_watchdogs_updated_by_agent_id_agents_id_fk": { "name": "issue_watchdogs_updated_by_agent_id_agents_id_fk", "tableFrom": "issue_watchdogs", + "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_watchdogs_updated_by_run_id_heartbeat_runs_id_fk": { "name": "issue_watchdogs_updated_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_watchdogs", + "tableTo": "heartbeat_runs", "columnsFrom": [ "updated_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -33413,9 +34674,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_work_products_company_execution_workspace_type_idx": { "name": "issue_work_products_company_execution_workspace_type_idx", @@ -33440,9 +34701,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_work_products_company_provider_external_id_idx": { "name": "issue_work_products_company_provider_external_id_idx", @@ -33467,9 +34728,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issue_work_products_company_updated_idx": { "name": "issue_work_products_company_updated_idx", @@ -33488,89 +34749,89 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "issue_work_products_company_id_companies_id_fk": { "name": "issue_work_products_company_id_companies_id_fk", "tableFrom": "issue_work_products", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issue_work_products_project_id_projects_id_fk": { "name": "issue_work_products_project_id_projects_id_fk", "tableFrom": "issue_work_products", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_work_products_issue_id_issues_id_fk": { "name": "issue_work_products_issue_id_issues_id_fk", "tableFrom": "issue_work_products", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "issue_work_products_execution_workspace_id_execution_workspaces_id_fk": { "name": "issue_work_products_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "issue_work_products", + "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], - "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk": { "name": "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk", "tableFrom": "issue_work_products", + "tableTo": "workspace_runtime_services", "columnsFrom": [ "runtime_service_id" ], - "tableTo": "workspace_runtime_services", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issue_work_products_created_by_run_id_heartbeat_runs_id_fk": { "name": "issue_work_products_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "issue_work_products", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -34012,9 +35273,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_status_idx": { "name": "issues_company_status_idx", @@ -34033,9 +35294,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_harness_kind_idx": { "name": "issues_company_harness_kind_idx", @@ -34054,9 +35315,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_assignee_status_idx": { "name": "issues_company_assignee_status_idx", @@ -34081,9 +35342,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_assignee_user_status_idx": { "name": "issues_company_assignee_user_status_idx", @@ -34108,9 +35369,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_responsible_user_idx": { "name": "issues_company_responsible_user_idx", @@ -34129,9 +35390,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_parent_idx": { "name": "issues_company_parent_idx", @@ -34150,9 +35411,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_privacy_parent_idx": { "name": "issues_company_privacy_parent_idx", @@ -34171,9 +35432,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_privacy_root_idx": { "name": "issues_company_privacy_root_idx", @@ -34192,9 +35453,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_project_idx": { "name": "issues_company_project_idx", @@ -34213,9 +35474,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_origin_idx": { "name": "issues_company_origin_idx", @@ -34240,9 +35501,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_project_workspace_idx": { "name": "issues_company_project_workspace_idx", @@ -34261,9 +35522,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_execution_workspace_idx": { "name": "issues_company_execution_workspace_idx", @@ -34282,9 +35543,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_monitor_due_idx": { "name": "issues_company_monitor_due_idx", @@ -34303,9 +35564,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_updated_idx": { "name": "issues_company_updated_idx", @@ -34324,9 +35585,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_company_created_idx": { "name": "issues_company_created_idx", @@ -34345,9 +35606,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_open_normalized_title_created_idx": { "name": "issues_open_normalized_title_created_idx", @@ -34366,8 +35627,8 @@ }, { "expression": "lower(regexp_replace(btrim(\"title\"), '\\s+', ' ', 'g'))", - "isExpression": true, "asc": true, + "isExpression": true, "nulls": "last" }, { @@ -34378,10 +35639,10 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"issues\".\"hidden_at\" is null and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issues_company_priority_idx": { "name": "issues_company_priority_idx", @@ -34400,9 +35661,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_identifier_idx": { "name": "issues_identifier_idx", @@ -34415,9 +35676,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "issues_title_search_idx": { "name": "issues_title_search_idx", @@ -34431,9 +35692,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} }, "issues_identifier_search_idx": { "name": "issues_identifier_search_idx", @@ -34447,9 +35708,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} }, "issues_description_search_idx": { "name": "issues_description_search_idx", @@ -34463,9 +35724,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} }, "issues_open_routine_execution_uq": { "name": "issues_open_routine_execution_uq", @@ -34496,10 +35757,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issues\".\"origin_kind\" = 'routine_execution'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"execution_run_id\" is not null\n and \"issues\".\"status\" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issues_active_liveness_recovery_incident_uq": { "name": "issues_active_liveness_recovery_incident_uq", @@ -34524,10 +35785,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issues_active_liveness_recovery_leaf_uq": { "name": "issues_active_liveness_recovery_leaf_uq", @@ -34552,10 +35813,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_fingerprint\" <> 'default'\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issues_active_stale_run_evaluation_uq": { "name": "issues_active_stale_run_evaluation_uq", @@ -34580,10 +35841,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issues\".\"origin_kind\" = 'stale_active_run_evaluation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issues_active_task_watchdog_uq": { "name": "issues_active_task_watchdog_uq", @@ -34608,10 +35869,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issues\".\"origin_kind\" = 'task_watchdog'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issues_active_productivity_review_uq": { "name": "issues_active_productivity_review_uq", @@ -34636,10 +35897,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issues\".\"origin_kind\" = 'issue_productivity_review'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issues_active_stranded_issue_recovery_uq": { "name": "issues_active_stranded_issue_recovery_uq", @@ -34664,10 +35925,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issues\".\"origin_kind\" = 'stranded_issue_recovery'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "issues_onboarding_first_task_uq": { "name": "issues_onboarding_first_task_uq", @@ -34680,192 +35941,192 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"issues\".\"origin_kind\" = 'onboarding_first_task'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "issues_company_id_companies_id_fk": { "name": "issues_company_id_companies_id_fk", "tableFrom": "issues", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issues_conversation_agent_id_agents_id_fk": { "name": "issues_conversation_agent_id_agents_id_fk", "tableFrom": "issues", + "tableTo": "agents", "columnsFrom": [ "conversation_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issues_project_id_projects_id_fk": { "name": "issues_project_id_projects_id_fk", "tableFrom": "issues", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issues_project_workspace_id_project_workspaces_id_fk": { "name": "issues_project_workspace_id_project_workspaces_id_fk", "tableFrom": "issues", + "tableTo": "project_workspaces", "columnsFrom": [ "project_workspace_id" ], - "tableTo": "project_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issues_goal_id_goals_id_fk": { "name": "issues_goal_id_goals_id_fk", "tableFrom": "issues", + "tableTo": "goals", "columnsFrom": [ "goal_id" ], - "tableTo": "goals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issues_parent_id_issues_id_fk": { "name": "issues_parent_id_issues_id_fk", "tableFrom": "issues", + "tableTo": "issues", "columnsFrom": [ "parent_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issues_privacy_root_issue_id_issues_id_fk": { "name": "issues_privacy_root_issue_id_issues_id_fk", "tableFrom": "issues", + "tableTo": "issues", "columnsFrom": [ "privacy_root_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issues_privacy_parent_issue_id_issues_id_fk": { "name": "issues_privacy_parent_issue_id_issues_id_fk", "tableFrom": "issues", + "tableTo": "issues", "columnsFrom": [ "privacy_parent_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issues_assignee_agent_id_agents_id_fk": { "name": "issues_assignee_agent_id_agents_id_fk", "tableFrom": "issues", + "tableTo": "agents", "columnsFrom": [ "assignee_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issues_checkout_run_id_heartbeat_runs_id_fk": { "name": "issues_checkout_run_id_heartbeat_runs_id_fk", "tableFrom": "issues", + "tableTo": "heartbeat_runs", "columnsFrom": [ "checkout_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issues_execution_run_id_heartbeat_runs_id_fk": { "name": "issues_execution_run_id_heartbeat_runs_id_fk", "tableFrom": "issues", + "tableTo": "heartbeat_runs", "columnsFrom": [ "execution_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "issues_created_by_agent_id_agents_id_fk": { "name": "issues_created_by_agent_id_agents_id_fk", "tableFrom": "issues", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "issues_execution_workspace_id_execution_workspaces_id_fk": { "name": "issues_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "issues", + "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], - "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "issues_company_id_uq": { "name": "issues_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -35034,9 +36295,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "join_requests_company_status_type_created_idx": { "name": "join_requests_company_status_type_created_idx", @@ -35067,9 +36328,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "join_requests_pending_human_user_uq": { "name": "join_requests_pending_human_user_uq", @@ -35088,10 +36349,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "join_requests_pending_human_email_uq": { "name": "join_requests_pending_human_email_uq", @@ -35104,57 +36365,57 @@ }, { "expression": "lower(\"request_email_snapshot\")", - "isExpression": true, "asc": true, + "isExpression": true, "nulls": "last" } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "join_requests_invite_id_invites_id_fk": { "name": "join_requests_invite_id_invites_id_fk", "tableFrom": "join_requests", + "tableTo": "invites", "columnsFrom": [ "invite_id" ], - "tableTo": "invites", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "join_requests_company_id_companies_id_fk": { "name": "join_requests_company_id_companies_id_fk", "tableFrom": "join_requests", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "join_requests_created_agent_id_agents_id_fk": { "name": "join_requests_created_agent_id_agents_id_fk", "tableFrom": "join_requests", + "tableTo": "agents", "columnsFrom": [ "created_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -35219,9 +36480,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "labels_company_name_idx": { "name": "labels_company_name_idx", @@ -35240,24 +36501,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "labels_company_id_companies_id_fk": { "name": "labels_company_id_companies_id_fk", "tableFrom": "labels", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -35407,9 +36668,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "managed_agent_profiles_company_key_uq": { "name": "managed_agent_profiles_company_key_uq", @@ -35428,9 +36689,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "managed_agent_profiles_company_resource_uq": { "name": "managed_agent_profiles_company_resource_uq", @@ -35461,37 +36722,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "managed_agent_profiles_company_id_companies_id_fk": { "name": "managed_agent_profiles_company_id_companies_id_fk", "tableFrom": "managed_agent_profiles", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "managed_agent_profiles_api_key_secret_id_company_secrets_id_fk": { "name": "managed_agent_profiles_api_key_secret_id_company_secrets_id_fk", "tableFrom": "managed_agent_profiles", + "tableTo": "company_secrets", "columnsFrom": [ "api_key_secret_id" ], - "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -35677,99 +36938,100 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"native_run_finalizations\".\"control_deadline_at\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "native_run_finalizations_company_id_companies_id_fk": { "name": "native_run_finalizations_company_id_companies_id_fk", "tableFrom": "native_run_finalizations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "native_run_finalizations_issue_company_fk": { "name": "native_run_finalizations_issue_company_fk", "tableFrom": "native_run_finalizations", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "native_run_finalizations_run_owner_fk": { "name": "native_run_finalizations_run_owner_fk", "tableFrom": "native_run_finalizations", + "tableTo": "heartbeat_runs", "columnsFrom": [ "company_id", "issue_id", "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "company_id", "native_issue_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "native_run_finalizations_result_owner_fk": { "name": "native_run_finalizations_result_owner_fk", "tableFrom": "native_run_finalizations", + "tableTo": "native_run_results", "columnsFrom": [ "company_id", "issue_id", "run_id", "result_id" ], - "tableTo": "native_run_results", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "native_run_finalizations_assessment_owner_fk": { "name": "native_run_finalizations_assessment_owner_fk", "tableFrom": "native_run_finalizations", + "tableTo": "work_assessments", "columnsFrom": [ "company_id", "issue_id", "run_id", "assessment_id" ], - "tableTo": "work_assessments", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "native_run_finalizations_decision_owner_fk": { "name": "native_run_finalizations_decision_owner_fk", "tableFrom": "native_run_finalizations", + "tableTo": "status_decisions", "columnsFrom": [ "company_id", "issue_id", @@ -35777,7 +37039,6 @@ "assessment_id", "decision_id" ], - "tableTo": "status_decisions", "columnsTo": [ "company_id", "issue_id", @@ -35785,8 +37046,8 @@ "assessment_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -35913,9 +37174,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "native_run_results_run_caller_result_uq": { "name": "native_run_results_run_caller_result_uq", @@ -35934,9 +37195,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "native_run_results_run_caller_dedupe_uq": { "name": "native_run_results_run_caller_dedupe_uq", @@ -35955,88 +37216,88 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "native_run_results_company_id_companies_id_fk": { "name": "native_run_results_company_id_companies_id_fk", "tableFrom": "native_run_results", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "native_run_results_issue_company_fk": { "name": "native_run_results_issue_company_fk", "tableFrom": "native_run_results", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "native_run_results_run_contract_owner_fk": { "name": "native_run_results_run_contract_owner_fk", "tableFrom": "native_run_results", + "tableTo": "heartbeat_runs", "columnsFrom": [ "company_id", "issue_id", "run_id", "completion_contract_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "company_id", "native_issue_id", "id", "completion_contract_id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "native_run_results_completion_contract_owner_fk": { "name": "native_run_results_completion_contract_owner_fk", "tableFrom": "native_run_results", + "tableTo": "completion_contracts", "columnsFrom": [ "company_id", "issue_id", "completion_contract_id" ], - "tableTo": "completion_contracts", "columnsTo": [ "company_id", "issue_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "native_run_results_company_issue_run_id_uq": { "name": "native_run_results_company_issue_run_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "run_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -36154,9 +37415,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_automation_executions_company_case_idx": { "name": "pipeline_automation_executions_company_case_idx", @@ -36175,9 +37436,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_automation_executions_routine_idx": { "name": "pipeline_automation_executions_routine_idx", @@ -36190,9 +37451,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_automation_executions_execution_issue_idx": { "name": "pipeline_automation_executions_execution_issue_idx", @@ -36205,9 +37466,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_automation_executions_retry_of_execution_idx": { "name": "pipeline_automation_executions_retry_of_execution_idx", @@ -36220,63 +37481,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipeline_automation_executions_company_id_companies_id_fk": { "name": "pipeline_automation_executions_company_id_companies_id_fk", "tableFrom": "pipeline_automation_executions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_automation_executions_case_id_pipeline_cases_id_fk": { "name": "pipeline_automation_executions_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_automation_executions", + "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], - "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_automation_executions_routine_id_routines_id_fk": { "name": "pipeline_automation_executions_routine_id_routines_id_fk", "tableFrom": "pipeline_automation_executions", + "tableTo": "routines", "columnsFrom": [ "routine_id" ], - "tableTo": "routines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_automation_executions_execution_issue_id_issues_id_fk": { "name": "pipeline_automation_executions_execution_issue_id_issues_id_fk", "tableFrom": "pipeline_automation_executions", + "tableTo": "issues", "columnsFrom": [ "execution_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -36352,9 +37613,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_case_blockers_blocked_by_idx": { "name": "pipeline_case_blockers_blocked_by_idx", @@ -36367,9 +37628,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_case_blockers_company_case_idx": { "name": "pipeline_case_blockers_company_case_idx", @@ -36388,50 +37649,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipeline_case_blockers_company_id_companies_id_fk": { "name": "pipeline_case_blockers_company_id_companies_id_fk", "tableFrom": "pipeline_case_blockers", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_case_blockers_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_blockers_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_blockers", + "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], - "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_case_blockers_blocked_by_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_blockers_blocked_by_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_blockers", + "tableTo": "pipeline_cases", "columnsFrom": [ "blocked_by_case_id" ], - "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -36519,9 +37780,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_case_documents_document_uq": { "name": "pipeline_case_documents_document_uq", @@ -36534,9 +37795,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_case_documents_company_case_updated_idx": { "name": "pipeline_case_documents_company_case_updated_idx", @@ -36561,50 +37822,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipeline_case_documents_company_id_companies_id_fk": { "name": "pipeline_case_documents_company_id_companies_id_fk", "tableFrom": "pipeline_case_documents", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_case_documents_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_documents_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_documents", + "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], - "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_case_documents_document_id_documents_id_fk": { "name": "pipeline_case_documents_document_id_documents_id_fk", "tableFrom": "pipeline_case_documents", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -36718,9 +37979,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_case_events_company_case_idx": { "name": "pipeline_case_events_company_case_idx", @@ -36739,76 +38000,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipeline_case_events_company_id_companies_id_fk": { "name": "pipeline_case_events_company_id_companies_id_fk", "tableFrom": "pipeline_case_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_case_events_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_events_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_events", + "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], - "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_case_events_actor_agent_id_agents_id_fk": { "name": "pipeline_case_events_actor_agent_id_agents_id_fk", "tableFrom": "pipeline_case_events", + "tableTo": "agents", "columnsFrom": [ "actor_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "pipeline_case_events_from_stage_id_pipeline_stages_id_fk": { "name": "pipeline_case_events_from_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_case_events", + "tableTo": "pipeline_stages", "columnsFrom": [ "from_stage_id" ], - "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "pipeline_case_events_to_stage_id_pipeline_stages_id_fk": { "name": "pipeline_case_events_to_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_case_events", + "tableTo": "pipeline_stages", "columnsFrom": [ "to_stage_id" ], - "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -36928,9 +38189,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_case_issue_links_issue_idx": { "name": "pipeline_case_issue_links_issue_idx", @@ -36943,9 +38204,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_case_issue_links_company_case_idx": { "name": "pipeline_case_issue_links_company_case_idx", @@ -36964,9 +38225,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_case_issue_links_automation_attempt_idx": { "name": "pipeline_case_issue_links_automation_attempt_idx", @@ -36979,50 +38240,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipeline_case_issue_links_company_id_companies_id_fk": { "name": "pipeline_case_issue_links_company_id_companies_id_fk", "tableFrom": "pipeline_case_issue_links", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_case_issue_links_case_id_pipeline_cases_id_fk": { "name": "pipeline_case_issue_links_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_case_issue_links", + "tableTo": "pipeline_cases", "columnsFrom": [ "case_id" ], - "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_case_issue_links_issue_id_issues_id_fk": { "name": "pipeline_case_issue_links_issue_id_issues_id_fk", "tableFrom": "pipeline_case_issue_links", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -37264,9 +38525,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_cases_parent_request_key_uq": { "name": "pipeline_cases_parent_request_key_uq", @@ -37285,10 +38546,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"pipeline_cases\".\"request_key\" is not null and \"pipeline_cases\".\"retired_at\" is null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "pipeline_cases_company_idx": { "name": "pipeline_cases_company_idx", @@ -37301,9 +38562,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_cases_pipeline_stage_idx": { "name": "pipeline_cases_pipeline_stage_idx", @@ -37322,9 +38583,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_cases_parent_idx": { "name": "pipeline_cases_parent_idx", @@ -37337,9 +38598,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_cases_automation_attempt_idx": { "name": "pipeline_cases_automation_attempt_idx", @@ -37352,9 +38613,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_cases_retired_idx": { "name": "pipeline_cases_retired_idx", @@ -37373,9 +38634,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_cases_lease_expires_idx": { "name": "pipeline_cases_lease_expires_idx", @@ -37388,90 +38649,90 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"pipeline_cases\".\"lease_expires_at\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "pipeline_cases_company_id_companies_id_fk": { "name": "pipeline_cases_company_id_companies_id_fk", "tableFrom": "pipeline_cases", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_cases_pipeline_id_pipelines_id_fk": { "name": "pipeline_cases_pipeline_id_pipelines_id_fk", "tableFrom": "pipeline_cases", + "tableTo": "pipelines", "columnsFrom": [ "pipeline_id" ], - "tableTo": "pipelines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_cases_stage_id_pipeline_stages_id_fk": { "name": "pipeline_cases_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_cases", + "tableTo": "pipeline_stages", "columnsFrom": [ "stage_id" ], - "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "pipeline_cases_parent_case_id_pipeline_cases_id_fk": { "name": "pipeline_cases_parent_case_id_pipeline_cases_id_fk", "tableFrom": "pipeline_cases", + "tableTo": "pipeline_cases", "columnsFrom": [ "parent_case_id" ], - "tableTo": "pipeline_cases", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "pipeline_cases_lease_agent_id_agents_id_fk": { "name": "pipeline_cases_lease_agent_id_agents_id_fk", "tableFrom": "pipeline_cases", + "tableTo": "agents", "columnsFrom": [ "lease_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "pipeline_cases_created_by_agent_id_agents_id_fk": { "name": "pipeline_cases_created_by_agent_id_agents_id_fk", "tableFrom": "pipeline_cases", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -37563,9 +38824,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_documents_document_uq": { "name": "pipeline_documents_document_uq", @@ -37578,9 +38839,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_documents_company_pipeline_updated_idx": { "name": "pipeline_documents_company_pipeline_updated_idx", @@ -37605,50 +38866,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipeline_documents_company_id_companies_id_fk": { "name": "pipeline_documents_company_id_companies_id_fk", "tableFrom": "pipeline_documents", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_documents_pipeline_id_pipelines_id_fk": { "name": "pipeline_documents_pipeline_id_pipelines_id_fk", "tableFrom": "pipeline_documents", + "tableTo": "pipelines", "columnsFrom": [ "pipeline_id" ], - "tableTo": "pipelines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_documents_document_id_documents_id_fk": { "name": "pipeline_documents_document_id_documents_id_fk", "tableFrom": "pipeline_documents", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -37738,9 +38999,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_stages_pipeline_position_idx": { "name": "pipeline_stages_pipeline_position_idx", @@ -37759,24 +39020,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipeline_stages_pipeline_id_pipelines_id_fk": { "name": "pipeline_stages_pipeline_id_pipelines_id_fk", "tableFrom": "pipeline_stages", + "tableTo": "pipelines", "columnsFrom": [ "pipeline_id" ], - "tableTo": "pipelines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -37864,9 +39125,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_transitions_pipeline_from_idx": { "name": "pipeline_transitions_pipeline_from_idx", @@ -37885,9 +39146,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipeline_transitions_pipeline_to_idx": { "name": "pipeline_transitions_pipeline_to_idx", @@ -37906,50 +39167,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipeline_transitions_pipeline_id_pipelines_id_fk": { "name": "pipeline_transitions_pipeline_id_pipelines_id_fk", "tableFrom": "pipeline_transitions", + "tableTo": "pipelines", "columnsFrom": [ "pipeline_id" ], - "tableTo": "pipelines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_transitions_from_stage_id_pipeline_stages_id_fk": { "name": "pipeline_transitions_from_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_transitions", + "tableTo": "pipeline_stages", "columnsFrom": [ "from_stage_id" ], - "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipeline_transitions_to_stage_id_pipeline_stages_id_fk": { "name": "pipeline_transitions_to_stage_id_pipeline_stages_id_fk", "tableFrom": "pipeline_transitions", + "tableTo": "pipeline_stages", "columnsFrom": [ "to_stage_id" ], - "tableTo": "pipeline_stages", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -38057,9 +39318,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipelines_company_idx": { "name": "pipelines_company_idx", @@ -38072,9 +39333,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "pipelines_company_project_idx": { "name": "pipelines_company_project_idx", @@ -38093,50 +39354,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "pipelines_company_id_companies_id_fk": { "name": "pipelines_company_id_companies_id_fk", "tableFrom": "pipelines", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "pipelines_project_id_projects_id_fk": { "name": "pipelines_project_id_projects_id_fk", "tableFrom": "pipelines", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "pipelines_created_by_agent_id_agents_id_fk": { "name": "pipelines_created_by_agent_id_agents_id_fk", "tableFrom": "pipelines", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -38215,9 +39476,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_company_settings_plugin_idx": { "name": "plugin_company_settings_plugin_idx", @@ -38230,9 +39491,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_company_settings_company_plugin_uq": { "name": "plugin_company_settings_company_plugin_uq", @@ -38251,37 +39512,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_company_settings_company_id_companies_id_fk": { "name": "plugin_company_settings_company_id_companies_id_fk", "tableFrom": "plugin_company_settings", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_company_settings_plugin_id_plugins_id_fk": { "name": "plugin_company_settings_plugin_id_plugins_id_fk", "tableFrom": "plugin_company_settings", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -38359,37 +39620,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_config_plugin_id_plugins_id_fk": { "name": "plugin_config_plugin_id_plugins_id_fk", "tableFrom": "plugin_config", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_config_company_id_companies_id_fk": { "name": "plugin_config_company_id_companies_id_fk", "tableFrom": "plugin_config", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -38468,9 +39729,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_database_namespaces_namespace_idx": { "name": "plugin_database_namespaces_namespace_idx", @@ -38483,9 +39744,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_database_namespaces_status_idx": { "name": "plugin_database_namespaces_status_idx", @@ -38498,24 +39759,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_database_namespaces_plugin_id_plugins_id_fk": { "name": "plugin_database_namespaces_plugin_id_plugins_id_fk", "tableFrom": "plugin_database_namespaces", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -38617,9 +39878,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_entities_company_idx": { "name": "plugin_entities_company_idx", @@ -38632,9 +39893,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_entities_type_idx": { "name": "plugin_entities_type_idx", @@ -38647,9 +39908,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_entities_scope_idx": { "name": "plugin_entities_scope_idx", @@ -38668,50 +39929,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_entities_plugin_id_plugins_id_fk": { "name": "plugin_entities_plugin_id_plugins_id_fk", "tableFrom": "plugin_entities", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_entities_company_id_companies_id_fk": { "name": "plugin_entities_company_id_companies_id_fk", "tableFrom": "plugin_entities", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "plugin_entities_external_idx": { "name": "plugin_entities_external_idx", + "nullsNotDistinct": true, "columns": [ "company_id", "plugin_id", "entity_type", "external_id" - ], - "nullsNotDistinct": true + ] } }, "policies": {}, @@ -38811,9 +40072,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_job_runs_plugin_idx": { "name": "plugin_job_runs_plugin_idx", @@ -38826,9 +40087,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_job_runs_company_idx": { "name": "plugin_job_runs_company_idx", @@ -38841,9 +40102,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_job_runs_status_idx": { "name": "plugin_job_runs_status_idx", @@ -38856,50 +40117,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_job_runs_job_id_plugin_jobs_id_fk": { "name": "plugin_job_runs_job_id_plugin_jobs_id_fk", "tableFrom": "plugin_job_runs", + "tableTo": "plugin_jobs", "columnsFrom": [ "job_id" ], - "tableTo": "plugin_jobs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_job_runs_plugin_id_plugins_id_fk": { "name": "plugin_job_runs_plugin_id_plugins_id_fk", "tableFrom": "plugin_job_runs", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_job_runs_company_id_companies_id_fk": { "name": "plugin_job_runs_company_id_companies_id_fk", "tableFrom": "plugin_job_runs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -38983,9 +40244,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_jobs_next_run_idx": { "name": "plugin_jobs_next_run_idx", @@ -38998,9 +40259,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_jobs_unique_idx": { "name": "plugin_jobs_unique_idx", @@ -39019,24 +40280,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_jobs_plugin_id_plugins_id_fk": { "name": "plugin_jobs_plugin_id_plugins_id_fk", "tableFrom": "plugin_jobs", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -39081,37 +40342,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_lifecycle_acknowledgments_plugin_id_plugins_id_fk": { "name": "plugin_lifecycle_acknowledgments_plugin_id_plugins_id_fk", "tableFrom": "plugin_lifecycle_acknowledgments", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_lifecycle_acknowledgments_event_id_resource_lifecycle_events_id_fk": { "name": "plugin_lifecycle_acknowledgments_event_id_resource_lifecycle_events_id_fk", "tableFrom": "plugin_lifecycle_acknowledgments", + "tableTo": "resource_lifecycle_events", "columnsFrom": [ "event_id" ], - "tableTo": "resource_lifecycle_events", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": { @@ -39196,9 +40457,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_logs_company_idx": { "name": "plugin_logs_company_idx", @@ -39211,9 +40472,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_logs_level_idx": { "name": "plugin_logs_level_idx", @@ -39226,37 +40487,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_logs_plugin_id_plugins_id_fk": { "name": "plugin_logs_plugin_id_plugins_id_fk", "tableFrom": "plugin_logs", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_logs_company_id_companies_id_fk": { "name": "plugin_logs_company_id_companies_id_fk", "tableFrom": "plugin_logs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -39346,9 +40607,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_managed_resources_plugin_idx": { "name": "plugin_managed_resources_plugin_idx", @@ -39361,9 +40622,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_managed_resources_resource_idx": { "name": "plugin_managed_resources_resource_idx", @@ -39382,9 +40643,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_managed_resources_company_plugin_resource_uq": { "name": "plugin_managed_resources_company_plugin_resource_uq", @@ -39415,37 +40676,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_managed_resources_company_id_companies_id_fk": { "name": "plugin_managed_resources_company_id_companies_id_fk", "tableFrom": "plugin_managed_resources", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_managed_resources_plugin_id_plugins_id_fk": { "name": "plugin_managed_resources_plugin_id_plugins_id_fk", "tableFrom": "plugin_managed_resources", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -39545,9 +40806,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_migrations_plugin_idx": { "name": "plugin_migrations_plugin_idx", @@ -39560,9 +40821,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_migrations_status_idx": { "name": "plugin_migrations_status_idx", @@ -39575,24 +40836,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_migrations_plugin_id_plugins_id_fk": { "name": "plugin_migrations_plugin_id_plugins_id_fk", "tableFrom": "plugin_migrations", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -39675,38 +40936,38 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_state_plugin_id_plugins_id_fk": { "name": "plugin_state_plugin_id_plugins_id_fk", "tableFrom": "plugin_state", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "plugin_state_unique_entry_idx": { "name": "plugin_state_unique_entry_idx", + "nullsNotDistinct": true, "columns": [ "plugin_id", "scope_kind", "scope_id", "namespace", "state_key" - ], - "nullsNotDistinct": true + ] } }, "policies": {}, @@ -39812,9 +41073,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_webhook_deliveries_company_idx": { "name": "plugin_webhook_deliveries_company_idx", @@ -39827,9 +41088,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_webhook_deliveries_status_idx": { "name": "plugin_webhook_deliveries_status_idx", @@ -39842,9 +41103,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugin_webhook_deliveries_key_idx": { "name": "plugin_webhook_deliveries_key_idx", @@ -39857,37 +41118,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "plugin_webhook_deliveries_plugin_id_plugins_id_fk": { "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk", "tableFrom": "plugin_webhook_deliveries", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "plugin_webhook_deliveries_company_id_companies_id_fk": { "name": "plugin_webhook_deliveries_company_id_companies_id_fk", "tableFrom": "plugin_webhook_deliveries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -39997,9 +41258,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "plugins_status_idx": { "name": "plugins_status_idx", @@ -40012,9 +41273,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": {}, @@ -40116,9 +41377,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "principal_permission_grants_company_permission_idx": { "name": "principal_permission_grants_company_permission_idx", @@ -40137,24 +41398,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "principal_permission_grants_company_id_companies_id_fk": { "name": "principal_permission_grants_company_id_companies_id_fk", "tableFrom": "principal_permission_grants", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -40230,9 +41491,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_access_members_subject_lookup_idx": { "name": "project_access_members_subject_lookup_idx", @@ -40263,37 +41524,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "project_access_members_company_id_companies_id_fk": { "name": "project_access_members_company_id_companies_id_fk", "tableFrom": "project_access_members", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "project_access_members_project_id_projects_id_fk": { "name": "project_access_members_project_id_projects_id_fk", "tableFrom": "project_access_members", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -40356,9 +41617,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_goals_goal_idx": { "name": "project_goals_goal_idx", @@ -40371,9 +41632,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_goals_company_idx": { "name": "project_goals_company_idx", @@ -40386,50 +41647,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "project_goals_project_id_projects_id_fk": { "name": "project_goals_project_id_projects_id_fk", "tableFrom": "project_goals", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "project_goals_goal_id_goals_id_fk": { "name": "project_goals_goal_id_goals_id_fk", "tableFrom": "project_goals", + "tableTo": "goals", "columnsFrom": [ "goal_id" ], - "tableTo": "goals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "project_goals_company_id_companies_id_fk": { "name": "project_goals_company_id_companies_id_fk", "tableFrom": "project_goals", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": { @@ -40521,9 +41782,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_memberships_company_user_starred_idx": { "name": "project_memberships_company_user_starred_idx", @@ -40548,9 +41809,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_memberships_project_idx": { "name": "project_memberships_project_idx", @@ -40563,9 +41824,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_memberships_company_user_project_uq": { "name": "project_memberships_company_user_project_uq", @@ -40590,37 +41851,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "project_memberships_company_id_companies_id_fk": { "name": "project_memberships_company_id_companies_id_fk", "tableFrom": "project_memberships", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "project_memberships_project_id_projects_id_fk": { "name": "project_memberships_project_id_projects_id_fk", "tableFrom": "project_memberships", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -40772,9 +42033,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_workspaces_project_primary_idx": { "name": "project_workspaces_project_primary_idx", @@ -40793,9 +42054,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_workspaces_project_source_type_idx": { "name": "project_workspaces_project_source_type_idx", @@ -40814,9 +42075,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_workspaces_company_shared_key_idx": { "name": "project_workspaces_company_shared_key_idx", @@ -40835,9 +42096,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "project_workspaces_project_remote_ref_idx": { "name": "project_workspaces_project_remote_ref_idx", @@ -40862,37 +42123,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "project_workspaces_company_id_companies_id_fk": { "name": "project_workspaces_company_id_companies_id_fk", "tableFrom": "project_workspaces", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "project_workspaces_project_id_projects_id_fk": { "name": "project_workspaces_project_id_projects_id_fk", "tableFrom": "project_workspaces", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -41043,9 +42304,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "projects_company_personal_owner_uq": { "name": "projects_company_personal_owner_uq", @@ -41064,51 +42325,51 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"projects\".\"personal_owner_user_id\" is not null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "projects_company_id_companies_id_fk": { "name": "projects_company_id_companies_id_fk", "tableFrom": "projects", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "projects_goal_id_goals_id_fk": { "name": "projects_goal_id_goals_id_fk", "tableFrom": "projects", + "tableTo": "goals", "columnsFrom": [ "goal_id" ], - "tableTo": "goals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "projects_lead_agent_id_agents_id_fk": { "name": "projects_lead_agent_id_agents_id_fk", "tableFrom": "projects", + "tableTo": "agents", "columnsFrom": [ "lead_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -41235,9 +42496,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "provider_trace_records_expiry_idx": { "name": "provider_trace_records_expiry_idx", @@ -41256,9 +42517,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "provider_trace_records_company_created_idx": { "name": "provider_trace_records_company_created_idx", @@ -41277,37 +42538,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "provider_trace_records_company_id_companies_id_fk": { "name": "provider_trace_records_company_id_companies_id_fk", "tableFrom": "provider_trace_records", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "provider_trace_records_run_id_heartbeat_runs_id_fk": { "name": "provider_trace_records_run_id_heartbeat_runs_id_fk", "tableFrom": "provider_trace_records", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -41418,9 +42679,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "remote_agent_profiles_company_key_uq": { "name": "remote_agent_profiles_company_key_uq", @@ -41439,24 +42700,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "remote_agent_profiles_company_id_companies_id_fk": { "name": "remote_agent_profiles_company_id_companies_id_fk", "tableFrom": "remote_agent_profiles", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -41484,15 +42745,15 @@ "primaryKey": true, "notNull": true, "identity": { + "type": "always", "name": "resource_lifecycle_events_id_seq", + "schema": "public", "increment": "1", + "startWith": "1", "minValue": "1", "maxValue": "9223372036854775807", - "startWith": "1", "cache": "1", - "cycle": false, - "schema": "public", - "type": "always" + "cycle": false } }, "company_id": { @@ -41551,10 +42812,10 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"resource_lifecycle_events\".\"action\" = 'create'", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} }, "resource_lifecycle_events_company_idx": { "name": "resource_lifecycle_events_company_idx", @@ -41573,9 +42834,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "resource_lifecycle_events_resource_order_idx": { "name": "resource_lifecycle_events_resource_order_idx", @@ -41606,24 +42867,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "resource_lifecycle_events_company_id_companies_id_fk": { "name": "resource_lifecycle_events_company_id_companies_id_fk", "tableFrom": "resource_lifecycle_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -41715,9 +42976,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_documents_document_uq": { "name": "routine_documents_document_uq", @@ -41730,9 +42991,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_documents_company_routine_updated_idx": { "name": "routine_documents_company_routine_updated_idx", @@ -41757,50 +43018,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "routine_documents_company_id_companies_id_fk": { "name": "routine_documents_company_id_companies_id_fk", "tableFrom": "routine_documents", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "routine_documents_routine_id_routines_id_fk": { "name": "routine_documents_routine_id_routines_id_fk", "tableFrom": "routine_documents", + "tableTo": "routines", "columnsFrom": [ "routine_id" ], - "tableTo": "routines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routine_documents_document_id_documents_id_fk": { "name": "routine_documents_document_id_documents_id_fk", "tableFrom": "routine_documents", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -41918,9 +43179,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_revisions_company_routine_created_idx": { "name": "routine_revisions_company_routine_created_idx", @@ -41945,9 +43206,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_revisions_company_responsible_user_idx": { "name": "routine_revisions_company_responsible_user_idx", @@ -41972,76 +43233,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "routine_revisions_company_id_companies_id_fk": { "name": "routine_revisions_company_id_companies_id_fk", "tableFrom": "routine_revisions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routine_revisions_routine_id_routines_id_fk": { "name": "routine_revisions_routine_id_routines_id_fk", "tableFrom": "routine_revisions", + "tableTo": "routines", "columnsFrom": [ "routine_id" ], - "tableTo": "routines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routine_revisions_restored_from_revision_id_routine_revisions_id_fk": { "name": "routine_revisions_restored_from_revision_id_routine_revisions_id_fk", "tableFrom": "routine_revisions", + "tableTo": "routine_revisions", "columnsFrom": [ "restored_from_revision_id" ], - "tableTo": "routine_revisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routine_revisions_created_by_agent_id_agents_id_fk": { "name": "routine_revisions_created_by_agent_id_agents_id_fk", "tableFrom": "routine_revisions", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routine_revisions_created_by_run_id_heartbeat_runs_id_fk": { "name": "routine_revisions_created_by_run_id_heartbeat_runs_id_fk", "tableFrom": "routine_revisions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "created_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -42192,9 +43453,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_runs_revision_idx": { "name": "routine_runs_revision_idx", @@ -42207,9 +43468,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_runs_company_responsible_user_idx": { "name": "routine_runs_company_responsible_user_idx", @@ -42234,9 +43495,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_runs_trigger_idx": { "name": "routine_runs_trigger_idx", @@ -42255,9 +43516,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_runs_dispatch_fingerprint_idx": { "name": "routine_runs_dispatch_fingerprint_idx", @@ -42276,9 +43537,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_runs_linked_issue_idx": { "name": "routine_runs_linked_issue_idx", @@ -42291,9 +43552,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_runs_trigger_idempotency_idx": { "name": "routine_runs_trigger_idempotency_idx", @@ -42312,76 +43573,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "routine_runs_company_id_companies_id_fk": { "name": "routine_runs_company_id_companies_id_fk", "tableFrom": "routine_runs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routine_runs_routine_id_routines_id_fk": { "name": "routine_runs_routine_id_routines_id_fk", "tableFrom": "routine_runs", + "tableTo": "routines", "columnsFrom": [ "routine_id" ], - "tableTo": "routines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routine_runs_trigger_id_routine_triggers_id_fk": { "name": "routine_runs_trigger_id_routine_triggers_id_fk", "tableFrom": "routine_runs", + "tableTo": "routine_triggers", "columnsFrom": [ "trigger_id" ], - "tableTo": "routine_triggers", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routine_runs_routine_revision_id_routine_revisions_id_fk": { "name": "routine_runs_routine_revision_id_routine_revisions_id_fk", "tableFrom": "routine_runs", + "tableTo": "routine_revisions", "columnsFrom": [ "routine_revision_id" ], - "tableTo": "routine_revisions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routine_runs_linked_issue_id_issues_id_fk": { "name": "routine_runs_linked_issue_id_issues_id_fk", "tableFrom": "routine_runs", + "tableTo": "issues", "columnsFrom": [ "linked_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -42569,9 +43830,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_triggers_company_kind_idx": { "name": "routine_triggers_company_kind_idx", @@ -42590,9 +43851,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_triggers_next_run_idx": { "name": "routine_triggers_next_run_idx", @@ -42605,9 +43866,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_triggers_public_id_idx": { "name": "routine_triggers_public_id_idx", @@ -42620,9 +43881,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routine_triggers_public_id_uq": { "name": "routine_triggers_public_id_uq", @@ -42635,76 +43896,76 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "routine_triggers_company_id_companies_id_fk": { "name": "routine_triggers_company_id_companies_id_fk", "tableFrom": "routine_triggers", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routine_triggers_routine_id_routines_id_fk": { "name": "routine_triggers_routine_id_routines_id_fk", "tableFrom": "routine_triggers", + "tableTo": "routines", "columnsFrom": [ "routine_id" ], - "tableTo": "routines", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routine_triggers_secret_id_company_secrets_id_fk": { "name": "routine_triggers_secret_id_company_secrets_id_fk", "tableFrom": "routine_triggers", + "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], - "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routine_triggers_created_by_agent_id_agents_id_fk": { "name": "routine_triggers_created_by_agent_id_agents_id_fk", "tableFrom": "routine_triggers", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routine_triggers_updated_by_agent_id_agents_id_fk": { "name": "routine_triggers_updated_by_agent_id_agents_id_fk", "tableFrom": "routine_triggers", + "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -42768,37 +44029,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "routine_webhook_test_receipts_company_id_companies_id_fk": { "name": "routine_webhook_test_receipts_company_id_companies_id_fk", "tableFrom": "routine_webhook_test_receipts", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk": { "name": "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk", "tableFrom": "routine_webhook_test_receipts", + "tableTo": "routine_triggers", "columnsFrom": [ "trigger_id" ], - "tableTo": "routine_triggers", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -43022,9 +44283,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routines_company_assignee_idx": { "name": "routines_company_assignee_idx", @@ -43043,9 +44304,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routines_company_project_idx": { "name": "routines_company_project_idx", @@ -43064,9 +44325,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routines_company_folder_idx": { "name": "routines_company_folder_idx", @@ -43085,9 +44346,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routines_company_responsible_user_idx": { "name": "routines_company_responsible_user_idx", @@ -43106,9 +44367,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "routines_company_origin_idx": { "name": "routines_company_origin_idx", @@ -43133,115 +44394,115 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "routines_company_id_companies_id_fk": { "name": "routines_company_id_companies_id_fk", "tableFrom": "routines", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routines_project_id_projects_id_fk": { "name": "routines_project_id_projects_id_fk", "tableFrom": "routines", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "routines_folder_id_folders_id_fk": { "name": "routines_folder_id_folders_id_fk", "tableFrom": "routines", + "tableTo": "folders", "columnsFrom": [ "folder_id" ], - "tableTo": "folders", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routines_goal_id_goals_id_fk": { "name": "routines_goal_id_goals_id_fk", "tableFrom": "routines", + "tableTo": "goals", "columnsFrom": [ "goal_id" ], - "tableTo": "goals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routines_parent_issue_id_issues_id_fk": { "name": "routines_parent_issue_id_issues_id_fk", "tableFrom": "routines", + "tableTo": "issues", "columnsFrom": [ "parent_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routines_assignee_agent_id_agents_id_fk": { "name": "routines_assignee_agent_id_agents_id_fk", "tableFrom": "routines", + "tableTo": "agents", "columnsFrom": [ "assignee_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "routines_created_by_agent_id_agents_id_fk": { "name": "routines_created_by_agent_id_agents_id_fk", "tableFrom": "routines", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "routines_updated_by_agent_id_agents_id_fk": { "name": "routines_updated_by_agent_id_agents_id_fk", "tableFrom": "routines", + "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -43354,9 +44615,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "run_identity_contexts_run_correlation_idx": { "name": "run_identity_contexts_run_correlation_idx", @@ -43375,9 +44636,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "run_identity_contexts_company_run_idx": { "name": "run_identity_contexts_company_run_idx", @@ -43396,24 +44657,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "run_identity_contexts_company_id_companies_id_fk": { "name": "run_identity_contexts_company_id_companies_id_fk", "tableFrom": "run_identity_contexts", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -43477,50 +44738,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "runner_api_response_reservations_company_id_companies_id_fk": { "name": "runner_api_response_reservations_company_id_companies_id_fk", "tableFrom": "runner_api_response_reservations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "runner_api_response_reservations_run_id_heartbeat_runs_id_fk": { "name": "runner_api_response_reservations_run_id_heartbeat_runs_id_fk", "tableFrom": "runner_api_response_reservations", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "runner_api_response_reservations_asset_id_assets_id_fk": { "name": "runner_api_response_reservations_asset_id_assets_id_fk", "tableFrom": "runner_api_response_reservations", + "tableTo": "assets", "columnsFrom": [ "asset_id" ], - "tableTo": "assets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -43687,9 +44948,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "secret_access_events_secret_created_idx": { "name": "secret_access_events_secret_created_idx", @@ -43708,9 +44969,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "secret_access_events_user_definition_created_idx": { "name": "secret_access_events_user_definition_created_idx", @@ -43729,9 +44990,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "secret_access_events_company_credential_owner_idx": { "name": "secret_access_events_company_credential_owner_idx", @@ -43756,9 +45017,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "secret_access_events_consumer_idx": { "name": "secret_access_events_consumer_idx", @@ -43783,9 +45044,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "secret_access_events_run_idx": { "name": "secret_access_events_run_idx", @@ -43798,89 +45059,89 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "secret_access_events_company_id_companies_id_fk": { "name": "secret_access_events_company_id_companies_id_fk", "tableFrom": "secret_access_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "secret_access_events_secret_id_company_secrets_id_fk": { "name": "secret_access_events_secret_id_company_secrets_id_fk", "tableFrom": "secret_access_events", + "tableTo": "company_secrets", "columnsFrom": [ "secret_id" ], - "tableTo": "company_secrets", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk": { "name": "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk", "tableFrom": "secret_access_events", + "tableTo": "user_secret_definitions", "columnsFrom": [ "user_secret_definition_id" ], - "tableTo": "user_secret_definitions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "secret_access_events_issue_id_issues_id_fk": { "name": "secret_access_events_issue_id_issues_id_fk", "tableFrom": "secret_access_events", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "secret_access_events", + "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "secret_access_events_plugin_id_plugins_id_fk": { "name": "secret_access_events_plugin_id_plugins_id_fk", "tableFrom": "secret_access_events", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -43981,9 +45242,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "smoke_run_steps_company_path_idx": { "name": "smoke_run_steps_company_path_idx", @@ -44002,37 +45263,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "smoke_run_steps_company_id_companies_id_fk": { "name": "smoke_run_steps_company_id_companies_id_fk", "tableFrom": "smoke_run_steps", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "smoke_run_steps_run_id_smoke_runs_id_fk": { "name": "smoke_run_steps_run_id_smoke_runs_id_fk", "tableFrom": "smoke_run_steps", + "tableTo": "smoke_runs", "columnsFrom": [ "run_id" ], - "tableTo": "smoke_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -44124,9 +45385,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "smoke_runs_company_status_idx": { "name": "smoke_runs_company_status_idx", @@ -44145,24 +45406,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "smoke_runs_company_id_companies_id_fk": { "name": "smoke_runs_company_id_companies_id_fk", "tableFrom": "smoke_runs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -44302,9 +45563,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "status_card_updates_generation_issue_idx": { "name": "status_card_updates_generation_issue_idx", @@ -44317,50 +45578,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "status_card_updates_card_id_status_cards_id_fk": { "name": "status_card_updates_card_id_status_cards_id_fk", "tableFrom": "status_card_updates", + "tableTo": "status_cards", "columnsFrom": [ "card_id" ], - "tableTo": "status_cards", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "status_card_updates_generation_issue_id_issues_id_fk": { "name": "status_card_updates_generation_issue_id_issues_id_fk", "tableFrom": "status_card_updates", + "tableTo": "issues", "columnsFrom": [ "generation_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "status_card_updates_run_id_heartbeat_runs_id_fk": { "name": "status_card_updates_run_id_heartbeat_runs_id_fk", "tableFrom": "status_card_updates", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -44593,9 +45854,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "status_cards_company_next_eval_idx": { "name": "status_cards_company_next_eval_idx", @@ -44614,102 +45875,102 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "status_cards_company_id_companies_id_fk": { "name": "status_cards_company_id_companies_id_fk", "tableFrom": "status_cards", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "status_cards_created_by_agent_id_agents_id_fk": { "name": "status_cards_created_by_agent_id_agents_id_fk", "tableFrom": "status_cards", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "status_cards_query_compiled_by_agent_id_agents_id_fk": { "name": "status_cards_query_compiled_by_agent_id_agents_id_fk", "tableFrom": "status_cards", + "tableTo": "agents", "columnsFrom": [ "query_compiled_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "status_cards_agent_id_agents_id_fk": { "name": "status_cards_agent_id_agents_id_fk", "tableFrom": "status_cards", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "status_cards_document_id_documents_id_fk": { "name": "status_cards_document_id_documents_id_fk", "tableFrom": "status_cards", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "status_cards_generating_issue_id_issues_id_fk": { "name": "status_cards_generating_issue_id_issues_id_fk", "tableFrom": "status_cards", + "tableTo": "issues", "columnsFrom": [ "generating_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "status_cards_archived_by_agent_id_agents_id_fk": { "name": "status_cards_archived_by_agent_id_agents_id_fk", "tableFrom": "status_cards", + "tableTo": "agents", "columnsFrom": [ "archived_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -44848,9 +46109,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "status_decision_effects_company_idempotency_uq": { "name": "status_decision_effects_company_idempotency_uq", @@ -44869,56 +46130,56 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "status_decision_effects_company_id_companies_id_fk": { "name": "status_decision_effects_company_id_companies_id_fk", "tableFrom": "status_decision_effects", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "status_decision_effects_issue_company_fk": { "name": "status_decision_effects_issue_company_fk", "tableFrom": "status_decision_effects", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "status_decision_effects_decision_owner_fk": { "name": "status_decision_effects_decision_owner_fk", "tableFrom": "status_decision_effects", + "tableTo": "status_decisions", "columnsFrom": [ "company_id", "issue_id", "decision_id" ], - "tableTo": "status_decisions", "columnsTo": [ "company_id", "issue_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -45055,9 +46316,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "status_decisions_company_assessment_uq": { "name": "status_decisions_company_assessment_uq", @@ -45076,9 +46337,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "status_decisions_company_issue_digest_uq": { "name": "status_decisions_company_issue_digest_uq", @@ -45103,98 +46364,98 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "status_decisions_company_id_companies_id_fk": { "name": "status_decisions_company_id_companies_id_fk", "tableFrom": "status_decisions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "status_decisions_issue_company_fk": { "name": "status_decisions_issue_company_fk", "tableFrom": "status_decisions", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "status_decisions_assessment_owner_fk": { "name": "status_decisions_assessment_owner_fk", "tableFrom": "status_decisions", + "tableTo": "work_assessments", "columnsFrom": [ "company_id", "issue_id", "run_id", "assessment_id" ], - "tableTo": "work_assessments", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "status_decisions_supersedes_owner_fk": { "name": "status_decisions_supersedes_owner_fk", "tableFrom": "status_decisions", + "tableTo": "status_decisions", "columnsFrom": [ "company_id", "issue_id", "supersedes_decision_id" ], - "tableTo": "status_decisions", "columnsTo": [ "company_id", "issue_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "status_decisions_company_issue_id_uq": { "name": "status_decisions_company_issue_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "id" - ], - "nullsNotDistinct": false + ] }, "status_decisions_company_issue_run_assessment_id_uq": { "name": "status_decisions_company_issue_run_assessment_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "run_id", "assessment_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -45306,9 +46567,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "summary_slots_company_scope_idx": { "name": "summary_slots_company_scope_idx", @@ -45333,9 +46594,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "summary_slots_company_generating_issue_idx": { "name": "summary_slots_company_generating_issue_idx", @@ -45354,9 +46615,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "summary_slots_company_updated_idx": { "name": "summary_slots_company_updated_idx", @@ -45375,76 +46636,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "summary_slots_company_id_companies_id_fk": { "name": "summary_slots_company_id_companies_id_fk", "tableFrom": "summary_slots", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "summary_slots_document_id_documents_id_fk": { "name": "summary_slots_document_id_documents_id_fk", "tableFrom": "summary_slots", + "tableTo": "documents", "columnsFrom": [ "document_id" ], - "tableTo": "documents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "summary_slots_generating_issue_id_issues_id_fk": { "name": "summary_slots_generating_issue_id_issues_id_fk", "tableFrom": "summary_slots", + "tableTo": "issues", "columnsFrom": [ "generating_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "summary_slots_last_generated_by_agent_id_agents_id_fk": { "name": "summary_slots_last_generated_by_agent_id_agents_id_fk", "tableFrom": "summary_slots", + "tableTo": "agents", "columnsFrom": [ "last_generated_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "summary_slots_company_scope_slot_uq": { "name": "summary_slots_company_scope_slot_uq", + "nullsNotDistinct": true, "columns": [ "company_id", "scope_kind", "scope_id", "slot_key" - ], - "nullsNotDistinct": true + ] } }, "policies": {}, @@ -45574,9 +46835,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_access_audit_connection_idx": { "name": "tool_access_audit_connection_idx", @@ -45589,9 +46850,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_access_audit_gateway_idx": { "name": "tool_access_audit_gateway_idx", @@ -45610,76 +46871,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_access_audit_events_company_id_companies_id_fk": { "name": "tool_access_audit_events_company_id_companies_id_fk", "tableFrom": "tool_access_audit_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_access_audit_events", + "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], - "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { "name": "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk", "tableFrom": "tool_access_audit_events", + "tableTo": "tool_mcp_gateway_tokens", "columnsFrom": [ "gateway_token_id" ], - "tableTo": "tool_mcp_gateway_tokens", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_access_audit_events_connection_id_tool_connections_id_fk": { "name": "tool_access_audit_events_connection_id_tool_connections_id_fk", "tableFrom": "tool_access_audit_events", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk": { "name": "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk", "tableFrom": "tool_access_audit_events", + "tableTo": "tool_catalog_entries", "columnsFrom": [ "catalog_entry_id" ], - "tableTo": "tool_catalog_entries", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -45748,63 +47009,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_action_deliveries_action_request_id_tool_action_requests_id_fk": { "name": "tool_action_deliveries_action_request_id_tool_action_requests_id_fk", "tableFrom": "tool_action_deliveries", + "tableTo": "tool_action_requests", "columnsFrom": [ "action_request_id" ], - "tableTo": "tool_action_requests", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_action_deliveries_company_id_companies_id_fk": { "name": "tool_action_deliveries_company_id_companies_id_fk", "tableFrom": "tool_action_deliveries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_action_deliveries_issue_id_issues_id_fk": { "name": "tool_action_deliveries_issue_id_issues_id_fk", "tableFrom": "tool_action_deliveries", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_action_deliveries_interaction_id_issue_thread_interactions_id_fk": { "name": "tool_action_deliveries_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "tool_action_deliveries", + "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], - "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -45972,9 +47233,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_action_requests_invocation_idx": { "name": "tool_action_requests_invocation_idx", @@ -45987,9 +47248,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_action_requests_issue_idx": { "name": "tool_action_requests_issue_idx", @@ -46008,115 +47269,115 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_action_requests_company_id_companies_id_fk": { "name": "tool_action_requests_company_id_companies_id_fk", "tableFrom": "tool_action_requests", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_action_requests_invocation_id_tool_invocations_id_fk": { "name": "tool_action_requests_invocation_id_tool_invocations_id_fk", "tableFrom": "tool_action_requests", + "tableTo": "tool_invocations", "columnsFrom": [ "invocation_id" ], - "tableTo": "tool_invocations", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_action_requests_issue_id_issues_id_fk": { "name": "tool_action_requests_issue_id_issues_id_fk", "tableFrom": "tool_action_requests", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_action_requests_interaction_id_issue_thread_interactions_id_fk": { "name": "tool_action_requests_interaction_id_issue_thread_interactions_id_fk", "tableFrom": "tool_action_requests", + "tableTo": "issue_thread_interactions", "columnsFrom": [ "interaction_id" ], - "tableTo": "issue_thread_interactions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_action_requests_approval_id_approvals_id_fk": { "name": "tool_action_requests_approval_id_approvals_id_fk", "tableFrom": "tool_action_requests", + "tableTo": "approvals", "columnsFrom": [ "approval_id" ], - "tableTo": "approvals", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_action_requests_requested_by_agent_id_agents_id_fk": { "name": "tool_action_requests_requested_by_agent_id_agents_id_fk", "tableFrom": "tool_action_requests", + "tableTo": "agents", "columnsFrom": [ "requested_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_action_requests_resolved_by_agent_id_agents_id_fk": { "name": "tool_action_requests_resolved_by_agent_id_agents_id_fk", "tableFrom": "tool_action_requests", + "tableTo": "agents", "columnsFrom": [ "resolved_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_action_requests_decided_by_agent_id_agents_id_fk": { "name": "tool_action_requests_decided_by_agent_id_agents_id_fk", "tableFrom": "tool_action_requests", + "tableTo": "agents", "columnsFrom": [ "decided_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -46231,9 +47492,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_applications_company_status_idx": { "name": "tool_applications_company_status_idx", @@ -46252,9 +47513,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_applications_company_name_uq": { "name": "tool_applications_company_name_uq", @@ -46273,9 +47534,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_applications_company_key_uq": { "name": "tool_applications_company_key_uq", @@ -46294,50 +47555,50 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_applications_company_id_companies_id_fk": { "name": "tool_applications_company_id_companies_id_fk", "tableFrom": "tool_applications", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_applications_plugin_id_plugins_id_fk": { "name": "tool_applications_plugin_id_plugins_id_fk", "tableFrom": "tool_applications", + "tableTo": "plugins", "columnsFrom": [ "plugin_id" ], - "tableTo": "plugins", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_applications_owner_agent_id_agents_id_fk": { "name": "tool_applications_owner_agent_id_agents_id_fk", "tableFrom": "tool_applications", + "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -46632,9 +47893,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_call_events_run_idx": { "name": "tool_call_events_run_idx", @@ -46653,9 +47914,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_call_events_issue_idx": { "name": "tool_call_events_issue_idx", @@ -46674,9 +47935,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_call_events_invocation_idx": { "name": "tool_call_events_invocation_idx", @@ -46689,9 +47950,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_call_events_gateway_idx": { "name": "tool_call_events_gateway_idx", @@ -46710,167 +47971,167 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_call_events_company_id_companies_id_fk": { "name": "tool_call_events_company_id_companies_id_fk", "tableFrom": "tool_call_events", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_call_events_agent_id_agents_id_fk": { "name": "tool_call_events_agent_id_agents_id_fk", "tableFrom": "tool_call_events", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_run_id_heartbeat_runs_id_fk": { "name": "tool_call_events_run_id_heartbeat_runs_id_fk", "tableFrom": "tool_call_events", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_issue_id_issues_id_fk": { "name": "tool_call_events_issue_id_issues_id_fk", "tableFrom": "tool_call_events", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_call_events_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_call_events", + "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], - "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { "name": "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk", "tableFrom": "tool_call_events", + "tableTo": "tool_mcp_gateway_tokens", "columnsFrom": [ "gateway_token_id" ], - "tableTo": "tool_mcp_gateway_tokens", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_application_id_tool_applications_id_fk": { "name": "tool_call_events_application_id_tool_applications_id_fk", "tableFrom": "tool_call_events", + "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], - "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_connection_id_tool_connections_id_fk": { "name": "tool_call_events_connection_id_tool_connections_id_fk", "tableFrom": "tool_call_events", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk": { "name": "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk", "tableFrom": "tool_call_events", + "tableTo": "tool_catalog_entries", "columnsFrom": [ "catalog_entry_id" ], - "tableTo": "tool_catalog_entries", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_invocation_id_tool_invocations_id_fk": { "name": "tool_call_events_invocation_id_tool_invocations_id_fk", "tableFrom": "tool_call_events", + "tableTo": "tool_invocations", "columnsFrom": [ "invocation_id" ], - "tableTo": "tool_invocations", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_action_request_id_tool_action_requests_id_fk": { "name": "tool_call_events_action_request_id_tool_action_requests_id_fk", "tableFrom": "tool_call_events", + "tableTo": "tool_action_requests", "columnsFrom": [ "action_request_id" ], - "tableTo": "tool_action_requests", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk": { "name": "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk", "tableFrom": "tool_call_events", + "tableTo": "tool_runtime_slots", "columnsFrom": [ "runtime_slot_id" ], - "tableTo": "tool_runtime_slots", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -47083,9 +48344,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_catalog_entries_application_idx": { "name": "tool_catalog_entries_application_idx", @@ -47098,9 +48359,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_catalog_entries_connection_idx": { "name": "tool_catalog_entries_connection_idx", @@ -47113,9 +48374,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_catalog_entries_company_status_idx": { "name": "tool_catalog_entries_company_status_idx", @@ -47134,9 +48395,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_catalog_entries_connection_name_uq": { "name": "tool_catalog_entries_connection_name_uq", @@ -47155,63 +48416,63 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_catalog_entries_company_id_companies_id_fk": { "name": "tool_catalog_entries_company_id_companies_id_fk", "tableFrom": "tool_catalog_entries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_catalog_entries_application_id_tool_applications_id_fk": { "name": "tool_catalog_entries_application_id_tool_applications_id_fk", "tableFrom": "tool_catalog_entries", + "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], - "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_catalog_entries_connection_id_tool_connections_id_fk": { "name": "tool_catalog_entries_connection_id_tool_connections_id_fk", "tableFrom": "tool_catalog_entries", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk": { "name": "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk", "tableFrom": "tool_catalog_entries", + "tableTo": "agents", "columnsFrom": [ "reviewed_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -47318,39 +48579,39 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_connection_app_snapshots_company_id_companies_id_fk": { "name": "tool_connection_app_snapshots_company_id_companies_id_fk", "tableFrom": "tool_connection_app_snapshots", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_connection_app_snapshots_company_connection_fk": { "name": "tool_connection_app_snapshots_company_connection_fk", "tableFrom": "tool_connection_app_snapshots", + "tableTo": "tool_connections", "columnsFrom": [ "company_id", "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -47471,39 +48732,39 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_connection_app_syncs_company_id_companies_id_fk": { "name": "tool_connection_app_syncs_company_id_companies_id_fk", "tableFrom": "tool_connection_app_syncs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_connection_app_syncs_company_connection_fk": { "name": "tool_connection_app_syncs_company_connection_fk", "tableFrom": "tool_connection_app_syncs", + "tableTo": "tool_connections", "columnsFrom": [ "company_id", "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -47591,9 +48852,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_connection_installs_connection_idx": { "name": "tool_connection_installs_connection_idx", @@ -47612,9 +48873,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_connection_installs_target_uq": { "name": "tool_connection_installs_target_uq", @@ -47645,50 +48906,50 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_connection_installs_company_id_companies_id_fk": { "name": "tool_connection_installs_company_id_companies_id_fk", "tableFrom": "tool_connection_installs", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_connection_installs_connection_id_tool_connections_id_fk": { "name": "tool_connection_installs_connection_id_tool_connections_id_fk", "tableFrom": "tool_connection_installs", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_connection_installs_created_by_agent_id_agents_id_fk": { "name": "tool_connection_installs_created_by_agent_id_agents_id_fk", "tableFrom": "tool_connection_installs", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -47915,9 +49176,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_connections_application_idx": { "name": "tool_connections_application_idx", @@ -47930,9 +49191,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_connections_company_enabled_idx": { "name": "tool_connections_company_enabled_idx", @@ -47951,9 +49212,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_connections_company_uid_uq": { "name": "tool_connections_company_uid_uq", @@ -47972,61 +49233,61 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_connections_company_id_companies_id_fk": { "name": "tool_connections_company_id_companies_id_fk", "tableFrom": "tool_connections", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_connections_application_id_tool_applications_id_fk": { "name": "tool_connections_application_id_tool_applications_id_fk", "tableFrom": "tool_connections", + "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], - "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "tool_connections_created_by_agent_id_agents_id_fk": { "name": "tool_connections_created_by_agent_id_agents_id_fk", "tableFrom": "tool_connections", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "tool_connections_company_id_uq": { "name": "tool_connections_company_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -48147,9 +49408,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_gateway_rate_limit_counters_window_uq": { "name": "tool_gateway_rate_limit_counters_window_uq", @@ -48174,24 +49435,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_gateway_rate_limit_counters_company_id_companies_id_fk": { "name": "tool_gateway_rate_limit_counters_company_id_companies_id_fk", "tableFrom": "tool_gateway_rate_limit_counters", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -48340,9 +49601,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_gateway_sessions_company_agent_idx": { "name": "tool_gateway_sessions_company_agent_idx", @@ -48361,9 +49622,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_gateway_sessions_company_expires_idx": { "name": "tool_gateway_sessions_company_expires_idx", @@ -48382,9 +49643,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_gateway_sessions_run_idx": { "name": "tool_gateway_sessions_run_idx", @@ -48403,9 +49664,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_gateway_sessions_issue_idx": { "name": "tool_gateway_sessions_issue_idx", @@ -48424,9 +49685,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_gateway_sessions_gateway_idx": { "name": "tool_gateway_sessions_gateway_idx", @@ -48445,102 +49706,102 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_gateway_sessions_company_id_companies_id_fk": { "name": "tool_gateway_sessions_company_id_companies_id_fk", "tableFrom": "tool_gateway_sessions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_gateway_sessions_agent_id_agents_id_fk": { "name": "tool_gateway_sessions_agent_id_agents_id_fk", "tableFrom": "tool_gateway_sessions", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_gateway_sessions_run_id_heartbeat_runs_id_fk": { "name": "tool_gateway_sessions_run_id_heartbeat_runs_id_fk", "tableFrom": "tool_gateway_sessions", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_gateway_sessions_issue_id_issues_id_fk": { "name": "tool_gateway_sessions_issue_id_issues_id_fk", "tableFrom": "tool_gateway_sessions", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_gateway_sessions_project_id_projects_id_fk": { "name": "tool_gateway_sessions_project_id_projects_id_fk", "tableFrom": "tool_gateway_sessions", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_gateway_sessions", + "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], - "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { "name": "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk", "tableFrom": "tool_gateway_sessions", + "tableTo": "tool_mcp_gateway_tokens", "columnsFrom": [ "gateway_token_id" ], - "tableTo": "tool_mcp_gateway_tokens", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -48855,9 +50116,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_invocations_run_idx": { "name": "tool_invocations_run_idx", @@ -48876,9 +50137,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_invocations_issue_idx": { "name": "tool_invocations_issue_idx", @@ -48897,9 +50158,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_invocations_gateway_idx": { "name": "tool_invocations_gateway_idx", @@ -48918,9 +50179,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_invocations_company_idempotency_uq": { "name": "tool_invocations_company_idempotency_uq", @@ -48939,128 +50200,128 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_invocations_company_id_companies_id_fk": { "name": "tool_invocations_company_id_companies_id_fk", "tableFrom": "tool_invocations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_invocations_agent_id_agents_id_fk": { "name": "tool_invocations_agent_id_agents_id_fk", "tableFrom": "tool_invocations", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_invocations_issue_id_issues_id_fk": { "name": "tool_invocations_issue_id_issues_id_fk", "tableFrom": "tool_invocations", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_invocations_run_id_heartbeat_runs_id_fk": { "name": "tool_invocations_run_id_heartbeat_runs_id_fk", "tableFrom": "tool_invocations", + "tableTo": "heartbeat_runs", "columnsFrom": [ "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_invocations_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_invocations_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_invocations", + "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], - "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { "name": "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk", "tableFrom": "tool_invocations", + "tableTo": "tool_mcp_gateway_tokens", "columnsFrom": [ "gateway_token_id" ], - "tableTo": "tool_mcp_gateway_tokens", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_invocations_application_id_tool_applications_id_fk": { "name": "tool_invocations_application_id_tool_applications_id_fk", "tableFrom": "tool_invocations", + "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], - "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_invocations_connection_id_tool_connections_id_fk": { "name": "tool_invocations_connection_id_tool_connections_id_fk", "tableFrom": "tool_invocations", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk": { "name": "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk", "tableFrom": "tool_invocations", + "tableTo": "tool_catalog_entries", "columnsFrom": [ "catalog_entry_id" ], - "tableTo": "tool_catalog_entries", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -49226,9 +50487,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateway_tokens_expiry_idx": { "name": "tool_mcp_gateway_tokens_expiry_idx", @@ -49247,9 +50508,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateway_tokens_gateway_idx": { "name": "tool_mcp_gateway_tokens_gateway_idx", @@ -49268,9 +50529,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateway_tokens_subject_idx": { "name": "tool_mcp_gateway_tokens_subject_idx", @@ -49295,9 +50556,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateway_tokens_company_expires_idx": { "name": "tool_mcp_gateway_tokens_company_expires_idx", @@ -49316,63 +50577,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_mcp_gateway_tokens_company_id_companies_id_fk": { "name": "tool_mcp_gateway_tokens_company_id_companies_id_fk", "tableFrom": "tool_mcp_gateway_tokens", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk": { "name": "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk", "tableFrom": "tool_mcp_gateway_tokens", + "tableTo": "tool_mcp_gateways", "columnsFrom": [ "gateway_id" ], - "tableTo": "tool_mcp_gateways", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk": { "name": "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk", "tableFrom": "tool_mcp_gateway_tokens", + "tableTo": "agents", "columnsFrom": [ "expiry_override_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_mcp_gateway_tokens_created_by_agent_id_agents_id_fk": { "name": "tool_mcp_gateway_tokens_created_by_agent_id_agents_id_fk", "tableFrom": "tool_mcp_gateway_tokens", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -49567,9 +50828,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateways_company_status_idx": { "name": "tool_mcp_gateways_company_status_idx", @@ -49588,9 +50849,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateways_profile_idx": { "name": "tool_mcp_gateways_profile_idx", @@ -49609,9 +50870,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateways_public_id_uq": { "name": "tool_mcp_gateways_public_id_uq", @@ -49624,9 +50885,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateways_company_slug_uq": { "name": "tool_mcp_gateways_company_slug_uq", @@ -49645,9 +50906,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_mcp_gateways_company_name_uq": { "name": "tool_mcp_gateways_company_name_uq", @@ -49666,102 +50927,102 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_mcp_gateways_company_id_companies_id_fk": { "name": "tool_mcp_gateways_company_id_companies_id_fk", "tableFrom": "tool_mcp_gateways", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_mcp_gateways_profile_id_tool_profiles_id_fk": { "name": "tool_mcp_gateways_profile_id_tool_profiles_id_fk", "tableFrom": "tool_mcp_gateways", + "tableTo": "tool_profiles", "columnsFrom": [ "profile_id" ], - "tableTo": "tool_profiles", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "restrict" + "onDelete": "restrict", + "onUpdate": "no action" }, "tool_mcp_gateways_agent_id_agents_id_fk": { "name": "tool_mcp_gateways_agent_id_agents_id_fk", "tableFrom": "tool_mcp_gateways", + "tableTo": "agents", "columnsFrom": [ "agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_mcp_gateways_project_id_projects_id_fk": { "name": "tool_mcp_gateways_project_id_projects_id_fk", "tableFrom": "tool_mcp_gateways", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_mcp_gateways_issue_id_issues_id_fk": { "name": "tool_mcp_gateways_issue_id_issues_id_fk", "tableFrom": "tool_mcp_gateways", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_mcp_gateways_approval_issue_id_issues_id_fk": { "name": "tool_mcp_gateways_approval_issue_id_issues_id_fk", "tableFrom": "tool_mcp_gateways", + "tableTo": "issues", "columnsFrom": [ "approval_issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_mcp_gateways_created_by_agent_id_agents_id_fk": { "name": "tool_mcp_gateways_created_by_agent_id_agents_id_fk", "tableFrom": "tool_mcp_gateways", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -49878,9 +51139,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_oauth_states_connection_idx": { "name": "tool_oauth_states_connection_idx", @@ -49893,9 +51154,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_oauth_states_actor_idx": { "name": "tool_oauth_states_actor_idx", @@ -49914,9 +51175,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_oauth_states_subject_agent_idx": { "name": "tool_oauth_states_subject_agent_idx", @@ -49935,9 +51196,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_oauth_states_expires_at_idx": { "name": "tool_oauth_states_expires_at_idx", @@ -49950,50 +51211,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_oauth_states_company_id_companies_id_fk": { "name": "tool_oauth_states_company_id_companies_id_fk", "tableFrom": "tool_oauth_states", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_oauth_states_connection_id_tool_connections_id_fk": { "name": "tool_oauth_states_connection_id_tool_connections_id_fk", "tableFrom": "tool_oauth_states", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_oauth_states_subject_agent_id_agents_id_fk": { "name": "tool_oauth_states_subject_agent_id_agents_id_fk", "tableFrom": "tool_oauth_states", + "tableTo": "agents", "columnsFrom": [ "subject_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -50115,9 +51376,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_policies_company_type_idx": { "name": "tool_policies_company_type_idx", @@ -50136,9 +51397,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_policies_company_name_uq": { "name": "tool_policies_company_name_uq", @@ -50157,37 +51418,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_policies_company_id_companies_id_fk": { "name": "tool_policies_company_id_companies_id_fk", "tableFrom": "tool_policies", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_policies_created_by_agent_id_agents_id_fk": { "name": "tool_policies_created_by_agent_id_agents_id_fk", "tableFrom": "tool_policies", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -50296,9 +51557,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_profile_bindings_target_profile_uq": { "name": "tool_profile_bindings_target_profile_uq", @@ -50329,50 +51590,50 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_profile_bindings_company_id_companies_id_fk": { "name": "tool_profile_bindings_company_id_companies_id_fk", "tableFrom": "tool_profile_bindings", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_profile_bindings_profile_id_tool_profiles_id_fk": { "name": "tool_profile_bindings_profile_id_tool_profiles_id_fk", "tableFrom": "tool_profile_bindings", + "tableTo": "tool_profiles", "columnsFrom": [ "profile_id" ], - "tableTo": "tool_profiles", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_profile_bindings_created_by_agent_id_agents_id_fk": { "name": "tool_profile_bindings_created_by_agent_id_agents_id_fk", "tableFrom": "tool_profile_bindings", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -50486,9 +51747,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_profile_entries_application_idx": { "name": "tool_profile_entries_application_idx", @@ -50507,9 +51768,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_profile_entries_connection_idx": { "name": "tool_profile_entries_connection_idx", @@ -50528,9 +51789,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_profile_entries_catalog_entry_idx": { "name": "tool_profile_entries_catalog_entry_idx", @@ -50549,76 +51810,76 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_profile_entries_company_id_companies_id_fk": { "name": "tool_profile_entries_company_id_companies_id_fk", "tableFrom": "tool_profile_entries", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_profile_entries_profile_id_tool_profiles_id_fk": { "name": "tool_profile_entries_profile_id_tool_profiles_id_fk", "tableFrom": "tool_profile_entries", + "tableTo": "tool_profiles", "columnsFrom": [ "profile_id" ], - "tableTo": "tool_profiles", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_profile_entries_application_id_tool_applications_id_fk": { "name": "tool_profile_entries_application_id_tool_applications_id_fk", "tableFrom": "tool_profile_entries", + "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], - "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_profile_entries_connection_id_tool_connections_id_fk": { "name": "tool_profile_entries_connection_id_tool_connections_id_fk", "tableFrom": "tool_profile_entries", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk": { "name": "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk", "tableFrom": "tool_profile_entries", + "tableTo": "tool_catalog_entries", "columnsFrom": [ "catalog_entry_id" ], - "tableTo": "tool_catalog_entries", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -50722,9 +51983,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_profiles_company_key_uq": { "name": "tool_profiles_company_key_uq", @@ -50743,9 +52004,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_profiles_company_name_uq": { "name": "tool_profiles_company_name_uq", @@ -50764,24 +52025,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_profiles_company_id_companies_id_fk": { "name": "tool_profiles_company_id_companies_id_fk", "tableFrom": "tool_profiles", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -50888,9 +52149,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_rate_limit_counters_window_uq": { "name": "tool_rate_limit_counters_window_uq", @@ -50927,37 +52188,37 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_rate_limit_counters_company_id_companies_id_fk": { "name": "tool_rate_limit_counters_company_id_companies_id_fk", "tableFrom": "tool_rate_limit_counters", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_rate_limit_counters_policy_id_tool_policies_id_fk": { "name": "tool_rate_limit_counters_policy_id_tool_policies_id_fk", "tableFrom": "tool_rate_limit_counters", + "tableTo": "tool_policies", "columnsFrom": [ "policy_id" ], - "tableTo": "tool_policies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -51041,9 +52302,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_runtime_metric_counters_bucket_uq": { "name": "tool_runtime_metric_counters_bucket_uq", @@ -51068,24 +52329,24 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_runtime_metric_counters_company_id_companies_id_fk": { "name": "tool_runtime_metric_counters_company_id_companies_id_fk", "tableFrom": "tool_runtime_metric_counters", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -51311,9 +52572,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_runtime_slots_connection_idx": { "name": "tool_runtime_slots_connection_idx", @@ -51326,9 +52587,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_runtime_slots_execution_workspace_idx": { "name": "tool_runtime_slots_execution_workspace_idx", @@ -51347,9 +52608,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_runtime_slots_slot_key_uq": { "name": "tool_runtime_slots_slot_key_uq", @@ -51368,89 +52629,89 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_runtime_slots_company_id_companies_id_fk": { "name": "tool_runtime_slots_company_id_companies_id_fk", "tableFrom": "tool_runtime_slots", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_runtime_slots_application_id_tool_applications_id_fk": { "name": "tool_runtime_slots_application_id_tool_applications_id_fk", "tableFrom": "tool_runtime_slots", + "tableTo": "tool_applications", "columnsFrom": [ "application_id" ], - "tableTo": "tool_applications", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_runtime_slots_connection_id_tool_connections_id_fk": { "name": "tool_runtime_slots_connection_id_tool_connections_id_fk", "tableFrom": "tool_runtime_slots", + "tableTo": "tool_connections", "columnsFrom": [ "connection_id" ], - "tableTo": "tool_connections", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk": { "name": "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk", "tableFrom": "tool_runtime_slots", + "tableTo": "project_workspaces", "columnsFrom": [ "project_workspace_id" ], - "tableTo": "project_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk": { "name": "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "tool_runtime_slots", + "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], - "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "tool_runtime_slots_issue_id_issues_id_fk": { "name": "tool_runtime_slots_issue_id_issues_id_fk", "tableFrom": "tool_runtime_slots", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -51573,9 +52834,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_stdio_command_templates_company_status_idx": { "name": "tool_stdio_command_templates_company_status_idx", @@ -51594,9 +52855,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "tool_stdio_command_templates_company_key_uq": { "name": "tool_stdio_command_templates_company_key_uq", @@ -51615,37 +52876,160 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "tool_stdio_command_templates_company_id_companies_id_fk": { "name": "tool_stdio_command_templates_company_id_companies_id_fk", "tableFrom": "tool_stdio_command_templates", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "tool_stdio_command_templates_created_by_agent_id_agents_id_fk": { "name": "tool_stdio_command_templates_created_by_agent_id_agents_id_fk", "tableFrom": "tool_stdio_command_templates", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_company_preferences": { + "name": "user_company_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_agent_id": { + "name": "primary_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "primary_agent_initialized": { + "name": "primary_agent_initialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_company_preferences_company_user_uq": { + "name": "user_company_preferences_company_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_company_preferences_primary_agent_idx": { + "name": "user_company_preferences_primary_agent_idx", + "columns": [ + { + "expression": "primary_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_company_preferences_company_id_companies_id_fk": { + "name": "user_company_preferences_company_id_companies_id_fk", + "tableFrom": "user_company_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_company_preferences_primary_agent_id_agents_id_fk": { + "name": "user_company_preferences_primary_agent_id_agents_id_fk", + "tableFrom": "user_company_preferences", + "tableTo": "agents", + "columnsFrom": [ + "primary_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -51724,9 +53108,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_inbox_agent_policies_allowed_agent_ids_idx": { "name": "user_inbox_agent_policies_allowed_agent_ids_idx", @@ -51739,24 +53123,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "gin", - "concurrently": false + "with": {} } }, "foreignKeys": { "user_inbox_agent_policies_company_id_companies_id_fk": { "name": "user_inbox_agent_policies_company_id_companies_id_fk", "tableFrom": "user_inbox_agent_policies", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -51871,9 +53255,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_secret_declarations_definition_idx": { "name": "user_secret_declarations_definition_idx", @@ -51886,9 +53270,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_secret_declarations_target_idx": { "name": "user_secret_declarations_target_idx", @@ -51913,9 +53297,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_secret_declarations_company_required_idx": { "name": "user_secret_declarations_company_required_idx", @@ -51934,9 +53318,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_secret_declarations_target_path_uq": { "name": "user_secret_declarations_target_path_uq", @@ -51967,9 +53351,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_secret_declarations_required_override_idx": { "name": "user_secret_declarations_required_override_idx", @@ -51988,38 +53372,38 @@ } ], "isUnique": false, - "with": {}, - "method": "btree", "where": "\"user_secret_declarations\".\"allow_missing_override\" = true", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "user_secret_declarations_company_id_companies_id_fk": { "name": "user_secret_declarations_company_id_companies_id_fk", "tableFrom": "user_secret_declarations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk": { "name": "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk", "tableFrom": "user_secret_declarations", + "tableTo": "user_secret_definitions", "columnsFrom": [ "user_secret_definition_id" ], - "tableTo": "user_secret_definitions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -52165,9 +53549,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_secret_definitions_company_provider_idx": { "name": "user_secret_definitions_company_provider_idx", @@ -52186,9 +53570,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_secret_definitions_provider_config_idx": { "name": "user_secret_definitions_provider_config_idx", @@ -52201,9 +53585,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "user_secret_definitions_company_key_uq": { "name": "user_secret_definitions_company_key_uq", @@ -52222,64 +53606,64 @@ } ], "isUnique": true, - "with": {}, - "method": "btree", "where": "\"user_secret_definitions\".\"deleted_at\" is null", - "concurrently": false + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { "user_secret_definitions_company_id_companies_id_fk": { "name": "user_secret_definitions_company_id_companies_id_fk", "tableFrom": "user_secret_definitions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk": { "name": "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk", "tableFrom": "user_secret_definitions", + "tableTo": "company_secret_provider_configs", "columnsFrom": [ "provider_config_id" ], - "tableTo": "company_secret_provider_configs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "user_secret_definitions_created_by_agent_id_agents_id_fk": { "name": "user_secret_definitions_created_by_agent_id_agents_id_fk", "tableFrom": "user_secret_definitions", + "tableTo": "agents", "columnsFrom": [ "created_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "user_secret_definitions_updated_by_agent_id_agents_id_fk": { "name": "user_secret_definitions_updated_by_agent_id_agents_id_fk", "tableFrom": "user_secret_definitions", + "tableTo": "agents", "columnsFrom": [ "updated_by_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -52339,9 +53723,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": {}, @@ -52496,137 +53880,137 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "work_assessments_company_id_companies_id_fk": { "name": "work_assessments_company_id_companies_id_fk", "tableFrom": "work_assessments", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "work_assessments_trigger_actor_company_id_companies_id_fk": { "name": "work_assessments_trigger_actor_company_id_companies_id_fk", "tableFrom": "work_assessments", + "tableTo": "companies", "columnsFrom": [ "trigger_actor_company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "work_assessments_issue_company_fk": { "name": "work_assessments_issue_company_fk", "tableFrom": "work_assessments", + "tableTo": "issues", "columnsFrom": [ "company_id", "issue_id" ], - "tableTo": "issues", "columnsTo": [ "company_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "work_assessments_run_owner_fk": { "name": "work_assessments_run_owner_fk", "tableFrom": "work_assessments", + "tableTo": "heartbeat_runs", "columnsFrom": [ "company_id", "issue_id", "run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "company_id", "native_issue_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "work_assessments_contract_owner_fk": { "name": "work_assessments_contract_owner_fk", "tableFrom": "work_assessments", + "tableTo": "completion_contracts", "columnsFrom": [ "company_id", "issue_id", "contract_id" ], - "tableTo": "completion_contracts", "columnsTo": [ "company_id", "issue_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "work_assessments_result_owner_fk": { "name": "work_assessments_result_owner_fk", "tableFrom": "work_assessments", + "tableTo": "native_run_results", "columnsFrom": [ "company_id", "issue_id", "run_id", "result_id" ], - "tableTo": "native_run_results", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "work_assessments_supersedes_owner_fk": { "name": "work_assessments_supersedes_owner_fk", "tableFrom": "work_assessments", + "tableTo": "work_assessments", "columnsFrom": [ "company_id", "issue_id", "run_id", "supersedes_assessment_id" ], - "tableTo": "work_assessments", "columnsTo": [ "company_id", "issue_id", "run_id", "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "work_assessments_company_issue_run_id_uq": { "name": "work_assessments_company_issue_run_id_uq", + "nullsNotDistinct": false, "columns": [ "company_id", "issue_id", "run_id", "id" - ], - "nullsNotDistinct": false + ] } }, "policies": {}, @@ -52805,9 +54189,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "workspace_operations_company_workspace_started_idx": { "name": "workspace_operations_company_workspace_started_idx", @@ -52832,9 +54216,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "workspace_operations_company_workspace_issue_started_idx": { "name": "workspace_operations_company_workspace_issue_started_idx", @@ -52865,63 +54249,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "workspace_operations_company_id_companies_id_fk": { "name": "workspace_operations_company_id_companies_id_fk", "tableFrom": "workspace_operations", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "workspace_operations_execution_workspace_id_execution_workspaces_id_fk": { "name": "workspace_operations_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "workspace_operations", + "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], - "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk": { "name": "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk", "tableFrom": "workspace_operations", + "tableTo": "heartbeat_runs", "columnsFrom": [ "heartbeat_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "workspace_operations_issue_id_issues_id_fk": { "name": "workspace_operations_issue_id_issues_id_fk", "tableFrom": "workspace_operations", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -53144,9 +54528,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "workspace_runtime_services_company_execution_workspace_status_idx": { "name": "workspace_runtime_services_company_execution_workspace_status_idx", @@ -53171,9 +54555,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "workspace_runtime_services_company_project_status_idx": { "name": "workspace_runtime_services_company_project_status_idx", @@ -53198,9 +54582,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "workspace_runtime_services_run_idx": { "name": "workspace_runtime_services_run_idx", @@ -53213,9 +54597,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "workspace_runtime_services_company_updated_idx": { "name": "workspace_runtime_services_company_updated_idx", @@ -53234,102 +54618,102 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "workspace_runtime_services_company_id_companies_id_fk": { "name": "workspace_runtime_services_company_id_companies_id_fk", "tableFrom": "workspace_runtime_services", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "no action" + "onDelete": "no action", + "onUpdate": "no action" }, "workspace_runtime_services_project_id_projects_id_fk": { "name": "workspace_runtime_services_project_id_projects_id_fk", "tableFrom": "workspace_runtime_services", + "tableTo": "projects", "columnsFrom": [ "project_id" ], - "tableTo": "projects", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", "columnsFrom": [ "project_workspace_id" ], - "tableTo": "project_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk": { "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "workspace_runtime_services", + "tableTo": "execution_workspaces", "columnsFrom": [ "execution_workspace_id" ], - "tableTo": "execution_workspaces", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "workspace_runtime_services_issue_id_issues_id_fk": { "name": "workspace_runtime_services_issue_id_issues_id_fk", "tableFrom": "workspace_runtime_services", + "tableTo": "issues", "columnsFrom": [ "issue_id" ], - "tableTo": "issues", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "workspace_runtime_services_owner_agent_id_agents_id_fk": { "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", "tableFrom": "workspace_runtime_services", + "tableTo": "agents", "columnsFrom": [ "owner_agent_id" ], - "tableTo": "agents", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" }, "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", "columnsFrom": [ "started_by_run_id" ], - "tableTo": "heartbeat_runs", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "set null" + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -53465,9 +54849,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_attachment_upload_expiry_idx": { "name": "mcp_attachment_upload_expiry_idx", @@ -53480,37 +54864,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_attachment_uploads_company_id_companies_id_fk": { "name": "mcp_attachment_uploads_company_id_companies_id_fk", "tableFrom": "mcp_attachment_uploads", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_attachment_uploads_user_id_user_id_fk": { "name": "mcp_attachment_uploads_user_id_user_id_fk", "tableFrom": "mcp_attachment_uploads", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -53586,9 +54970,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_event_admissions_grant_idx": { "name": "mcp_event_admissions_grant_idx", @@ -53601,9 +54985,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_event_admissions_company_idx": { "name": "mcp_event_admissions_company_idx", @@ -53616,37 +55000,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_event_admissions_company_id_companies_id_fk": { "name": "mcp_event_admissions_company_id_companies_id_fk", "tableFrom": "mcp_event_admissions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_event_admissions", + "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -53676,7 +55060,13 @@ "name": "activity_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false + }, + "mailbox_item_id": { + "name": "mailbox_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false }, "event": { "name": "event", @@ -53712,6 +55102,27 @@ } }, "indexes": { + "mcp_event_deliveries_mailbox_uq": { + "name": "mcp_event_deliveries_mailbox_uq", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mailbox_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "mcp_event_deliveries_activity_uq": { "name": "mcp_event_deliveries_activity_uq", "columns": [ @@ -53729,9 +55140,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_event_deliveries_due_idx": { "name": "mcp_event_deliveries_due_idx", @@ -53744,30 +55155,35 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_event_deliveries_subscription_id_mcp_event_subscriptions_id_fk": { "name": "mcp_event_deliveries_subscription_id_mcp_event_subscriptions_id_fk", "tableFrom": "mcp_event_deliveries", + "tableTo": "mcp_event_subscriptions", "columnsFrom": [ "subscription_id" ], - "tableTo": "mcp_event_subscriptions", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "mcp_delivery_source_check": { + "name": "mcp_delivery_source_check", + "value": "(\"mcp_event_deliveries\".\"activity_id\" IS NOT NULL AND \"mcp_event_deliveries\".\"mailbox_item_id\" IS NULL) OR (\"mcp_event_deliveries\".\"activity_id\" IS NULL AND \"mcp_event_deliveries\".\"mailbox_item_id\" IS NOT NULL)" + } + }, "isRLSEnabled": false }, "public.mcp_event_subscriptions": { @@ -53802,7 +55218,13 @@ "name": "task_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false + }, + "binding_id": { + "name": "binding_id", + "type": "uuid", + "primaryKey": false, + "notNull": false }, "arguments": { "name": "arguments", @@ -53861,9 +55283,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_event_subscriptions_company_idx": { "name": "mcp_event_subscriptions_company_idx", @@ -53876,43 +55298,48 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_event_subscriptions_company_id_companies_id_fk": { "name": "mcp_event_subscriptions_company_id_companies_id_fk", "tableFrom": "mcp_event_subscriptions", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_event_subscriptions_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_event_subscriptions_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_event_subscriptions", + "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "mcp_subscription_resource_check": { + "name": "mcp_subscription_resource_check", + "value": "(\"mcp_event_subscriptions\".\"task_id\" IS NOT NULL AND \"mcp_event_subscriptions\".\"binding_id\" IS NULL) OR (\"mcp_event_subscriptions\".\"task_id\" IS NULL AND \"mcp_event_subscriptions\".\"binding_id\" IS NOT NULL)" + } + }, "isRLSEnabled": false }, "public.mcp_file_tickets": { @@ -53975,9 +55402,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_file_ticket_grant_idx": { "name": "mcp_file_ticket_grant_idx", @@ -53990,50 +55417,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_file_tickets_company_id_companies_id_fk": { "name": "mcp_file_tickets_company_id_companies_id_fk", "tableFrom": "mcp_file_tickets", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_file_tickets", + "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk": { "name": "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk", "tableFrom": "mcp_file_tickets", + "tableTo": "mcp_attachment_uploads", "columnsFrom": [ "upload_id" ], - "tableTo": "mcp_attachment_uploads", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -54140,9 +55567,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_mutation_receipts_company_idx": { "name": "mcp_mutation_receipts_company_idx", @@ -54155,50 +55582,50 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_mutation_receipts_company_id_companies_id_fk": { "name": "mcp_mutation_receipts_company_id_companies_id_fk", "tableFrom": "mcp_mutation_receipts", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_mutation_receipts_user_id_user_id_fk": { "name": "mcp_mutation_receipts_user_id_user_id_fk", "tableFrom": "mcp_mutation_receipts", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_mutation_receipts_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_mutation_receipts_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_mutation_receipts", + "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -54364,9 +55791,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_oauth_user_code_uq": { "name": "mcp_oauth_user_code_uq", @@ -54379,9 +55806,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_oauth_device_expiry_idx": { "name": "mcp_oauth_device_expiry_idx", @@ -54394,37 +55821,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk": { "name": "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk", "tableFrom": "mcp_oauth_device_requests", + "tableTo": "mcp_oauth_clients", "columnsFrom": [ "client_id" ], - "tableTo": "mcp_oauth_clients", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_oauth_device_requests", + "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -54468,6 +55895,19 @@ "primaryKey": false, "notNull": true }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "scopes": { "name": "scopes", "type": "jsonb", @@ -54506,50 +55946,63 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_oauth_grants_company_id_companies_id_fk": { "name": "mcp_oauth_grants_company_id_companies_id_fk", "tableFrom": "mcp_oauth_grants", + "tableTo": "companies", "columnsFrom": [ "company_id" ], - "tableTo": "companies", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_oauth_grants_user_id_user_id_fk": { "name": "mcp_oauth_grants_user_id_user_id_fk", "tableFrom": "mcp_oauth_grants", + "tableTo": "user", "columnsFrom": [ "user_id" ], - "tableTo": "user", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_oauth_grants_client_id_mcp_oauth_clients_id_fk": { "name": "mcp_oauth_grants_client_id_mcp_oauth_clients_id_fk", "tableFrom": "mcp_oauth_grants", + "tableTo": "mcp_oauth_clients", "columnsFrom": [ "client_id" ], - "tableTo": "mcp_oauth_clients", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_grants_agent_id_agents_id_fk": { + "name": "mcp_oauth_grants_agent_id_agents_id_fk", + "tableFrom": "mcp_oauth_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -54594,9 +56047,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": {}, @@ -54708,9 +56161,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_oauth_requests_expiry_idx": { "name": "mcp_oauth_requests_expiry_idx", @@ -54723,37 +56176,37 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_oauth_requests_client_id_mcp_oauth_clients_id_fk": { "name": "mcp_oauth_requests_client_id_mcp_oauth_clients_id_fk", "tableFrom": "mcp_oauth_requests", + "tableTo": "mcp_oauth_clients", "columnsFrom": [ "client_id" ], - "tableTo": "mcp_oauth_clients", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" }, "mcp_oauth_requests_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_oauth_requests_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_oauth_requests", + "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -54801,7 +56254,7 @@ "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, - "notNull": true + "notNull": false }, "created_at": { "name": "created_at", @@ -54823,9 +56276,9 @@ } ], "isUnique": true, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_oauth_tokens_grant_idx": { "name": "mcp_oauth_tokens_grant_idx", @@ -54838,9 +56291,9 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} }, "mcp_oauth_tokens_expiry_idx": { "name": "mcp_oauth_tokens_expiry_idx", @@ -54853,24 +56306,24 @@ } ], "isUnique": false, - "with": {}, + "concurrently": false, "method": "btree", - "concurrently": false + "with": {} } }, "foreignKeys": { "mcp_oauth_tokens_grant_id_mcp_oauth_grants_id_fk": { "name": "mcp_oauth_tokens_grant_id_mcp_oauth_grants_id_fk", "tableFrom": "mcp_oauth_tokens", + "tableTo": "mcp_oauth_grants", "columnsFrom": [ "grant_id" ], - "tableTo": "mcp_oauth_grants", "columnsTo": [ "id" ], - "onUpdate": "no action", - "onDelete": "cascade" + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -54882,24 +56335,24 @@ }, "enums": {}, "schemas": {}, - "views": {}, "sequences": { "public.chat_telegram_draft_ids": { "name": "chat_telegram_draft_ids", + "schema": "public", "increment": "1", + "startWith": "1", "minValue": "1", "maxValue": "2147483647", - "startWith": "1", "cache": "1", - "cycle": false, - "schema": "public" + "cycle": false } }, "roles": {}, "policies": {}, + "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 871a9ee4276..09c0dc3d150 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2224,8 +2224,15 @@ { "idx": 319, "version": "7", - "when": 1791456875837, - "tag": "0319_copilot_defaults", + "when": 1791490887468, + "tag": "0319_heavy_captain_midlands", + "breakpoints": true + }, + { + "idx": 320, + "version": "7", + "when": 1791490887469, + "tag": "0320_copilot_defaults", "breakpoints": true } ] diff --git a/packages/db/src/schema/public_mcp.ts b/packages/db/src/schema/public_mcp.ts index 0725d5e0ae1..0fe918cda71 100644 --- a/packages/db/src/schema/public_mcp.ts +++ b/packages/db/src/schema/public_mcp.ts @@ -65,7 +65,8 @@ export const mcpOauthTokens = pgTable("mcp_oauth_tokens", { tokenHash: text("token_hash").notNull(), kind: text("kind").$type<"access" | "refresh">().notNull(), usedAt: timestamp("used_at", { withTimezone: true }), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + // Null only for renewable agent connection refresh tokens; access tokens expire. + expiresAt: timestamp("expires_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [ uniqueIndex("mcp_oauth_tokens_hash_uq").on(t.tokenHash), diff --git a/packages/google-sheets-mcp-server/package.json b/packages/google-sheets-mcp-server/package.json index ff51871fe15..6f0e194e2ff 100644 --- a/packages/google-sheets-mcp-server/package.json +++ b/packages/google-sheets-mcp-server/package.json @@ -45,7 +45,7 @@ "test": "vitest run" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.31.0", "googleapis": "^182.0.0", "zod": "^4.4.3" }, diff --git a/packages/kv-demo-mcp-server/package.json b/packages/kv-demo-mcp-server/package.json index 4748badd509..60dab9ae975 100644 --- a/packages/kv-demo-mcp-server/package.json +++ b/packages/kv-demo-mcp-server/package.json @@ -44,7 +44,7 @@ "test": "vitest run" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.31.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 5af1dc180d3..7e87abac9fe 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -43,7 +43,7 @@ "test": "vitest run" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.31.0", "@paperclipai/shared": "workspace:*", "zod": "^4.4.3" }, diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 735cfddeb1c..f87a1d07652 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -58,6 +58,33 @@ checks installed dependency pins and agreement with Cursor's distribution manife and immutable release attestation (`cursor-contract.json`). Cursor's per-platform closure pins are generated from `cursor-distributions.json`. +Codex startup and browser login prefer the CLI from the installed dependency +graph. If that dependency is absent, they use an executable `codex` from the +selected execution host's `PATH`. An explicit execution command takes precedence, +and resumed sessions retain their recorded command. Older or newer CLI versions +are allowed. Relative PATH entries use the selected process working directory. +Compatibility is established by the actual protocol or login attempt. +Missing executables and real protocol failures still return actionable errors. +Package identity and executable containment remain checked. Browser login keeps +its existing provider-specific credential home. Linux ARM64 retains its existing +legacy login path because native execution is not qualified there. + +Exact dependency pins remain release and ACPX artifact-qualification checks. +They make published builds reproducible and verify sandbox artifacts; they do not +add a version-number gate to ordinary native Codex startup or browser login. + +Release packages retain the pinned Codex JavaScript dependency graph, but do not +bundle Codex native binaries. The published manifest declares the official, +exact-version platform packages as optional dependencies. npm installs the +package for the consumer's operating system and architecture. Those declarations +belong to the published server manifest; the bundled JavaScript wrapper delegates +platform installation there so npm can keep native and legacy versions separate. +The wrapper code and patched ACP bridge stay unchanged. +Codex sandbox read roots include the separately installed native vendor resources +without granting access to enclosing npm directories or credential homes. +This packaging does not change agent defaults or +the selected runner of an existing agent. + Every ACPX harness accepts an explicit caller-selected model without a Paperclip model allowlist. The adapter sends that ID unchanged and verifies the provider's effective model before prompting. An incomplete discovery catalog does not block @@ -66,6 +93,17 @@ Qualification model selections live in test catalogs, separately from optional product defaults. The legacy resolved snapshot field `qualificationModel` contains the caller's selected model; its serialized name preserves recovery identities. Historical profile fixtures remain immutable evidence, not release declarations. + +Pi credentials are selected from the explicit run environment and remain session-bound. +The native Rust launcher forwards the controller-bound credential names, including +custom provider references, without restricting Pi to an OpenRouter credential. +Built-in providers can use their usual API-key environment variables. Custom Pi +providers are supported through `PAPERCLIP_PI_PROVIDERS`: a JSON object containing +the native `models.json` provider entries (without the outer `providers` key). +The runner writes that configuration to the private Pi agent directory and binds +its digest to recovery. API-key and header environment references are forwarded +only from the explicit run environment; command-based credential resolution is +unsupported. Select the provider/model ID explicitly in the agent configuration. The bundled ACPX package tests exercise unlisted model selection, rejection, exact acknowledgement, and replay on a loaded connection. Catalog membership and Cursor model-alias expansion do not determine the selected model. @@ -145,6 +183,28 @@ Ordinary remote Cursor startup uses the packaged Linux daemon and verifies every image asset against that manifest. A mismatched image fails before the provider starts; install the matching package and image together. +Pi cold provider admission has an absolute 60-second budget. Warm run attachment +checkpoints the old ACPX sidecar and starts a new one, so it uses that same budget +while retaining the existing Runner authority. Live adoption and ordinary +commands retain their 30-second bounds. Closing the transport cancels admission; +an acknowledgement received after the admission deadline cannot revive it. + +The board's transcript parser coalesces consecutive identical Pi runtime-failure +display rows within one run, turn and session, including the exit handler and +its late prompt rejection. Both original PRP facts remain in the run log. +Distinct failure details, intervening retry activity and later turns remain +visible. The profile-14 notice projection, wrapper bytes and terminal settlement +remain unchanged. + +Cursor candidate configuration accepts `acpxSessionMode: "agent" | "plan" | "ask"` +(default `agent`). This selects the native Cursor mode independently of +`acpxPermissionMode` and Paperclip task planning or company approvals. The mode +is validated at the API boundary and is bound to provider admission and recovery; +changing it cannot reuse an incompatible warm session. Other providers reject +this setting. Cursor configuration requires an explicit model. Pending providers retain +operator-controlled qualification admission. + + Remote Codex sessions relay assigned app tools through the server's configured gateway. Small catalogs are sent directly. When a catalog would exceed the runner's 256-operation or 768 KiB contract limit, the server exposes diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 3eb50258ce5..3a79a740325 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -14,30 +14,30 @@ "requiresProviderPolicy": false }, "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", "requiresProviderPolicy": true }, "cursor": { - "agentProfileVersion": 14, + "agentProfileVersion": 15, "agentServerPackage": "cursor-agent", "agentServerVersion": "2026.09.26-dd393fe", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:a5e70580e4933a1a9248cd3c1b16500c6c93e1e14913e0a98cd5ef878bd53d39", + "commandDigest": "sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b", "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 33, + "agentProfileVersion": 34, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:482c4e997b4690c7e4a5375dd035560aaad69000f8f5291904d76f8c3a88284a", + "commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/cursor-contract.json b/packages/paperclip-runner/cursor-contract.json index c19f5f94f66..e1d6881b96c 100644 --- a/packages/paperclip-runner/cursor-contract.json +++ b/packages/paperclip-runner/cursor-contract.json @@ -1,11 +1,11 @@ { "schema": "paperclip.cursor.production-contract.v1", - "profileVersion": 14, + "profileVersion": 15, "sourceSnapshot": "22c78242a4e0c2369fecf0c2dc4e7600fbad6706", "base": "dd868ed125cd709506dd9b29fca640a44d580501", "vendor": "2026.09.26-dd393fe", "nativePatch": "paperclip-cursor-usage-v4", - "acpxPatchSha256": "fedf1e498c1e19daf9c973d475234488aa9acabbf1b9de890245bfac7a534483", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", "publicSetup": "paperclipai runtime setup cursor", "assets": "public-server-and-provider-pack", "modes": [ @@ -19,5 +19,5 @@ "planAcceptance": "successful-run-open-task-explicit-continuation", "otherProviders": "mainline-gated", "modelSelection": "explicit-provider-verified-startup-and-reconnect", - "commandDigest": "sha256:a5e70580e4933a1a9248cd3c1b16500c6c93e1e14913e0a98cd5ef878bd53d39" + "commandDigest": "sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b" } diff --git a/packages/paperclip-runner/devtools/browser/static-replay.spec.ts b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts index 98dfaf7288d..6fe4489ea0e 100644 --- a/packages/paperclip-runner/devtools/browser/static-replay.spec.ts +++ b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts @@ -5,6 +5,7 @@ test("validates and renders the shared Replay fixture", async ({ page }, testInf await expect(page.getByRole("heading", { name: "Live runner diagnostics" })).toBeVisible(); await page.getByRole("button", { name: "Static replay" }).click(); await expect(page.getByRole("heading", { name: "Static protocol replay" })).toBeVisible(); + await page.getByLabel("Fixture", { exact: true }).selectOption("happy-path"); await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded"); await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9); await expect(page.getByTestId("timeline")).not.toContainText("workspace_preparing"); @@ -26,7 +27,7 @@ test("shows duplicate and unsupported-version replay states", async ({ page }) = .getByLabel("Fixture", { exact: true }) .selectOption("unsupported-required-version"); await expect(page.getByRole("heading", { name: "Fixture cannot be replayed" })).toBeVisible(); - await expect(page.getByText(/protocolVersion 2 is unsupported/)).toBeVisible(); + await expect(page.getByText(/protocolVersion 3 is unsupported/)).toBeVisible(); }); test("streams a live run and proves replay parity", async ({ page }, testInfo) => { 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 fa9c0f25514..e907fa9513d 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 14554af4612..eebd41fb15b 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 7ba6106c9d0..42598ff3fd9 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 0a9d5d9a7a1..6a9b6e3fbb0 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 afadb64769b..1df8f015f95 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 36b36fbded7..b7f8efad0c2 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 9c99672009d..f58accf57c2 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 1f9d2cdade4..f1ec09e14ec 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -11,8 +11,7 @@ "paperclip-runner-eval-session": "./dist/cli/eval-session.js", "paperclip-runner-codex-proxy": "./dist/cli/codex-app-server-unix-proxy.js", "paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js", - "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js", - "paperclip-runner-copilot-metadata-probe": "./dist/cli/copilot-metadata-probe.js" + "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js" }, "exports": { ".": { @@ -59,10 +58,16 @@ "scripts/materialize-copilot-binary.mjs", "scripts/copilot-inner-distribution.mjs" ], + "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", - "build:typescript": "pnpm run check:acpx-profiles && pnpm run check:copilot-profile && pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs && node scripts/install-copilot-assets.mjs", + "build:typescript": "pnpm run check:acpx-profiles && pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs", "build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins", "build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs", "stage:release-binaries": "node scripts/stage-release-runner-binaries.mjs", @@ -79,7 +84,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 && pnpm run test:copilot:contracts", + "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 scripts/provider-pack-executable-shims.test.mjs && pnpm run test:copilot:contracts", "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", @@ -166,11 +171,11 @@ "verify": "pnpm run build && pnpm run typecheck && pnpm run check:replay-goldens && pnpm run test && pnpm run test:sdk && pnpm run test:scenarios && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:sdk && pnpm run test:browser:scenarios && pnpm run check:forbidden-imports && pnpm run check:tracked-imports && pnpm run check:numbered-milestones && pnpm run check:package-boundaries && pnpm run check:clean-consumers && pnpm run docs:validate && pnpm run check:conformance-parity && pnpm run check:replay-parity && pnpm run trace:conformance && pnpm run replay:fixture && pnpm run trace:local-runner -- --quiet", "verify:rootless": "bash scripts/verify-rootless-linux.sh", "test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs", + "generate:acpx-profiles": "node scripts/generate-acpx-profiles.mjs", + "check:acpx-profiles": "node scripts/generate-acpx-profiles.mjs --check", "check:copilot-profile": "node scripts/copilot-profile-identity.mjs --check", "test:copilot:contracts": "node --test scripts/build-copilot-distribution.test.mjs scripts/copilot-inner-distribution.test.mjs scripts/copilot-smoke-resources.test.mjs scripts/discover-copilot-acp.test.mjs scripts/materialize-copilot-binary.test.mjs scripts/install-copilot-assets.test.mjs scripts/probe-copilot-acp.test.mjs scripts/qualify-copilot-acp.test.mjs test/acpx-message-boundaries-package-contract.test.mjs", - "postinstall": "node scripts/install-copilot-assets.mjs", - "generate:acpx-profiles": "node scripts/generate-acpx-profiles.mjs", - "check:acpx-profiles": "node scripts/generate-acpx-profiles.mjs --check" + "postinstall": "node scripts/install-copilot-assets.mjs" }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.73.0", 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 adec9d83d29..bbfdfd46930 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 0f1262b6d59..2eefd4f3069 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 1d285c52add..89e6242f5bb 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 d4f1833057d..660054b6f7e 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 5921ad34467..cd8f731bf6a 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/Cargo.lock b/packages/paperclip-runner/runner/Cargo.lock index 0d730210424..068c99f9fa0 100644 --- a/packages/paperclip-runner/runner/Cargo.lock +++ b/packages/paperclip-runner/runner/Cargo.lock @@ -2211,9 +2211,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", 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 b40c18aafad..2610dbf1aea 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 f316e99004c..8f01f705a43 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}")) @@ -2067,6 +2181,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(), @@ -2391,6 +2514,9 @@ mod tests { if agent == "cursor" { value["mode"] = json!("agent"); } + if agent == "pi" { + value["piThinkingLevel"] = json!("low"); + } value } @@ -2400,6 +2526,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(); @@ -2561,6 +2765,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(); @@ -2701,6 +2906,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"); @@ -2811,6 +3041,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], }; @@ -2962,6 +3193,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"); @@ -3009,6 +3275,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() { @@ -3083,12 +3478,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 { @@ -3103,6 +3518,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(); @@ -3205,11 +3621,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; @@ -3239,7 +3701,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" }); @@ -3275,7 +3737,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(); @@ -3341,6 +3803,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(); @@ -3620,7 +4083,7 @@ mod tests { descriptor_value["agentRuntimePackage"] = Value::Null; descriptor_value["agentRuntimeVersion"] = Value::Null; descriptor_value["commandDigest"] = serde_json::from_str::(include_str!( - "../../../../test/fixtures/copilot-profile-v33-identity.json" + "../../../../test/fixtures/copilot-profile-v34-identity.json" )) .unwrap()["commandDigest"] .clone(); @@ -3911,6 +4374,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(); @@ -4083,6 +4547,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 8b627d35650..65169a5a7bd 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" | "copilot" => RunAttachmentPolicy::AuthenticatedRunGrants, + "cursor" | "copilot" | "pi" => RunAttachmentPolicy::AuthenticatedRunGrants, "claude" => RunAttachmentPolicy::AuthenticatedAssetPaths, _ => RunAttachmentPolicy::ImmutableInstructions, } @@ -31,11 +31,15 @@ mod tests { run_attachment_policy("copilot"), RunAttachmentPolicy::AuthenticatedRunGrants ); + assert_eq!( + run_attachment_policy("pi"), + RunAttachmentPolicy::AuthenticatedRunGrants + ); assert_eq!( run_attachment_policy("claude"), RunAttachmentPolicy::AuthenticatedAssetPaths ); - for agent in ["codex", "grok", "pi", "unknown"] { + for agent in ["codex", "grok", "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 b34deeed7ea..48c79292cdb 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 046ea2d626a..85da2d348b7 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 7c706b5dd8e..ca65f299343 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() { @@ -796,6 +931,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 bef6c550e39..29468749552 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 @@ -82,6 +102,7 @@ fn run() -> Result<(), Box> { &request, mode, profile_digest, + lifetime_fence_candidates, &mut session_identity, ) }; @@ -147,6 +168,7 @@ fn run() -> Result<(), Box> { &request, mode, profile_digest, + lifetime_fence_candidates, &mut session_identity, ), )?; @@ -218,7 +240,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::>()), } }), )?; @@ -273,6 +295,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" @@ -311,6 +336,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" @@ -330,17 +358,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") @@ -731,15 +767,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", @@ -810,6 +838,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" { @@ -860,7 +889,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")}, @@ -883,6 +918,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 a0347281479..8943d40f57d 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 e10c28e2861..dd2ed8962b7 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:482c4e997b4690c7e4a5375dd035560aaad69000f8f5291904d76f8c3a88284a", + "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", 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 415a72186b5..372c96ea323 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 85b114b132d..23268296bd6 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 c7f72d3152b..570080ff877 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 0987d0dd7cc..b3116f7dd25 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 4c8a488e805..48daa24f6fa 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 4fded95a364..f64f89e4365 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 911b765b749..71dcb93462d 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 3af110c46a3..e8e65d76289 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 4380191fb29..973011b98f6 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 59497b18795..08ed56fdba0 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 34e3b46d0c7..b4bc2a316b5 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 20ddd31e054..2fb94b988b7 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 } @@ -1029,7 +1030,7 @@ fn provider_death_expires_input_and_commits_failure_without_querying_the_dead_pr let mut config = acpx_config(&directory, mode); let mut payload = prepare_payload_with_mode(&directory, "codex", mode); let identity: Value = serde_json::from_str(include_str!( - "../../../../test/fixtures/copilot-profile-v33-identity.json" + "../../../../test/fixtures/copilot-profile-v34-identity.json" )) .unwrap(); let digest = identity["commandDigest"].as_str().unwrap(); @@ -1474,6 +1475,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 d1fac57753f..3bbd49d902d 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 acbf3cfa3ce..2ead09c0b4a 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 index a890427a568..73baa55a9ae 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -1,3 +1,4 @@ +import profiles from "../acpx-profiles.json" with { type: "json" }; import { createHash, timingSafeEqual } from "node:crypto"; import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -7,7 +8,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:482c4e997b4690c7e4a5375dd035560aaad69000f8f5291904d76f8c3a88284a"; +const PROFILE_DIGEST = profiles.profiles.copilot.commandDigest; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { diff --git a/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs b/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs index 6772e5941a9..b79659e95ee 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 d17538a0752..bdf0886af4a 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, "../.."); @@ -175,7 +175,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: ["cursor", "copilot"].includes(provider) ? "qualified" : "pending", path: assetPath, + closureDigest: metadata.closureDigest, qualification: ["pi", "cursor", "copilot"].includes(provider) ? "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 605795b1a83..306a626d305 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 223af558178..fd2c324822f 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 5f7a89c52bb..00a9d3bb4f5 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.copilot ? { providers: { ...(providers.cursor ? { cursor: providers.cursor } : {}), ...(providers.copilot ? { copilot: providers.copilot } : {}) } } : {}), - ...(candidates.length ? { candidateProviders: Object.fromEntries(candidates.filter(provider => provider !== "copilot").map(provider => [provider, providers[provider]])) } : {}), + ...(["pi", "cursor", "copilot"].some(provider => providers[provider]) ? { providers: Object.fromEntries(["pi", "cursor", "copilot"].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}), + ...(["pi", ...candidates].some(provider => provider !== "copilot" && providers[provider]) ? { candidateProviders: Object.fromEntries([...new Set(["pi", ...candidates])].filter(provider => provider !== "copilot" && 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", "copilot"] : []), ...candidates])]; + const nativeSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`); + return [...new Set([...(nativeSupported ? ["pi", "cursor", "copilot"] : []), ...candidates])]; } export function parseProviderPackArguments(args) { @@ -33,6 +34,7 @@ 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 }); diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs index ed2060e168e..353b8db6bb1 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -32,8 +32,8 @@ test("candidate selection is explicit", () => { }); test("normal packs include Cursor and Copilot on their three pinned targets without breaking other hosts", () => { for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) { - assert.deepEqual(providerPackProviders(platform, architecture, []), ["cursor", "copilot"]); - assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), ["cursor", "copilot"]); + assert.deepEqual(providerPackProviders(platform, architecture, []), ["pi", "cursor", "copilot"]); + assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), ["pi", "cursor", "copilot"]); } assert.deepEqual(providerPackProviders("linux", "arm64", []), []); assert.deepEqual(providerPackProviders("win32", "x64", []), []); @@ -55,3 +55,24 @@ test("normal Copilot inventory needs no candidate override and has no duplicate assert.equal(digest(JSON.parse(JSON.stringify(fields))), digest(fields)); } }); +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 65b9310404e..f3ad0550e5d 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 f4dc12e447c..faa3b01543c 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 79778857de3..66151ddc837 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-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index 9103a118f2d..3b932b141be 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v33-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v34-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 33 }; +const declaration = { ...historical.declaration, agentProfileVersion: 34 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", @@ -92,15 +92,15 @@ if (process.argv.includes("--check")) { manifest.profiles.copilot.agentProfileVersion = declaration.agentProfileVersion; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); } -const distributionPath = resolve(root, "scripts/build-copilot-distribution.mjs"); -const before = readFileSync(distributionPath, "utf8"); -const after = before.replace(/(?<=const PROFILE_DIGEST = ")sha256:[a-f0-9]{64}/, commandDigest); -if (process.argv.includes("--check")) { - if (before !== after) throw new Error("Copilot identity is stale in build-copilot-distribution.mjs"); -} else writeFileSync(distributionPath, after); -execFileSync(process.execPath, [resolve(root, "scripts/generate-acpx-profiles.mjs"), - ...(process.argv.includes("--check") ? ["--check"] : [])], { stdio: "inherit" }); +// The builder reads the active manifest; no duplicate digest literal is allowed. +const distributionSource = readFileSync(resolve(root, "scripts/build-copilot-distribution.mjs"), "utf8"); +if (!distributionSource.includes("profiles.profiles.copilot.commandDigest")) { + throw new Error("Copilot distribution builder must use the active manifest identity"); +} +// The shared generator validates this exact attestation before emitting admission. if (process.argv.includes("--check")) { if (readFileSync(fixture, "utf8") !== identity) throw new Error("Copilot source identity is stale"); } else writeFileSync(fixture, identity); +execFileSync(process.execPath, [resolve(root, "scripts/generate-acpx-profiles.mjs"), + ...(process.argv.includes("--check") ? ["--check"] : [])], { stdio: "inherit" }); console.log(commandDigest); diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index 38669bd34e4..38322261173 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-v34-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/install-copilot-assets.test.mjs b/packages/paperclip-runner/scripts/install-copilot-assets.test.mjs index 0c56d3c39a4..d437fea01bb 100644 --- a/packages/paperclip-runner/scripts/install-copilot-assets.test.mjs +++ b/packages/paperclip-runner/scripts/install-copilot-assets.test.mjs @@ -51,6 +51,12 @@ for (const owner of ["runner", "server"]) { bundle: true, platform: "node", format: "esm", target: "node24", logLevel: "silent", }); + if (owner === "server") { + // The public server owns a compiled sidecar beside the vendored verifier. + // This installer fixture does not execute the sidecar. + await mkdir(join(runtimeRoot, "cli"), { recursive: true }); + await writeFile(join(runtimeRoot, "cli/acpx-runtime-sidecar.cjs"), "// installer fixture sidecar\n"); + } const scriptRoot = owner === "server" ? join(root, "dist") : join(root, "scripts"); await mkdir(scriptRoot, { recursive: true }); for (const name of helpers) await cp(join(runnerRoot, "scripts", name), join(scriptRoot, name)); diff --git a/packages/paperclip-runner/scripts/lib/forbidden-imports.mjs b/packages/paperclip-runner/scripts/lib/forbidden-imports.mjs index 5132fe4519f..be6b0f68305 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 d11dd14e93f..97a98efca86 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 777837d3fee..fc67929dea0 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 cde0b7b97c8..3eefda18898 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-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs new file mode 100644 index 00000000000..72fd11c3bd4 --- /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 00000000000..3c65cf25d99 --- /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 00000000000..be498f65172 --- /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 00000000000..0ade8dbcf3f --- /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/provision-pi-package.test.mjs b/packages/paperclip-runner/scripts/provision-pi-package.test.mjs new file mode 100644 index 00000000000..7577d016120 --- /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 00000000000..30424682163 --- /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/run-pr-vitest-lane.mjs b/packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs index e383bd90246..cc49d2a94e2 100644 --- a/packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs +++ b/packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs @@ -15,10 +15,10 @@ // Contract, mirrored in scripts/run-vitest-stable.mjs (prWorkflowName) and // pinned by scripts/__tests__/run-vitest-stable-shard.test.mjs: // - Only the PR workflow (pr.yml, whose GITHUB_WORKFLOW the reusable -// pr-trusted.yml jobs inherit) excludes the suite from the server shards, -// and only there does this wrapper run it. Any other caller — local runs, -// release-verify.yml — keeps the suite in the server group, so a renamed -// workflow degrades to the slower covered path instead of losing coverage. +// pr-trusted.yml jobs inherit) excludes these suites from the existing +// without-chat group, and only there does this wrapper run them. Other +// callers keep that group complete. Release verification instead selects +// an explicit partition and its own required cached native lane. // - The suite runs on the lane whose --shard=N/M has N === M (or an unsharded // invocation), so exactly one PR lane carries it. import { spawnSync } from "node:child_process"; 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 00000000000..a3605172d72 --- /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 d75f4bbb6de..48643ea7083 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 00000000000..81696b8ba65 --- /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 03180671a29..a77e4100263 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 5bf6c9c020c..a13ef381a94 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -31,7 +31,6 @@ export interface CodexNativeSessionBackendOptions { startedAt: string; }) => Promise; transportFactory?: (context?: { - /** Current run instructions are required before a recovered transport opens. */ baseInstructions?: string; providerRecoveryPolicy?: PersistedNativeSession["providerRecoveryPolicy"]; persistedSession?: Pick< @@ -146,6 +145,7 @@ function createTransportBackedNativeSessionBackend( ]; const baseInstructions = nativeSystemInstructions(input); + const transportFactory = options.transportFactory; return new HarnessDriverBackend( new CodexAppServerDriver({ ...(input.provider.model ? { model: input.provider.model } : {}), @@ -181,8 +181,8 @@ function createTransportBackedNativeSessionBackend( runnerInstanceId: options.runnerInstanceId ?? `paperclip-native-${input.binding.runId}`, onSpawn: options.onSpawn, - transportFactory: options.transportFactory - ? (context) => options.transportFactory!({ ...context, baseInstructions }) + transportFactory: transportFactory + ? (context) => transportFactory({ ...context, baseInstructions }) : undefined, dynamicTools: options.dynamicTools, dynamicToolHandler: options.dynamicToolHandler, 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 c1357cfbbb6..0f3c82dd677 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts @@ -855,7 +855,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" }); @@ -902,7 +902,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 }], @@ -944,11 +944,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 () => { @@ -1014,6 +1017,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 4e6f1ff39ae..fbd32544037 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -511,7 +511,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 { @@ -634,7 +634,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; @@ -642,7 +642,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 { @@ -674,16 +674,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 { @@ -694,16 +696,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 c48c18d5707..264c4e02676 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 bcdfbb7ff15..7692309b236 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 540276e6027..53a6898d917 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, agent, request, context, toolEvidence) { ${source.slice(start, end)}`)( - permissions, { agent }, normalizeAcpxPermission, (_event: string, payload: { choices: Array<{ key: string }> }) => emitted.push(payload), + permissions, { agent }, normalizeAcpxPermission, (_event: string, payload: { choices: Array<{ key: string }>; origin: unknown }) => emitted.push(payload), ); const abort = new AbortController(); const pending = wait("turn-1", agent, { sessionId: "session", inferredKind: "edit", raw: { 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); return true; }, + (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 9985a32199e..c351b33cfa4 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,9 +66,11 @@ import { type AcpxSidecarResponse, } from "../drivers/acpx/sidecar-protocol.js"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; -import type { CopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.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"; @@ -291,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, @@ -514,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") { @@ -620,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); @@ -633,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); @@ -976,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 { @@ -990,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") @@ -1196,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 && @@ -1220,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( @@ -1290,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, ), @@ -1314,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 10b29d815f8..12c80c49221 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-provider-runtime.test.ts b/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts index 5116264a92c..f6cf321bc6e 100644 --- a/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts +++ b/packages/paperclip-runner/src/cli/eval-provider-runtime.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { evalProviderTransportOptions } from "./eval-provider-runtime.js"; describe("direct eval provider runtime", () => { - it("uses the installed pinned Codex dependency without requiring a global CLI", () => { + it("uses the installed Codex dependency without requiring a global CLI", () => { const options = evalProviderTransportOptions("codex"); expect(isAbsolute(options.codexCommand!)).toBe(true); expect(existsSync(options.codexCommand!)).toBe(true); diff --git a/packages/paperclip-runner/src/cli/eval-provider-runtime.ts b/packages/paperclip-runner/src/cli/eval-provider-runtime.ts index ff2c2f96d13..424c6d779a2 100644 --- a/packages/paperclip-runner/src/cli/eval-provider-runtime.ts +++ b/packages/paperclip-runner/src/cli/eval-provider-runtime.ts @@ -1,6 +1,4 @@ -import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; +import { resolveCodexCommand } from "../drivers/codex/codex-command.js"; import type { CapabilityRunnerdCodexTransportOptions } from "../live/runnerd-codex-transport.js"; @@ -8,6 +6,7 @@ import type { CapabilityRunnerdCodexTransportOptions } from "../live/runnerd-cod export function evalProviderTransportOptions( provider: "codex" | "opencode" | "acpx" | "claude_managed" | "aws_agentcore", turnTimeoutMs = 120_000, + workingDirectory = process.cwd(), ): Pick { @@ -22,15 +21,5 @@ export function evalProviderTransportOptions( return { acpxPermissionMode: "approve-all", acpxPermissionModePinned: true }; } if (provider !== "codex") return {}; - const runnerRequire = createRequire(import.meta.url); - const codexRequire = createRequire( - runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"), - ); - const manifestPath = codexRequire.resolve("@openai/codex/package.json"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { - bin?: string | Record; - }; - const executable = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex; - if (!executable) throw new Error("Pinned Codex dependency does not expose its executable"); - return { codexCommand: resolve(dirname(manifestPath), executable) }; + return { codexCommand: resolveCodexCommand(undefined, undefined, workingDirectory) }; } 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 beecf047b0c..559cba217ad 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -7,9 +7,9 @@ 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 type { CapabilityLiveSessionSnapshot } from "../live/live-session.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 { evalSessionUsage, parseEvalSessionRequest, @@ -37,6 +37,7 @@ function request(overrides: Record = {}): unknown { maxEstimatedCostNanodollars: 100_000_000, }, session: {}, + ...(overrides.acpxAgent === "pi" ? { piThinkingLevel: "low", session: { piThinkingLevel: "low" } } : {}), ...overrides, }; } @@ -68,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" }); @@ -78,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", () => { @@ -99,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]; @@ -109,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 [ @@ -168,6 +178,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}`, @@ -233,11 +244,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", @@ -479,7 +491,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"); @@ -503,10 +515,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"); @@ -516,6 +536,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 3e4c229cb35..d134af83084 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 237f018dc51..0c012db55f0 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: {} }; } @@ -379,7 +401,7 @@ export async function runEvalSessionCli( const service = options.serviceFactory?.(runnerdPath) ?? new CapabilityLiveSessionService({ transportOptions: { - ...evalProviderTransportOptions(requestedProvider, request.limits.turnTimeoutMs), + ...evalProviderTransportOptions(requestedProvider, request.limits.turnTimeoutMs, request.session.workingDirectory), runnerBinary: runnerdPath, ...(cli.candidateProfile === undefined ? {} : { acpxCandidateProfile: cli.candidateProfile }), runtimeContext, @@ -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 161bcfe422b..6596e2b9c88 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -457,6 +457,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 4710a757a03..7bf605ef8d4 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -1,8 +1,8 @@ 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"; -import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js"; import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, @@ -117,6 +117,22 @@ describe("NativeExecutionInputV1", () => { expect(composeNativeSystemInstructions(parsed.runtimeContext, "Follow sibling.md")).toBe( `${PAPERCLIP_EXECUTION_PROMPT}\n\nFollow sibling.md\n\nRead-only instruction sibling root: /runtime/instructions`, ); + const agentFilesContext = { + ...parsed.runtimeContext, + instructions: { + ...parsed.runtimeContext.instructions, + workingCopy: { kind: "agent_files" as const, rootPath: "/runtime/current-agent-copy", entryPath: "AGENTS.md" }, + }, + }; + const agentFilesInstructions = composeNativeSystemInstructions(agentFilesContext, "Preserve my personal notes."); + expect(agentFilesInstructions).toContain("AGENT_HOME) is /runtime/current-agent-copy."); + expect(agentFilesInstructions).toContain("its absolute path may change between turns"); + expect(agentFilesInstructions).toContain("use the current $AGENT_HOME environment variable instead of an absolute agent-directory path from an earlier turn"); + expect(agentFilesInstructions).toContain("All supported files and subfolders there are restored across tasks and sessions"); + expect(agentFilesInstructions).toContain("Write task deliverables in the task working directory"); + expect(agentFilesInstructions).toContain("Preserve my personal notes."); + expect(agentFilesInstructions).toMatch(/Read-only instruction sibling root: \/runtime\/instructions$/); + expect(composeNativeSystemInstructions(parsed.runtimeContext, "Follow sibling.md")).not.toContain("$AGENT_HOME"); expect(canonicalNativeRuntimeContextDigest({ ...context, mcp: { ...context.mcp, bindingId: "native-mcp:run-2" }, @@ -325,21 +341,21 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); - it.each(Object.values(QUALIFIED_ACPX_PROFILES))("round-trips the registered $agent profile through execution admission", (profile) => { - const { qualificationModel: _qualificationModel, reportedModelId: _reportedModelId, - permissionPolicy, qualificationStatus: _qualificationStatus, modelPolicy: _modelPolicy, - ...snapshot } = profile; - const provider = { kind: "acpx", agent: profile.agent, model: "explicit-test-model", permissionPolicy, profile: snapshot } as const; - const parsed = parseNativeExecutionInput({ - ...input, - session: { ...input.session, driverKind: "acpx_runtime" }, - provider, - }); - expect(parsed.provider).toEqual(provider); + 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] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { + 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", @@ -354,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; @@ -371,11 +387,11 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 6, 14, 15, 16, 17, 18, 19, 23, 24, 25, 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" }); @@ -403,7 +419,7 @@ describe("NativeExecutionInputV1", () => { const parsed = parseNativeExecutionInput(value); expect(parsed.provider).toEqual(provider); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 34, 1.5, "33", null]) { + for (const unsupportedVersion of [0, 35, 1.5, "34", null]) { expect(() => parseNativeExecutionInput({ ...value, provider: { ...provider, profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } }, })).toThrow("qualified ACPX v1 profile"); @@ -415,6 +431,26 @@ describe("NativeExecutionInputV1", () => { ...value, session: { ...value.session, driverKind: "opencode_server" }, })).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; diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 86eb9a43342..ca23b5f0246 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"; /** Decodable history; current immutable admission comes from the shared manifest. */ export type NativeAcpxProfileVersion = AcpxProfileVersion; @@ -132,6 +133,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; @@ -147,6 +149,7 @@ export type NativeProviderConfigV4 = model: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; profile: NativeAcpxProfileSnapshot; }; @@ -504,7 +507,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 @@ -624,6 +627,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"); } @@ -662,6 +668,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)) { @@ -675,6 +684,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 7cd873afe35..c7860c8bf24 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 5021b3ef970..e7b764e1e4f 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 c1b21e35409..598cd9d8bda 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -179,7 +179,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 72315f6e6f9..94f9b79505b 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 e0aef3887b4..4b444eaaa65 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 43fd62b0961..55f01bb9e82 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 @@ -13,6 +13,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(); @@ -83,3 +92,61 @@ describe("ACP permission normalization", () => { } finally { rmSync(root, { recursive: true, force: true }); } }); }); + + +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 3d4508eceb6..59e068afa9b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts @@ -3,6 +3,7 @@ import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-dr import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; + import { safeCopilotEditTarget } from "./copilot-permission-context.js"; export type AcpxPermissionAction = "accept" | "accept_for_session" | "decline" | "cancel"; diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.test.ts index e2e6b87ce3d..e00e8559353 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 7250cc1a434..e36ef686369 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts @@ -148,6 +148,7 @@ function normalizeField( question: { ...base, answerMode: "text", + ...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}), textValidation: { inputType: "text", ...(minLength !== undefined ? { minLength } : {}), @@ -168,6 +169,7 @@ function normalizeField( question: { ...base, answerMode: "text", + ...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}), textValidation: { inputType: type, ...(minimum !== undefined ? { minimum } : {}), @@ -376,3 +378,10 @@ function finiteNonNegativeInteger(value: unknown): number | undefined { ? Number(value) : undefined; } + +/** Defaults are editable presentation, not acceptance or native response content. */ +function initialText(value: unknown, type: string): string { + if (type === "string" && typeof value === "string") return value; + if ((type === "number" || type === "integer") && typeof value === "number" && Number.isFinite(value)) return String(value); + throw new Error(`ACP ${type} text field default has an incompatible type`); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts index 6d1103f8a3d..fce35cf840e 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 }); @@ -33,7 +219,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}`); @@ -50,7 +235,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/); @@ -1565,6 +1750,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" }); @@ -1573,10 +1794,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; @@ -1591,6 +1813,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); @@ -1598,6 +1823,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 9238efdfb5a..43ed6d27f09 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1,7 +1,9 @@ -import type { CopilotToolEvidence } from "./copilot-tool-evidence.js"; +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"; @@ -87,7 +89,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; @@ -146,6 +148,7 @@ export interface CodexAcpxDriverOptions { model: string; permissionMode?: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; providerPolicy?: { readOnly: boolean }; runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"]; systemInstructions?: string; @@ -460,6 +463,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, @@ -1286,6 +1290,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, }, @@ -1455,7 +1460,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); @@ -1464,6 +1470,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. @@ -1654,7 +1661,7 @@ class CodexAcpxSession implements HarnessSession { } #mapRuntimeEvent( - event: AcpRuntimeEvent, + event: PiProjectedMessageEvent, turnId: string, index: number, ): void { @@ -1666,7 +1673,8 @@ class CodexAcpxSession implements HarnessSession { 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 && (this.#agent !== "copilot" || messageId)) { + 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 }, @@ -2136,6 +2144,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 7d2631103ec..3058f734fef 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 @@ -164,6 +164,24 @@ describe("Codex ACPX runtime adapter", () => { await port.close({ reason: "test finished" }); }); + it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => { + 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); @@ -349,6 +367,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 2bc35fcf826..d67ba30a5a9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -1,3 +1,4 @@ +import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js"; import type { ChildProcess } from "node:child_process"; import { @@ -30,9 +31,9 @@ import { import type { AcpxModelStatus } from "./model-verification.js"; import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js"; import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js"; -import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js"; import { admitCursorInstructions, createCursorInstructionAdmission } from "./cursor-instructions.js"; import { createAcpxModeBinding } from "./provider-mode.js"; +import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js"; const VERIFIED_COMMAND_SENTINEL = "paperclip-verified-acpx-command"; const DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS = 2_000; @@ -310,6 +311,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; @@ -320,6 +323,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 @@ -519,6 +523,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); @@ -534,6 +550,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(); @@ -588,7 +605,7 @@ export async function openQualifiedAcpxRuntime( return runtimePort( runtime, handle, - { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}) }, + { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}) }, baseStore, children, runtimeCloseTimeoutMs, diff --git a/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts index 1fd684fe19e..452544791a8 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 38896668caa..961972b0c4e 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-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index bcd8db4afd2..c9138ecb3bc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -70,7 +70,7 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { it("admits the current declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v33-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v34-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); @@ -89,7 +89,9 @@ describe("Copilot build-owned installation", () => { for (const [relative, field] of [...PERMISSION_POLICY_SOURCES, ...COPILOT_DISPLAY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES, ["copilot-policy.ts", "policySha256"], ["profile-compatibility.ts", "profileCompatibilitySourceSha256"], ["provider-assets-root.ts", "assetOwnershipSourceSha256"], ["../../../scripts/install-copilot-assets.mjs", "assetInstallerSourceSha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { expect(createHash("sha256").update(readFileSync(new URL(relative!, import.meta.url))).digest("hex")).toBe(identity.declaration[field!]); } - expect(readFileSync(new URL("../../../scripts/build-copilot-distribution.mjs", import.meta.url), "utf8")).toContain(identity.commandDigest); + const manifest = JSON.parse(readFileSync(new URL("../../../acpx-profiles.json", import.meta.url), "utf8")); + expect(manifest.profiles.copilot.commandDigest).toBe(identity.commandDigest); + expect(readFileSync(new URL("../../../scripts/build-copilot-distribution.mjs", import.meta.url), "utf8")).toContain("profiles.profiles.copilot.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 () => { @@ -101,7 +103,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); it.each([...PERMISSION_POLICY_SOURCES, ...COPILOT_DISPLAY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v33-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v34-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); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts index 64cf5a89bb8..12c372bffff 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js"; import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; import { COPILOT_LAUNCH_ARGUMENTS, COPILOT_VERSION } from "./copilot-profile.js"; -import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { QUALIFIED_ACPX_PROFILES, type AcpxReleaseProfile } from "./qualified-profiles.js"; export const COPILOT_CLOSURE_SHA256 = Object.freeze({ "darwin-arm64": "362f2663e967fb9e34a814bac4619cbf89e6b23069c4051ab1f2f686852d0a32", @@ -11,7 +11,7 @@ export const COPILOT_CLOSURE_SHA256 = Object.freeze({ }); /** Admission primitive only. Pending candidates remain gated by qualification. */ -export async function verifyCopilotInstallation(profile: QualifiedAcpxProfile): Promise { +export async function verifyCopilotInstallation(profile: AcpxReleaseProfile): Promise { const expected = QUALIFIED_ACPX_PROFILES.copilot; if (profile.agent !== "copilot" || profile.agentProfileVersion !== expected.agentProfileVersion || profile.acpxVersion !== expected.acpxVersion || profile.agentServerPackage !== "@github/copilot" diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts index 3c2cbcb6e2c..5d33731d5cf 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -8,7 +8,7 @@ import { safeCopilotEditTarget } from "./copilot-permission-context.js"; const LIMIT = 256; const CATEGORY = "copilot_tool_evidence_v1"; type Fields = Record; -interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence } +interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence; pendingReadNotice?: boolean } const record = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; const identity = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v); const shellIdentity = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(v); @@ -101,7 +101,13 @@ export function createCopilotToolEvidence(binding: { if (state.kind && state.kind !== call.kind) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_kind" }); return; } state.kind = call.kind; } - if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); + let publishPendingRead = false; + if (state.kind === "read") { + state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); + if (state.read.pendingOriginInput && call.tag === "tool_call") state.pendingReadNotice = true; + if (state.pendingReadNotice && state.read.pendingOriginInput) return; + if (state.pendingReadNotice) { state.pendingReadNotice = false; publishPendingRead = true; } + } if (call.rawInput !== undefined) { // Retain only a bounded digest of the native arguments, never their text. let inputDigest: string | undefined; @@ -113,6 +119,7 @@ export function createCopilotToolEvidence(binding: { if (state.input && state.input !== fingerprint) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; } state.input = fingerprint; state.fields = fields; } + if (publishPendingRead) notice("tool", id, { ...state.fields, status: "pending", operation: "read", ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) }); const status = ["pending", "in_progress", "completed", "failed"].includes(String(call.status)) ? String(call.status) : undefined; if (!status) return; const fields: Fields = { ...state.fields, status, ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) }; diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts index 1a720978c9d..43095950840 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 c9bf2da8696..c629c2fb7b5 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 5449b7791fa..3bc038a101e 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 069333204bf..b437e75c35b 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/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index f16204f1bfa..e63e7896fdb 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": 33, + "agentProfileVersion": 34, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:482c4e997b4690c7e4a5375dd035560aaad69000f8f5291904d76f8c3a88284a", + "commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", "permissionPolicy": "interactive" }, "claude": { 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 da9a8b369da..089aeec06bc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -54,6 +54,104 @@ afterEach(async () => { }); describe("ACPX installation integrity", () => { + it.each([false, true])("admits only the declared qualified Codex platform in its npm-hoisted slot (published platform declaration: %s)", async (publishedDeclaration) => { + const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-codex-npm-hoist-"))); + temporaryDirectories.push(parent); + const root = join(parent, "node_modules/@paperclipai/server"); + const runtime = join(root, "node_modules/@openai/codex/package.json"); + const platformRoot = join(parent, "node_modules/@openai/codex-linux-x64"); + const platform = join(platformRoot, "package.json"); + const alias = "npm:@openai/codex@0.160.0-linux-x64"; + const selected = { name: "@paperclipai/server", optionalDependencies: { "@openai/codex-linux-x64": alias } }; + const runtimeMetadata = { name: "@openai/codex", version: "0.160.0", ...(!publishedDeclaration && { optionalDependencies: selected.optionalDependencies }) }; + const platformMetadata = { name: "@openai/codex", version: "0.160.0-linux-x64", os: ["linux"], cpu: ["x64"] }; + await mkdir(dirname(runtime), { recursive: true }); + await mkdir(platformRoot, { recursive: true }); + const reset = async () => Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify(selected)), + writeFile(runtime, JSON.stringify(runtimeMetadata)), + writeFile(platform, JSON.stringify(platformMetadata)), + ]); + await reset(); + const resolver = createAcpxPackageJsonResolver(root); + expect(resolver("@openai/codex-linux-x64", runtime)).toBe(platform); + // Direct provider discovery remains closed; only the selected runtime can + // hand off its explicitly declared, qualified executable dependency. + expect(() => resolver("@openai/codex-linux-x64")).toThrow("outside the selected provider root"); + for (const [file, metadata] of [ + [join(root, "package.json"), { ...selected, optionalDependencies: {} }], + [join(root, "package.json"), { ...selected, optionalDependencies: { "@openai/codex-linux-x64": "latest" } }], + [runtime, { ...runtimeMetadata, version: "0.159.0" }], + [runtime, { ...runtimeMetadata, optionalDependencies: {} }], + [runtime, { ...runtimeMetadata, optionalDependencies: { "@openai/codex-linux-x64": "latest" } }], + [platform, { ...platformMetadata, version: "0.159.0-linux-x64" }], + [platform, { ...platformMetadata, name: "unqualified-provider" }], + [platform, { ...platformMetadata, cpu: ["arm64"] }], + ] as const) { + await writeFile(file, JSON.stringify(metadata)); + expect(() => resolver("@openai/codex-linux-x64", runtime)).toThrow("outside the selected provider root"); + await reset(); + } + const outside = join(parent, "outside-platform"); + await mkdir(outside); + await writeFile(join(outside, "package.json"), JSON.stringify(platformMetadata)); + await rm(platformRoot, { recursive: true }); + await symlink(outside, platformRoot); + expect(() => resolver("@openai/codex-linux-x64", runtime)).toThrow("outside the selected provider root"); + }); + + it.each([false, true])("retains Codex executable version and byte admission for the hoisted platform (stale bundled alias: %s)", async (staleBundledAlias) => { + const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-codex-npm-admission-"))); + temporaryDirectories.push(parent); + const root = join(parent, "node_modules/@paperclipai/server"); + const bridge = join(root, "node_modules/@agentclientprotocol/codex-acp"); + const runtime = join(bridge, "node_modules/@openai/codex"); + const platformRoot = join(parent, "node_modules/@openai/codex-linux-x64"); + const native = join(platformRoot, "vendor/x86_64-unknown-linux-musl/bin/codex"); + const optionalDependencies = { "@openai/codex-linux-x64": "npm:@openai/codex@0.160.0-linux-x64" }; + await Promise.all([mkdir(runtime, { recursive: true }), mkdir(dirname(native), { recursive: true })]); + const command = 'process.stdout.write("unexecuted fixture");'; + await Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/server", optionalDependencies })), + writeFile(join(bridge, "package.json"), JSON.stringify({ name: "@agentclientprotocol/codex-acp", version: "1.6.2", bin: "server.js" })), + writeFile(join(bridge, "server.js"), command), + writeFile(join(runtime, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.160.0" })), + writeFile(native, "unqualified native bytes", { mode: 0o755 }), + ]); + const profile = { ...resolveQualifiedAcpxProfile("codex", "gpt-5.6-sol"), commandDigest: `sha256:${createHash("sha256").update(command).digest("hex")}` }; + const resolver = createAcpxPackageJsonResolver(root); + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + try { + const metadata = { name: "@openai/codex", version: "0.160.0-linux-x64", os: ["linux"], cpu: ["x64"] }; + await writeFile(join(platformRoot, "package.json"), JSON.stringify(metadata)); + // A stale in-bundle package shadows the correct declared npm slot and + // must fail rather than falling through to the hoisted installation. + if (staleBundledAlias) { + const stale = join(runtime, "node_modules/@openai/codex-linux-x64"); + await mkdir(stale, { recursive: true }); + await writeFile(join(stale, "package.json"), JSON.stringify({ ...metadata, version: "0.159.0-linux-x64" })); + } + // A callback without an explicit selected package authority cannot + // adopt the published server's platform declaration implicitly. + await expect(verifyQualifiedAcpxInstallation(profile, (...args) => resolver(...args))).rejects.toThrow( + "runtime omitted its verified platform executable package", + ); + await expect(verifyQualifiedAcpxInstallation(profile, resolver)).rejects.toThrow(staleBundledAlias + ? /runtime executable package version mismatch/ + : /digest mismatch/); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", root); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + await expect(verifyQualifiedAcpxInstallation(profile)).rejects.toThrow(staleBundledAlias + ? /runtime executable package version mismatch/ + : /digest mismatch/); + } finally { + vi.unstubAllEnvs(); + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + it.each([["linux", "arm64"], ["darwin", "ia32"], ["freebsd", "x64"]] as const)( "rejects the actual Claude runtime probe on unsupported %s %s", async (platform, arch) => { @@ -197,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( @@ -1309,7 +1407,7 @@ describe("ACPX installation integrity", () => { fixture.runtimePackageJsonPath, JSON.stringify({ name: packageName, - version: "0.84.2", + version: "1.0.0", main: "index.js", }), ), @@ -1926,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]*|tmp)\/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); } @@ -2103,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 42d6b7c38a3..836212c19ec 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -7,7 +7,7 @@ import { type ChildProcess, type SpawnOptionsWithoutStdio, } from "node:child_process"; -import { constants, existsSync, realpathSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, realpathSync } from "node:fs"; import { lstat, open, @@ -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"; @@ -289,6 +292,11 @@ export type AcpxPackageJsonResolver = ( issuerPackageJsonPath?: string, ) => string; +const rootedPackageResolvers = new WeakMap(); + export function createAcpxPackageJsonResolver( providerPackageRoot: string | undefined, providerPackageManifest?: string, @@ -332,7 +340,7 @@ export function createAcpxPackageJsonResolver( "ACPX provider node_modules resolves outside the selected provider root", ); } - return (packageName, issuerPackageJsonPath) => { + const resolver: AcpxPackageJsonResolver = (packageName, issuerPackageJsonPath) => { const canonicalIssuer = issuerPackageJsonPath === undefined ? canonicalManifest @@ -345,13 +353,75 @@ export function createAcpxPackageJsonResolver( const packageJsonPath = realpathSync( resolvePackageJsonFromIssuer(packageName, canonicalIssuer), ); - if (!pathIsInside(canonicalNodeModules, packageJsonPath)) { + if (!pathIsInside(canonicalNodeModules, packageJsonPath) + && !isDeclaredHoistedCodexPlatform(canonicalRoot, canonicalManifest, canonicalIssuer, packageName, packageJsonPath)) { throw new Error( `ACPX provider package ${packageName} resolves outside the selected provider root`, ); } return packageJsonPath; }; + rootedPackageResolvers.set(resolver, { root: canonicalRoot, manifest: canonicalManifest }); + return resolver; +} + +function hasPublishedCodexPlatformDeclaration(resolver: AcpxPackageJsonResolver, runtimeManifest: string): boolean { + const authority = rootedPackageResolvers.get(resolver); + if (!authority || !pathIsInside(authority.root, runtimeManifest)) return false; + const selected = readResolverPackageJson(authority.manifest); + const runtime = readResolverPackageJson(runtimeManifest); + const declarations = selected.optionalDependencies as Record | undefined; + return selected.name === "@paperclipai/server" + && runtime.name === QUALIFIED_CODEX_LINUX_X64_RUNTIME.runtimePackageName + && runtime.version === QUALIFIED_CODEX_LINUX_X64_RUNTIME.runtimePackageVersion + && runtime.optionalDependencies === undefined + && declarations?.[QUALIFIED_CODEX_LINUX_X64_RUNTIME.packageName] === QUALIFIED_CODEX_LINUX_X64_RUNTIME.dependencyDeclaration; +} + +// npm installs an optional platform alias outside a bundled server graph. +// Admit only the qualified executable package through its declared npm slot; +// all executable bytes still pass the existing digest/no-follow admission. +function isDeclaredHoistedCodexPlatform(root: string, manifest: string, issuer: string, packageName: string, packageJsonPath: string): boolean { + const qualified = QUALIFIED_CODEX_LINUX_X64_RUNTIME; + if (packageName !== qualified.packageName || issuer === manifest) return false; + try { + const selected = readResolverPackageJson(manifest); + const runtime = readResolverPackageJson(issuer); + const optional = (value: Record) => value.optionalDependencies as Record | undefined; + if (selected.name !== "@paperclipai/server" || runtime.name !== qualified.runtimePackageName + || runtime.version !== qualified.runtimePackageVersion + || optional(selected)?.[packageName] !== qualified.dependencyDeclaration + || (runtime.optionalDependencies !== undefined && optional(runtime)?.[packageName] !== qualified.dependencyDeclaration)) return false; + if (realpathSync(resolvePackageJsonFromIssuer(packageName, manifest)) !== packageJsonPath) return false; + const declaredSlot = (createRequire(manifest).resolve.paths(packageName) ?? []).some((directory) => { + if (!pathIsInside(dirname(directory), root)) return false; + try { + return realpathSync(directory) === directory + && pathIsInside(directory, packageJsonPath) + && realpathSync(resolve(directory, packageName, "package.json")) === packageJsonPath + && realpathSync(resolve(directory, packageName)) === resolve(directory, packageName); + } catch { return false; } + }); + if (!declaredSlot) return false; + const platform = readResolverPackageJson(packageJsonPath); + return (platform.name === qualified.packageName || platform.name === qualified.runtimePackageName) + && platform.version === qualified.packageVersion + && Array.isArray(platform.os) && platform.os.includes("linux") + && Array.isArray(platform.cpu) && platform.cpu.includes("x64"); + } catch { return false; } +} + +function readResolverPackageJson(path: string): Record { + 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(MAX_PACKAGE_JSON_BYTES)) throw new Error("Invalid provider package manifest"); + const bytes = readFileSync(fd), after = fstatSync(fd, { bigint: true }); + if (bytes.length !== Number(before.size) || !sameIdentity(fileIdentity(before), fileIdentity(after))) throw new Error("Provider package manifest changed during resolution"); + const value: unknown = JSON.parse(bytes.toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid provider package metadata"); + return value as Record; + } finally { closeSync(fd); } } function resolvePackageJsonFromIssuer( @@ -406,7 +476,7 @@ export interface VerifiedAcpxInstallation { readonly commandDigest: string; readonly agentServerPackageJsonPath: string | null; readonly agentRuntimePackageJsonPath: string | null; - openCommand(): Promise; + openCommand(options?: { signal?: AbortSignal }): Promise; } /** @@ -425,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", [], @@ -563,6 +633,14 @@ export async function verifyQualifiedAcpxInstallation( profile: QualifiedAcpxProfile, resolvePackageJson: AcpxPackageJsonResolver = defaultPackageJsonResolver, ): Promise { + // Keep the selected package authority available when its bundled Codex + // wrapper delegates platform installation to the published server manifest. + if (profile.agent === "codex" && resolvePackageJson === defaultPackageJsonResolver && process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT !== undefined) { + resolvePackageJson = createAcpxPackageJsonResolver( + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT, + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST, + ); + } const builtin = profile.agent === "grok"; if (builtin && (profile.agentServerPackage !== "builtin:grok-acp" || profile.agentServerVersion !== "1" || profile.agentRuntimePackage !== "native:grok" || profile.agentRuntimeVersion !== "1.0.13")) { throw new Error("Grok builtin profile identity mismatch"); @@ -938,14 +1016,12 @@ async function verifyQualifiedRuntimeExecutable(input: { } const optionalDependencies = input.runtimePackage.optionalDependencies; - if ( - typeof optionalDependencies !== "object" || - optionalDependencies === null || - Array.isArray(optionalDependencies) || - (optionalDependencies as Record)[ - qualification.packageName - ] !== qualification.dependencyDeclaration - ) { + const ownDeclaration = typeof optionalDependencies === "object" + && optionalDependencies !== null && !Array.isArray(optionalDependencies) + && (optionalDependencies as Record)[qualification.packageName] === qualification.dependencyDeclaration; + const publishedDeclaration = input.profile.agent === "codex" && optionalDependencies === undefined + && hasPublishedCodexPlatformDeclaration(input.resolvePackageJson, input.runtimePackageJsonPath); + if (!ownDeclaration && !publishedDeclaration) { throw new Error( `ACPX ${input.profile.agent} runtime omitted its verified platform executable package`, ); @@ -1368,9 +1444,18 @@ function commandLease( privateSnapshot: AcpxPrivateSnapshot | null, ): VerifiedAcpxCommandLease { let consumed = false; - let spawnedChild: ChildProcess | undefined; let directoriesRelease: Promise | undefined; - let snapshotRetirement: Promise | undefined; + 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 = (): Promise => { return (directoriesRelease ??= Promise.all([ commandDirectory.close(), @@ -1380,24 +1465,6 @@ function commandLease( : [providerRuntimeExecutable.close()]), ]).then(() => undefined)); }; - const retireSnapshot = (): Promise => { - if ( - privateSnapshot && - spawnedChild?.pid !== undefined && - spawnedChild.exitCode === null && - spawnedChild.signalCode === null - ) { - return Promise.reject( - new Error("Verified ACPX snapshot cannot retire before provider exit"), - ); - } - return (snapshotRetirement ??= Promise.resolve() - .then(() => privateSnapshot?.close()) - .catch((error: unknown) => { - snapshotRetirement = undefined; - throw error; - })); - }; const releaseDirectoriesBestEffort = (): void => { void releaseDirectories().catch(() => undefined); }; @@ -1407,8 +1474,31 @@ function commandLease( // A spawned command owns its source buffer until its pipe write completes. if (!wasConsumed) verifiedBytes.fill(0); await releaseDirectories(); - // Consumption prevents a second launch; it does not complete retirement. - await retireSnapshot(); + // 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( @@ -1553,6 +1643,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 @@ -1582,18 +1687,11 @@ function commandLease( } catch (error) { verifiedBytes.fill(0); releaseDirectoriesBestEffort(); - if (spawnedChild?.pid !== undefined) { - spawnedChild.once("exit", () => { void retireSnapshot().catch(() => undefined); }); - spawnedChild.once("error", () => { void retireSnapshot().catch(() => undefined); }); - spawnedChild.kill(); - } else { - void retireSnapshot().catch(() => undefined); - } + spawnedChild?.kill(); + void Promise.resolve(childExit).then(cleanSnapshot).catch(() => undefined); throw error; } releaseDirectoriesBestEffort(); - child.once("exit", () => { void retireSnapshot().catch(() => undefined); }); - child.once("error", () => { void retireSnapshot().catch(() => undefined); }); const sourceInput = child.stdio[COMMAND_SOURCE_FD] as Writable | null; if (sourceInput === null) { verifiedBytes.fill(0); @@ -2124,3 +2222,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 fc561ba5289..438a3db12e3 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"; @@ -14,7 +16,7 @@ import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; 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[] = []; @@ -60,6 +62,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; @@ -79,6 +97,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 }]]) { @@ -94,6 +145,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"); @@ -103,38 +214,108 @@ describe("native ACPX execution closure", () => { expect(() => lease.spawn()).toThrow("closed"); await lease.close(); }); - it("awaits consumed command snapshot deletion before lease retirement", async () => { + it("waits for consumed native snapshot deletion before command retirement completes", async () => { const declaration = await fixture(); const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand(); - const originalRemove = vi.mocked(rm).getMockImplementation()!; - const deleting = gate(); const holdDeletion = gate(); - let privateRoot: string | undefined; + 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-")) { - privateRoot = String(path); deleting.release(); await holdDeletion.promise; + snapshotRoot = String(path); + deleting.release(); + await releaseDeletion.promise; } - return await originalRemove(path, options); + return originalRm(path, options); }); let retired = false; - let retirement: Promise | undefined; + let closing: Promise | undefined; try { - const result = await output(lease.spawn()); - expect(result.code, result.error).toBe(0); + expect((await output(lease.spawn())).code).toBe(0); await deleting.promise; - retirement = lease.close().then(() => { retired = true; }); + closing = lease.close().then(() => { retired = true; }); await new Promise(resolve => setImmediate(resolve)); expect(retired).toBe(false); - expect(privateRoot).toBeDefined(); - await expect(stat(privateRoot!)).resolves.toBeDefined(); } finally { - holdDeletion.release(); - await retirement; - if (privateRoot) await vi.waitFor(async () => { - await expect(stat(privateRoot!)).rejects.toMatchObject({ code: "ENOENT" }); - }); - vi.mocked(rm).mockImplementation(originalRemove); + releaseDeletion.release(); + await closing; } - expect(retired).toBe(true); + 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 }; @@ -144,26 +325,6 @@ describe("native ACPX execution closure", () => { expect(first.text).toMatch(/^native:fixed:.*paperclip-acpx-native-.*\/state$/); expect(first.text).not.toBe(second.text); }); - it("retains the snapshot until the live provider exits", async () => { - const declaration = { ...await fixture({ node: true, script: 'console.log(process.env.COPILOT_PKG_CACHE_HOME); setInterval(() => {}, 1000);' }), isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME" as const }; - const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand(); - const child = lease.spawn(); - const completed = output(child); - let privateRoot: string | undefined; - try { - const [chunk] = await once(child.stdout!, "data"); - privateRoot = dirname(String(chunk).trim()); - expect(privateRoot).toContain("paperclip-acpx-native-"); - await expect(lease.close()).rejects.toThrow("cannot retire before provider exit"); - await expect(stat(privateRoot)).resolves.toBeDefined(); - expect(child.exitCode).toBeNull(); - } finally { - child.kill("SIGTERM"); - await completed; - await lease.close(); - } - await expect(stat(privateRoot!)).rejects.toMatchObject({ code: "ENOENT" }); - }, 30_000); it("reports failed deletion and retries retirement without relaunching", async () => { const lease = await (await verifyNativeAcpxInstallation(await fixture())).openCommand(); const originalRemove = vi.mocked(rm).getMockImplementation()!; @@ -194,6 +355,32 @@ describe("native ACPX execution closure", () => { await lease.close(); } }); + // 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()); @@ -203,8 +390,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; @@ -215,16 +502,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]); @@ -247,7 +555,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; @@ -260,7 +568,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); @@ -271,12 +579,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 d0a9da44498..88f44996f74 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 } @@ -30,7 +30,8 @@ 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"; @@ -80,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"); @@ -99,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; @@ -120,31 +159,49 @@ 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]; @@ -172,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) { 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 00000000000..92610780975 --- /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 00000000000..bb2049ff7e7 --- /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 00000000000..17463af390e --- /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 00000000000..3244bbdfe31 --- /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 00000000000..b2f320937a2 --- /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 00000000000..c04f4c3f71e --- /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 00000000000..2ca4e870374 --- /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 00000000000..c4a273778a8 --- /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 00000000000..7c3a5bab902 --- /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 00000000000..33266401e89 --- /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 00000000000..93d1b1a4e95 --- /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 00000000000..7f4fa25b501 --- /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 00000000000..eca68f7de3b --- /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 00000000000..b9dfcd6868d --- /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 00000000000..08539b6abd7 --- /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 00000000000..c11999afac8 --- /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 00000000000..45ce3866cb8 --- /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 00000000000..999e69be04d --- /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 00000000000..aa5d67cde3f --- /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 00000000000..6a08c7a3f90 --- /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 00000000000..15b69dca495 --- /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-compatibility.test.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts index f85a8df9631..e706200ec77 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts @@ -1,10 +1,20 @@ 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"]) { + 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); } @@ -13,7 +23,7 @@ describe("historical ACPX profile decoding", () => { for (const version of [1, 5, 6, 12, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]) { expect(isSupportedAcpxProfileVersion("copilot", version)).toBe(true); } - expect(isSupportedAcpxProfileVersion("copilot", 34)).toBe(false); + expect(isSupportedAcpxProfileVersion("copilot", 35)).toBe(false); for (const agent of ["cursor", "pi", "claude", "codex", "grok"]) { expect(isSupportedAcpxProfileVersion(agent, 23)).toBe(false); expect(isSupportedAcpxProfileVersion(agent, 24)).toBe(false); @@ -22,9 +32,14 @@ describe("historical ACPX profile decoding", () => { expect(isSupportedAcpxProfileVersion(agent, 27)).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 ba815d12969..fb546726832 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 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33; -const historicalVersions: Readonly> = { - pi: [1, 2, 3, 4, 5], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33; +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, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33], - cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + 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 b5bd6c8bede..cce795037af 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts @@ -1,4 +1,5 @@ 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"; @@ -39,13 +40,14 @@ export function createAcpxProfileExtensionAdapter( agent: QualifiedAcpxAgent, context: AcpxProfileExtensionContext, ): AcpxProfileExtensionAdapter | null { - if (agent === "copilot") return createCopilotProfileExtensionAdapter(context); 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 === "copilot") return structuredClone(COPILOT_ACP_CLIENT_CAPABILITIES); 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 58e8b2ed165..c2d7f11ace0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -3,10 +3,12 @@ import { verifyCopilotInstallation } from "./copilot-installation.js"; 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 { 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); } 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 90f08600a0e..b08d79b55d0 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 @@ -32,7 +32,7 @@ it("resolves descriptor sidecars only through the runner-bound canonical package }); it("uses the public server owner for its vendored Copilot probe and selected sidecar", async () => { - const path = await root(); + const path = await serverFixture(); await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); @@ -46,7 +46,8 @@ it("uses the public server owner for its vendored Copilot probe and selected sid 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")); @@ -65,7 +66,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 () => { @@ -82,3 +83,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 a6198213282..5f4929963b8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts @@ -5,6 +5,15 @@ 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 { @@ -29,13 +38,13 @@ export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: Nat if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || bytes.length !== Number(before.size) || realpathSync(manifest) !== canonicalManifest) throw new Error("Runner provider manifest changed during admission"); const value = JSON.parse(bytes.toString("utf8")) as { name?: unknown }; - if (value?.name === SERVER_PACKAGE_NAME && provider === "copilot") { - packageRoot = dirname(canonicalManifest); - } else if (value?.name === SERVER_PACKAGE_NAME) { + 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. - packageRoot = join(dirname(canonicalManifest), "dist/vendor/paperclip-runner"); + packageRoot = provider === "copilot" ? dirname(canonicalManifest) : join(dirname(canonicalManifest), "dist/vendor/paperclip-runner"); if (realpathSync(packageRoot) !== packageRoot) throw new Error("Vendored runner directory is not contained by its server package"); } else if (value?.name === RUNNER_PACKAGE_NAME) { packageRoot = dirname(canonicalManifest); @@ -45,8 +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 (provider === "copilot" && new RegExp(`/dist/vendor/paperclip-runner/drivers/acpx/${provider}-installation\\.js$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../../../../", url)); - else 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); + if (provider !== "copilot") 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)); @@ -75,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 7e7d8b9a89f..ddd5d29314b 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 4e2faa18f95..4439b02edcf 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 c833da2ddce..41ec9bde429 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; @@ -1377,6 +1526,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 64879be0828..0f441eb29b8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,5 +1,7 @@ +import { piProviderConfiguration } from "./pi-provider-config.js"; import { withAcpxTurnCancellation } from "./turn-cancellation.js"; import { resolveAcpxProviderMode } from "./provider-mode.js"; +import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js"; import { dirname, join } from "node:path"; import { bindAcpxAgentFiles } from "./agent-files-binding.js"; import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js"; @@ -80,6 +82,7 @@ const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000; export interface AcpxRuntimePortIdentity { mode?: string; + piThinkingLevel?: PiThinkingLevel; acpxRecordId: string; backendSessionId: string; agentSessionId: string; @@ -146,6 +149,8 @@ export interface AcpxRuntimePort { } export interface AcpxRuntimePortOpenOptions { + /** A durable identity was admitted; restored native mode must already match. */ + restoringSession?: boolean; /** Ephemeral provider extension metadata; mandatory ACP caps remain host-owned. */ clientCapabilities?: Record; command: VerifiedAcpxCommandLease; @@ -157,6 +162,7 @@ export interface AcpxRuntimePortOpenOptions { providerSessionKey: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: PiThinkingLevel; permissionPolicy: ReturnType; launchEnvironment: Readonly; /** Kernel credential-home quorum inherited by the provider sentinel. */ @@ -231,6 +237,7 @@ export interface OpenAcpxRuntimeHostOptions { model: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: PiThinkingLevel; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; @@ -339,8 +346,10 @@ export class AcpxRuntimeHost { workingDirectory: options.workingDirectory, profile, requestedModel: options.model, + ...(options.agent === "pi" ? { providerConfigurationDigest: piProviderConfiguration(options.environment)?.digest } : {}), permissionMode: options.permissionMode, mode: resolveAcpxProviderMode(options.agent, options.mode), + piThinkingLevel: resolvePiThinkingLevel(options.agent, options.piThinkingLevel), ...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined ? { providerPolicy: options.providerPolicy } : {}), }), @@ -474,12 +483,13 @@ 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" || options.agent === "copilot" + 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") { @@ -526,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) => @@ -534,7 +544,7 @@ export class AcpxRuntimeHost { }); const commandOwner = createAcpxCommandLeaseOwner( command, - () => installation.openCommand(), + signal => installation.openCommand({ signal }), ); command = commandOwner.command; toolBridge = options.semanticTools @@ -567,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, ), @@ -629,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 fbe2ff00faa..66b786f3594 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -1,3 +1,4 @@ +import { COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; import { configuredEnvironmentProjection } from "../../configured-environment.js"; import { chmod, @@ -21,6 +22,7 @@ import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import { createAcpxRecoveryBinding } from "./recovery-identity.js"; import { prepareAcpxRuntimeSandbox, + refreshCopilotSystemInstructions, readAcpxRecoveryWorkspace, } from "./runtime-sandbox.js"; @@ -422,6 +424,39 @@ 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 composed = (value: string) => `${value}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`; + const first = await prepare("Original registered instructions."); + const path = join(first.agentHomeDirectory, "copilot-instructions.md"); + expect(await readFile(path, "utf8")).toBe(composed("Original registered instructions.")); + await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "copilot", + providerPolicy: { readOnly: false, systemInstructions: "Contender must not write during preparation." } }); + expect(await readFile(path, "utf8")).toBe(composed("Original registered instructions.")); + 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(composed("Current registered instructions.")); + await prepare(""); + expect(await readFile(path, "utf8")).toBe(composed("")); + await expect(prepare("x".repeat(32 * 1024 + 1))).rejects.toThrow("bounded size"); + expect(await readFile(path, "utf8")).toBe(composed("")); +}); + async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "cursor" | "copilot") { const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-sandbox-")); temporaryDirectories.push(root); @@ -442,6 +477,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 3546bd4bb2e..3fd9b1ba8eb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -1,3 +1,4 @@ +import { piProviderConfiguration } from "./pi-provider-config.js"; import { configuredEnvironmentKeys } from "../../configured-environment.js"; import { COPILOT_SYSTEM_INSTRUCTIONS_FILE, COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js"; import { randomBytes } from "node:crypto"; @@ -389,6 +390,10 @@ export async function prepareAcpxRuntimeSandbox(input: { workspaceRecordPath, `${input.binding.workspacePath}\n`, ); + if (input.agent === "pi") { + const configuration = piProviderConfiguration(input.environment); + if (configuration) await writePrivateFile(join(agentHomeDirectory, "models.json"), configuration.json); + } if (input.agent === "claude") { // ACP otherwise rewrites exact IDs (including user-entered model IDs) to // picker aliases such as "sonnet". Its supported availableModels setting diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index 6e457d35e2e..d9ce539a9f6 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/usage-accounting.test.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts index d8d3e84191c..3e6b51999c9 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 2d8c2ff3579..4143f655a68 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts @@ -95,3 +95,70 @@ export function acpxUsageEstimateNotice(usage: Record, itemId: }, }; } + +type CursorPromptUsage = { + request_id: string; prompt_message_id: string; + receipt: { + schema: "paperclip.cursor.native-usage.v1"; source: "native_turn_ended"; promptId: string; + completeness: "partial"; reasons: string[]; truncated: boolean; + limits: { maxObservations: 64; maxInvocations: 64; maxBytes: 16384 }; + observations: Array<{ invocationId: string; role: "parent" | "child"; nativeRun: number; sequence: number; counters: Record }>; + }; +}; +/** Closed diagnostic receipt. Counter semantics and billing are always unverified. */ +export function parseCursorPromptUsage(raw: unknown): CursorPromptUsage | null { + try { + const text = JSON.stringify(raw); + if (!text || Buffer.byteLength(text, "utf8") > 16384) return null; + const v = JSON.parse(text) as CursorPromptUsage; + const object = (x: unknown): x is object => x !== null && typeof x === "object" && !Array.isArray(x); + const exact = (x: object, keys: readonly string[]) => Object.keys(x).length === keys.length && keys.every(k => Object.hasOwn(x, k)); + const id = (x: unknown) => typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(x); + if (!object(v) || !exact(v, ["request_id", "prompt_message_id", "receipt"]) || !id(v.request_id) || !id(v.prompt_message_id)) return null; + const r = v.receipt; + if (!object(r) || !exact(r, ["schema", "source", "promptId", "completeness", "reasons", "observations", "limits", "truncated"])) return null; + if (r.schema !== "paperclip.cursor.native-usage.v1" || r.source !== "native_turn_ended" || r.completeness !== "partial" || typeof r.promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(r.promptId) || typeof r.truncated !== "boolean") return null; + const reasons = ["native_counter_semantics_unverified", "native_counters_missing", "invalid_native_counter", "multiple_terminal_observations", "native_terminal_not_observed", "observation_limit_reached", "child_run_attribution_unverified"]; + if (!Array.isArray(r.reasons) || r.reasons.length > reasons.length || !r.reasons.includes(reasons[0]!) || new Set(r.reasons).size !== r.reasons.length || !r.reasons.every(x => reasons.includes(x))) return null; + if (!object(r.limits) || !exact(r.limits, ["maxObservations", "maxInvocations", "maxBytes"]) || r.limits.maxObservations !== 64 || r.limits.maxInvocations !== 64 || r.limits.maxBytes !== 16384 || !Array.isArray(r.observations) || r.observations.length > 64) return null; + const fields = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"]; + const invocations = new Map(); + for (const o of r.observations) { + if (!object(o) || !exact(o, ["invocationId", "role", "nativeRun", "sequence", "counters"]) || typeof o.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(o.invocationId) || (o.role !== "parent" && o.role !== "child") || !Number.isSafeInteger(o.nativeRun) || o.nativeRun < 1 || !Number.isSafeInteger(o.sequence) || o.sequence < 1 || !object(o.counters)) return null; + if (!Object.entries(o.counters).every(([k, n]) => fields.includes(k) && Number.isSafeInteger(n) && n >= 0)) return null; + const previous = invocations.get(o.invocationId); + if (previous && (previous.role !== o.role || previous.nativeRun !== o.nativeRun || o.sequence <= previous.sequence)) return null; + invocations.set(o.invocationId, { role: o.role, nativeRun: o.nativeRun, sequence: o.sequence }); + } + return v; + } catch { return null; } +} + +/** Partial native observations never enter usage_update, aggregate tokens, or USD. */ +export function persistedCursorUsageNotice(before: unknown, after: unknown, requestId: string, agent: QualifiedAcpxAgent | null, itemId: string): CanonicalProviderEvent | null { + if (agent !== "cursor") return null; + const current = record(after), prior = record(before); + if (current.lastRequestId !== requestId || prior.lastRequestId === requestId) return null; + const receipt = parseCursorPromptUsage(current.cursorPromptUsage); + if (!receipt || receipt.request_id !== requestId) return null; + const previous = parseCursorPromptUsage(prior.cursorPromptUsage); + if (previous && (previous.request_id === receipt.request_id || previous.prompt_message_id === receipt.prompt_message_id || previous.receipt.promptId === receipt.receipt.promptId)) return null; + // A message must have been created by this turn; replacing metadata on an old message is not fresh evidence. + if (!Array.isArray(current.promptMessageIds) || current.promptMessageIds.filter(id => id === receipt.prompt_message_id).length !== 1 + || !Array.isArray(prior.promptMessageIds) || prior.promptMessageIds.includes(receipt.prompt_message_id)) return null; + const details = [ + { name: "Provenance", value: "Cursor native turnEnded observations; partial, unsummed, unverified counter semantics" }, + { name: "Partial reasons", value: receipt.receipt.reasons.join(", ") }, + { name: "Collector truncated", value: String(receipt.receipt.truncated) }, + ...Array.from({ length: Math.ceil(receipt.receipt.observations.length / 8) }, (_, group) => ({ + name: `Native observations ${group * 8 + 1}-${Math.min((group + 1) * 8, receipt.receipt.observations.length)}`, + value: JSON.stringify(receipt.receipt.observations.slice(group * 8, (group + 1) * 8)), + })), + ]; + // Eight closed observations fit within each 4,000-byte detail, without dropping any counters. + return { + eventType: "provider.notice.recorded", itemId, + payload: { schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", category: "cursor_native_usage_observed", scope: "turn", recoverable: true, userActionable: false, + summary: "Cursor reported partial native counters. These observations are not authoritative token usage or billing cost.", details }, + }; +} diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index ebd0f9d2948..cee7bb80b2e 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -45,6 +45,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 ecc4c6167d3..cd9ee34129d 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 e29d2203d8a..329e2fc00e6 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-command.ts b/packages/paperclip-runner/src/drivers/codex/codex-command.ts new file mode 100644 index 00000000000..37d53bf9fc2 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/codex/codex-command.ts @@ -0,0 +1,78 @@ +import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { delimiter, dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +/** Prefer the installed dependency; compatibility is established by the CLI protocol. */ +export function resolveCodexCommand( + issuer: string | URL = import.meta.url, + environment: NodeJS.ProcessEnv = process.env, + workingDirectory = process.cwd(), +): string { + try { + const runnerRequire = createRequire(issuer); + let manifestPath: string; + try { + let bridgeManifest: string; + try { + bridgeManifest = runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "MODULE_NOT_FOUND") throw error; + // pnpm can retain the bridge inside its declared adapter dependency. + const adapter = runnerRequire.resolve("@paperclipai/adapter-codex-local/server"); + bridgeManifest = createRequire(adapter).resolve("@agentclientprotocol/codex-acp/package.json"); + } + manifestPath = createRequire(bridgeManifest).resolve("@openai/codex/package.json"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "MODULE_NOT_FOUND") throw error; + try { + manifestPath = runnerRequire.resolve("@openai/codex/package.json"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "MODULE_NOT_FOUND") throw error; + return resolveCodexOnPath(environment, workingDirectory); + } + } + manifestPath = realpathSync(manifestPath); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + name?: unknown; version?: unknown; bin?: string | Record; + }; + if (manifest.name !== "@openai/codex") throw new Error("Installed Codex dependency has an unexpected package identity"); + const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.codex; + if (typeof bin !== "string" || !bin || bin.includes("\0") || isAbsolute(bin)) { + throw new Error("Installed Codex dependency does not expose a contained executable"); + } + const root = dirname(manifestPath), candidate = resolve(root, bin); + const inside = (path: string) => { + const value = relative(root, path); + return value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value); + }; + if (!inside(candidate)) throw new Error("Installed Codex executable escapes its package"); + const executable = realpathSync(candidate); + if (!inside(executable)) throw new Error("Installed Codex executable escapes its package"); + if (!statSync(executable).isFile()) throw new Error("Installed Codex executable is not a regular file"); + accessSync(executable, constants.X_OK); + return executable; + } catch (error) { + throw new Error(`Codex runtime unavailable: ${(error as Error).message}. Install Codex or restore Paperclip's runtime dependencies.`, { cause: error }); + } +} + +function resolveCodexOnPath(environment: NodeJS.ProcessEnv, workingDirectory: string): string { + const names = process.platform === "win32" + ? ["codex", ...(environment.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map(extension => `codex${extension.toLowerCase()}`)] + : ["codex"]; + // Empty PATH components mean cwd; an omitted PATH has no search entries. + for (const directory of environment.PATH === undefined ? [] : environment.PATH.split(delimiter)) { + for (const name of names) { + try { + const executable = realpathSync(resolve(workingDirectory, directory, name)); + if (!statSync(executable).isFile()) continue; + accessSync(executable, constants.X_OK); + return executable; + } catch { /* Continue looking for an executable in the selected host PATH. */ } + } + } + throw new Error("No installed Codex dependency or executable on PATH"); +} + +// Compatibility export: the release pin is not a runtime version gate. +export const resolvePinnedCodexCommand = resolveCodexCommand; 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 fe52e695aa0..c6644732cb7 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 e804a9538e4..955040bf7be 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts @@ -65,7 +65,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-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts index acecaf2c838..635afadad44 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts @@ -1,5 +1,7 @@ import { createRequire } from "node:module"; +import { execFileSync } from "node:child_process"; import { dirname, resolve } from "node:path"; +import { resolvePinnedCodexCommand } from "./codex-command.js"; import { evalProviderTransportOptions } from "../../cli/eval-provider-runtime.js"; import { describe, expect, it } from "vitest"; @@ -12,6 +14,236 @@ import { } from "./codex-security-config.js"; describe("Codex security configuration", () => { + it("uses the same actual pinned executable for native defaults and direct evals without a global Codex PATH", () => { + const command = resolvePinnedCodexCommand(); + expect(evalProviderTransportOptions("codex").codexCommand).toBe(command); + expect(execFileSync(command, ["--version"], { env: { PATH: dirname(process.execPath) }, + encoding: "utf8", timeout: 30_000, maxBuffer: 16 * 1024 }).trim()).toBe("codex-cli 0.160.0"); + }); + + it("resolves usable installed Codex commands and an absent-package PATH fallback while rejecting malformed or escaped dependencies", () => { + // Vitest adds its dependency directories to global module lookup paths. + // Use the existing Node/tsx boundary so an absent fixture dependency cannot + // be supplied by the test runner's installed bridge instead. + const source = ` + import assert from "node:assert/strict"; + import { execFileSync } from "node:child_process"; + import { createRequire } from "node:module"; + import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; + import { tmpdir } from "node:os"; + import { delimiter, dirname, isAbsolute, join } from "node:path"; + import { resolveCodexCommand, resolvePinnedCodexCommand } from ${JSON.stringify(new URL("./codex-command.ts", import.meta.url).href)}; + import { codexExecutableReadOnlyRoots, createIsolatedCodexAppServerArgs } from ${JSON.stringify(new URL("./codex-security-config.ts", import.meta.url).href)}; + const root = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-pinned-codex-test-"))); + const pathOnlyRoot = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-path-codex-test-"))); + const pathDirectory = join(pathOnlyRoot, "bin"); + const pathCommand = join(pathDirectory, "codex"); + const commandEnvironment = { PATH: pathDirectory }; + const issuer = join(root, "node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/codex/codex-command.js"); + const adapter = join(root, "node_modules/@paperclipai/adapter-codex-local"); + const bridge = join(adapter, "node_modules/@agentclientprotocol/codex-acp"); + const codex = join(bridge, "node_modules/@openai/codex"); + const executable = join(codex, "bin/codex.js"); + const platformName = "@openai/codex-" + process.platform + "-" + process.arch; + const platform = join(root, "node_modules", platformName), vendor = join(platform, "vendor"); + const metadata = { name: "@openai/codex", version: "0.160.0", bin: { codex: "bin/codex.js" }, + optionalDependencies: { [platformName]: "npm:@openai/codex@0.160.0-" + process.platform + "-" + process.arch } }; + try { + mkdirSync(pathDirectory); + writeFileSync(pathCommand, "#!" + process.execPath + "\\nif (process.argv.slice(2).join(' ') !== '--version') process.exit(9); console.log('codex-cli 0.141.0');\\n", { mode: 0o755 }); + const pathOnlyIssuer = join(pathOnlyRoot, "standalone-command.js"); + assert.equal(resolveCodexCommand(pathOnlyIssuer, commandEnvironment), pathCommand, "Only absent dependency graphs use the selected PATH command"); + assert.equal(execFileSync(resolveCodexCommand(pathOnlyIssuer, commandEnvironment), ["--version"], { + env: commandEnvironment, encoding: "utf8", timeout: 5_000 }).trim(), "codex-cli 0.141.0"); + const relativeCommand = resolveCodexCommand(pathOnlyIssuer, { PATH: "./bin" }, pathOnlyRoot); + assert.equal(relativeCommand, pathCommand, "Relative PATH entries use the provider working directory"); + assert.ok(isAbsolute(relativeCommand), "Provider launch receives an absolute executable"); + assert.equal(execFileSync(relativeCommand, ["--version"], { + cwd: root, env: { PATH: "/missing-codex-command" }, encoding: "utf8", timeout: 5_000 }).trim(), "codex-cli 0.141.0"); + const cwdCommand = join(pathOnlyRoot, "codex"); + writeFileSync(cwdCommand, "#!" + process.execPath + "\\n", { mode: 0o755 }); + const secondDirectory = join(pathOnlyRoot, "second-bin"); + mkdirSync(secondDirectory); + const secondCommand = join(secondDirectory, "codex"); + writeFileSync(secondCommand, "#!" + process.execPath + "\\n", { mode: 0o755 }); + for (const path of [".", "", "./missing" + delimiter, delimiter + "./bin"]) { + assert.equal(resolveCodexCommand(pathOnlyIssuer, { PATH: path }, pathOnlyRoot), cwdCommand, + "Dot and empty PATH entries retain their ordered current-directory meaning"); + } + for (const [path, expected] of [ + ["./bin" + delimiter + ".", pathCommand], + ["./bin" + delimiter + "./second-bin", pathCommand], + ["./second-bin" + delimiter + "./bin", secondCommand], + ["./missing" + delimiter + "./bin", pathCommand], + ]) assert.equal(resolveCodexCommand(pathOnlyIssuer, { PATH: path }, pathOnlyRoot), expected, + "Relative PATH resolution preserves search ordering and skips missing entries"); + for (const environment of [{}, { PATH: "/missing-codex-command" }, { PATH: "./missing" }]) { + assert.throws(() => resolveCodexCommand(pathOnlyIssuer, environment, pathOnlyRoot), + /runtime unavailable.*No installed Codex dependency or executable on PATH/, + "An absent PATH must not implicitly authorize the provider working directory"); + } + assert.throws(() => resolveCodexCommand(pathOnlyIssuer, { PATH: "./bin" }, root), /runtime unavailable/, + "Relative PATH must not use a different provider working directory"); + const previousCwd = process.cwd(); + try { + process.chdir(pathOnlyRoot); + assert.equal(resolveCodexCommand(pathOnlyIssuer, { PATH: "./bin" }), pathCommand, + "Omitted working directory follows the current process directory"); + } finally { process.chdir(previousCwd); } + const directCodex = join(pathOnlyRoot, "node_modules/@openai/codex"); + const directCommand = join(directCodex, "bin/codex.js"); + mkdirSync(dirname(directCommand), { recursive: true }); + writeFileSync(join(directCodex, "package.json"), JSON.stringify({ ...metadata, version: "0.155.0" })); + writeFileSync(directCommand, "#!" + process.execPath + "\\n", { mode: 0o755 }); + assert.equal(resolveCodexCommand(pathOnlyIssuer, commandEnvironment), directCommand, "A directly installed older wrapper takes precedence over PATH"); + writeFileSync(join(directCodex, "package.json"), JSON.stringify({ ...metadata, name: "unowned-wrapper" })); + assert.throws(() => resolveCodexCommand(pathOnlyIssuer, commandEnvironment), /unexpected package identity/); + writeFileSync(join(directCodex, "package.json"), "{"); + assert.throws(() => resolveCodexCommand(pathOnlyIssuer, commandEnvironment), /runtime unavailable/); + mkdirSync(dirname(issuer), { recursive: true }); + mkdirSync(join(codex, "bin"), { recursive: true }); + writeFileSync(join(adapter, "package.json"), JSON.stringify({ name: "@paperclipai/adapter-codex-local", exports: { "./server": "./server.js" } })); + writeFileSync(join(adapter, "server.js"), ""); + writeFileSync(join(bridge, "package.json"), JSON.stringify({ name: "@agentclientprotocol/codex-acp", version: "1.6.2" })); + writeFileSync(join(codex, "package.json"), JSON.stringify(metadata)); + mkdirSync(vendor, { recursive: true }); + const platformMetadata = { name: "@openai/codex", version: "0.160.0-" + process.platform + "-" + process.arch, + os: [process.platform], cpu: [process.arch] }; + writeFileSync(join(platform, "package.json"), JSON.stringify(platformMetadata)); + writeFileSync(executable, "#!" + process.execPath + "\\nif (process.argv.slice(2).join(' ') !== '--version') process.exit(9); console.log('codex-cli 0.160.0');\\n", { mode: 0o755 }); + assert.equal(resolveCodexCommand(issuer, commandEnvironment), executable, "The selected installed bridge closure takes precedence over PATH"); + assert.equal(resolvePinnedCodexCommand(issuer, commandEnvironment), executable, "The published compatibility alias keeps ordinary command semantics"); + const roots = codexExecutableReadOnlyRoots({ PATH: "/missing-codex-command" }, executable); + assert.ok(roots.includes(vendor), "The npm-hoisted native vendor directory remains readable"); + assert.ok(!roots.includes(platform) && !roots.includes(root), "Hoisting must not expose npm ancestry"); + assert.equal(execFileSync(resolveCodexCommand(issuer, commandEnvironment), ["--version"], { env: { PATH: "/missing-codex-command" }, + encoding: "utf8", timeout: 5_000 }).trim(), "codex-cli 0.160.0"); + + for (const bin of ["../../escaped-codex", "/usr/bin/codex", {}, ""]) { + writeFileSync(join(codex, "package.json"), JSON.stringify({ ...metadata, bin })); + assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /contained executable|escapes its package/); + } + for (const version of ["0.159.0", "0.161.0"]) { + writeFileSync(join(codex, "package.json"), JSON.stringify({ ...metadata, version })); + assert.equal(resolveCodexCommand(issuer, commandEnvironment), executable, "Independently usable older and newer installed wrappers remain selectable"); + } + writeFileSync(join(codex, "package.json"), JSON.stringify({ ...metadata, name: "unowned-wrapper" })); + assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /unexpected package identity/); + writeFileSync(join(codex, "package.json"), "{"); + assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /runtime unavailable/); + writeFileSync(join(codex, "package.json"), JSON.stringify(metadata)); + chmodSync(executable, 0o600); + assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /runtime unavailable/); + rmSync(executable); + writeFileSync(join(root, "external-codex"), "external executable", { mode: 0o755 }); + symlinkSync(join(root, "external-codex"), executable); + assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /escapes its package/); + rmSync(executable); + assert.throws(() => resolveCodexCommand(issuer, commandEnvironment), /runtime unavailable/); + rmSync(codex, { recursive: true }); + // Use the existing fresh-process boundary: Node caches earlier module + // paths, whereas a genuinely absent installed graph must use PATH. + const resolverModule = ${JSON.stringify(new URL("./codex-command.ts", import.meta.url).href)}; + const freshAbsence = [ + 'import assert from "node:assert/strict";', + 'import { resolveCodexCommand } from ' + JSON.stringify(resolverModule) + ';', + 'assert.equal(resolveCodexCommand(' + JSON.stringify(issuer) + ',' + JSON.stringify(commandEnvironment) + '),' + JSON.stringify(pathCommand) + ');', + 'assert.throws(() => resolveCodexCommand(' + JSON.stringify(issuer) + ', { PATH: "/missing-codex-command" }), /runtime unavailable.*No installed Codex dependency or executable on PATH/);', + ].join("\\n"); + execFileSync(process.execPath, ['--no-global-search-paths', '--import', createRequire(resolverModule).resolve('tsx'), + '--input-type=module', '--eval', freshAbsence], { env: commandEnvironment, encoding: 'utf8', timeout: 5_000 }); + + // npm installs the host alias declared by the published server while + // its bundled JS wrapper deliberately omits optionalDependencies. + const server = join(root, "node_modules/@paperclipai/server"); + const serverManifest = join(server, "package.json"); + const normalized = join(server, "node_modules/@openai/codex"); + const normalizedManifest = join(normalized, "package.json"); + const normalizedCommand = join(normalized, "bin/codex.js"); + const publishedBridgeManifest = join(server, "node_modules/@agentclientprotocol/codex-acp/package.json"); + const publishedBridgeMetadata = { name: "@agentclientprotocol/codex-acp", version: "1.6.2" }; + const serverMetadata = { name: "@paperclipai/server", dependencies: { "@agentclientprotocol/codex-acp": "1.6.2" }, + optionalDependencies: metadata.optionalDependencies }; + const normalizedMetadata = { ...metadata, optionalDependencies: undefined }; + mkdirSync(dirname(normalizedCommand), { recursive: true }); + mkdirSync(dirname(publishedBridgeManifest), { recursive: true }); + writeFileSync(publishedBridgeManifest, JSON.stringify(publishedBridgeMetadata)); + writeFileSync(serverManifest, JSON.stringify(serverMetadata)); + writeFileSync(normalizedManifest, JSON.stringify(normalizedMetadata)); + writeFileSync(normalizedCommand, "#!" + process.execPath + "\\n", { mode: 0o755 }); + const normalizedRoots = () => codexExecutableReadOnlyRoots({ PATH: "/missing-codex-command" }, normalizedCommand); + assert.ok(normalizedRoots().includes(vendor), "The normalized published wrapper must retain its selected native sandbox resources"); + const sandbox = createIsolatedCodexAppServerArgs({ HOME: "/private-provider-home" }, normalizedRoots()).join("\\n"); + assert.ok(sandbox.includes(JSON.stringify(vendor) + '=\"read\"'), "Native sandbox resources must reach the actual read-only permission configuration"); + assert.ok(sandbox.includes('\"/private-provider-home\"=\"none\"'), "The provider home remains denied"); + for (const ancestor of [platform, dirname(platform), join(root, "node_modules"), server, root]) { + assert.ok(!normalizedRoots().includes(ancestor), "Platform discovery must not grant enclosing npm or server roots"); + } + const restoreMetadata = () => { + writeFileSync(serverManifest, JSON.stringify(serverMetadata)); + writeFileSync(publishedBridgeManifest, JSON.stringify(publishedBridgeMetadata)); + writeFileSync(normalizedManifest, JSON.stringify(normalizedMetadata)); + writeFileSync(join(platform, "package.json"), JSON.stringify(platformMetadata)); + }; + // Resource lookup protects package ownership and paths. Version + // qualification belongs to ACPX, not ordinary Codex shell access. + for (const version of ["0.159.0", "0.161.0"]) { + for (const [path, changed] of [ + [serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "npm:@openai/codex@" + version + "-" + process.platform + "-" + process.arch } }], + [serverManifest, { ...serverMetadata, dependencies: { "@agentclientprotocol/codex-acp": version } }], + [publishedBridgeManifest, { ...publishedBridgeMetadata, version }], + [normalizedManifest, { ...normalizedMetadata, version }], + [join(platform, "package.json"), { ...platformMetadata, version: version + "-" + process.platform + "-" + process.arch }], + ]) { + writeFileSync(path, JSON.stringify(changed)); + assert.ok(normalizedRoots().includes(vendor), "Older or newer usable metadata must retain native resources independently of other version numbers"); + for (const ancestor of [platform, dirname(platform), server, root]) assert.ok(!normalizedRoots().includes(ancestor)); + restoreMetadata(); + } + } + for (const [path, changed] of [ + [serverManifest, { ...serverMetadata, optionalDependencies: {} }], + [serverManifest, { ...serverMetadata, name: "unowned-server" }], + [serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "npm:unowned/codex@0.160.0-" + process.platform + "-" + process.arch } }], + [serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "file:/unowned/native" } }], + [serverManifest, { ...serverMetadata, optionalDependencies: { [platformName]: "npm:@openai/codex@0.160.0-unsupported-os-" + process.arch } }], + [serverManifest, { ...serverMetadata, dependencies: {} }], + [serverManifest, { ...serverMetadata, dependencies: { "@agentclientprotocol/codex-acp": "" } }], + [publishedBridgeManifest, { ...publishedBridgeMetadata, name: "unqualified-bridge" }], + [normalizedManifest, { ...normalizedMetadata, name: "unowned-wrapper" }], + [normalizedManifest, { ...normalizedMetadata, optionalDependencies: {} }], + [join(platform, "package.json"), { ...platformMetadata, name: "unqualified-native" }], + [join(platform, "package.json"), { ...platformMetadata, os: ["unsupported-os"] }], + [join(platform, "package.json"), { ...platformMetadata, cpu: ["unsupported-cpu"] }], + ]) { + writeFileSync(path, JSON.stringify(changed)); + assert.ok(!normalizedRoots().includes(vendor), "Unqualified metadata must not grant native resources"); + restoreMetadata(); + } + const outsideVendor = join(root, "outside-vendor"); + mkdirSync(outsideVendor); + rmSync(vendor, { recursive: true }); + symlinkSync(outsideVendor, vendor); + assert.ok(!normalizedRoots().includes(outsideVendor), "A vendor symlink must not escape the selected platform package"); + rmSync(vendor); mkdirSync(vendor); + const outsidePlatform = join(root, "outside-platform"); + mkdirSync(join(outsidePlatform, "vendor"), { recursive: true }); + writeFileSync(join(outsidePlatform, "package.json"), JSON.stringify(platformMetadata)); + rmSync(platform, { recursive: true }); symlinkSync(outsidePlatform, platform); + assert.ok(!normalizedRoots().includes(join(outsidePlatform, "vendor")), "An alias symlink must not grant a foreign package"); + rmSync(platform); mkdirSync(vendor, { recursive: true }); + writeFileSync(join(platform, "package.json"), JSON.stringify(platformMetadata)); + + } finally { rmSync(root, { recursive: true, force: true }); rmSync(pathOnlyRoot, { recursive: true, force: true }); } + process.stdout.write("PINNED_CODEX_ISOLATION_VERIFIED"); + `; + expect(execFileSync(process.execPath, [ + "--no-global-search-paths", "--import", createRequire(import.meta.url).resolve("tsx"), + "--input-type=module", "--eval", source, + ], { env: { ...process.env, NODE_PATH: "", NODE_OPTIONS: "" }, encoding: "utf8", timeout: 10_000, + maxBuffer: 32 * 1024 })).toBe("PINNED_CODEX_ISOLATION_VERIFIED"); + }); + it("allows only the registered private instruction directory while keeping shared context read-only", () => { const args = createIsolatedCodexAppServerArgs({ HOME: "/host/home" }, ["/runtime/immutable-context"], "/runtime/instruction-edits/run-1").join("\n"); expect(args).toContain('"/runtime/instruction-edits/run-1"="write"'); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index f363bd8163b..d02e0cfb4b0 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -1,7 +1,8 @@ import { configuredEnvironmentKeys } from "../../configured-environment.js"; -import { resolve, isAbsolute, join, dirname, delimiter } from "node:path"; -import { existsSync, realpathSync, readFileSync, statSync } from "node:fs"; +import { resolve, isAbsolute, join, dirname, delimiter, relative, sep } from "node:path"; +import { closeSync, constants, existsSync, fstatSync, openSync, realpathSync, readFileSync, statSync } from "node:fs"; import { createRequire } from "node:module"; +import { QUALIFIED_ACPX_PROFILES } from "../acpx/qualified-profiles.js"; import { githubCredentialEnvironmentKeys, @@ -54,8 +55,12 @@ export function codexExecutableReadOnlyRoots(source: NodeJS.ProcessEnv, command // The npm entrypoint launches the platform package's native executable, // which Codex invokes again inside bwrap when starting each shell command. const platformPackage = `@openai/codex-${process.platform}-${process.arch}`; - if (manifest.optionalDependencies?.[platformPackage]) { - const platformManifest = createRequire(manifestPath).resolve(`${platformPackage}/package.json`); + const normalizedPlatformManifest = manifest.optionalDependencies === undefined + ? normalizedCodexPlatformManifest(manifestPath, platformPackage) + : undefined; + if (manifest.optionalDependencies?.[platformPackage] || normalizedPlatformManifest) { + const platformManifest = normalizedPlatformManifest + ?? createRequire(manifestPath).resolve(`${platformPackage}/package.json`); const packageRoot = realpathSync(dirname(platformManifest)); const vendor = realpathSync(resolve(packageRoot, "vendor")); if (vendor.startsWith(`${packageRoot}/`)) add(vendor); @@ -68,6 +73,79 @@ export function codexExecutableReadOnlyRoots(source: NodeJS.ProcessEnv, command return [...roots]; } +/** The published server, rather than its bundled JS wrapper, declares npm's host package. */ +function normalizedCodexPlatformManifest(runtimeManifest: string, platformPackage: string): string | undefined { + const profile = QUALIFIED_ACPX_PROFILES.codex; + const runtime = readCodexPackageManifest(runtimeManifest); + if (runtime.name !== profile.agentRuntimePackage || runtime.optionalDependencies !== undefined) return undefined; + for (let root = dirname(dirname(runtimeManifest)), depth = 0; depth < 24; depth += 1) { + const serverManifest = resolve(root, "package.json"); + if (existsSync(serverManifest)) { + const server = readCodexPackageManifest(serverManifest); + if (server.name === "@paperclipai/server") { + const bridgeDeclaration = server.dependencies?.[profile.agentServerPackage]; + const platformDeclaration = server.optionalDependencies?.[platformPackage]; + const declarationPrefix = `npm:${profile.agentRuntimePackage}@`; + const declarationSuffix = `-${process.platform}-${process.arch}`; + if (typeof bridgeDeclaration !== "string" || !bridgeDeclaration.trim() + || typeof platformDeclaration !== "string" || !platformDeclaration.startsWith(declarationPrefix) + || !platformDeclaration.endsWith(declarationSuffix) + || !/^[0-9A-Za-z][0-9A-Za-z.+-]*$/.test(platformDeclaration.slice(declarationPrefix.length, -declarationSuffix.length)) + || !codexPathInside(resolve(root, "node_modules"), runtimeManifest)) return undefined; + const bridgeManifest = createRequire(serverManifest).resolve(`${profile.agentServerPackage}/package.json`); + if (!codexPathInside(resolve(root, "node_modules"), bridgeManifest) + || realpathSync(bridgeManifest) !== bridgeManifest) return undefined; + const bridge = readCodexPackageManifest(bridgeManifest); + if (bridge.name !== profile.agentServerPackage + || createRequire(bridgeManifest).resolve(`${profile.agentRuntimePackage}/package.json`) !== runtimeManifest) return undefined; + const runtimeSelection = createRequire(runtimeManifest).resolve(`${platformPackage}/package.json`); + const selected = createRequire(serverManifest).resolve(`${platformPackage}/package.json`); + if (runtimeSelection !== selected || realpathSync(selected) !== selected) return undefined; + const slot = (createRequire(serverManifest).resolve.paths(platformPackage) ?? []).some(directory => { + try { + return (dirname(directory) === root || codexPathInside(dirname(directory), root)) + && realpathSync(directory) === directory + && selected === resolve(directory, platformPackage, "package.json") + && realpathSync(dirname(selected)) === dirname(selected); + } catch { return false; } + }); + if (!slot) return undefined; + const native = readCodexPackageManifest(selected); + if ((native.name !== platformPackage && native.name !== profile.agentRuntimePackage) + || !Array.isArray(native.os) || !native.os.includes(process.platform) + || !Array.isArray(native.cpu) || !native.cpu.includes(process.arch)) return undefined; + return selected; + } + } + const parent = dirname(root); + if (parent === root) break; + root = parent; + } + return undefined; +} + +function codexPathInside(root: string, path: string): boolean { + const value = relative(root, path); + return value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value); +} + +function readCodexPackageManifest(path: string): Record & { + dependencies?: Record; + optionalDependencies?: Record; +} { + const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatSync(descriptor, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > 256n * 1024n) throw new Error("Unsafe Codex package manifest"); + const bytes = readFileSync(descriptor), after = fstatSync(descriptor, { 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) throw new Error("Codex package manifest changed during lookup"); + const value: unknown = JSON.parse(bytes.toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Codex package manifest"); + return value as ReturnType; + } finally { closeSync(descriptor); } +} + export const CODEX_SKILLLESS_PERMISSION_PROFILE = "paperclip-runner-workspace-only"; export const CODEX_PLANNING_PERMISSION_PROFILE = 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 2986309cafb..ad678244262 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts @@ -351,12 +351,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 73b4bd6dddb..0eb5ccf2579 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/dot/runnerd-dot-driver.test.ts b/packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.test.ts index 1ac5da7d976..72bd5aa98d5 100644 --- a/packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.test.ts @@ -1,5 +1,6 @@ +import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { expect, it, vi } from "vitest"; @@ -125,7 +126,7 @@ it.each(["shutdown", "unexpected exit"] as const)("classifies a real Rust %s bef } }, 45000); -it("reattaches the same Rust bridge without duplicating a settled operation and refuses a lost checkpoint", async () => { +it.each(["local", "custom local directory", "external launcher"])("reattaches the %s Rust bridge without duplicate operations and refuses lost checkpoints", async launch => { const root = await mkdtemp(join(tmpdir(), "dot-driver-recovery-")); await writeFile(join(root, "AGENTS.md"), "Use only the synthetic counter."); const input = execution(root); @@ -151,6 +152,26 @@ it("reattaches the same Rust bridge without duplicating a settled operation and port: { dispatch: async event => { dispatches.push(event); }, settle: async event => { settlements.set(String(event.payload.requestId), event.payload.outcome); }, attach: async callback => { send = callback; return async () => { send = undefined; }; } }, }; + const runnerState = launch === "local" ? join(root, "state/runner") : join(root, "external-runtime/runner"); + if (launch === "custom local directory") options.runnerStateDirectory = runnerState; + if (launch === "external launcher") { + await mkdir(runnerState, { recursive: true }); + options.runnerStateDirectory = runnerState; + options.readProviderState = async () => { + try { return JSON.parse(await readFile(join(runnerState, "dot-provider-state.json"), "utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + }; + options.runnerProcessLauncher = spec => { + expect(spec.environment).not.toHaveProperty("OPENAI_API_KEY"); + expect(spec.args).toContain(runnerState); + const child = spawn(spec.command, [...spec.args], { cwd: runnerState, env: spec.environment, stdio: "ignore" }); + const startedAt = new Date().toISOString(); + spawned = { pid: child.pid!, processGroupId: null, startedAt }; + return { child, startedAt, completion: new Promise((resolve, reject) => { + child.once("error", reject); child.once("exit", (code, signal) => resolve({ code, signal, stdout: "", stderr: "" })); + }) }; + }; + } const driver = new RunnerdDotDriver(options); let session = await driver.openSession({ runId: input.binding.runId, normalizedSessionId: input.session.normalizedSessionId! }); const operation = (action: ExternalProviderOperation["action"], args: Record): ExternalProviderOperation => ({ @@ -176,7 +197,7 @@ it("reattaches the same Rust bridge without duplicating a settled operation and expect(dispatches).toHaveLength(1); await session.interrupt!({ reason: "Synthetic test complete" }); await session.close({ reason: "Synthetic test complete" }); - await rm(join(root, "state/runner/dot-provider-state.json")); + await rm(join(runnerState, "dot-provider-state.json")); expect(await recoveredDriver.recoverSession(snapshot, { signal: new AbortController().signal })).toMatchObject({ recovered: false }); } finally { await session.close({ reason: "Test cleanup", force: true }).catch(() => {}); diff --git a/packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.ts b/packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.ts index 91d0e2b2b0d..d56a15a33f4 100644 --- a/packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.ts +++ b/packages/paperclip-runner/src/drivers/dot/runnerd-dot-driver.ts @@ -19,6 +19,10 @@ export interface RunnerdDotDriverOptions { identity: DurableRecoveryIdentity; port: ExternalProviderPort; runnerBinary?: string; + runnerStateDirectory?: string; + runnerProcessLauncher?: CapabilityRunnerdCodexTransportOptions["runnerProcessLauncher"]; + /** Reads the checkpoint from the execution target. Missing state cannot authorize replay. */ + readProviderState?: () => Promise | null>; controlPlaneRegistration?: CapabilityRunnerdCodexTransportOptions["controlPlaneRegistration"]; onSpawn?: CodexNativeSessionBackendOptions["onSpawn"]; dynamicTools?: CodexNativeSessionBackendOptions["dynamicTools"]; @@ -59,10 +63,10 @@ export class RunnerdDotDriver implements HarnessDriver { || snapshot.driverSessionId !== this.options.identity.normalizedSessionId || snapshot.providerSessionId != null) { return { recovered: false, reason: "Dot bridge checkpoint identity mismatch" }; } - const path = resolve(this.options.stateDirectory, "runner/dot-provider-state.json"); - if (!existsSync(path) || statSync(path).size > 32 * 1024 * 1024) return { recovered: false, reason: "Dot bridge checkpoint missing; external work requires reconciliation" }; - let saved: Record; - try { saved = JSON.parse(readFileSync(path, "utf8")); } catch { return { recovered: false, reason: "Dot bridge checkpoint invalid; external work requires reconciliation" }; } + let saved: Record | null; + try { saved = await readProviderCheckpoint(this.options); } + catch { return { recovered: false, reason: "Dot bridge checkpoint invalid; external work requires reconciliation" }; } + if (!saved) return { recovered: false, reason: "Dot bridge checkpoint missing; external work requires reconciliation" }; if (saved.schema !== "paperclip.runner.dot-provider-state.v1" || saved.runId !== snapshot.runId || saved.sessionId !== snapshot.driverSessionId || saved.turnId !== this.options.identity.turnId) { return { recovered: false, reason: "Dot bridge checkpoint authority mismatch" }; } @@ -71,6 +75,14 @@ export class RunnerdDotDriver implements HarnessDriver { } } +async function readProviderCheckpoint(options: RunnerdDotDriverOptions): Promise | null> { + if (options.readProviderState) return options.readProviderState(); + const path = resolve(options.runnerStateDirectory ?? resolve(options.stateDirectory, "runner"), "dot-provider-state.json"); + if (!existsSync(path)) return null; + if (statSync(path).size > 32 * 1024 * 1024) throw new Error("dot_provider_checkpoint_too_large"); + return JSON.parse(readFileSync(path, "utf8")); +} + class RunnerdDotSession implements HarnessSession { #core: DurablePrpControlPlane | null = null; #process: RunnerProcessHandle | null = null; @@ -86,8 +98,8 @@ class RunnerdDotSession implements HarnessSession { async open(signal?: AbortSignal) { const o = this.options; mkdirSync(o.stateDirectory, { recursive: true, mode: 0o700 }); - const runnerState = resolve(o.stateDirectory, "runner"); - mkdirSync(runnerState, { recursive: true, mode: 0o700 }); + const runnerState = o.runnerStateDirectory ?? resolve(o.stateDirectory, "runner"); + if (!o.runnerProcessLauncher) mkdirSync(runnerState, { recursive: true, mode: 0o700 }); const binary = o.runnerBinary ?? defaultCapabilityRunnerdBinary(); const artifact = readRunnerdArtifactBinding(binary); const core = this.#core = new DurablePrpControlPlane({ @@ -125,7 +137,8 @@ class RunnerdDotSession implements HarnessSession { if (!registration) await core.start(); signal?.throwIfAborted(); if (!o.adoptExistingRunner) { - this.#process = spawnRunner({ connectUrl: registration?.connectUrl ?? core.connectUrl, + this.#process = spawnRunner({ + processLauncher: o.runnerProcessLauncher, connectUrl: registration?.connectUrl ?? core.connectUrl, connection: registration?.connection, stateDirectory: runnerState, identity: o.identity, ticket: core.issueBootstrapTicket(60_000), runnerBinaryPath: binary, runnerVersion: artifact.version, runnerDigest: artifact.digest, @@ -135,7 +148,7 @@ class RunnerdDotSession implements HarnessSession { environment: { PATH: process.env.PATH }, }); const pid = this.#process.child.pid; - if (pid) await o.onSpawn?.({ pid, processGroupId: this.#process.processGroupId ?? null, + if (pid && !o.runnerProcessLauncher) await o.onSpawn?.({ pid, processGroupId: this.#process.processGroupId ?? null, startedAt: this.#process.startedAt ?? new Date().toISOString() }); void this.#process.completion.then(result => { if (this.#closed) return; @@ -152,9 +165,16 @@ class RunnerdDotSession implements HarnessSession { } else if (!await o.adoptExistingRunner.isAlive()) { throw new Error("dot_runner_adoption_failed"); } await registration?.activate?.(); await registration?.ready?.(); + if (registration?.failure) void registration.failure.catch(error => { + if (!this.#closed) { this.#failure = error instanceof Error ? error : new Error("dot_runner_transport_failed"); this.#wake(); } + }); + const checkpoint = await readProviderCheckpoint(o); + if (checkpoint && (checkpoint.schema !== "paperclip.runner.dot-provider-state.v1" + || checkpoint.runId !== o.identity.runId || checkpoint.sessionId !== o.identity.normalizedSessionId + || checkpoint.turnId !== o.identity.turnId)) throw new Error("dot_runner_checkpoint_authority_mismatch"); this.#detachPort = await o.port.attach(async operation => { await this.#command("external_provider.operation", { ...operation }, `dot_operation_${operation.requestId}`); - }, () => this.interrupt(), existsSync(resolve(runnerState, "dot-provider-state.json"))); + }, () => this.interrupt(), !!checkpoint); await this.#command("run.prepare", { provider: { kind: "openai_dot", ...o.execution.provider.binding, instructions: `${nativeSystemInstructions(o.execution)}\n\nAccept the assignment before calling tools. Invoke paperclip_finish or paperclip_block, then submit the same structured result with paperclip_dot_finish. Keep request IDs stable across retries.` }, diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 2641cf334c6..fe7e4c70978 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -45,6 +45,7 @@ export { export type { DurableRecoveryIdentity } from "./control-plane/prp-transport-types.js"; export * from "./drivers/codex/app-server-transport.js"; export * from "./drivers/codex/codex-app-server-driver.js"; +export { resolveCodexCommand, resolvePinnedCodexCommand } from "./drivers/codex/codex-command.js"; export * from "./drivers/opencode/opencode-server-driver.js"; export * from "./drivers/opencode/mcp-bridge.js"; export * from "./drivers/acpx/qualified-profiles.js"; @@ -72,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"; @@ -84,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 00000000000..4eb952ed63d --- /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 00000000000..f31b0c301f4 --- /dev/null +++ b/packages/paperclip-runner/src/live/cursor-usage-notice.ts @@ -0,0 +1,98 @@ +import type { CanonicalProviderEvent } from "../provider-events.js"; + +const SUMMARY = "Cursor reported partial native counters. These observations are not authoritative token usage or billing cost."; +const PROVENANCE = "Cursor native turnEnded observations; partial, unsummed, unverified counter semantics"; +const REASONS = new Set([ + "native_counter_semantics_unverified", "native_counters_missing", "invalid_native_counter", + "multiple_terminal_observations", "native_terminal_not_observed", "observation_limit_reached", + "child_run_attribution_unverified", +]); +const COUNTERS = new Set(["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"]); +const object = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); +const positive = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0; + +/** + * The eval transport wraps rich events in a Codex-shaped notification. Admit + * only the closed Cursor diagnostic, never arbitrary canonical provider data. + * This notice no longer contains the private prompt/request receipt IDs, so + * validate its projected shape without inventing a persisted usage receipt. + * Neither these unsummed observations nor their absence are accounting facts. + */ +export function cursorUsageNotice( + raw: unknown, + owner: { provider?: string; agent?: string; threadId: string; turnId: string | null }, +): CanonicalProviderEvent | null { + try { + if (owner.provider !== "acpx" || owner.agent !== "cursor" || !owner.threadId || !owner.turnId + || !object(raw) || !exact(raw, ["threadId", "turnId", "eventType", "itemId", "payload"]) + || raw.threadId !== owner.threadId || raw.turnId !== owner.turnId + || raw.eventType !== "provider.notice.recorded" || typeof raw.itemId !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(raw.itemId)) return null; + // Runnerd's envelope item is the durable authority item. The native notice + // keeps its own display identity inside the payload; these are independent. + const p = raw.payload; + if (!object(p) || !exact(p, ["schema", "noticeId", "severity", "category", "scope", "recoverable", "userActionable", "summary", "details"]) + || p.schema !== "paperclip.provider.notice.v1" || typeof p.noticeId !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(p.noticeId) + || p.severity !== "info" || p.category !== "cursor_native_usage_observed" || p.scope !== "turn" + || p.recoverable !== true || p.userActionable !== false || p.summary !== SUMMARY + || !Array.isArray(p.details) || p.details.length < 3 || p.details.length > 11) return null; + // Bound strings before JSON parsing/cloning. The collector's complete + // envelope is <=16 KiB; its observation strings cannot exceed that budget. + const details: Array<{ name: string; value: string }> = []; + let bytes = 0; + for (const detail of p.details) { + if (!object(detail) || !exact(detail, ["name", "value"]) || typeof detail.name !== "string" + || detail.name.length > 64 || typeof detail.value !== "string" || detail.value.length > 4_000) return null; + bytes += Buffer.byteLength(detail.value, "utf8"); + if (bytes > 16_384) return null; + details.push({ name: detail.name, value: detail.value }); + } + if (details[0]!.name !== "Provenance" || details[0]!.value !== PROVENANCE + || details[1]!.name !== "Partial reasons" || details[2]!.name !== "Collector truncated" + || !["true", "false"].includes(details[2]!.value)) return null; + const reasons = details[1]!.value.split(", "); + if (!reasons.includes("native_counter_semantics_unverified") || reasons.length > REASONS.size + || new Set(reasons).size !== reasons.length || !reasons.every(reason => REASONS.has(reason))) return null; + const invocations = new Map(); + let count = 0; + const retained = details.slice(0, 3); + for (let group = 3; group < details.length; group++) { + const values: unknown = JSON.parse(details[group]!.value); + if (!Array.isArray(values) || values.length < 1 || values.length > 8 + || (group < details.length - 1 && values.length !== 8)) return null; + if (details[group]!.name !== `Native observations ${count + 1}-${count + values.length}`) return null; + const observations = []; + for (const value of values) { + if (!object(value) || !exact(value, ["invocationId", "role", "nativeRun", "sequence", "counters"]) + || typeof value.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(value.invocationId) + || (value.role !== "parent" && value.role !== "child") || !positive(value.nativeRun) + || !positive(value.sequence) || !object(value.counters)) return null; + const counters: Record = {}; + for (const [key, number] of Object.entries(value.counters)) { + if (!COUNTERS.has(key) || !Number.isSafeInteger(number) || Number(number) < 0) return null; + counters[key] = Number(number); + } + const prior = invocations.get(value.invocationId); + if (prior && (prior.role !== value.role || prior.nativeRun !== value.nativeRun || prior.sequence >= value.sequence)) return null; + invocations.set(value.invocationId, { role: value.role, nativeRun: value.nativeRun, sequence: value.sequence }); + observations.push({ invocationId: value.invocationId, role: value.role, nativeRun: value.nativeRun, sequence: value.sequence, counters }); + } + count += observations.length; + if (count > 64) return null; + retained.push({ name: details[group]!.name, value: JSON.stringify(observations) }); + } + return { + eventType: "provider.notice.recorded", itemId: raw.itemId, + payload: { schema: "paperclip.provider.notice.v1", noticeId: p.noticeId, severity: "info", + category: "cursor_native_usage_observed", scope: "turn", recoverable: true, userActionable: false, + summary: SUMMARY, details: retained }, + }; + } catch { + // Optional diagnostics, including malformed JSON, cannot poison settlement. + return null; + } +} diff --git a/packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs b/packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs new file mode 100644 index 00000000000..9cf8cef901d --- /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 4653a8f8332..ab80904063e 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -4,11 +4,10 @@ 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 { probeCopilotMetadata, type CopilotMetadataResult } from "../drivers/copilot-metadata-probe.js"; - -export { validateCopilotMetadata } from "../drivers/copilot-metadata-probe.js"; +export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "../drivers/acpx/installation-integrity.js"; +export { probeCopilotMetadata, validateCopilotMetadata, type CopilotMetadataResult } from "../drivers/copilot-metadata-probe.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 00000000000..4cc8a684444 --- /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 00000000000..66abe1322dc --- /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 c7e881670cc..27fdc73202d 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 78071fd47f4..b79755a6685 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 b760a59e099..826d5c466fc 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 ff1e398003f..f3db235e71a 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, @@ -24,7 +26,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ControlPlanePort } from "../contracts/control-plane-port.js"; import type { NativeExecutionInputV1 } from "../contracts/native-execution.js"; import type { @@ -44,12 +46,14 @@ import { parsePaperclipQuestionSet } from "../contracts/question-set.js"; import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js"; import { DurablePrpControlPlane } from "../control-plane/durable-prp-control-plane.js"; import * as durableControlPlane from "../control-plane/durable-prp-control-plane.js"; +import * as codexCommandRuntime from "../drivers/codex/codex-command.js"; import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, canonicalNativeRuntimeContextDigest, + composeNativeSystemInstructions, nativeRuntimePromptDigest, type NativeRuntimeContextSnapshot, } from "../contracts/runtime-context.js"; @@ -587,6 +591,120 @@ it.each(["claude", "codex", "cursor", "pi"])("preserves %s's cross-run context w { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "new-run", normalizedSessionId: "session", turnId: "turn", itemId: "item" }, null, undefined, undefined); expect(payload.provider).toMatchObject({ ...provider, runId: "new-run" }); + }); + +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", () => { @@ -659,6 +777,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([ @@ -669,6 +790,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) => { @@ -1335,6 +1457,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" }, @@ -1834,12 +1992,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", ], }, @@ -1975,6 +2131,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({ @@ -2005,6 +2176,108 @@ it("denies the isolated Codex home without denying a remote execution workspace" expect(serialized).toContain('\":workspace_roots\"={\".\"=\"write\"}'); }); +it.each(["installed", "explicit"] as const)("records the selected %s Codex command before launch", async (selection) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-codex-command-selection-")); + const command = join(root, "codex"); + const runnerBinary = join(root, "runnerd"); + await writeFile(command, "#!/bin/sh\nexit 99\n", { mode: 0o700 }); + await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 }); + const resolver = vi.spyOn(codexCommandRuntime, "resolveCodexCommand").mockReturnValue(command); + let template: Record | null = null; + const launch = vi.fn(() => { throw new Error("A provider process must not start in this fixture"); }); + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", + environment: { PATH: "/missing-ambient-codex" }, + ...(selection === "explicit" ? { codexCommand: command } : {}), + controlPlaneRegistration: async (authority) => { + template = structuredClone(authority.store.state.runAttachTemplate!); + throw new Error("fixture_stop_before_runner_launch"); + }, + runnerProcessLauncher: launch, + }); + try { + await expect(transport.request("thread/start", { cwd: root, model: "gpt-6.1-sol", dynamicTools: [] })) + .rejects.toThrow("fixture_stop_before_runner_launch"); + expect(template).toMatchObject({ provider: { command, model: "gpt-6.1-sol" } }); + expect(JSON.stringify(template)).toContain(`${JSON.stringify(command).replaceAll('"', '\\"')}=\\"read\\"`); + if (selection === "explicit") expect(resolver).not.toHaveBeenCalled(); + else expect(resolver).toHaveBeenCalledExactlyOnceWith(undefined, { PATH: "/missing-ambient-codex" }, root); + expect(launch).not.toHaveBeenCalled(); + } finally { + resolver.mockRestore(); + await transport.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +it("rejects remote Codex without a guest executable before resolving controller dependencies", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-codex-command-")); + const runnerBinary = join(root, "runnerd"); + await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 }); + const resolver = vi.spyOn(codexCommandRuntime, "resolveCodexCommand").mockImplementation(() => { + throw new Error("Controller package resolution must not authorize a guest executable"); + }); + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", + runnerFilesystemRoot: "/workspaces/task/.paperclip-runtime/session", + }); + try { + await expect(transport.request("thread/start", { cwd: "/workspaces/task", model: "gpt-6.1-sol", dynamicTools: [] })) + .rejects.toThrow("runner_remote_provider_artifact_incompatible: remote Codex omitted its qualified guest executable"); + expect(resolver).not.toHaveBeenCalled(); + } finally { + resolver.mockRestore(); + await transport.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +it.each(["missing", "changed"] as const)("reuses the recorded Codex command when dependency discovery is %s during resume", async (discovery) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-codex-recorded-command-")); + const command = join(root, "recorded-codex"); + const replacement = join(root, "replacement-codex"); + const runnerBinary = join(root, "runnerd"); + await writeFile(command, "#!/bin/sh\nexit 99\n", { mode: 0o700 }); + await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 }); + const priorIdentity = { runnerInstanceId: "runner-command", environmentLeaseId: "lease-command", normalizedSessionId: "session-command", + runId: "run-prior", turnId: "turn-prior", itemId: "item-prior" }; + const nextIdentity = { ...priorIdentity, runId: "run-next", turnId: "turn-next", itemId: "item-next" }; + const core = new DurablePrpControlPlane({ stateDirectory: join(root, "control-plane"), identity: priorIdentity, + expectedRunnerVersion: "fixture", expectedRunnerDigest: "sha256:" + createHash("sha256").update(await readFile(runnerBinary)).digest("hex") }); + core.persistRunAttachTemplate({ provider: { kind: "codex", command, args: [], model: "gpt-6.1-sol" }, workspace: { cwd: root } }); + await core.stop(); + await mkdir(join(root, "runner"), { mode: 0o700 }); + await writeFile(join(root, "runner", "runner-state.json"), JSON.stringify({ schema: "paperclip.runner.durable.state.v1", ...priorIdentity, lifecycle: "suspended" }), { mode: 0o600 }); + const resolver = vi.spyOn(codexCommandRuntime, "resolveCodexCommand").mockImplementation(() => { + if (discovery === "missing") throw new Error("The current dependency is missing"); + return replacement; + }); + let template: Record | null = null; + const launch = vi.fn(() => { throw new Error("A provider process must not start in this fixture"); }); + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", prpIdentity: nextIdentity, + environment: { PATH: "/missing-ambient-codex" }, + controlPlaneRegistration: async (authority) => { + template = structuredClone(authority.store.state.commands.find(command => command.type === "run.attach")?.payload ?? null); + throw new Error("fixture_stop_before_resumed_runner_launch"); + }, + runnerProcessLauncher: launch, + }); + try { + await expect(transport.request("thread/read", { threadId: "provider-prior" })) + .rejects.toThrow("fixture_stop_before_resumed_runner_launch"); + expect(template).toMatchObject({ provider: { command, model: "gpt-6.1-sol" } }); + expect(JSON.stringify(template)).toContain(`${JSON.stringify(command).replaceAll('"', '\\"')}=\\"read\\"`); + expect(JSON.stringify(template)).not.toContain(replacement); + expect(resolver).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + } finally { + resolver.mockRestore(); + await transport.close(); + await rm(root, { recursive: true, force: true }); + } +}); + it("rejects remote OpenCode before spawn when provider-pack paths are absent", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-pack-")); const { transport } = createCapabilityRunnerdCodexTransport({ @@ -4551,9 +4824,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[] = []; @@ -4689,6 +4967,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) => { @@ -4762,7 +5068,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, )!; @@ -6196,6 +6511,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-"), @@ -7375,12 +7759,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, @@ -7435,6 +7821,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 }); } @@ -7613,7 +8005,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 @@ -7702,3 +8094,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 1fe263278ac..18d14c588dc 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,11 +1,15 @@ +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"; import { RunnerdTraceFrameIndex } from "./runnerd-trace-frame-index.js"; import { waitForWarmAttachmentReadiness } from "./warm-attachment-readiness.js"; import { codexExecutableReadOnlyRoots } from "../drivers/codex/codex-security-config.js"; +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, @@ -98,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( @@ -594,7 +598,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) @@ -611,7 +617,8 @@ function providerDrainStateFromSnapshot(state: Record): { pendingEventCount: pending + queued, activeProviderTurnId, providerSettled: - activeProviderTurnId === null && state.ambiguousTurnStartPending !== true, + activeProviderTurnId === null && state.ambiguousTurnStartPending !== true + && state.providerExitUnconfirmed !== true, }; } @@ -848,6 +855,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, @@ -938,8 +953,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, @@ -958,6 +977,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, @@ -1153,6 +1202,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; @@ -1176,6 +1227,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. */ @@ -3548,9 +3600,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"); } @@ -3559,7 +3613,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"); } @@ -3748,7 +3802,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", @@ -3809,6 +3866,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 @@ -3906,6 +3972,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; } @@ -4036,7 +4110,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"); @@ -4224,7 +4307,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 = @@ -4237,9 +4320,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; @@ -4258,7 +4349,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; } @@ -4365,6 +4458,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) && @@ -4376,7 +4475,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 @@ -4395,7 +4494,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())), ); @@ -4411,6 +4510,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }, readRunnerState: async () => { const state = await this.#readDurableRunnerState(); + lastSuspensionState = state; assertSuspendedRunnerState(state, this.#core!.store.state.identity); return state; }, @@ -4419,6 +4519,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", ); @@ -4529,6 +4644,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const settlement = { runnerSuspended, providerDrained, + suspensionState, semanticTools: this.#core?.semanticToolSettlementDiagnostics(), finalProviderState, }; @@ -4596,6 +4712,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } const provider = this.options.provider ?? "codex"; const sourceRuntimeContext = this.options.runtimeContext ?? null; + if (provider === "codex" && this.options.runnerFilesystemRoot && !this.options.codexCommand) { + throw new Error("runner_remote_provider_artifact_incompatible: remote Codex omitted its qualified guest executable; verify the selected environment's Codex artifacts"); + } const runtimeContext = this.options.runnerRuntimeContext ?? sourceRuntimeContext; const localRuntimeContextPath = resolve(this.#root, "runtime-context.json"); @@ -4645,6 +4764,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { )); const providerNodeCommand = this.options.providerNodeCommand ?? process.execPath; + const effectiveCodexCommand = provider === "codex" + ? this.options.codexCommand ?? resolveCodexCommand(undefined, this.options.environment, String(params.cwd ?? tmpdir())) + : undefined; const opencodeExecutable = provider === "opencode" ? (this.options.opencodeCommand ?? @@ -4788,6 +4910,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, ), @@ -4849,7 +4972,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { command: provider === "opencode" ? providerNodeCommand - : (this.options.codexCommand ?? "codex"), + : effectiveCodexCommand!, args: provider === "opencode" ? [opencodeProxyPath] @@ -4857,7 +4980,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { createRunnerdCodexAppServerArgs({ environment: this.options.environment, codexHome, - codexCommand: this.options.codexCommand, + codexCommand: effectiveCodexCommand, instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots( @@ -4924,6 +5047,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", @@ -4978,9 +5102,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); @@ -5339,12 +5463,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (provider === "codex") { // These controller-owned, token-free paths belong to the new run. // Keep the durable provider profile and thread identity unchanged. + const recordedCommand = record(runAttachTemplate.provider).command; runAttachTemplate.runtimeLaunchArgs = this.options.codexArgs ?? createRunnerdCodexAppServerArgs({ environment: this.options.environment, codexHome, - codexCommand: this.options.codexCommand, + codexCommand: typeof recordedCommand === "string" ? recordedCommand : this.options.codexCommand, instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots(this.options.environment), @@ -5548,6 +5673,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({ @@ -5646,11 +5773,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 @@ -5658,7 +5787,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", @@ -5676,6 +5805,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { !this.#checkpointProviderIdentityConfirmed ? "session.resumed" : undefined, + admissionDeadline, ); this.#startupComplete = true; this.#diagnostic( @@ -5943,12 +6073,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`); @@ -6003,12 +6134,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 && @@ -6030,28 +6168,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, + }); } @@ -6468,6 +6598,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) { @@ -6974,6 +7106,7 @@ export const runnerdRecoveryInternals = Object.freeze({ recoveredRunAttachment, releaseRunnerProcessOwnership, runnerCloseDeadlines, + runnerCloseGraceMs, rotatedRunAttachPayload, rotateExternalAuthorityEpoch, turnStartCommandResultValid, @@ -7007,3 +7140,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 18f17f27466..9d9efa46acf 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 7b47ad10751..45bd5bdf832 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 00000000000..d5c36f1561e --- /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 00000000000..ff8e9c07267 --- /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 00000000000..156128effe4 --- /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 00000000000..c302868de63 --- /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 00000000000..d3502462644 --- /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 00000000000..41029c4b0b4 --- /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 00000000000..73921f1b6e2 --- /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 00000000000..37467da7e4d --- /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 00000000000..d0602bcc46d --- /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 00000000000..8819e06f70b --- /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 00000000000..a8b6094b036 --- /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 00000000000..b292668a165 --- /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 00000000000..35e2447d4cc --- /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 00000000000..9ed5737d323 --- /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 00000000000..b2708858384 --- /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 00000000000..81a8302c9c5 --- /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 00000000000..09aeba44266 --- /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 00000000000..2cfffbbef78 --- /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 00000000000..962f55425c3 --- /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 00000000000..2ca25f6644d --- /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 00000000000..3c598cd1cde --- /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 00000000000..eb1a4e094e7 --- /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 00000000000..bb1f7caa450 --- /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 00000000000..c03ec81101c --- /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 00000000000..5e1c25a1e8f --- /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 00000000000..5ab8fc6d694 --- /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 00000000000..9c251613f09 --- /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 00000000000..93b68d74a2b --- /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 00000000000..784e8816b78 --- /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 00000000000..8eb2c022cb8 --- /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 00000000000..35314d2ed1e --- /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 00000000000..685d8fb3e75 --- /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 00000000000..cbcd7257672 --- /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 00000000000..85d8e82f8b0 --- /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 00000000000..aced3bc0660 --- /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 00000000000..0dbc04a0421 --- /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 00000000000..92aeb214edf --- /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 00000000000..11bb6323f5c --- /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 00000000000..b30a12edfdc --- /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 00000000000..489f751f187 --- /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 00000000000..70719d93d95 --- /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 00000000000..434a7c0ad83 --- /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 00000000000..bf1e7ce1576 --- /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 00000000000..a86fc915920 --- /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 00000000000..8c917d959d8 --- /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 00000000000..c7bf2776f73 --- /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 00000000000..0633e0f144d --- /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 00000000000..f94e67713b5 --- /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 00000000000..b0f15e72f6f --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/thinking-modes.v13.darwin-arm64.json @@ -0,0 +1,177 @@ +{ + "schema": "paperclip.pi-thinking-mode-proof.v1", + "status": "provider-free-compatibility-passed-not-paid-qualification", + "sourceBase": "b70cf84ea6cb20095aaeebc6af7f025ee6fdce73", + "profileDigest": "sha256:fe1e6da01b2a9e4c691ca27cf689d2d6de846a93be6b23fc1e103c9addd7b177", + "piVersion": "1.0.0", + "wrapperVersion": "0.0.33", + "platform": "darwin-arm64", + "patchSha256": "8b3dbb7e08c6c356e81624b9afcab13827269579cef085db15fdec0ef72aedb3", + "wrapperSha256": "8ef777e9e6a3cef37775bab2b800e4931330c23a0b151ce07fdc5b943f28435d", + "helperSha256": "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + "testFixtureSha256": "cd66c8afb34a6fc54ead7dd939470b8f01344914d0513db58d3f6e816140a45d", + "privateReceiptSha256": "a2e03c0ee4a07491ee5efda8e9dc251f88d52fcbb3d845b16f13376ec345bee3", + "externalProviderCalls": 0, + "syntheticLoopbackPrompts": 2, + "afterModelSelection": { + "type": "select", + "id": "thought_level", + "category": "thought_level", + "name": "Thinking", + "description": "Set the reasoning effort for this session", + "currentValue": "high", + "options": [ + { + "value": "off", + "name": "Thinking: off", + "description": null + }, + { + "value": "low", + "name": "Thinking: low", + "description": null + }, + { + "value": "high", + "name": "Thinking: high", + "description": null + }, + { + "value": "max", + "name": "Thinking: max", + "description": null + } + ] + }, + "lowAcknowledgement": { + "type": "select", + "id": "thought_level", + "category": "thought_level", + "name": "Thinking", + "description": "Set the reasoning effort for this session", + "currentValue": "low", + "options": [ + { + "value": "off", + "name": "Thinking: off", + "description": null + }, + { + "value": "low", + "name": "Thinking: low", + "description": null + }, + { + "value": "high", + "name": "Thinking: high", + "description": null + }, + { + "value": "max", + "name": "Thinking: max", + "description": null + } + ] + }, + "requestBodies": [ + { + "model": "deepseek/deepseek-v4-flash-0731", + "reasoning": { + "effort": "high" + } + }, + { + "model": "deepseek/deepseek-v4-flash-0731", + "reasoning": { + "effort": "low" + } + } + ], + "loadedModes": { + "currentModeId": "max", + "availableModes": [ + { + "id": "off", + "name": "Thinking: off", + "description": null + }, + { + "id": "low", + "name": "Thinking: low", + "description": null + }, + { + "id": "high", + "name": "Thinking: high", + "description": null + }, + { + "id": "max", + "name": "Thinking: max", + "description": null + } + ] + }, + "loadedThoughtLevel": { + "type": "select", + "id": "thought_level", + "category": "thought_level", + "name": "Thinking", + "description": "Set the reasoning effort for this session", + "currentValue": "max", + "options": [ + { + "value": "off", + "name": "Thinking: off", + "description": null + }, + { + "value": "low", + "name": "Thinking: low", + "description": null + }, + { + "value": "high", + "name": "Thinking: high", + "description": null + }, + { + "value": "max", + "name": "Thinking: max", + "description": null + } + ] + }, + "modeUpdates": [ + "high", + "low", + "max" + ], + "networkDenial": { + "deniedBeforeConnect": true, + "underlyingConnections": 0 + }, + "retirement": { + "allOwnedChildrenClosed": true, + "spawnErrors": 0, + "count": 2 + }, + "scratchRemoved": true, + "limits": [ + "The actual pinned Pi SDK/CLI and patched ACP wrapper were exercised against guarded loopback synthetic model responses.", + "This proves mode propagation and effective state, not paid model behavior, final platform packaging, complete runner admission or production readiness.", + "The prior paid question HTTP body was not captured. Its timeout cause remains unproven.", + "Three earlier private fixture teardown failures are retained and not counted as passes." + ], + "maintainedRegression": { + "path": "packages/paperclip-runner/test/pi-native-package-contract.test.mjs", + "sha256": "8a03f65dd5bc52634d683e2fa6157ae53c85063ac91647d1b936cdbd13b58a38", + "testName": "real Pi 1 serialized RPC thinking-modes through owned ACP/MCP bridge" + }, + "focusedSuites": { + "wrapper": 48, + "nativeSdk": 13, + "skips": 0, + "externalProviderCalls": 0 + } +} diff --git a/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs index 103557b4973..50bfcd016a7 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 9213288907e..8adbe3b46f7 100644 --- a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -47,6 +47,44 @@ test("the ACPX sidecar schema accepts each versioned message family", () => { } }); +test("Pi session admission requires an explicit supported thinking level including recovery", () => { + const open = (params) => ({ protocolVersion, id: 1, command: "session.open", params }); + for (const piThinkingLevel of ["off", "low", "high", "max"]) { + assert.equal(validate(open({ agent: "pi", piThinkingLevel })), true, JSON.stringify(validate.errors)); + assert.equal(validate(open({ agent: "pi", piThinkingLevel, expectedIdentity: null })), true); + assert.equal(validate(open({ agent: "pi", piThinkingLevel, expectedIdentity: { piThinkingLevel } })), true); + } + for (const piThinkingLevel of [undefined, null, "medium", "minimal", "xhigh", " LOW ", 1]) { + assert.equal(validate(open({ agent: "pi", piThinkingLevel })), false); + assert.equal(validate(open({ agent: "pi", piThinkingLevel: "low", expectedIdentity: { piThinkingLevel } })), false); + } + for (const agent of ["claude", "codex", "grok", "cursor", "copilot"]) { + assert.equal(validate(open({ agent })), true); + assert.equal(validate(open({ agent, expectedIdentity: null })), true); + assert.equal(validate(open({ agent, piThinkingLevel: "low" })), false); + assert.equal(validate(open({ agent, expectedIdentity: { piThinkingLevel: "low" } })), false); + } +}); + +test("public Pi descriptors preserve effective thinking levels without invalidating historical replay", async () => { + const descriptorSchema = JSON.parse(await readFile(new URL("../protocol/schemas/provider-descriptor.schema.json", import.meta.url), "utf8")); + const validateDescriptor = new Ajv2020({ allErrors: true, strict: true, strictRequired: false }).compile(descriptorSchema); + const historical = { + provider: "acpx", driver: "acpx_runtime", model: "model", executionKind: "local_process", + providerVersion: "0.13.1", agent: "pi", requestedModel: "model", acpProtocolVersion: 1, + agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", acpxRecordId: null, agentProcessId: null, + }; + assert.equal(validateDescriptor(historical), true, JSON.stringify(validateDescriptor.errors)); + for (const piThinkingLevel of ["off", "low", "high", "max"]) { + assert.equal(validateDescriptor({ ...historical, piThinkingLevel }), true); + } + for (const piThinkingLevel of ["medium", null, "xhigh", 1]) { + assert.equal(validateDescriptor({ ...historical, piThinkingLevel }), false); + } + assert.equal(validateDescriptor({ ...historical, agent: "copilot", piThinkingLevel: "low" }), false); + assert.equal(validateDescriptor({ ...historical, provider: "codex", driver: "codex_app_server", piThinkingLevel: "low" }), false); +}); + test("the ACPX sidecar schema shares the durable stable-identity boundary", () => { const longestTurnId = "t".repeat(240); assert.equal(validate({ ...messages[2], turnId: longestTurnId }), true); diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v13-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v13-identity.json new file mode 100644 index 00000000000..3f443926d73 --- /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-mainline-pi-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v14-mainline-pi-identity.json new file mode 100644 index 00000000000..74191266281 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v14-mainline-pi-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-mainline-pi-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v15-mainline-pi-identity.json new file mode 100644 index 00000000000..12ef2b3780d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v15-mainline-pi-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-mainline-pi-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v16-mainline-pi-identity.json new file mode 100644 index 00000000000..710031028ab --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v16-mainline-pi-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-mainline-pi-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v17-mainline-pi-identity.json new file mode 100644 index 00000000000..a35bae20552 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v17-mainline-pi-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-v34-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v34-identity.json new file mode 100644 index 00000000000..e65d6be0e4a --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v34-identity.json @@ -0,0 +1,81 @@ +{ + "commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 34, + "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": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "7fd06053db98cc763c7205424069a5286c9d4cfefb3d197a4a765c693dccb7e6", + "semanticDirectDriverSourceSha256": "77882e21ad45b66a688fd1933dbd4fc89fad38e8c1f092805cb9485bdd5223c5", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fe82ac2cbf194d31ac801de6a3cacfb165cb34146c5c26557f283b8c87a5f01e", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "a03575b3a737dea60da94cf0fa1feed80719a9aaa7f09456eb57a845d9e8e087", + "configuredEnvironmentRustSourceSha256": "55d44e48e217385b5c64b1b7d8df16dda62319ca3fb524e452fd78c9ed804d3b", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "aac818c1cf50a25899ae95cafc70f093c8cd8af37d19f88cbb7847b4d202d2b6", + "nativeRuntimeContextSourceSha256": "0b350f5f4044f342b974254738bab45a470c24db9352b04c92d785d26e7853c8", + "nativeSessionContractSourceSha256": "93251b1eadc686e9d8339d4dfc50b995a3d0a9e6ad8aedc6f2bdf7e30c16138d", + "harnessSessionContractSourceSha256": "9e3d62b712cae91168c96d11ff850b97a144809b5cddfe08ec9a91229e9bfc40", + "harnessBackendSourceSha256": "46fd6523f986d5406eca3b420fe920f1d51a6435fb7afc2acf4d21be13af447f", + "harnessSessionSourceSha256": "4dff9bb3370c5560cfc910a7ee814a27725b469c3b1b5dc021e3c0b158ebe746", + "transportContractSourceSha256": "5cd208fe11c3bedd476494b0609fb32efbabe4a1170e2c5854d0aa5e429bd6f4", + "nativeBackendSourceSha256": "294b9d830bc58c05985087875523db133d32cf999703140fd927e843399424db", + "nativeBackendFactorySourceSha256": "ce22825a53669f8c2db5aaa890bae94af19ede22295bda4ed7987a31d804cd8a", + "nativeInstructionsSourceSha256": "ea85be2aca8dfbd6b4042dcd7b1ed9db9a57baf8c379649a3f5d7d80ca54a6a2", + "runtimeRequestBridgeSourceSha256": "a66ee14f858ccf75c69dfbce132900a30724fe1e6baffc26b02d58a6bc5924ac", + "runtimeRequestFacadeSourceSha256": "e1993906ac9f47eb4ea04ae815db869ecde72f582af460cc0080308670612c8f", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "2881b655112f165320a73753385cd54286139a0273a5fa30c1b43fc3b5981a25", + "nativeExecutionSourceSha256": "9c702e518862c396ccfa12d84d11cb8dec1fc064c595359e31cc3e299a7378cc", + "profileCompatibilitySourceSha256": "20f8aaa363496a4cbede0f33aeacf869fb50171eaee2d63a892c65b3bda4c6a1", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "sidecarErrorSourceSha256": "23f3dbdc1ce488c690d218a854c820f4b4f3edd822bad574d67707314f8c512a", + "profileSourceSha256": "baf0b64371fada6c92331f9f88e915292b2761c029fd86452f6de32e26dc7fd6", + "metadataProbeSourceSha256": "826e74747a89b83a6f546ee1355de64ae5265eb920b50ae498a41dd561a493ee", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "runtimeHostSourceSha256": "cd825fe44b1d142aff2d293c3fc8a64d55e42a98bdea48e4d4b90f3a3163d232", + "runtimeAdapterSourceSha256": "3aea6448dc6474e465e5267b61798ad5449bdaec3f4a41bc4be831426893a9bb", + "runtimeSandboxSourceSha256": "b68686298054da576baa5544fe1364d9febe3f5d6aab5c49f9c69f011764afd3", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} 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 00000000000..1b954a59a17 --- /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 d978802f879..f51a893006f 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 00000000000..6d1dce690ae --- /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 00000000000..378e166acf3 --- /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 00000000000..fa74a09b6f0 --- /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/plugins/sandbox-providers/daytona/src/file-sync-diagnostics.test.ts b/packages/plugins/sandbox-providers/daytona/src/file-sync-diagnostics.test.ts new file mode 100644 index 00000000000..4e5e2da1cee --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/file-sync-diagnostics.test.ts @@ -0,0 +1,113 @@ +import { promises as fs } from "node:fs"; +import { execFileSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { environmentSyncErrorData, withEnvironmentSyncErrorCapture, readEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk"; +vi.mock("./plugin.js", async () => { + const { NOOP_PLUGIN_TRACER } = await import("@paperclipai/plugin-sdk"); + return { getPluginTracer: () => NOOP_PLUGIN_TRACER }; +}); +import { performSyncOut } from "./file-sync.js"; + +const dirs: string[] = []; +afterEach(async () => { await Promise.all(dirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); }); + +it.each([ + ["guard", "sandbox_guard", "command_failed", 42], + ["create", "archive_create", "command_failed", 2], + ["download", "archive_download", "download_failed", undefined], + ["missing", "archive_download", "download_missing", undefined], + ["file", "file_download", "download_failed", undefined], + ["file-missing", "file_download", "download_missing", undefined], +] as const)("records producer evidence for %s without copying output or paths", async (failure, step, kind, exitCode) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-diagnostic-")); + dirs.push(dir); + const sandbox = { + process: { executeCommand: async (command: string) => ({ + exitCode: failure === "guard" ? 42 : failure === "create" && command.includes("tar -c") ? 2 : 0, + result: "private-command-output", + }) }, + fs: { + downloadFiles: async (requests: Array<{ source: string }>) => failure.endsWith("missing") ? [] + : requests.map(request => ({ source: request.source, error: "private-download-error" })), + deleteFile: async () => undefined, + }, + }; + await withEnvironmentSyncErrorCapture(async () => { + let caught: unknown; + try { + await performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30, + operations: [{ operationId: "fixture", files: [{ + sourcePath: "/workspace/private-path", targetPath: path.join(dir, "target"), kind: failure.startsWith("file") ? "file" : "directory", + }] }], + }); + } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(Error); + const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(caught) }); + expect(diagnostic).toEqual({ errorCode: "unknown", transferStep: step, transferFailureKind: kind, + ...(exitCode !== undefined ? { exitCode } : {}), + }); + expect(JSON.stringify(diagnostic)).not.toContain("private-"); + expect(caught).not.toHaveProperty("transferStep"); + expect(caught).not.toHaveProperty("exitCode"); + }); +}); + +it("keeps a thrown download exception identical and its nested safe code", async () => { + const original = Object.freeze(new Error("private-download-error", { cause: { code: "ECONNRESET", token: "private-token" } })); + const sandbox = { + process: { executeCommand: async () => ({ exitCode: 0 }) }, + fs: { downloadFiles: async () => { throw original; }, deleteFile: async () => undefined }, + }; + await withEnvironmentSyncErrorCapture(async () => { + await expect(performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30, + operations: [{ operationId: "fixture", files: [{ sourcePath: "/workspace/private-path", targetPath: "/unused", kind: "directory" }] }], + })).rejects.toBe(original); + expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(original) })).toEqual({ + errorCode: "ECONNRESET", transferStep: "archive_download", + }); + }); +}); + + +it.each(["file_finalize", "archive_validate", "archive_extract"] as const)("identifies the host-side %s failure without relaxing confinement", async (step) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-host-diagnostic-")); + dirs.push(dir); + const target = path.join(dir, "target"); + const archive = path.join(dir, "safe.tar"); + if (step === "archive_extract") { + await fs.writeFile(path.join(dir, "safe.txt"), "fixture"); + execFileSync("tar", ["-cf", archive, "-C", dir, "safe.txt"]); + await fs.writeFile(target, "preserve target"); + } + const sandbox = { + process: { executeCommand: async () => ({ exitCode: 0 }) }, + fs: { + downloadFiles: async (requests: Array<{ source: string; destination: string }>) => { + for (const request of requests) { + if (step === "archive_validate") await fs.writeFile(request.destination, "invalid private archive"); + if (step === "archive_extract") await fs.copyFile(archive, request.destination); + } + return requests.map(request => ({ source: request.source })); + }, + deleteFile: async () => undefined, + }, + }; + await withEnvironmentSyncErrorCapture(async () => { + let caught: unknown; + try { + await performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30, + operations: [{ operationId: "fixture", files: [{ sourcePath: "/workspace/private-path", targetPath: target, + kind: step === "file_finalize" ? "file" : "directory" }] }], + }); + } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(Error); + const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(caught) }); + expect(diagnostic?.transferStep).toBe(step); + expect(JSON.stringify(diagnostic)).not.toContain("private"); + if (step === "file_finalize") expect(diagnostic?.errorCode).toBe("ENOENT"); + if (step === "archive_validate") expect(diagnostic?.transferFailureKind).toBe("command_failed"); + if (step === "archive_extract") expect(await fs.readFile(target, "utf8")).toBe("preserve target"); + }); +}); diff --git a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts index ad84d90d394..89847f9ddbd 100644 --- a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts +++ b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts @@ -14,6 +14,7 @@ import type { PluginSyncFileMapping, PluginSyncOperation, } from "@paperclipai/plugin-sdk"; +import { recordEnvironmentSyncError, withEnvironmentSyncTransferStep } from "@paperclipai/plugin-sdk"; import { getPluginTracer } from "./plugin.js"; const execFileAsync = promisify(execFile); @@ -318,9 +319,13 @@ export async function assertTarballEntriesConfined( const validateLine = (line: Buffer) => { // Count empty lines too, so whitespace cannot evade the parsing-work quota. if (++entries > TAR_LISTING_MAX_ENTRIES) { - throw new Error("Daytona syncOut tar listing entry limit exceeded (250000)"); + throw recordEnvironmentSyncError(new Error("Daytona syncOut tar listing entry limit exceeded (250000)"), { transferFailureKind: "listing_entry_limit" }); + } + try { assertTarListingLineConfined(line.toString("utf8")); } + catch (error) { + if (error instanceof UnsafeOutboundArchiveError) recordEnvironmentSyncError(error, { transferFailureKind: "unsafe_archive" }); + throw error; } - assertTarListingLineConfined(line.toString("utf8")); }; const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { child.once("error", (error) => { spawnError = error; }); @@ -331,11 +336,11 @@ export async function assertTarballEntriesConfined( child.kill("SIGKILL"); }; const timer = setTimeout(() => { - stop(new Error("Daytona syncOut tar listing validation timed out")); + stop(recordEnvironmentSyncError(new Error("Daytona syncOut tar listing validation timed out"), { transferFailureKind: "listing_timeout", errorCode: "ETIMEDOUT" })); }, Math.max(1, Math.min(timeoutMs, TAR_LISTING_TIMEOUT_MS))); child.stderr.on("data", (chunk: Buffer) => { if (stderr.length + chunk.length > TAR_LISTING_MAX_STDERR_BYTES) { - stop(new Error("Daytona syncOut tar listing diagnostics exceed the byte limit")); + stop(recordEnvironmentSyncError(new Error("Daytona syncOut tar listing diagnostics exceed the byte limit"), { transferFailureKind: "listing_stderr_limit" })); return; } stderr = Buffer.concat([stderr, chunk]); @@ -346,14 +351,14 @@ export async function assertTarballEntriesConfined( const bytes = chunk as Buffer; totalBytes += bytes.length; if (totalBytes > TAR_LISTING_MAX_TOTAL_BYTES) { - throw new Error("Daytona syncOut tar total listing byte limit exceeded (64 MiB)"); + throw recordEnvironmentSyncError(new Error("Daytona syncOut tar total listing byte limit exceeded (64 MiB)"), { transferFailureKind: "listing_byte_limit" }); } let start = 0; while (start < bytes.length) { const newline = bytes.indexOf(10, start); const end = newline < 0 ? bytes.length : newline; if (pending.length + end - start > TAR_LISTING_MAX_LINE_BYTES) { - throw new Error("Daytona syncOut refusing tarball with an entry listing exceeding the byte limit"); + throw recordEnvironmentSyncError(new Error("Daytona syncOut refusing tarball with an entry listing exceeding the byte limit"), { transferFailureKind: "listing_line_limit" }); } pending = Buffer.concat([pending, bytes.subarray(start, end)]); if (newline < 0) break; @@ -367,7 +372,9 @@ export async function assertTarballEntriesConfined( if (failure) throw failure; if (spawnError) throw spawnError; if (result.code !== 0) { - throw new Error(`Daytona syncOut tar listing failed (${result.signal ?? result.code}): ${stderr.toString("utf8").trim()}`); + throw recordEnvironmentSyncError(new Error(`Daytona syncOut tar listing failed (${result.signal ?? result.code}): ${stderr.toString("utf8").trim()}`), { + transferFailureKind: "command_failed", exitCode: result.code ?? undefined, + }); } } catch (error) { stop(error instanceof Error ? error : new Error(String(error))); @@ -381,11 +388,13 @@ export async function assertTarballEntriesConfined( async function extractHostTarball(input: { archivePath: string; localDir: string }): Promise { // The archive is sandbox-authored and untrusted: validate every member (and // link target) is confined before letting host-side tar write a single byte. - await assertTarballEntriesConfined(input.archivePath); - await fs.mkdir(input.localDir, { recursive: true }); - await execFileAsync("tar", ["-xf", input.archivePath, "-C", input.localDir], { - env: { ...process.env, COPYFILE_DISABLE: "1" }, - maxBuffer: 32 * 1024 * 1024, + await withEnvironmentSyncTransferStep("archive_validate", () => assertTarballEntriesConfined(input.archivePath)); + await withEnvironmentSyncTransferStep("archive_extract", async () => { + await fs.mkdir(input.localDir, { recursive: true }); + await execFileAsync("tar", ["-xf", input.archivePath, "-C", input.localDir], { + env: { ...process.env, COPYFILE_DISABLE: "1" }, + maxBuffer: 32 * 1024 * 1024, + }); }); } @@ -423,7 +432,9 @@ async function assertSandboxCommandOkWithOutput( const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds); if ((result.exitCode ?? 1) !== 0) { const detail = (result.result ?? result.artifacts?.stdout ?? "").toString().trim(); - throw new Error(`Daytona ${label} command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`); + throw recordEnvironmentSyncError(new Error(`Daytona ${label} command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`), { + transferFailureKind: "command_failed", exitCode: result.exitCode, + }); } return (result.result ?? result.artifacts?.stdout ?? "").toString(); } @@ -1143,17 +1154,14 @@ async function syncOutFileMappings(input: { const { sandbox, mappings, remoteDir, timeoutSeconds } = input; if (mappings.length === 0) return { filesTransferred: 0, bytesTransferred: 0 }; - for (const mapping of mappings) { - assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source"); - } // Close the validation→download TOCTOU: instead of re-opening each mutable // source, validate-and-snapshot it in one atomic sandbox-side step and download // the immutable snapshot. `snapshots` is index-aligned with `mappings`. - const snapshots = await snapshotOutboundFileSources({ - sandbox, - remoteDir, - sources: mappings.map((mapping) => mapping.sourcePath), - timeoutSeconds, + const snapshots = await withEnvironmentSyncTransferStep("sandbox_guard", async () => { + for (const mapping of mappings) assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source"); + return snapshotOutboundFileSources({ + sandbox, remoteDir, sources: mappings.map((mapping) => mapping.sourcePath), timeoutSeconds, + }); }); const requests: FileDownloadRequest[] = []; @@ -1179,6 +1187,7 @@ async function syncOutFileMappings(input: { await fs.mkdir(path.dirname(entry.target), { recursive: true }); } } catch (error) { + recordEnvironmentSyncError(error, { transferStep: "file_finalize" }); await cleanup(); throw error; } @@ -1193,7 +1202,7 @@ async function syncOutFileMappings(input: { try { // One batched bulk download for all file mappings, reading the snapshots. // `transfer` span: the real byte download — `sandbox.fs.downloadFiles`. - responses = await withProviderSpan({ + responses = await withEnvironmentSyncTransferStep("file_download", () => withProviderSpan({ name: "transfer", wallMsAttr: SPAN_ATTR.transferWallMs, attributes: { @@ -1201,7 +1210,7 @@ async function syncOutFileMappings(input: { [SPAN_ATTR.transferDirection]: "outbound", }, run: () => sandbox.fs.downloadFiles(requests, timeoutSeconds), - }); + })); } catch (error) { await cleanup(); throw error; @@ -1215,9 +1224,9 @@ async function syncOutFileMappings(input: { const response = bySource.get(entry.snapshot); if (!response || response.error) { await cleanup(); - throw new Error( + throw recordEnvironmentSyncError(new Error( `Daytona syncOut download failed for ${entry.source}: ${response?.error ?? "no response returned"}`, - ); + ), { transferStep: "file_download", transferFailureKind: response ? "download_failed" : "download_missing" }); } } @@ -1233,6 +1242,7 @@ async function syncOutFileMappings(input: { await fs.rename(entry.temp, entry.target); } } catch (error) { + recordEnvironmentSyncError(error, { transferStep: "file_finalize" }); await cleanup(); throw error; } @@ -1251,16 +1261,15 @@ async function syncOutDirectoryMapping(input: { onArchiveRecovery?: () => void; }): Promise<{ filesTransferred: number; bytesTransferred: number }> { const { sandbox, mapping, remoteDir, timeoutSeconds } = input; - assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source"); // Count the serial sandbox round trips before the transfer, so the transfer // span records how much of the wall time is guard cost. let guardRoundTrips = 0; - await assertSandboxPathsConfined({ - sandbox, - remoteDir, - paths: [mapping.sourcePath], - timeoutSeconds, - label: "outbound symlink-escape guard", + await withEnvironmentSyncTransferStep("sandbox_guard", async () => { + assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source"); + await assertSandboxPathsConfined({ + sandbox, remoteDir, paths: [mapping.sourcePath], timeoutSeconds, + label: "outbound symlink-escape guard", + }); }); guardRoundTrips += 1; @@ -1310,10 +1319,11 @@ async function syncOutDirectoryMapping(input: { let bytesTransferred = 0; try { for (let attempt = 0; attempt < 2; attempt += 1) { - await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(attempt === 0 ? tarScript : confinedEntriesScript)}`, timeoutSeconds, "syncOut tar"); + await withEnvironmentSyncTransferStep("archive_create", () => + assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(attempt === 0 ? tarScript : confinedEntriesScript)}`, timeoutSeconds, "syncOut tar")); guardRoundTrips += 1; // `transfer` span: the real byte download — `sandbox.fs.downloadFiles`. - const responses = await withProviderSpan({ + const responses = await withEnvironmentSyncTransferStep("archive_download", () => withProviderSpan({ name: "transfer", wallMsAttr: SPAN_ATTR.transferWallMs, attributes: { @@ -1322,14 +1332,14 @@ async function syncOutDirectoryMapping(input: { }, run: () => sandbox.fs.downloadFiles([{ source: remoteTar, destination: localTar }], timeoutSeconds), - }); + })); const response = responses.find((entry) => entry.source === remoteTar) ?? responses[0]; if (!response || response.error) { - throw new Error( + throw recordEnvironmentSyncError(new Error( `Daytona syncOut directory download failed for ${mapping.sourcePath}: ${response?.error ?? "no response returned"}`, - ); + ), { transferStep: "archive_download", transferFailureKind: response ? "download_failed" : "download_missing" }); } - bytesTransferred += (await fs.stat(localTar)).size; + bytesTransferred += await withEnvironmentSyncTransferStep("archive_download", async () => (await fs.stat(localTar)).size); try { await extractHostTarball({ archivePath: localTar, localDir: mapping.targetPath }); break; diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index ac3a7c1dd0a..79eb85bd0a5 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -36,10 +36,25 @@ import plugin, { __setDaytonaPluginContextForTest, } from "./plugin.js"; import type { PluginContext } from "@paperclipai/plugin-sdk"; -import { environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk"; +import { environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk"; import manifest from "./manifest.js"; import { parseTarVerboseListingLine, splitLinkEntryOnce } from "./file-sync.js"; +describe("Daytona idle eligibility", () => { + it("permits a fresh unused provider but remains conservative after provider access", async () => { + __resetDaytonaSandboxHandleCacheForTest(); + const signal = new AbortController().signal; + expect(await plugin.definition.onIdleDrain!(signal)).toBe("none"); + mockGet.mockRejectedValueOnce(new Error("provider unreachable")); + await plugin.definition.onEnvironmentProbe!({ + driverKey: "daytona", companyId: "fixture-company", environmentId: "fixture-environment", + config: { apiKey: "fixture-not-a-key" }, + }).catch(() => undefined); + expect(await plugin.definition.onIdleDrain!(signal)).toBe("present"); + __resetDaytonaSandboxHandleCacheForTest(); + }); +}); + function createMockSandbox(overrides: { id?: string; name?: string; @@ -5248,33 +5263,32 @@ describe("daytona native file-sync hooks", () => { }); }); - it("classifies a deleted sandbox during syncOut with a stable unrecoverable code", async () => { + it.each(["sandbox_access", "file_download"] as const)("keeps the deleted-sandbox policy and bounded %s evidence", async (step) => { const hostDir = await makeHostDir(); - mockGet.mockRejectedValue( - new MockDaytonaNotFoundError("provider detail must not escape"), - ); - - await expect( - plugin.definition.onEnvironmentSyncOut?.({ - driverKey: "daytona", - companyId: "company-1", - environmentId: "env-1", - config: { timeoutMs: 300000, reuseLease: true }, - lease: syncLease(), - operations: [ - { - operationId: "sync-op-missing-sandbox", - files: [ - { - sourcePath: `${REMOTE_DIR}/out/result.txt`, - targetPath: path.join(hostDir, "result.txt"), - kind: "file", - }, - ], - }, - ], - }), - ).rejects.toThrow("daytona_sandbox_not_found"); + const source = Object.freeze(Object.assign(new MockDaytonaNotFoundError("private-provider-detail"), { + status: 404, cause: { code: "EIO", token: "private-provider-token" }, + })); + if (step === "sandbox_access") mockGet.mockRejectedValue(source); + else { + const sandbox = createMockSandbox(); + sandbox.fs.downloadFiles.mockRejectedValue(source); + mockGet.mockResolvedValue(sandbox); + } + await withEnvironmentSyncErrorCapture(async () => { + const error = await plugin.definition.onEnvironmentSyncOut!({ + driverKey: "daytona", companyId: "company-1", environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: true }, lease: syncLease(), + operations: [{ operationId: "sync-op-missing-sandbox", files: [{ + sourcePath: `${REMOTE_DIR}/out/result.txt`, targetPath: path.join(hostDir, "result.txt"), kind: "file", + }] }], + }).catch(error => error); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("daytona_sandbox_not_found"); + expect(error).not.toHaveProperty("cause"); + const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) }); + expect(diagnostic).toEqual({ errorCode: "EIO", httpStatus: 404, transferStep: step }); + expect(JSON.stringify(diagnostic)).not.toContain("private-provider-"); + }); }); it("syncOut snapshot guard re-checks the resolved source is a non-symlink regular file immediately before copying (validation→copy TOCTOU)", async () => { diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index 4dfa65c9d59..bbfa70a049d 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -9,7 +9,7 @@ import type { Resources, Sandbox, } from "@daytonaio/sdk"; -import { decodeChannelBytes, definePlugin, NOOP_PLUGIN_TRACER, PluginEnvironmentCreationCleanupError, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk"; +import { decodeChannelBytes, definePlugin, NOOP_PLUGIN_TRACER, PluginEnvironmentCreationCleanupError, readEnvironmentCreationCleanupError, withEnvironmentSyncTransferStep, preserveEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk"; import type { PluginContext, PluginEnvironmentCreationCleanup, @@ -107,6 +107,10 @@ let timingNow: () => number = () => Date.now(); // read the tracer through `getPluginTracer()`. Before `setup` runs (or in a // test) the tracer is a no-op, so a span never throws. let pluginContext: PluginContext | null = null; +// Provider deadlines and terminal close failures do not prove remote work +// stopped. Until those paths provide complete cleanup receipts, only a worker +// which has never contacted the provider can opt into automatic idle sleep. +let providerUsed = false; /** * Return the plugin tracer. It is the injected `ctx.tracer` after `setup`, or a @@ -313,6 +317,7 @@ function resolveApiKey(config: DaytonaDriverConfig): string { } function createDaytonaClient(config: DaytonaDriverConfig): Daytona { + providerUsed = true; const clientConfig: DaytonaConfig = { apiKey: resolveApiKey(config), }; @@ -1570,6 +1575,7 @@ const sandboxHandleSessionStore = (() => { * Not used in production. */ export function __resetDaytonaSandboxHandleCacheForTest(): void { + providerUsed = false; sandboxHandleCache.reset(); sandboxHandleTeardownGates.reset(); sandboxHandleActivityGates.reset(); @@ -2281,6 +2287,11 @@ const plugin = definePlugin({ return { status: "ok", message: "Daytona sandbox provider plugin healthy" }; }, + async onIdleDrain() { + return providerUsed || daytonaLoginPtyByRoute.size > 0 || daytonaDuplexChannelByRoute.size > 0 + ? "present" : "none"; + }, + async onEnvironmentValidateConfig( params: PluginEnvironmentValidateConfigParams, ): Promise { @@ -3260,8 +3271,11 @@ const plugin = definePlugin({ }; try { return await withSandboxActivityGate(scope, async () => { - const sandbox = await getSandbox(scope, { bypassTeardownGate: true }); - await ensureSandboxStarted(sandbox, timeoutSeconds); + const sandbox = await withEnvironmentSyncTransferStep("sandbox_access", async () => { + const resolved = await getSandbox(scope, { bypassTeardownGate: true }); + await ensureSandboxStarted(resolved, timeoutSeconds); + return resolved; + }); const result = await performSyncOut({ sandbox, operations: params.operations, @@ -3277,7 +3291,7 @@ const plugin = definePlugin({ // unexported workspace bytes no longer exist. Convert the SDK class to a // stable cross-worker message; every other error remains retryable. if (error instanceof DaytonaNotFoundError) { - throw new Error("daytona_sandbox_not_found"); + throw preserveEnvironmentSyncErrorDiagnostic(new Error("daytona_sandbox_not_found"), error); } throw error; } diff --git a/packages/plugins/sandbox-providers/daytona/src/tar-listing.test.ts b/packages/plugins/sandbox-providers/daytona/src/tar-listing.test.ts index 796e93bc00b..cf0fb98a770 100644 --- a/packages/plugins/sandbox-providers/daytona/src/tar-listing.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/tar-listing.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, expect, it, vi } from "vitest"; vi.mock("./plugin.js", () => ({ getPluginTracer: () => undefined })); +import { environmentSyncErrorData, withEnvironmentSyncErrorCapture, withEnvironmentSyncTransferStep, readEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk"; import { assertTarballEntriesConfined } from "./file-sync.js"; const temporaryDirectories: string[] = []; @@ -100,18 +101,28 @@ it.each([false, true])("bounds aggregate listing bytes at 64 MiB (over quota: %s const line = "-rw-r--r-- 0/0 0 2026-09-27 12:00 ".padEnd(4095, "x") + "\n"; const count = 64 * 1024 * 1024 / Buffer.byteLength(line) + Number(overQuota); const checkReaped = await fakeTar(listingWriter(line, count)); - const result = assertTarballEntriesConfined("unused.tar"); - if (overQuota) await expect(result).rejects.toThrow("total listing byte limit"); - else await expect(result).resolves.toBeUndefined(); + await withEnvironmentSyncErrorCapture(async () => { + const result = assertTarballEntriesConfined("unused.tar"); + if (overQuota) { + const error = await result.catch(error => error); + expect(error.message).toContain("total listing byte limit"); + expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferFailureKind).toBe("listing_byte_limit"); + } else await expect(result).resolves.toBeUndefined(); + }); await checkReaped(); }, 30_000); it.each([false, true])("bounds aggregate listing entries at 250,000 (over quota: %s)", async (overQuota) => { // The over-quota member has no newline: EOF must pass the same admission gate. const checkReaped = await fakeTar(listingWriter(safeLine + "\n", 250_000, overQuota ? safeLine : "")); - const result = assertTarballEntriesConfined("unused.tar"); - if (overQuota) await expect(result).rejects.toThrow("listing entry limit"); - else await expect(result).resolves.toBeUndefined(); + await withEnvironmentSyncErrorCapture(async () => { + const result = assertTarballEntriesConfined("unused.tar"); + if (overQuota) { + const error = await result.catch(error => error); + expect(error.message).toContain("listing entry limit"); + expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferFailureKind).toBe("listing_entry_limit"); + } else await expect(result).resolves.toBeUndefined(); + }); await checkReaped(); }, 30_000); @@ -189,3 +200,26 @@ it("rejects spawn failures without hanging", async () => { vi.stubEnv("PATH", await temporaryDirectory()); await expect(assertTarballEntriesConfined("unused.tar")).rejects.toThrow("ENOENT"); }); + + +it.each([ + ["process.exitCode = 2;", "command_failed", 2, "unknown"], + ['process.stdout.write("x".repeat(65_537)); setInterval(() => {}, 1_000);', "listing_line_limit", undefined, "unknown"], + ['process.stderr.write("x".repeat(65_537)); setInterval(() => {}, 1_000);', "listing_stderr_limit", undefined, "unknown"], + ['process.stdout.write("invalid-private-listing");', "unsafe_archive", undefined, "unknown"], + ['setInterval(() => {}, 1_000);', "listing_timeout", undefined, "ETIMEDOUT"], +] as const)("records bounded listing evidence and reaps the child (%s)", async (script, kind, exitCode, errorCode) => { + const checkReaped = await fakeTar(script); + await withEnvironmentSyncErrorCapture(async () => { + let caught: unknown; + try { await withEnvironmentSyncTransferStep("archive_validate", () => assertTarballEntriesConfined("private-archive.tar", 2_000)); } + catch (error) { caught = error; } + expect(caught).toBeInstanceOf(Error); + const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(caught) }); + expect(diagnostic).toEqual({ errorCode, transferStep: "archive_validate", transferFailureKind: kind, + ...(exitCode !== undefined ? { exitCode } : {}), + }); + expect(JSON.stringify(diagnostic)).not.toContain("private-"); + }); + await checkReaped(); +}); diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index 947a67cbb55..12b763957c8 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -293,6 +293,17 @@ export interface PluginDefinition { */ onShutdown?(): Promise; + /** Prove plugin-owned background work is quiescent for idle sleep. + * The SDK first closes admission and checks accepted RPCs and notifications. + * Return `none` only when timers, sockets, detached operations and cleanup + * are settled and cannot start work until signal aborts. Do not cancel useful + * work to satisfy this check. Missing hooks, failures and uncertainty block + * sleep. The signal aborts on exact-owner release or bounded hold expiry. + * Plugins with autonomous work must keep returning `present` unless they + * can safely suspend and resume that work under this contract. + */ + onIdleDrain?(signal: AbortSignal): Promise<"none" | "present" | "unknown">; + /** * Called to validate the current plugin configuration. * diff --git a/packages/plugins/sdk/src/environment-sync-error.ts b/packages/plugins/sdk/src/environment-sync-error.ts index 0450310a07a..a630bb126e1 100644 --- a/packages/plugins/sdk/src/environment-sync-error.ts +++ b/packages/plugins/sdk/src/environment-sync-error.ts @@ -1,8 +1,67 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + /** Diagnostic evidence only. Never use this envelope to authorize cleanup or retry. */ export interface PluginEnvironmentSyncErrorDiagnostic { errorCode: string; httpStatus?: number; exitCode?: number; + transferStep?: EnvironmentSyncTransferStep; + transferFailureKind?: EnvironmentSyncTransferFailureKind; +} + +const TRANSFER_STEPS = new Set([ + "sandbox_access", "sandbox_guard", "file_download", "file_finalize", + "archive_create", "archive_download", "archive_validate", "archive_extract", +] as const); +export type EnvironmentSyncTransferStep = typeof TRANSFER_STEPS extends Set ? T : never; +const TRANSFER_FAILURE_KINDS = new Set([ + "command_failed", "download_failed", "download_missing", "unsafe_archive", + "listing_timeout", "listing_entry_limit", "listing_byte_limit", "listing_line_limit", "listing_stderr_limit", +] as const); +export type EnvironmentSyncTransferFailureKind = typeof TRANSFER_FAILURE_KINDS extends Set ? T : never; +interface Capture { + active: boolean; + sequence: number; + errors: WeakMap; +} +const capture = new AsyncLocalStorage(); + +/** Scope evidence to one outbound RPC, including its error serialization. */ +export async function withEnvironmentSyncErrorCapture(operation: () => Promise): Promise { + const scope: Capture = { active: true, sequence: 0, errors: new WeakMap() }; + return capture.run(scope, async () => { + try { return await operation(); } + finally { scope.active = false; } + }); +} + +/** Producer-owned evidence only; never mutate, wrap, or retain a raw cause. */ +export function recordEnvironmentSyncError(error: T, diagnostic: Partial): T { + const scope = capture.getStore(); + const safe = sanitize(diagnostic); + if (scope?.active && error && typeof error === "object") { + if (safe) scope.errors.set(error, { sequence: ++scope.sequence, diagnostic: safe }); + else scope.errors.delete(error); + } + return error; +} + +/** A nested failing stage wins; a later attempt gets fresh attribution. */ +export async function withEnvironmentSyncTransferStep(step: EnvironmentSyncTransferStep, operation: () => Promise): Promise { + const scope = capture.getStore(); + if (!scope?.active || !TRANSFER_STEPS.has(step)) return await operation(); + const started = scope.sequence; + try { return await operation(); } + catch (error) { + if (scope.active && error && typeof error === "object") { + const saved = scope.errors.get(error); + const diagnostic = saved && saved.sequence > started ? saved.diagnostic : { errorCode: "unknown" }; + scope.errors.set(error, { + sequence: ++scope.sequence, diagnostic: { ...diagnostic, transferStep: diagnostic.transferStep ?? step }, + }); + } + throw error; + } } const SCHEMA = "paperclip/environment-sync-error/v1"; @@ -29,13 +88,23 @@ function sanitize(value: unknown): PluginEnvironmentSyncErrorDiagnostic | undefi const errorCode = typeof code === "string" && ERROR_CODES.has(code) ? code : "unknown"; const httpStatus = integer(field(value, "httpStatus"), 400, 599); const exitCode = integer(field(value, "exitCode"), 1, 255); - if (errorCode === "unknown" && httpStatus === undefined && exitCode === undefined) return undefined; - return { errorCode, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(exitCode !== undefined ? { exitCode } : {}) }; + const step = field(value, "transferStep"); + const kind = field(value, "transferFailureKind"); + const transferStep = typeof step === "string" && TRANSFER_STEPS.has(step as EnvironmentSyncTransferStep) ? step as EnvironmentSyncTransferStep : undefined; + const transferFailureKind = typeof kind === "string" && TRANSFER_FAILURE_KINDS.has(kind as EnvironmentSyncTransferFailureKind) ? kind as EnvironmentSyncTransferFailureKind : undefined; + if (errorCode === "unknown" && httpStatus === undefined && exitCode === undefined && !transferStep && !transferFailureKind) return undefined; + return { errorCode, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(exitCode !== undefined ? { exitCode } : {}), + ...(transferStep ? { transferStep } : {}), ...(transferFailureKind ? { transferFailureKind } : {}), + }; } /** Capture known codes and numbers, never arbitrary error data, text, or names. */ -export function environmentSyncErrorData(error: unknown): unknown { +function captureDiagnostic(error: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined { const diagnostic: PluginEnvironmentSyncErrorDiagnostic = { errorCode: "unknown" }; + const scope = capture.getStore(); + if (scope?.active && error && typeof error === "object") { + Object.assign(diagnostic, scope.errors.get(error)?.diagnostic); + } let current = error; // Bound nested SDK causes, including cyclic errors and throwing getters. for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) { @@ -47,8 +116,18 @@ export function environmentSyncErrorData(error: unknown): unknown { diagnostic.exitCode ??= integer(field(current, "exitCode"), 1, 255) ?? integer(code, 1, 255); current = field(current, "cause"); } - const safe = sanitize(diagnostic); - return safe ? { schema: SCHEMA, diagnostic: safe } : undefined; + return sanitize(diagnostic); +} + +export function environmentSyncErrorData(error: unknown): unknown { + const diagnostic = captureDiagnostic(error); + return diagnostic ? { schema: SCHEMA, diagnostic } : undefined; +} + +/** Retain bounded evidence across an existing wrapper, without adding a raw cause. */ +export function preserveEnvironmentSyncErrorDiagnostic(wrapper: T, source: unknown): T { + const diagnostic = captureDiagnostic(source); + return recordEnvironmentSyncError(wrapper, diagnostic ?? { errorCode: "unknown" }); } /** Revalidate the worker envelope before adapting it into host-only diagnostics. */ diff --git a/packages/plugins/sdk/src/idle-drain.test.ts b/packages/plugins/sdk/src/idle-drain.test.ts new file mode 100644 index 00000000000..cc59f7881e7 --- /dev/null +++ b/packages/plugins/sdk/src/idle-drain.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginIdleDrain } from "./idle-drain.js"; + +describe("plugin idle admission", () => { + afterEach(() => vi.useRealTimers()); + const hold = () => ({ ownerId: "idle-owner", expiresAt: Date.now() + 10_000 }); + + it("keeps accepted work counted until actual settlement", async () => { + const gate = createPluginIdleDrain(); + const done = gate.begin(); + const inspect = vi.fn(async () => "none" as const); + expect(await gate.prepare(hold(), inspect)).toBe("present"); + expect(inspect).not.toHaveBeenCalled(); + done(); done(); + expect(await gate.prepare(hold(), inspect)).toBe("none"); + expect(() => gate.begin()).toThrow("held"); + gate.close(); + }); + + it("rejects legacy hooks, invalid deadlines, busy replies and hook errors", async () => { + const gate = createPluginIdleDrain(); + expect(await gate.prepare(hold())).toBe("present"); + expect(await gate.prepare({ ...hold(), expiresAt: Infinity }, async () => "none")).toBe("unknown"); + expect(await gate.prepare(hold(), async () => "present")).toBe("present"); + expect(await gate.prepare(hold(), async () => { throw new Error("private detail"); })).toBe("unknown"); + gate.begin()(); + }); + + it("releases only the matching owner and resumes on expiry without another request", async () => { + vi.useFakeTimers(); + const gate = createPluginIdleDrain(); + const lease = hold(); + let signal!: AbortSignal; + expect(await gate.prepare(lease, async (value) => { signal = value; return "none"; })).toBe("none"); + gate.release("stale-owner"); + expect(signal.aborted).toBe(false); + expect(await gate.prepare({ ...lease, ownerId: "other-owner" }, async () => "none")).toBe("unknown"); + await vi.advanceTimersByTimeAsync(10_000); + expect(signal.aborted).toBe(true); + gate.begin()(); + }); + + it("holds admission while the hook runs and rejects a late acknowledgement", async () => { + vi.useFakeTimers(); + const gate = createPluginIdleDrain(); + let resolve!: (state: "none") => void; + const pending = gate.prepare(hold(), () => new Promise((done) => { resolve = done; })); + expect(() => gate.begin()).toThrow("held"); + await vi.advanceTimersByTimeAsync(10_000); + expect(await gate.prepare(hold(), async () => "none")).toBe("present"); + resolve("none"); + expect(await pending).toBe("unknown"); + gate.begin()(); + }); +}); diff --git a/packages/plugins/sdk/src/idle-drain.ts b/packages/plugins/sdk/src/idle-drain.ts new file mode 100644 index 00000000000..ae6e4c6be46 --- /dev/null +++ b/packages/plugins/sdk/src/idle-drain.ts @@ -0,0 +1,67 @@ +export type PluginIdleHold = { ownerId: string; expiresAt: number }; +export type PluginIdleWork = "none" | "present" | "unknown"; + +/** Worker admission stays closed for the exact bounded hold. A caller timeout + * never releases a token: only the actual operation's settlement does. */ +export function createPluginIdleDrain() { + let active = 0; + let preparing = 0; + let hold: (PluginIdleHold & { controller: AbortController; ready: boolean }) | null = null; + let timer: ReturnType | undefined; + + function release(ownerId: string): void { + if (hold?.ownerId !== ownerId) return; + const prior = hold; + hold = null; + clearTimeout(timer); + prior.controller.abort(); + } + + function current() { + if (hold && hold.expiresAt <= Date.now()) release(hold.ownerId); + return hold; + } + + function begin(): () => void { + if (current()) throw new Error("Plugin is held for idle sleep"); + active++; + let done = false; + return () => { if (!done) { done = true; active--; } }; + } + + async function prepare( + requested: PluginIdleHold, + inspect?: (signal: AbortSignal) => Promise, + ): Promise { + if (!requested || typeof requested.ownerId !== "string" || !requested.ownerId || + !Number.isFinite(requested.expiresAt) || requested.expiresAt <= Date.now() || + requested.expiresAt > Date.now() + 300_000) return "unknown"; + if (!inspect) return "present"; + const prior = current(); + if (prior) return prior.ownerId === requested.ownerId && prior.expiresAt === requested.expiresAt && prior.ready + ? "none" : "unknown"; + if (active !== 0 || preparing !== 0) return "present"; + const candidate = { ...requested, controller: new AbortController(), ready: false }; + hold = candidate; + timer = setTimeout(() => release(candidate.ownerId), requested.expiresAt - Date.now()); + timer.unref?.(); + preparing++; + try { + const result = await inspect(candidate.controller.signal); + if (current() !== candidate || active !== 0) return "unknown"; + if (result !== "none") { + release(candidate.ownerId); + return result === "present" ? "present" : "unknown"; + } + candidate.ready = true; + return "none"; + } catch { + if (hold === candidate) release(candidate.ownerId); + return "unknown"; + } finally { + preparing--; + } + } + + return { begin, prepare, release, close: () => { if (hold) release(hold.ownerId); } }; +} diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 7610aaa9a1d..50430912516 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -452,7 +452,7 @@ export { export { PluginEnvironmentCreationCleanupError, environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError } from "./environment-creation-cleanup.js"; export type { PluginEnvironmentCreationCleanup } from "./environment-creation-cleanup.js"; -export { readEnvironmentSyncErrorDiagnostic } from "./environment-sync-error.js"; +export { preserveEnvironmentSyncErrorDiagnostic, environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, withEnvironmentSyncTransferStep, recordEnvironmentSyncError } from "./environment-sync-error.js"; export type { PluginEnvironmentSyncErrorDiagnostic } from "./environment-sync-error.js"; export type { AiConnectionPool, AiConnectionPoolConfig, AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, AiConnectionRouterSelection } from "@paperclipai/shared"; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 76f0cd42284..7b2af372750 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -1330,6 +1330,8 @@ export interface HostToWorkerMethods { health: [params: Record, result: PluginHealthDiagnostics]; /** @see PLUGIN_SPEC.md §12.5 */ shutdown: [params: Record, result: void]; + prepareIdleSleep: [params: { ownerId: string; expiresAt: number }, result: { ownerId: string; expiresAt: number; backgroundWork: "none" | "present" | "unknown" }]; + releaseIdleSleep: [params: { ownerId: string }, result: void]; /** @see PLUGIN_SPEC.md §13.3 */ validateConfig: [params: ValidateConfigParams, result: PluginConfigValidationResult]; /** @see PLUGIN_SPEC.md §13.4 */ @@ -1471,6 +1473,8 @@ export const HOST_TO_WORKER_REQUIRED_METHODS: readonly HostToWorkerMethodName[] /** Optional methods the worker MAY implement. */ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] = [ + "prepareIdleSleep", + "releaseIdleSleep", "validateConfig", "configChanged", "onEvent", diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 786e71954fb..a374e31922d 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -1,6 +1,7 @@ import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared"; import { environmentCreationCleanupErrorData } from "./environment-creation-cleanup.js"; -import { environmentSyncErrorData } from "./environment-sync-error.js"; +import { environmentSyncErrorData, withEnvironmentSyncErrorCapture } from "./environment-sync-error.js"; +import { createPluginIdleDrain } from "./idle-drain.js"; /** * Worker-side RPC host — runs inside the child process spawned by the host. * @@ -335,6 +336,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost let running = true; let initialized = false; + const idleDrain = createPluginIdleDrain(); + const unsettledHostCalls = new Map void>(); let manifest: PaperclipPluginManifestV1 | null = null; let currentConfig: Record = {}; // The company whose config was last applied via configChanged. Used to fail @@ -397,6 +400,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost nextOutboundId = 1; } const id = nextOutboundId++; + // Keep the token after an RPC timeout. Only an actual host response + // proves that a host-side write has finished. + unsettledHostCalls.set(id, idleDrain.begin()); const timeout = timeoutMs ?? rpcTimeoutMs; let settled = false; @@ -439,6 +445,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }; sendMessage(request); } catch (err) { + unsettledHostCalls.get(id)?.(); + unsettledHostCalls.delete(id); settle(reject, err instanceof Error ? err : new Error(String(err))); } }); @@ -1562,9 +1570,19 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost * Dispatches to the correct handler based on the method name. */ async function handleHostRequest(request: JsonRpcRequest): Promise { - const { id, method, params } = request; + if (request.method === "environmentSyncOut") { + return withEnvironmentSyncErrorCapture(() => handleHostRequestInScope(request)); + } + return handleHostRequestInScope(request); + } + async function handleHostRequestInScope(request: JsonRpcRequest): Promise { + const { id, method, params } = request; + let done: (() => void) | undefined; try { + if (method !== "prepareIdleSleep" && method !== "releaseIdleSleep" && method !== "shutdown") { + done = idleDrain.begin(); + } const invoke = () => dispatchMethod(method, params); const result = request.paperclipInvocation ? await invocationContextStorage.run(request.paperclipInvocation, invoke) @@ -1584,6 +1602,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost method === "environmentAcquireLease" || method === "environmentDestroyLease" ? environmentCreationCleanupErrorData(err) : method === "environmentSyncOut" ? environmentSyncErrorData(err) : undefined)); + } finally { + done?.(); } } @@ -1592,6 +1612,16 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost */ async function dispatchMethod(method: string, params: unknown): Promise { switch (method) { + case "prepareIdleSleep": { + const hold = params as { ownerId: string; expiresAt: number }; + const backgroundWork = !initialized || sessionEventCallbacks.size > 0 + ? "present" + : await idleDrain.prepare(hold, plugin.definition.onIdleDrain?.bind(plugin.definition)); + return { ...hold, backgroundWork }; + } + case "releaseIdleSleep": + idleDrain.release((params as { ownerId: string }).ownerId); + return; case "initialize": return handleInitialize(params as InitializeParams); @@ -1746,6 +1776,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (plugin.definition.onConfigChanged) supportedMethods.push("configChanged"); if (plugin.definition.onHealth) supportedMethods.push("health"); if (plugin.definition.onShutdown) supportedMethods.push("shutdown"); + if (plugin.definition.onIdleDrain) supportedMethods.push("prepareIdleSleep", "releaseIdleSleep"); if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest"); if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects"); if (plugin.definition.onRouteAiConnection) supportedMethods.push("routeAiConnection"); @@ -2228,6 +2259,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost const id = response.id; if (id === null || id === undefined) return; + unsettledHostCalls.get(id)?.(); + unsettledHostCalls.delete(id); + const pending = pendingRequests.get(id); if (!pending) return; @@ -2284,16 +2318,23 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost } else if (isJsonRpcNotification(message)) { // Dispatch host→worker push notifications const notif = message as JsonRpcNotification & { method: string; params?: unknown }; - const runNotification = (fn: () => void | Promise) => { - if (notif.paperclipInvocation) { - return invocationContextStorage.run(notif.paperclipInvocation, fn); - } - return fn(); + const runNotification = async (fn: () => void | Promise) => { + const done = idleDrain.begin(); + try { + await (notif.paperclipInvocation ? invocationContextStorage.run(notif.paperclipInvocation, fn) : fn()); + } finally { done(); } }; - if (notif.method === "agents.sessions.event" && notif.params) { + if (notif.method === "releaseIdleSleep" && notif.params) { + idleDrain.release((notif.params as { ownerId: string }).ownerId); + } else if (notif.method === "agents.sessions.event" && notif.params) { const event = notif.params as AgentSessionEvent; const cb = sessionEventCallbacks.get(event.sessionId); - if (cb) cb(event); + if (cb) void runNotification(() => cb(event)).catch((err) => { + notifyHost("log", { + level: "error", + message: `Failed to handle session event notification: ${err instanceof Error ? err.message : String(err)}`, + }); + }); } else if (notif.method === "onEvent" && notif.params) { // Plugin event bus notifications — dispatch to registered event handlers Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => { @@ -2312,6 +2353,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost function cleanup(): void { running = false; + idleDrain.close(); // Close readline if (readline) { diff --git a/packages/plugins/sdk/tests/environment-sync-error.test.ts b/packages/plugins/sdk/tests/environment-sync-error.test.ts index 26d98054936..c2480a707ce 100644 --- a/packages/plugins/sdk/tests/environment-sync-error.test.ts +++ b/packages/plugins/sdk/tests/environment-sync-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic } from "../src/environment-sync-error.js"; +import { preserveEnvironmentSyncErrorDiagnostic, environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, withEnvironmentSyncTransferStep, recordEnvironmentSyncError } from "../src/environment-sync-error.js"; const schema = "paperclip/environment-sync-error/v1"; @@ -46,3 +46,70 @@ describe("environment sync diagnostic envelope", () => { expect(readEnvironmentSyncErrorDiagnostic(Object.defineProperty({}, "data", { get() { throw new Error("private"); } }))).toBeUndefined(); }); }); + + +describe("request-local transfer evidence", () => { + it("preserves frozen error identity, nested attribution, and later-attempt attribution", async () => { + const error = Object.freeze(new Error("private-message")); + const before = Object.getOwnPropertyDescriptors(error); + await withEnvironmentSyncErrorCapture(async () => { + await expect(withEnvironmentSyncTransferStep("archive_download", () => + withEnvironmentSyncTransferStep("sandbox_access", async () => { throw error; }))).rejects.toBe(error); + expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferStep).toBe("sandbox_access"); + await expect(withEnvironmentSyncTransferStep("archive_create", async () => { throw error; })).rejects.toBe(error); + expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferStep).toBe("archive_create"); + }); + expect(environmentSyncErrorData(error)).toBeUndefined(); + expect(Object.getOwnPropertyDescriptors(error)).toEqual(before); + await withEnvironmentSyncErrorCapture(async () => expect(environmentSyncErrorData(error)).toBeUndefined()); + }); + + it("does not trust labels on raw errors, their causes, or unknown producer values", async () => { + const error = { transferStep: "archive_create", transferFailureKind: "command_failed", + cause: { transferStep: "archive_validate", message: "private-message" } }; + await withEnvironmentSyncErrorCapture(async () => { + expect(environmentSyncErrorData(error)).toBeUndefined(); + recordEnvironmentSyncError(error, { transferStep: "private-stage", transferFailureKind: "private-kind", exitCode: 900 } as never); + expect(environmentSyncErrorData(error)).toBeUndefined(); + }); + expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: { + errorCode: "unknown", transferStep: "private-stage", transferFailureKind: "private-kind", rpcCode: -32003, + } } })).toBeUndefined(); + }); + + it("omits evidence from detached work after the RPC scope closes", async () => { + const error = new Error("private-message"); + let resume!: () => void; + const gate = new Promise(resolve => { resume = resolve; }); + let detached!: Promise; + await withEnvironmentSyncErrorCapture(async () => { + detached = withEnvironmentSyncTransferStep("archive_create", async () => { await gate; throw error; }) + .catch(failure => environmentSyncErrorData(failure)); + }); + resume(); + expect(await detached).toBeUndefined(); + }); +}); + + +it("copies safe evidence into an existing wrapper only within its request", async () => { + const source = Object.freeze(Object.assign(new Error("private-message"), { status: 404, cause: { code: "EIO", token: "private-token" } })); + const wrapper = Object.freeze(new Error("stable-policy-message")); + await withEnvironmentSyncErrorCapture(async () => { + await expect(withEnvironmentSyncTransferStep("file_download", async () => { throw source; })).rejects.toBe(source); + expect(preserveEnvironmentSyncErrorDiagnostic(wrapper, source)).toBe(wrapper); + expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(wrapper) })).toEqual({ + errorCode: "EIO", httpStatus: 404, transferStep: "file_download", + }); + expect(wrapper).not.toHaveProperty("cause"); + expect(wrapper.message).toBe("stable-policy-message"); + // Reusing a wrapper for a later untyped failure must clear the earlier evidence. + preserveEnvironmentSyncErrorDiagnostic(wrapper, new Error("private-untyped-message")); + expect(environmentSyncErrorData(wrapper)).toBeUndefined(); + }); + expect(environmentSyncErrorData(wrapper)).toBeUndefined(); + await withEnvironmentSyncErrorCapture(async () => { + preserveEnvironmentSyncErrorDiagnostic(wrapper, source); + expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(wrapper) })).toEqual({ errorCode: "EIO", httpStatus: 404 }); + }); +}); diff --git a/packages/shared/src/agent-appearance.test.ts b/packages/shared/src/agent-appearance.test.ts index 82edbdfbf83..95cb8b22a18 100644 --- a/packages/shared/src/agent-appearance.test.ts +++ b/packages/shared/src/agent-appearance.test.ts @@ -14,6 +14,12 @@ describe("agent appearance", () => { expect(legacyAgentAppearance("agent-1")).toEqual(legacyAgentAppearance("agent-1")); expect(agentAvatarUrl(appearance, 24, 2)).toBe("/api/agent-avatars/cap-v1/deep-tide/rest.png?size=24&scale=2"); }); + it("uses a private asset URL for custom avatars while retaining the fallback palette", () => { + const appearance = { ...appearanceForPalette("deep-tide"), customAvatarAssetId: "22222222-2222-4222-8222-222222222222" }; + expect(resolveAgentAppearance(appearance)).toEqual(appearance); + expect(agentAvatarUrl(appearance, 128, 2)).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content"); + expect(agentAppearanceSchema.safeParse({ ...appearance, customAvatarAssetId: "https://example.test/image" }).success).toBe(false); + }); it("renders without a browser and preserves logical-size detail at high density", () => { const appearance = appearanceForPalette("bubblegum-sky"); // ClipLab v0.2.0 draws an enlarged compact face from 16px; only 12px and diff --git a/packages/shared/src/agent-appearance.ts b/packages/shared/src/agent-appearance.ts index 44042ef8df4..d45525d26e5 100644 --- a/packages/shared/src/agent-appearance.ts +++ b/packages/shared/src/agent-appearance.ts @@ -14,6 +14,8 @@ export const agentAppearanceSchema = z.object({ schemaVersion: z.literal(1), characterVersion: z.literal("cap-v1"), paletteId: z.enum(AGENT_PALETTE_IDS), + /** Company-scoped normalized image, set through the avatar upload service. */ + customAvatarAssetId: z.uuid().optional(), }).strict(); export type AgentAppearance = z.infer; @@ -39,6 +41,7 @@ export function resolveAgentAppearance(appearance: unknown, id = "agent"): Agent return parsed.success ? parsed.data : legacyAgentAppearance(id); } export function agentAvatarUrl(appearance: AgentAppearance, size: AgentAvatarSize = 512, scale: 1 | 2 = 1, pose: CharacterState = "rest", muted = false, background: AgentAvatarBackground = "transparent"): string { + if (appearance.customAvatarAssetId) return `/api/assets/${appearance.customAvatarAssetId}/content`; return `/api/agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}.png?size=${size}&scale=${scale}${background === "paperclip-dark" ? "&background=paperclip-dark" : ""}`; } export function characterStateForAgent(status: string): CharacterState { diff --git a/packages/shared/src/agent-avatar-upload.ts b/packages/shared/src/agent-avatar-upload.ts new file mode 100644 index 00000000000..7146d88adf3 --- /dev/null +++ b/packages/shared/src/agent-avatar-upload.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const MAX_AGENT_AVATAR_BYTES = 512 * 1024; +/** Raw PNG/JPEG/WebP bytes, not a URL or a data URL. Null restores the Paperclip character. */ +export const setAgentAvatarSchema = z.object({ + imageBase64: z.string().min(4).max(4 * Math.ceil(MAX_AGENT_AVATAR_BYTES / 3)) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) + .nullable(), +}).strict(); +export type SetAgentAvatarInput = z.infer; diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index 153a509f844..1d9711a5d8b 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -12,6 +12,7 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets"); expect(getAppDefinitionForUrl("https://gmailmcp.googleapis.com/mcp/v1")?.slug).toBe("gmail"); expect(getAppDefinitionForUrl("https://mcp.neon.tech/mcp")?.slug).toBe("neon"); + expect(getAppDefinitionForUrl("https://app.withgauge.com/mcp")?.slug).toBe("gauge"); expect(getAppDefinitionForUrl("https://www.superagent.sh/mcp")?.slug).toBe("superagent"); }); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 5a6482dbec6..62047f770f7 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -71,23 +71,31 @@ import a69 from "./app-definitions/honcho.json" with { type: "json" }; import a70 from "./app-definitions/neon.json" with { type: "json" }; import a71 from "./app-definitions/superagent.json" with { type: "json" }; import a72 from "./app-definitions/telem.json" with { type: "json" }; -import a73 from "./app-definitions/gmail.json" with { type: "json" }; -import a74 from "./app-definitions/google-drive.json" with { type: "json" }; -import a75 from "./app-definitions/google-docs.json" with { type: "json" }; -import a76 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a77 from "./app-definitions/google-slides.json" with { type: "json" }; -import a78 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a79 from "./app-definitions/google-chat.json" with { type: "json" }; -import a80 from "./app-definitions/google-people.json" with { type: "json" }; -import a81 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a82 from "./app-definitions/openai.json" with { type: "json" }; -import a83 from "./app-definitions/openrouter.json" with { type: "json" }; -import a84 from "./app-definitions/xai.json" with { type: "json" }; -import a85 from "./app-definitions/google.json" with { type: "json" }; -import a86 from "./app-definitions/bedrock.json" with { type: "json" }; -import a87 from "./app-definitions/responses-api.json" with { type: "json" }; -import a88 from "./app-definitions/messages-api.json" with { type: "json" }; -import a89 from "./app-definitions/chat-completions-api.json" with { type: "json" }; -import a90 from "./app-definitions/local.json" with { type: "json" }; +import a73 from "./app-definitions/gauge.json" with { type: "json" }; +import a74 from "./app-definitions/tavily.json" with { type: "json" }; +import a75 from "./app-definitions/calendly.json" with { type: "json" }; +import a76 from "./app-definitions/exa.json" with { type: "json" }; +import a77 from "./app-definitions/firecrawl.json" with { type: "json" }; +import a78 from "./app-definitions/parallel-search.json" with { type: "json" }; +import a79 from "./app-definitions/windsor-ai.json" with { type: "json" }; +import a80 from "./app-definitions/gsc-wizard.json" with { type: "json" }; +import a81 from "./app-definitions/gmail.json" with { type: "json" }; +import a82 from "./app-definitions/google-drive.json" with { type: "json" }; +import a83 from "./app-definitions/google-docs.json" with { type: "json" }; +import a84 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a85 from "./app-definitions/google-slides.json" with { type: "json" }; +import a86 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a87 from "./app-definitions/google-chat.json" with { type: "json" }; +import a88 from "./app-definitions/google-people.json" with { type: "json" }; +import a89 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a90 from "./app-definitions/openai.json" with { type: "json" }; +import a91 from "./app-definitions/openrouter.json" with { type: "json" }; +import a92 from "./app-definitions/xai.json" with { type: "json" }; +import a93 from "./app-definitions/google.json" with { type: "json" }; +import a94 from "./app-definitions/bedrock.json" with { type: "json" }; +import a95 from "./app-definitions/responses-api.json" with { type: "json" }; +import a96 from "./app-definitions/messages-api.json" with { type: "json" }; +import a97 from "./app-definitions/chat-completions-api.json" with { type: "json" }; +import a98 from "./app-definitions/local.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index e92becd28c9..20cbf16bbb7 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -273,7 +273,7 @@ describe("AppDefinition catalog", () => { "google-workspace-search", ]), ); - expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(52); + expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(60); expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([ "g2", "vercel", @@ -438,15 +438,15 @@ describe("AppDefinition catalog", () => { expect(channel("slack")?.guidanceMd).toContain("reactions"); expect(channel("slack")?.guidanceMd).toContain("direct messages"); }); - it("keeps a complete, unique, dated evidence ledger for all 55 researched MCP providers", () => { + it("keeps a complete, unique, dated evidence ledger for all 63 researched MCP providers", () => { // Ledger-wide date reflects the last full re-verification (2026-08-26); // later provider additions carry their own research evidence, but // bumping the shared date would overstate freshness for the other providers. expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26"); - expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(55); + expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(63); expect( new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)), - ).toHaveProperty("size", 55); + ).toHaveProperty("size", 63); for (const entry of SELF_SERVE_MCP_RESEARCH.entries) { expect(new URL(entry.docsUrl).protocol).toBe("https:"); expect(new URL(entry.serverUrl).protocol).toBe("https:"); @@ -540,6 +540,30 @@ describe("AppDefinition catalog", () => { expect(credentialField).not.toHaveProperty("defaultValue"); }); + it("exposes Make with its reviewed browser OAuth and MCP token alternatives", () => { + const app = APP_STORE_DEFINITIONS.find((entry) => entry.slug === "make"); + expect(app).toBeTruthy(); + expect(APP_STORE_HIDDEN_SLUGS.has("make")).toBe(false); + expect(CONNECTABLE_APP_DEFINITIONS.some((entry) => entry.slug === "make")).toBe(true); + expect(getAvailableConnectionMethods(app!).map((method) => method.key)).toEqual([ + "mcp-oauth", + "mcp-api-token", + ]); + expect(app!.methods[0]).toMatchObject({ + transport: "mcp_remote", + auth: "oauth", + ownershipModes: ["dcr"], + defaults: { serverUrl: "https://mcp.make.com" }, + }); + expect(app!.methods[1]).toMatchObject({ + transport: "mcp_remote", + auth: "api_key", + ownershipModes: ["customer"], + defaults: { serverUrlTemplate: "https://{zone}/mcp" }, + keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " }, + }); + }); + it("uses the reviewed current endpoints and configuration modes", () => { const method = (slug: string, key?: string) => APP_DEFINITIONS.find((app) => app.slug === slug)?.methods.find( @@ -835,7 +859,6 @@ describe("AppDefinition catalog", () => { "embat", "kernel", "local-falcon", - "make", "manufact", "oreilly", "planetscale", @@ -846,7 +869,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(68); + expect(APP_STORE_DEFINITIONS).toHaveLength(77); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); @@ -1131,6 +1154,56 @@ describe("AppDefinition catalog", () => { expect(method.warnings?.length).toBe(2); } }); + it("connects Gauge's hosted server with browser sign-in or an organization API key", () => { + const gauge = APP_DEFINITIONS.find((app) => app.slug === "gauge")!; + expect(gauge).toMatchObject({ + name: "Gauge", + categories: ["analytics"], + urlPatterns: ["https://app.withgauge.com/*"], + docsUrl: "https://docs.withgauge.com/help/mcp", + branding: { logoUrl: "/brands/apps/gauge.png" }, + redirectConstraints: "https-or-loopback-http", + }); + expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "gauge")).toBe(true); + expect(gauge.methods.map((method) => method.key)).toEqual(["mcp-oauth", "mcp-api-key"]); + const [oauth, apiKey] = gauge.methods; + expect(oauth).toMatchObject({ + transport: "mcp_remote", + auth: "oauth", + ownershipModes: ["dcr"], + riskTier: "S3", + // Gauge advertises identity scopes too; only the reviewed MCP scope is + // requested, and the organization is chosen on Gauge's consent screen. + defaults: { serverUrl: "https://app.withgauge.com/mcp", scopesHint: ["mcp"] }, + }); + expect(apiKey).toMatchObject({ + transport: "mcp_remote", + auth: "api_key", + ownershipModes: ["customer"], + riskTier: "S3", + defaults: { serverUrl: "https://app.withgauge.com/mcp" }, + keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " }, + }); + expect(apiKey!.credentialFields).toEqual([ + expect.objectContaining({ key: "authorization", type: "password", secret: true, required: true }), + ]); + // The key form renders credential helper text, not method warnings, so the + // publish advice must live in the helper. + expect(apiKey!.credentialFields?.[0]?.helperMd).toContain("set publish actions to Ask first"); + for (const method of gauge.methods) { + // One shared profile: no capability picker, but the access step shows + // its description (with the publish warning) before sign-in. + expect(method.capabilityProfile).toMatchObject({ + key: "write", + description: expect.stringContaining("publish to your connected CMS"), + }); + expect(method.tenantFields ?? []).toEqual([]); + expect(method.warnings).toEqual([ + expect.stringContaining("one Gauge organization"), + expect.stringContaining("publish to your connected CMS"), + ]); + } + }); it("connects Superagent's hosted server with an organization API key only", () => { const superagent = APP_DEFINITIONS.find((app) => app.slug === "superagent")!; expect(superagent).toMatchObject({ @@ -1195,6 +1268,7 @@ describe("AppDefinition catalog", () => { expect(required).toEqual([ "clickhouse:mcp-oauth:serviceId", "honcho:mcp-api-key:workspaceId", + "make:mcp-api-token:zone", "shopify:storefront-mcp:storeDomain", "shopify:ucp-commerce:storeDomain", "supabase:mcp-api-key:projectRef", @@ -1317,3 +1391,21 @@ it("validates native pool catalog entries without inventing an authentication me expect(appDefinitionSchema.safeParse({ ...entry, methods: APP_STORE_DEFINITIONS[0]!.methods }).success).toBe(false); expect(appDefinitionSchema.safeParse({ ...entry, categories: ["developer"] }).success).toBe(false); }); + +it("limits explicit DCR registration preference to remote MCP OAuth methods with DCR ownership", () => { + const entry = structuredClone(APP_DEFINITIONS.find((app) => app.slug === "airtable")!); + entry.methods[0]!.oauthClientRegistration = "dcr"; + expect(appDefinitionSchema.parse(entry).methods[0]!.oauthClientRegistration).toBe("dcr"); + + const customerOwned = structuredClone(entry); + customerOwned.methods[0]!.ownershipModes = ["customer"]; + expect(appDefinitionSchema.safeParse(customerOwned).success).toBe(false); + + const apiKey = structuredClone(entry); + apiKey.methods[0]!.auth = "api_key"; + expect(appDefinitionSchema.safeParse(apiKey).success).toBe(false); + + const nonMcp = structuredClone(entry); + nonMcp.methods[0]!.transport = "rest_api"; + expect(appDefinitionSchema.safeParse(nonMcp).success).toBe(false); +}); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 4f0a1e6c894..2e1409f9f6e 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -60,7 +60,6 @@ export const APP_STORE_HIDDEN_SLUGS = new Set([ "embat", "kernel", "local-falcon", - "make", "manufact", "oreilly", "planetscale", diff --git a/packages/shared/src/app-definitions/airtable.json b/packages/shared/src/app-definitions/airtable.json index ad8efa1c687..c9b216a2bbe 100644 --- a/packages/shared/src/app-definitions/airtable.json +++ b/packages/shared/src/app-definitions/airtable.json @@ -20,6 +20,7 @@ "key": "mcp-oauth", "transport": "mcp_remote", "auth": "oauth", + "oauthClientRegistration": "dcr", "ownershipModes": [ "dcr" ], @@ -45,6 +46,44 @@ "warnings": [ "An Airtable account; enterprise administrators may need to allowlist the client." ] + }, + { + "key": "mcp-pat", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a personal access token when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://mcp.airtable.com/mcp" + }, + "guidanceMd": "Create an Airtable personal access token with the scopes and base resources required for the actions you intend to allow. MCP calls inherit your Airtable role and base permissions; organization allowlisting may still apply.", + "riskTier": "S3", + "label": "Use a personal access token", + "credentialFields": [ + { + "key": "authorization", + "label": "Airtable personal access token", + "type": "password", + "required": true, + "placeholder": "pat...", + "secret": true, + "helperMd": "Choose provider scopes and resource bases for intended actions; token and user/base role permissions both restrict access." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "docs": "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server", + "keys": "https://airtable.com/create/tokens" + }, + "warnings": [ + "Use a disposable or development base for initial write qualification. Enterprise admins may need to allowlist MCP access." + ] } ] } diff --git a/packages/shared/src/app-definitions/calendly.json b/packages/shared/src/app-definitions/calendly.json new file mode 100644 index 00000000000..cdedf7bed69 --- /dev/null +++ b/packages/shared/src/app-definitions/calendly.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "slug": "calendly", + "name": "Calendly", + "description": "Review Calendly event types and availability, and use supported scheduling actions.", + "categories": [ + "productivity" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/calendly.png" + }, + "urlPatterns": [ + "https://mcp.calendly.com/*" + ], + "docsUrl": "https://developer.calendly.com/docs/mcp/calendly-mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Calendly", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Connect the provider-hosted Calendly MCP server with browser sign-in.", + "defaults": { + "serverUrl": "https://mcp.calendly.com", + "scopesHint": [ + "mcp:scheduling:read", + "mcp:scheduling:write" + ] + }, + "guidanceMd": "Check the connected account, event type, timezone, availability, and attendee details before using a scheduling write. Use a test scheduling artifact only when the discovered tool explicitly supports it.", + "warnings": [ + "Calendly MCP requires DCR and OAuth 2.1 authorization-code with PKCE S256. It does not support personal access tokens or manually provisioned static OAuth clients.", + "The provider advertises both scheduling read and write scopes for full access; verify the live tool catalog before enabling booking or link creation." + ], + "consoleLinks": { + "docs": "https://developer.calendly.com/docs/mcp/calendly-mcp-server" + }, + "riskTier": "S3" + } + ] +} diff --git a/packages/shared/src/app-definitions/exa.json b/packages/shared/src/app-definitions/exa.json new file mode 100644 index 00000000000..f5e9a384c03 --- /dev/null +++ b/packages/shared/src/app-definitions/exa.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "slug": "exa", + "name": "Exa", + "description": "Search the public web and read source pages with Exa.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/exa.svg" + }, + "urlPatterns": [ + "https://mcp.exa.ai/*" + ], + "docsUrl": "https://exa.ai/docs/get-started/exa-mcp", + "methods": [ + { + "key": "public-search", + "label": "Use public Exa search", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use Exa’s public, keyless search MCP.", + "defaults": { + "serverUrl": "https://mcp.exa.ai/mcp" + }, + "guidanceMd": "The public MCP route provides web search and page fetching. Keep citations with results. Account-authenticated Agent and advanced tools are not included in this method.", + "riskTier": "S2" + } + ] +} diff --git a/packages/shared/src/app-definitions/firecrawl.json b/packages/shared/src/app-definitions/firecrawl.json new file mode 100644 index 00000000000..09f884d3dee --- /dev/null +++ b/packages/shared/src/app-definitions/firecrawl.json @@ -0,0 +1,93 @@ +{ + "schemaVersion": 1, + "slug": "firecrawl", + "name": "Firecrawl", + "description": "Search, scrape, and parse public web pages with Firecrawl.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/firecrawl.svg" + }, + "urlPatterns": [ + "https://mcp.firecrawl.dev/*" + ], + "docsUrl": "https://docs.firecrawl.dev/mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Firecrawl", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use interactive Firecrawl account authorization for the full hosted MCP tool surface.", + "defaults": { + "serverUrl": "https://mcp.firecrawl.dev/v2/mcp-oauth", + "scopesHint": [ + "firecrawl:global", + "offline_access" + ] + }, + "guidanceMd": "Sign in and choose the Firecrawl team whose usage should apply. This route exposes the account-authorized tool surface; review paid jobs and action policies before execution.", + "riskTier": "S4", + "warnings": [ + "Account authorization exposes Firecrawl’s broader hosted MCP surface, including job-based research tools that may use account credits.", + "Crawls and research jobs can use provider credits; review their tool actions and limits before execution." + ] + }, + { + "key": "mcp-api-key", + "label": "Use a Firecrawl API key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a customer-created Firecrawl API key in a Bearer header for unattended or higher-limit access.", + "defaults": { + "serverUrl": "https://mcp.firecrawl.dev/v2/mcp" + }, + "guidanceMd": "Create a Firecrawl API key for the intended team, then connect it with a Bearer header. The authenticated server may expose additional job tools and spend team credits.", + "riskTier": "S4", + "credentialFields": [ + { + "key": "authorization", + "label": "Use a Firecrawl API key", + "type": "password", + "required": true, + "placeholder": "Enter provider credential", + "secret": true, + "helperMd": "Create an API key for the intended Firecrawl team with access to the search, scrape, parse, and any job tools you intend to use. Jobs can consume credits; Paperclip cannot increase an existing key’s permissions." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://docs.firecrawl.dev/mcp-server", + "docs": "https://docs.firecrawl.dev/mcp-server" + } + }, + { + "key": "keyless", + "label": "Try keyless access", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the rate-limited keyless public web-search route without a Firecrawl credential.", + "defaults": { + "serverUrl": "https://mcp.firecrawl.dev/v2/mcp" + }, + "guidanceMd": "The keyless route currently exposes Search, Scrape, and Parse. Keep requests bounded; these calls can consume the provider’s keyless allowance.", + "riskTier": "S2" + } + ] +} diff --git a/packages/shared/src/app-definitions/gauge.json b/packages/shared/src/app-definitions/gauge.json new file mode 100644 index 00000000000..da9d1f2bd15 --- /dev/null +++ b/packages/shared/src/app-definitions/gauge.json @@ -0,0 +1,94 @@ +{ + "schemaVersion": 1, + "slug": "gauge", + "name": "Gauge", + "description": "Track how AI answers mention your brand, research keywords and traffic, and run content workflows.", + "categories": [ + "analytics" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/gauge.png" + }, + "urlPatterns": [ + "https://app.withgauge.com/*" + ], + "docsUrl": "https://docs.withgauge.com/help/mcp", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://app.withgauge.com/mcp", + "scopesHint": [ + "mcp" + ] + }, + "guidanceMd": "Connect Gauge in the browser and choose the organization this connection may use. Write tools start enabled and remain governed by Paperclip's action policies.", + "riskTier": "S3", + "label": "Sign in with Gauge", + "consoleLinks": { + "docs": "https://docs.withgauge.com/help/mcp" + }, + "warnings": [ + "A Gauge account. Each connection reaches one Gauge organization: sign-in uses the organization chosen during consent, and an API key uses the organization that created it.", + "Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended." + ], + "capabilityProfile": { + "key": "write", + "label": "Read and write", + "description": "Read AI visibility, SEO and traffic data, and run content workflows. Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended." + } + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a Gauge organization API key when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://app.withgauge.com/mcp" + }, + "guidanceMd": "In Gauge, open Settings → Integrations → API Keys, generate a key for Paperclip, and paste it below.", + "riskTier": "S3", + "label": "Use an API key", + "credentialFields": [ + { + "key": "authorization", + "label": "Gauge API key", + "type": "password", + "required": true, + "placeholder": "Paste your Gauge API key", + "secret": true, + "helperMd": "Gauge API keys are not scoped: a key reaches every MCP tool in the organization that created it, including publishing to a connected CMS, and requests are attributed to the organization rather than a user. Generate a separate key for Paperclip so it can be deleted independently, and set publish actions to Ask first before agents run unattended. Paperclip cannot narrow an existing key." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://docs.withgauge.com/help/mcp", + "docs": "https://docs.withgauge.com/help/mcp" + }, + "warnings": [ + "A Gauge account. Each connection reaches one Gauge organization: sign-in uses the organization chosen during consent, and an API key uses the organization that created it.", + "Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended." + ], + "capabilityProfile": { + "key": "write", + "label": "Read and write", + "description": "Read AI visibility, SEO and traffic data, and run content workflows. Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended." + } + } + ] +} diff --git a/packages/shared/src/app-definitions/gsc-wizard.json b/packages/shared/src/app-definitions/gsc-wizard.json new file mode 100644 index 00000000000..a4ba6f183d5 --- /dev/null +++ b/packages/shared/src/app-definitions/gsc-wizard.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 1, + "slug": "gsc-wizard", + "name": "GSC Wizard", + "description": "Read Google Search Console, Bing Webmaster, and analytics data for connected properties.", + "categories": [ + "analytics" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/gsc-wizard.png" + }, + "urlPatterns": [ + "https://mcp.gscwizard.com/*" + ], + "docsUrl": "https://www.gscwizard.com/claude-search-console-mcp.html", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with GSC Wizard", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use remote OAuth authorization for the GSC Wizard MCP.", + "defaults": { + "serverUrl": "https://mcp.gscwizard.com/mcp", + "scopesHint": [ + "mcp:read", + "mcp:write" + ] + }, + "guidanceMd": "Connect the GSC Wizard account and its authorized properties. Check site identity and date bounds before querying; review any URL submission or property-changing action separately.", + "riskTier": "S3", + "warnings": [ + "Some tools may submit URLs or otherwise change external indexing state; review action details before use.", + "GSC Wizard connects multiple search and analytics providers; confirm the authorized property and action policy for each tool." + ] + }, + { + "key": "mcp-api-key", + "label": "Use a GSC Wizard MCP key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a customer-created GSC Wizard MCP key in a Bearer header.", + "defaults": { + "serverUrl": "https://mcp.gscwizard.com/mcp" + }, + "guidanceMd": "Create a key for the intended account and select the appropriate read or write permission during key creation. Verify each connected property before querying; review URL submission actions separately.", + "riskTier": "S3", + "credentialFields": [ + { + "key": "authorization", + "label": "Use a GSC Wizard MCP key", + "type": "password", + "required": true, + "placeholder": "Enter provider credential", + "secret": true, + "helperMd": "At key creation, choose read-only or read/write according to the approved task. Read/write keys can reach URL-submission and related provider actions; Paperclip cannot increase an existing key’s permissions." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://www.gscwizard.com/claude-search-console-mcp.html", + "docs": "https://www.gscwizard.com/claude-search-console-mcp.html" + } + } + ] +} diff --git a/packages/shared/src/app-definitions/linear.json b/packages/shared/src/app-definitions/linear.json index 9dc209229f6..552c1ea4ef8 100644 --- a/packages/shared/src/app-definitions/linear.json +++ b/packages/shared/src/app-definitions/linear.json @@ -33,7 +33,7 @@ "write" ] }, - "guidanceMd": "Connect Linear for issues and projects. Paperclip registers its own OAuth client with Linear's MCP server, so no developer-console setup is needed.", + "guidanceMd": "Connect Linear for issues and projects. Paperclip registers its own OAuth client with Linear MCP; no developer-console setup is needed.", "riskTier": "S2", "requiredResourceFilters": [ "workspace", @@ -57,7 +57,50 @@ "prefix": "Bearer " } } + }, + "label": "Sign in with Linear", + "consoleLinks": { + "docs": "https://linear.app/docs/mcp" } + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a Linear API key when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://mcp.linear.app/mcp" + }, + "guidanceMd": "Create a Linear API key with the permissions needed for the actions you intend to allow. A key restricted to Read is supported for read-only use; workspace and role access still apply.", + "riskTier": "S2", + "label": "Use an API key", + "credentialFields": [ + { + "key": "authorization", + "label": "Linear API key", + "type": "password", + "required": true, + "secret": true, + "helperMd": "Choose intended permissions and teams when creating the personal API key. Read-only is supported with Read permission only.", + "placeholder": "Paste your Linear API key" + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "docs": "https://linear.app/docs/api-and-webhooks" + }, + "warnings": [ + "Linear’s standard MCP endpoint exposes read and write tools; review the discovered catalog and Paperclip action policies." + ] } - ] + ], + "docsUrl": "https://linear.app/docs/mcp", + "redirectConstraints": "https-or-loopback-http" } diff --git a/packages/shared/src/app-definitions/make.json b/packages/shared/src/app-definitions/make.json index 8b5584c1dbf..a8cd0bce733 100644 --- a/packages/shared/src/app-definitions/make.json +++ b/packages/shared/src/app-definitions/make.json @@ -12,7 +12,8 @@ "darkLogoUrl": "/brands/apps/make-dark.svg" }, "urlPatterns": [ - "https://mcp.make.com/*" + "https://mcp.make.com/*", + "https://*.make.com/mcp" ], "docsUrl": "https://developers.make.com/mcp-server", "redirectConstraints": "https-or-loopback-http", @@ -37,6 +38,57 @@ "warnings": [ "A Make account and access to the scenarios exposed to MCP." ] + }, + { + "key": "mcp-api-token", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a Make MCP token when browser sign-in is not suitable.", + "defaults": { + "serverUrlTemplate": "https://{zone}/mcp" + }, + "guidanceMd": "Create a Make API token with the mcp:use scope and any additional scopes required by the selected scenarios. Enter the zone host from your Make organization. The token is sent as a Bearer header and remains a secret.", + "riskTier": "S3", + "label": "Use an MCP token", + "tenantFields": [ + { + "key": "zone", + "label": "Make zone host", + "type": "text", + "required": true, + "placeholder": "eu2.make.com", + "helperMd": "Enter the zone host for your Make organization exactly as shown in the Make MCP client setup instructions. The server URL is https:///mcp.", + "validation": { + "pattern": "^[A-Za-z0-9-]+\\.make\\.com$", + "maxLength": 253 + } + } + ], + "credentialFields": [ + { + "key": "authorization", + "label": "Make MCP token", + "type": "password", + "required": true, + "placeholder": "Paste your MCP token", + "secret": true, + "helperMd": "Provider-issued Make token. Include documented mcp:use and only additional scopes required by selected scenarios; exact scopes are user selected." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "docs": "https://developers.make.com/mcp-server/connect-using-mcp-token" + }, + "warnings": [ + "A token is a secret; do not place it in the server URL. Scenario availability depends on token scopes and scenario access controls." + ] } ] } diff --git a/packages/shared/src/app-definitions/parallel-search.json b/packages/shared/src/app-definitions/parallel-search.json new file mode 100644 index 00000000000..cbc9843fb96 --- /dev/null +++ b/packages/shared/src/app-definitions/parallel-search.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "slug": "parallel-search", + "name": "Parallel Search", + "description": "Search the public web and extract source pages with Parallel.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/parallel.svg", + "darkLogoUrl": "/brands/apps/parallel-dark.svg" + }, + "urlPatterns": [ + "https://search.parallel.ai/*" + ], + "docsUrl": "https://docs.parallel.ai/integrations/mcp/search-mcp", + "methods": [ + { + "key": "public-search", + "label": "Use public Parallel Search", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use Parallel Search’s public, keyless MCP route.", + "defaults": { + "serverUrl": "https://search.parallel.ai/mcp" + }, + "guidanceMd": "The public route exposes web_search and web_fetch. Keep source URLs, excerpts, and retrieval context in the result; this is separate from Parallel Task MCP.", + "riskTier": "S2" + } + ] +} diff --git a/packages/shared/src/app-definitions/tavily.json b/packages/shared/src/app-definitions/tavily.json new file mode 100644 index 00000000000..3cbc727ad0e --- /dev/null +++ b/packages/shared/src/app-definitions/tavily.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "slug": "tavily", + "name": "Tavily", + "description": "Search the public web and extract page content with Tavily’s hosted MCP server.", + "categories": [ + "analytics" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/tavily.png" + }, + "urlPatterns": [ + "https://mcp.tavily.com/*" + ], + "docsUrl": "https://docs.tavily.com/documentation/mcp", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Tavily", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Connect the provider-hosted Tavily MCP server with browser sign-in.", + "defaults": { + "serverUrl": "https://mcp.tavily.com/mcp", + "scopesHint": [ + "openid", + "offline_access" + ], + "mcpSessionRequired": true + }, + "guidanceMd": "Browser sign-in requires a public HTTPS Paperclip callback; local HTTP callbacks are rejected by Tavily. Use Tavily for bounded public research. Set focused queries and relevant domains, cite the source URLs, and distinguish retrieved facts from inference. Research does not authorize contacting a company or submitting forms.", + "warnings": [ + "Tavily OAuth selects the account’s `mcp_auth_default` key when configured; otherwise it may use the personal default or first available key. Review the chosen account and usage allowance.", + "Do not use the documented API-key-in-query alternative; OAuth keeps the key out of the server URL." + ], + "consoleLinks": { + "docs": "https://docs.tavily.com/documentation/mcp" + }, + "riskTier": "S2" + } + ] +} diff --git a/packages/shared/src/app-definitions/windsor-ai.json b/packages/shared/src/app-definitions/windsor-ai.json new file mode 100644 index 00000000000..89ba85b5dc1 --- /dev/null +++ b/packages/shared/src/app-definitions/windsor-ai.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "slug": "windsor-ai", + "name": "Windsor.ai", + "description": "Query connected marketing, analytics, CRM, and warehouse data through Windsor.ai.", + "categories": [ + "analytics" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/windsor-ai.png" + }, + "urlPatterns": [ + "https://mcp.windsor.ai/*" + ], + "docsUrl": "https://mcp.windsor.ai/", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Windsor.ai", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser OAuth for Windsor.ai’s hosted data MCP.", + "defaults": { + "serverUrl": "https://mcp.windsor.ai/", + "scopesHint": [ + "create", + "delete", + "offline_access" + ] + }, + "guidanceMd": "Browser sign-in requires a public HTTPS Paperclip callback; local HTTP callbacks are rejected by Windsor.ai. Connect Windsor.ai, then verify which advertising, analytics, CRM, or warehouse sources are linked. Keep source account and metric definitions with results. Review any action that changes campaigns, budgets, posts, or listings.", + "riskTier": "S4", + "warnings": [ + "Windsor.ai requires the provider-level delete scope to establish the MCP session, including for read-only use. This scope may expose destructive actions; do not approve it unless those provider permissions are acceptable.", + "The server includes actions across connected marketing platforms. A source connection does not imply authorization to modify budgets or publish content.", + "The server exposes write actions across some connected ad and commerce platforms." + ] + }, + { + "key": "mcp-api-key", + "label": "Use a Windsor.ai API key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a customer-created Windsor.ai API key as a Bearer credential.", + "defaults": { + "serverUrl": "https://mcp.windsor.ai/" + }, + "guidanceMd": "Create a Windsor.ai key for the intended account. Verify connected source accounts and action availability before use; broad tool exposure may include advertising changes.", + "riskTier": "S4", + "credentialFields": [ + { + "key": "authorization", + "label": "Use a Windsor.ai API key", + "type": "password", + "required": true, + "placeholder": "Enter provider credential", + "secret": true, + "helperMd": "Use an account key for the intended Windsor.ai organization and connect only source accounts needed for the task. Review write tools and source-specific effects; Paperclip cannot increase key permissions." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://mcp.windsor.ai/", + "docs": "https://mcp.windsor.ai/" + } + } + ] +} diff --git a/packages/shared/src/connection-instructions.test.ts b/packages/shared/src/connection-instructions.test.ts index e8e415f8f3d..5095a9eeaa3 100644 --- a/packages/shared/src/connection-instructions.test.ts +++ b/packages/shared/src/connection-instructions.test.ts @@ -52,6 +52,7 @@ describe("generic connection instructions", () => { "packages/shared/src/self-serve-mcp-research.json", "doc/connections/tool-method-permission-reviews.json", "packages/shared/src/app-definitions", + "scripts/app-definition-overrides", ]) { mkdirSync(dirname(join(fixture, source)), { recursive: true }); cpSync(join(root, source), join(fixture, source), { recursive: true }); diff --git a/packages/shared/src/dot-invitations.ts b/packages/shared/src/dot-invitations.ts new file mode 100644 index 00000000000..469eb5df94c --- /dev/null +++ b/packages/shared/src/dot-invitations.ts @@ -0,0 +1,22 @@ +/** JSON responses for the operator-owned Dot invitation flow. Pairing capabilities are transient. */ +export interface DotBinding { + id: string; + status: string; + connected: boolean; + subscriptionVerified: boolean; + hasPendingChallenge: boolean; + pairingExpiresAt: string | null; + challengeExpiresAt: string | null; +} +export interface DotInvitation { + agent: { id: string; name: string; status: string }; + approvalId: string | null; + binding: DotBinding | null; +} +export interface DotConnection { + enabled: boolean; + resourceUrl: string | null; + agentStatus: string; + binding: DotBinding | null; +} +export interface DotPairing { bindingId: string; pairingCode: string; expiresAt: string } diff --git a/packages/shared/src/easy-mcp-connectors.test.ts b/packages/shared/src/easy-mcp-connectors.test.ts new file mode 100644 index 00000000000..31d14b574c4 --- /dev/null +++ b/packages/shared/src/easy-mcp-connectors.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import { APP_DEFINITIONS } from "./app-definitions.generated.js"; +import { appDefinitionSchema } from "./validators/app-definition.js"; + +const appBySlug = (slug: string) => { + const app = APP_DEFINITIONS.find((candidate) => candidate.slug === slug); + expect(app, "expected app definition for " + slug).toBeDefined(); + return app!; +}; + +const methodFor = (slug: string, key: string) => { + const method = appBySlug(slug).methods.find((candidate) => candidate.key === key); + expect(method, "expected method " + slug + "/" + key).toBeDefined(); + return method!; +}; + +const verifiedProviders = [ + "airtable", "calendly", "exa", "firecrawl", "gsc-wizard", "linear", + "make", "parallel-search", "posthog", "tavily", "windsor-ai", +]; +const newProviders = ["calendly", "exa", "firecrawl", "gsc-wizard", "parallel-search", "tavily", "windsor-ai"]; + +describe("verified MCP connector definitions", () => { + it("keeps the 11 qualified providers on remote MCP methods", () => { + expect(new Set(verifiedProviders).size).toBe(11); + for (const slug of verifiedProviders) { + expect(appBySlug(slug).methods.some((method) => method.transport === "mcp_remote"), slug).toBe(true); + expect(appBySlug(slug).availability?.available, slug).not.toBe(false); + } + }); + + it("parses the seven newly added definitions against the shared schema", () => { + for (const slug of newProviders) { + const definition = appBySlug(slug); + expect(appDefinitionSchema.parse(definition)).toEqual(definition); + } + }); + + it("uses reviewed DCR registration for Airtable and retains the manual key method", () => { + expect(methodFor("airtable", "mcp-oauth")).toMatchObject({ + transport: "mcp_remote", auth: "oauth", ownershipModes: ["dcr"], oauthClientRegistration: "dcr", + defaults: { serverUrl: "https://mcp.airtable.com/mcp" }, + }); + expect(methodFor("airtable", "mcp-pat")).toMatchObject({ + transport: "mcp_remote", auth: "api_key", keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " }, + }); + }); + + it("keeps keyless search endpoints credential-free", () => { + for (const [slug, key, endpoint] of [ + ["exa", "public-search", "https://mcp.exa.ai/mcp"], + ["parallel-search", "public-search", "https://search.parallel.ai/mcp"], + ]) { + const method = methodFor(slug!, key!); + expect(method).toMatchObject({ transport: "mcp_remote", auth: "none", defaults: { serverUrl: endpoint } }); + expect(method.credentialFields).toBeUndefined(); + expect(method.keyPlacement).toBeUndefined(); + } + }); + + it("retains Tavily's reviewed MCP session initialization preference", () => { + const overridePath = new URL("../../../scripts/app-definition-overrides/tavily.json", import.meta.url); + const parsed = appDefinitionSchema.parse(JSON.parse(fs.readFileSync(overridePath, "utf8"))); + expect(parsed.methods[0]?.defaults?.mcpSessionRequired).toBe(true); + const invalidTransport = structuredClone(parsed); + invalidTransport.methods[0]!.transport = "rest_api"; + expect(appDefinitionSchema.safeParse(invalidTransport).success).toBe(false); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cbbcc890bc6..a94a156b7fe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2854,3 +2854,7 @@ export * from "./connection-instructions.js"; export * from "./customer-success.js"; export * from "./decision-models.js"; export { updatePrimaryAgentSchema, type UpdatePrimaryAgent, type PrimaryAgentPreference } from "./primary-agent.js"; + +export * from "./agent-avatar-upload.js"; + +export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js"; diff --git a/packages/shared/src/self-serve-mcp-research.json b/packages/shared/src/self-serve-mcp-research.json index 83b4601523e..c743f3bb29b 100644 --- a/packages/shared/src/self-serve-mcp-research.json +++ b/packages/shared/src/self-serve-mcp-research.json @@ -27,7 +27,6 @@ { "slug": "todoist", "name": "Todoist", "wave": 1, "status": "self_serve", "docsUrl": "https://developer.todoist.com/", "serverUrl": "https://ai.todoist.net/mcp", "authMode": "dcr", "prerequisite": "A Todoist account.", "riskTier": "S3" }, { "slug": "webflow", "name": "Webflow", "wave": 1, "status": "self_serve", "docsUrl": "https://developers.webflow.com/mcp/reference/getting-started", "serverUrl": "https://mcp.webflow.com/mcp", "authMode": "dcr", "prerequisite": "A Webflow account; workspace and site roles constrain accessible sites.", "riskTier": "S3" }, { "slug": "wix", "name": "Wix", "wave": 1, "status": "self_serve", "docsUrl": "https://www.wix.com/studio/developers/mcp-server", "serverUrl": "https://mcp.wix.com/mcp", "authMode": "dcr", "prerequisite": "A Wix account with access to the relevant sites.", "riskTier": "S3" }, - { "slug": "brex", "name": "Brex", "wave": 2, "status": "self_serve", "docsUrl": "https://www.brex.com/support/using-brex-in-ai-apps", "serverUrl": "https://api.brex.com/mcp", "authMode": "dcr", "prerequisite": "Brex early access and an administrator enabling the integration; financial actions require explicit approval.", "riskTier": "S4" }, { "slug": "clickhouse", "name": "ClickHouse", "wave": 2, "status": "self_serve", "docsUrl": "https://clickhouse.com/blog/announcing-managed-clickstack-mcp-server", "serverUrl": "https://mcp.clickhouse.cloud/clickstack", "authMode": "dcr", "prerequisite": "A ClickHouse Cloud ClickStack service and its service ID.", "riskTier": "S4" }, { "slug": "egnyte", "name": "Egnyte", "wave": 2, "status": "self_serve", "docsUrl": "https://developers.egnyte.com/docs/Remote_MCP_Server", "serverUrl": "https://mcp-server.egnyte.com/mcp", "authMode": "dcr", "prerequisite": "An eligible Egnyte plan and administrator approval for external LLM access.", "riskTier": "S3" }, @@ -39,7 +38,6 @@ { "slug": "stripe", "name": "Stripe", "wave": 2, "status": "self_serve", "docsUrl": "https://docs.stripe.com/mcp", "serverUrl": "https://mcp.stripe.com", "authMode": "dcr_or_api_key", "prerequisite": "A Stripe account; the server is public preview and payment actions require explicit approval.", "riskTier": "S4" }, { "slug": "supabase", "name": "Supabase", "wave": 2, "status": "self_serve", "docsUrl": "https://supabase.com/docs/guides/ai-tools/mcp", "serverUrl": "https://mcp.supabase.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Supabase account; use a development project and review write actions before connecting production data.", "riskTier": "S4" }, { "slug": "ticket-tailor", "name": "Ticket Tailor", "wave": 2, "status": "self_serve", "docsUrl": "https://developers.tickettailor.com/docs/mcp/authentication/", "serverUrl": "https://mcp.tickettailor.ai/mcp", "authMode": "dcr", "prerequisite": "A Ticket Tailor account; the provider may request an API key during its hosted authorization prompt.", "riskTier": "S3" }, - { "slug": "asana", "name": "Asana", "wave": 3, "status": "self_serve", "docsUrl": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", "serverUrl": "https://mcp.asana.com/v2/mcp", "authMode": "dcr", "prerequisite": "An active Asana account. Re-verified 2026-09-28: mcp.asana.com advertises a registration endpoint and issues clients for http://localhost callbacks, so a local Paperclip registers its own OAuth client. Asana refuses non-localhost redirect URIs, so a hosted deployment still registers an app in Asana's developer console.", "riskTier": "S3" }, { "slug": "linear", "name": "Linear", "wave": 3, "status": "self_serve", "docsUrl": "https://linear.app/docs/mcp", "serverUrl": "https://mcp.linear.app/mcp", "authMode": "dcr_cimd", "prerequisite": "An active Linear workspace. Verified 2026-09-28: mcp.linear.app advertises a registration endpoint and client-ID metadata documents, so Paperclip registers its own OAuth client.", "riskTier": "S2" }, { "slug": "box", "name": "Box", "wave": 3, "status": "self_serve", "docsUrl": "https://support.box.com/hc/en-us/articles/43847256139923-Managing-Box-MCP-Servers", "serverUrl": "https://mcp.box.com", "authMode": "customer_oauth", "prerequisite": "A Box administrator creates the OAuth integration and enables AI access.", "riskTier": "S3" }, @@ -48,18 +46,24 @@ { "slug": "similarweb", "name": "Similarweb", "wave": 3, "status": "self_serve", "docsUrl": "https://developers.similarweb.com/docs/similarweb-mcp", "serverUrl": "https://mcp.similarweb.com", "authMode": "api_key", "prerequisite": "A Similarweb subscription with API access and an API key.", "riskTier": "S2" }, { "slug": "xero", "name": "Xero", "wave": 3, "status": "self_serve", "docsUrl": "https://developer.xero.com/ai", "serverUrl": "https://mcp.xero.com/mcp", "authMode": "customer_oauth", "prerequisite": "Create a Xero OAuth app and confirm the applicable AI and data-use terms before connecting.", "riskTier": "S4" }, { "slug": "zapier", "name": "Zapier", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.zapier.com/mcp/quickstart", "serverUrl": "https://mcp.zapier.com/", "authMode": "generated_url", "prerequisite": "Create a Zapier MCP server, choose the actions it exposes, and paste its generated connection URL.", "riskTier": "S3" }, - { "slug": "youcom", "name": "You.com", "wave": 4, "status": "self_serve", "docsUrl": "https://you.com/docs/build-with-agents/mcp-server", "serverUrl": "https://api.you.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A You.com account for browser sign-in, or a You.com API key from you.com/platform for higher rate limits; a keyless free profile is also available.", "riskTier": "S2" }, - - {"slug": "fireflies", "name": "Fireflies", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.fireflies.ai/getting-started/mcp-configuration", "serverUrl": "https://api.fireflies.ai/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Fireflies account with access to the meetings you want to use. API keys are available in Settings → Developer Settings.", "riskTier": "S3"}, + { "slug": "fireflies", "name": "Fireflies", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.fireflies.ai/getting-started/mcp-configuration", "serverUrl": "https://api.fireflies.ai/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Fireflies account with access to the meetings you want to use. API keys are available in Settings → Developer Settings.", "riskTier": "S3" }, { "slug": "g2", "name": "G2", "wave": "blocked", "status": "blocked", "docsUrl": "https://documentation.g2.com/docs/g2-mcp-server", "serverUrl": "https://mcp.g2.com/mcp", "authMode": "provider_approval", "prerequisite": "G2 must enable cross-application token introspection before an independently registered client can work.", "riskTier": "S3" }, { "slug": "vercel", "name": "Vercel", "wave": "blocked", "status": "blocked", "docsUrl": "https://vercel.com/docs/agent-resources/vercel-mcp", "serverUrl": "https://mcp.vercel.com", "authMode": "provider_approval", "prerequisite": "Vercel currently reviews and approves MCP clients.", "riskTier": "S3" }, { "slug": "zomato", "name": "Zomato", "wave": "blocked", "status": "blocked", "docsUrl": "https://github.com/Zomato/mcp-server-manifest", "serverUrl": "https://mcp-server.zomato.com/mcp", "authMode": "provider_approval", "prerequisite": "Zomato currently limits third-party clients and requires redirect-URI allowlisting.", "riskTier": "S3" }, - {"slug": "zep", "name": "Zep", "wave": 3, "status": "self_serve", "docsUrl": "https://help.getzep.com/memory-mcp-server", "serverUrl": "https://api.getzep.com/mcp", "authMode": "dcr_cimd", "prerequisite": "A Zep project with Memory MCP enabled, available MCP seats, and Google Workspace or enterprise OIDC configured by its administrator. Project API keys do not authenticate Memory MCP.", "riskTier": "S3"}, - {"slug": "supermemory", "name": "Supermemory", "wave": 3, "status": "self_serve", "docsUrl": "https://supermemory.ai/docs/supermemory-mcp/mcp", "serverUrl": "https://mcp.supermemory.ai/mcp", "authMode": "dcr", "prerequisite": "Sign in to Supermemory and select the spaces this connection may access. The hosted MCP uses OAuth, not a developer API key.", "riskTier": "S3"}, - {"slug": "honcho", "name": "Honcho", "wave": 3, "status": "self_serve", "docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp", "serverUrl": "https://mcp.honcho.dev", "authMode": "api_key", "prerequisite": "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools.", "riskTier": "S3"}, + { "slug": "zep", "name": "Zep", "wave": 3, "status": "self_serve", "docsUrl": "https://help.getzep.com/memory-mcp-server", "serverUrl": "https://api.getzep.com/mcp", "authMode": "dcr_cimd", "prerequisite": "A Zep project with Memory MCP enabled, available MCP seats, and Google Workspace or enterprise OIDC configured by its administrator. Project API keys do not authenticate Memory MCP.", "riskTier": "S3" }, + { "slug": "supermemory", "name": "Supermemory", "wave": 3, "status": "self_serve", "docsUrl": "https://supermemory.ai/docs/supermemory-mcp/mcp", "serverUrl": "https://mcp.supermemory.ai/mcp", "authMode": "dcr", "prerequisite": "Sign in to Supermemory and select the spaces this connection may access. The hosted MCP uses OAuth, not a developer API key.", "riskTier": "S3" }, + { "slug": "honcho", "name": "Honcho", "wave": 3, "status": "self_serve", "docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp", "serverUrl": "https://mcp.honcho.dev", "authMode": "api_key", "prerequisite": "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools.", "riskTier": "S3" }, { "slug": "neon", "name": "Neon", "wave": 4, "status": "self_serve", "docsUrl": "https://neon.com/docs/ai/neon-mcp-server", "serverUrl": "https://mcp.neon.tech/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Neon account. The hosted server grants broad project and database management, so use a development project and review write actions before connecting production data.", "riskTier": "S4" }, { "slug": "superagent", "name": "Superagent", "wave": 4, "status": "self_serve", "docsUrl": "https://www.superagent.sh/docs/mcp", "serverUrl": "https://www.superagent.sh/mcp", "authMode": "api_key", "prerequisite": "Superagent report and triage tools consume organization credits, and deleting a finding is permanent. Set those actions to Ask first before agents run unattended.", "riskTier": "S4" }, - { "slug": "telem", "name": "Telem.AI", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.telem.ai", "serverUrl": "https://mcp.telem.ai/mcp", "authMode": "api_key", "prerequisite": "A Telem API key. Create one in the Telem console at app.telem.ai.", "riskTier": "S2" } + { "slug": "telem", "name": "Telem.AI", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.telem.ai", "serverUrl": "https://mcp.telem.ai/mcp", "authMode": "api_key", "prerequisite": "A Telem API key. Create one in the Telem console at app.telem.ai.", "riskTier": "S2" }, + { "slug": "gauge", "name": "Gauge", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.withgauge.com/help/mcp", "serverUrl": "https://app.withgauge.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Gauge account. Each connection reaches one Gauge organization: sign-in uses the organization chosen during consent, and an API key uses the organization that created it.", "riskTier": "S3" }, + { "slug": "tavily", "name": "Tavily", "wave": 2, "status": "self_serve", "docsUrl": "https://docs.tavily.com/documentation/mcp", "serverUrl": "https://mcp.tavily.com/mcp", "authMode": "dcr", "prerequisite": "OAuth uses the Tavily API key selected by account settings. Name a key `mcp_auth_default` to select it explicitly; otherwise Tavily uses the personal default key or first available key.", "riskTier": "S2", "verifiedAt": "2026-10-08" }, + { "slug": "calendly", "name": "Calendly", "wave": 2, "status": "self_serve", "docsUrl": "https://developer.calendly.com/docs/mcp/calendly-mcp-server", "serverUrl": "https://mcp.calendly.com", "authMode": "dcr", "prerequisite": "Connect with a Calendly account that has the desired scheduling permissions.", "riskTier": "S3", "verifiedAt": "2026-10-08" }, + { "slug": "exa", "name": "Exa", "wave": 3, "status": "self_serve", "docsUrl": "https://exa.ai/docs/get-started/exa-mcp", "serverUrl": "https://mcp.exa.ai/mcp", "authMode": "public", "prerequisite": "The public MCP route provides web search and page fetching. Keep citations with results. Account-authenticated Agent and advanced tools are not included in this method.", "riskTier": "S3", "verifiedAt": "2026-10-08" }, + { "slug": "firecrawl", "name": "Firecrawl", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.firecrawl.dev/mcp-server", "serverUrl": "https://mcp.firecrawl.dev/v2/mcp-oauth", "authMode": "dcr_or_api_key", "prerequisite": "Sign in and choose the Firecrawl team whose usage should apply. This route exposes the account-authorized tool surface; review paid jobs and action policies before execution.", "riskTier": "S4", "verifiedAt": "2026-10-08" }, + { "slug": "parallel-search", "name": "Parallel Search", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.parallel.ai/integrations/mcp/search-mcp", "serverUrl": "https://search.parallel.ai/mcp", "authMode": "public", "prerequisite": "The public route exposes web_search and web_fetch. Keep source URLs, excerpts, and retrieval context in the result; this is separate from Parallel Task MCP.", "riskTier": "S3", "verifiedAt": "2026-10-08" }, + { "slug": "windsor-ai", "name": "Windsor.ai", "wave": 3, "status": "self_serve", "docsUrl": "https://mcp.windsor.ai/", "serverUrl": "https://mcp.windsor.ai/", "authMode": "dcr_or_api_key", "prerequisite": "Connect Windsor.ai, then verify which advertising, analytics, CRM, or warehouse sources are linked. Keep source account and metric definitions with results. Review any action that changes campaigns, budgets, posts, or listings.", "riskTier": "S4", "verifiedAt": "2026-10-08" }, + { "slug": "gsc-wizard", "name": "GSC Wizard", "wave": 3, "status": "self_serve", "docsUrl": "https://www.gscwizard.com/claude-search-console-mcp.html", "serverUrl": "https://mcp.gscwizard.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "Connect the GSC Wizard account and its authorized properties. Check site identity and date bounds before querying; review any URL submission or property-changing action separately.", "riskTier": "S3", "verifiedAt": "2026-10-08" } ] } diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index e00b97f4f77..72d742c8975 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -2,7 +2,7 @@ import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionPurpos export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other"; export type OAuthRedirectConstraints = "https-or-loopback-http"; export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} } -export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } +export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; oauthClientRegistration?:"dcr"; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record;mcpSessionRequired?:boolean}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } export interface AppDefinition { aiConnectionRouter?: { pluginKey: string }; agentInstructions?: import("../connection-instructions.js").ConnectionInstructionTemplate; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; tags?:string[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } export type SelfServeMcpAuthMode = @@ -12,6 +12,7 @@ export type SelfServeMcpAuthMode = | "customer_oauth" | "api_key" | "generated_url" + | "public" | "provider_approval"; export interface SelfServeMcpResearchEntry { @@ -24,6 +25,7 @@ export interface SelfServeMcpResearchEntry { authMode: SelfServeMcpAuthMode; prerequisite: string; riskTier: "S1" | "S2" | "S3" | "S4"; + verifiedAt?: string; } export interface SelfServeMcpResearchManifest { diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 7e0673f378e..31a9468a3f5 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/app-definition.ts b/packages/shared/src/validators/app-definition.ts index d220a2d8897..9ee39a68b57 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -7,7 +7,7 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{ try{return new URL(value).protocol==="https:";}catch{return false;} },{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"}); const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})}); -export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); +export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),oauthClientRegistration:z.literal("dcr").optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional(),mcpSessionRequired:z.boolean().optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});if(v.oauthClientRegistration&&(v.auth!=="oauth"||v.transport!=="mcp_remote"||!v.ownershipModes.includes("dcr")))c.addIssue({code:"custom",message:"DCR client registration requires a remote MCP OAuth method with DCR ownership",path:["oauthClientRegistration"]});if(v.defaults?.mcpSessionRequired&&v.transport!=="mcp_remote")c.addIssue({code:"custom",message:"MCP session initialization preferences require remote MCP transport",path:["defaults","mcpSessionRequired"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); export const appDefinitionSchema=z.object({agentInstructions:connectionInstructionTemplateSchema.optional(),aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),tags:z.array(z.string().min(1)).optional(),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}).superRefine((value, ctx) => { if (value.aiConnectionRouter) { if (value.methods.length !== 0) ctx.addIssue({ code: "custom", message: "Pooled connectors use existing connections, not authentication methods", path: ["methods"] }); diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 27f688d3cc8..ab714359114 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 bbb44441d61..f383d5bb92e 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 322597a9005..99c753fd63d 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 bf1bb7e9f0e..bfc33c0eb2d 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 00000000000..bb868b662a1 --- /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 00000000000..074863bf507 --- /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/pnpm-lock.yaml b/pnpm-lock.yaml index fdda7fe759d..8303bb243c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ patchedDependencies: hash: 6st5fzvttegjv64llnfegwwpca path: patches/acpx@0.12.0.patch acpx@0.13.1: - hash: btis2ixbxfyq422xwpnza3ncle + hash: j4huaoi2baejrlq7vdcgzlx54m path: patches/acpx@0.13.1.patch embedded-postgres@18.1.0-beta.16: hash: 55uhvnotpqyiy37rn3pqpukhei @@ -174,7 +174,7 @@ importers: dependencies: '@agentclientprotocol/claude-agent-acp': specifier: ^0.73.0 - version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5)) + version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5)) '@anthropic-ai/sdk': specifier: 0.129.0 version: 0.129.0(zod@4.6.5) @@ -426,8 +426,8 @@ importers: packages/google-sheets-mcp-server: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) + specifier: ^1.31.0 + version: 1.31.0(zod@4.4.3) googleapis: specifier: ^182.0.0 version: 182.0.0 @@ -448,8 +448,8 @@ importers: packages/kv-demo-mcp-server: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) + specifier: ^1.31.0 + version: 1.31.0(zod@4.4.3) zod: specifier: ^4.4.3 version: 4.4.3 @@ -467,8 +467,8 @@ importers: packages/mcp-server: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) + specifier: ^1.31.0 + version: 1.31.0(zod@4.4.3) '@paperclipai/shared': specifier: workspace:* version: link:../shared @@ -499,7 +499,7 @@ importers: dependencies: '@agentclientprotocol/claude-agent-acp': specifier: 0.73.0 - version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5)) + version: 0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5)) '@agentclientprotocol/codex-acp': specifier: 1.6.2 version: 1.6.2(patch_hash=ilkykmzaax6atpgtip6bxikv6i) @@ -508,7 +508,7 @@ importers: version: 0.160.0 acpx: specifier: 0.13.1 - version: 0.13.1(patch_hash=btis2ixbxfyq422xwpnza3ncle) + version: 0.13.1(patch_hash=j4huaoi2baejrlq7vdcgzlx54m) ajv: specifier: ^8.20.0 version: 8.20.0 @@ -884,6 +884,9 @@ importers: server: dependencies: + '@agentclientprotocol/codex-acp': + specifier: 1.6.2 + version: 1.6.2(patch_hash=ilkykmzaax6atpgtip6bxikv6i) '@ai-sdk/openai': specifier: 4.0.86 version: 4.0.86(zod@4.4.3) @@ -912,8 +915,8 @@ importers: specifier: 1.14.5 version: 1.14.5 '@modelcontextprotocol/sdk': - specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) + specifier: ^1.31.0 + version: 1.31.0(zod@4.4.3) '@openrouter/ai-sdk-provider': specifier: 3.1.0 version: 3.1.0(ai@7.0.130(zod@4.4.3))(zod@4.4.3) @@ -973,13 +976,13 @@ importers: version: 2.1.0(@grpc/grpc-js@1.14.5)(nice-grpc-common@2.0.4)(nice-grpc@2.1.17) '@vercel/connect': specifier: 2.3.3 - version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ai@7.0.130(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=sryle6qd5qfmnxvoebal4gashi)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)))) + version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/sdk@1.31.0(zod@4.4.3))(ai@7.0.130(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=sryle6qd5qfmnxvoebal4gashi)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)))) acorn: specifier: 8.18.0 version: 8.18.0 acpx: specifier: 0.13.1 - version: 0.13.1(patch_hash=btis2ixbxfyq422xwpnza3ncle) + version: 0.13.1(patch_hash=j4huaoi2baejrlq7vdcgzlx54m) ai: specifier: 7.0.130 version: 7.0.130(zod@4.4.3) @@ -2662,8 +2665,8 @@ packages: cpu: [x64] os: [win32] - '@hono/node-server@2.1.0': - resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} + '@hono/node-server@2.1.3': + resolution: {integrity: sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==} engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -3141,8 +3144,8 @@ packages: resolution: {integrity: sha512-tEdVCj76gEdgvxoYkBH3li+mr8Tfe94pp1cSOUMQNwOnsuKYvretKMZfAnt9adJCgGzGH2MYZeDDtgwmOBufGQ==} engines: {node: '>=20'} - '@modelcontextprotocol/sdk@1.30.0': - resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + '@modelcontextprotocol/sdk@1.31.0': + resolution: {integrity: sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -6057,8 +6060,8 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@3.0.0: - resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==} + content-type@3.1.1: + resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==} engines: {node: '>=22'} convert-source-map@2.0.0: @@ -6653,8 +6656,8 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.6.2: - resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -6877,8 +6880,8 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hono@4.13.2: - resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} engines: {node: '>=16.9.0'} html-encoding-sniffer@6.0.0: @@ -6950,8 +6953,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.5.0: - resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + ip-address@10.7.3: + resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -7039,8 +7042,8 @@ packages: jose@6.2.10: resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} - jose@6.2.8: - resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -7757,7 +7760,6 @@ packages: opencode-ai@1.18.34: resolution: {integrity: sha512-9WUS2T0t4HHDVzXvuwTHF0nvhXvZ9mQ0r+ozCvKdJu0LVoQpOzqAW4qWTC3ygNc5Oedcc7j+Oct24JYlQYnySA==} - cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true @@ -9110,10 +9112,10 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@agentclientprotocol/claude-agent-acp@0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5))': + '@agentclientprotocol/claude-agent-acp@0.73.0(patch_hash=riqgguivxjxydoydnzpedjy5au)(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5))': dependencies: '@agentclientprotocol/sdk': 1.4.0(zod@4.4.3) - '@anthropic-ai/claude-agent-sdk': 0.3.286(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5))(zod@4.4.3) + '@anthropic-ai/claude-agent-sdk': 0.3.286(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5))(zod@4.4.3) zod: 4.4.3 transitivePeerDependencies: - '@anthropic-ai/sdk' @@ -9209,10 +9211,10 @@ snapshots: '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.286': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.286(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(zod@4.6.5))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.286(@anthropic-ai/sdk@0.129.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.5))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.129.0(zod@4.6.5) - '@modelcontextprotocol/sdk': 1.30.0(zod@4.6.5) + '@modelcontextprotocol/sdk': 1.31.0(zod@4.6.5) zod: 4.4.3 optionalDependencies: '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.286 @@ -10558,9 +10560,9 @@ snapshots: '@heif2jpeg/heif2jpeg-win32-x64-msvc@0.1.6': optional: true - '@hono/node-server@2.1.0(hono@4.13.2)': + '@hono/node-server@2.1.3(hono@4.13.13)': dependencies: - hono: 4.13.2 + hono: 4.13.13 '@iconify/types@2.0.0': {} @@ -11160,9 +11162,9 @@ snapshots: - debug - supports-color - '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.31.0(zod@4.4.3)': dependencies: - '@hono/node-server': 2.1.0(hono@4.13.2) + '@hono/node-server': 2.1.3(hono@4.13.13) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -11171,9 +11173,9 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.1 express: 5.2.1 - express-rate-limit: 8.6.2(express@5.2.1) - hono: 4.13.2 - jose: 6.2.8 + express-rate-limit: 8.7.1(express@5.2.1) + hono: 4.13.13 + jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -11182,9 +11184,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.30.0(zod@4.6.5)': + '@modelcontextprotocol/sdk@1.31.0(zod@4.6.5)': dependencies: - '@hono/node-server': 2.1.0(hono@4.13.2) + '@hono/node-server': 2.1.3(hono@4.13.13) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -11193,9 +11195,9 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.1 express: 5.2.1 - express-rate-limit: 8.6.2(express@5.2.1) - hono: 4.13.2 - jose: 6.2.8 + express-rate-limit: 8.7.1(express@5.2.1) + hono: 4.13.13 + jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -11320,7 +11322,7 @@ snapshots: '@octokit/endpoint': 11.0.5 '@octokit/request-error': 7.1.2 '@octokit/types': 18.0.0 - content-type: 3.0.0 + content-type: 3.1.1 json-with-bigint: 3.5.12 universal-user-agent: 7.0.3 @@ -13387,12 +13389,12 @@ snapshots: dependencies: execa: 5.1.1 - '@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ai@7.0.130(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=sryle6qd5qfmnxvoebal4gashi)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))))': + '@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3))(@modelcontextprotocol/sdk@1.31.0(zod@4.4.3))(ai@7.0.130(zod@4.4.3))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=sryle6qd5qfmnxvoebal4gashi)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))))': dependencies: '@vercel/oidc': 3.8.9 optionalDependencies: '@chat-adapter/slack': 4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.4.3))(zod@4.4.3) - '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.31.0(zod@4.4.3) ai: 7.0.130(zod@4.4.3) better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=sryle6qd5qfmnxvoebal4gashi)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))) @@ -13500,7 +13502,7 @@ snapshots: - bare-buffer - react-native-b4a - acpx@0.13.1(patch_hash=btis2ixbxfyq422xwpnza3ncle): + acpx@0.13.1(patch_hash=j4huaoi2baejrlq7vdcgzlx54m): dependencies: '@agentclientprotocol/sdk': 1.4.0(zod@4.4.3) commander: 15.0.0 @@ -13900,7 +13902,7 @@ snapshots: content-type@1.0.5: {} - content-type@3.0.0: {} + content-type@3.1.1: {} convert-source-map@2.0.0: {} @@ -14482,11 +14484,11 @@ snapshots: expect-type@1.4.0: {} - express-rate-limit@8.6.2(express@5.2.1): + express-rate-limit@8.7.1(express@5.2.1): dependencies: debug: 4.4.3 express: 5.2.1 - ip-address: 10.5.0 + ip-address: 10.7.3 transitivePeerDependencies: - supports-color @@ -14780,7 +14782,7 @@ snapshots: help-me@5.0.0: {} - hono@4.13.2: {} + hono@4.13.13: {} html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): dependencies: @@ -14846,7 +14848,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.5.0: {} + ip-address@10.7.3: {} ipaddr.js@1.9.1: {} @@ -14907,7 +14909,7 @@ snapshots: jose@6.2.10: {} - jose@6.2.8: {} + jose@6.2.12: {} joycon@3.1.1: {} diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 316b417d316..d084ee9be81 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from '../grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from '../grok-public-install-sandbox.mjs'; const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 }; const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []); @@ -38,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'] }); @@ -50,3 +80,152 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h assert.doesNotMatch(source, /\bsudo\b/); assert.ok(source.includes("const prerequisite = join(root, 'native/grok')")); }); + +test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'installed-codex-probe-'))); + try { + const consumer = join(root, 'consumer'); mkdirSync(consumer); + const target = `${process.platform}-${process.arch}`; + const platformPath = `node_modules/@openai/codex-${target}`; + const platform = join(consumer, platformPath); mkdirSync(platform, { recursive: true }); + const nativeVendor = join(platform, 'vendor'); mkdirSync(nativeVendor); + const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: [process.platform], cpu: [process.arch] }; + writeFileSync(join(platform, 'package.json'), JSON.stringify(manifest)); + const entry = { name: manifest.name, version: manifest.version, + resolved: `https://registry.npmjs.org/@openai/codex/-/codex-${manifest.version}.tgz`, integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}` }; + const lock = { lockfileVersion: 3, packages: { [platformPath]: entry } }; + writeFileSync(join(consumer, 'package-lock.json'), JSON.stringify(lock)); + const server = join(consumer, 'node_modules/@paperclipai/server'); + const vendor = join(server, 'dist/vendor/paperclip-runner'), drivers = join(vendor, 'drivers/acpx'); mkdirSync(drivers, { recursive: true }); + writeFileSync(join(server, 'package.json'), JSON.stringify({ name: '@paperclipai/server', type: 'module' })); + const integrity = join(drivers, 'installation-integrity.js'); + const security = join(vendor, 'drivers/codex/codex-security-config.js'); mkdirSync(join(vendor, 'drivers/codex')); + const securitySource = `export const codexExecutableReadOnlyRoots = () => [${JSON.stringify(nativeVendor)}]; + export const createIsolatedCodexAppServerArgs = (source, roots) => roots.map(root => JSON.stringify(root) + '=\"read\"');`; + writeFileSync(security, securitySource); + const moduleSource = `import assert from 'node:assert/strict'; import { writeFileSync } from 'node:fs'; + export function createAcpxPackageJsonResolver(root, manifest) { assert.equal(root, ${JSON.stringify(server)}); assert.equal(manifest, root + '/package.json'); return () => 'selected-authority'; } + export async function verifyQualifiedAcpxInstallation(profile, resolver) { assert.deepEqual(profile, { agent: 'codex', model: 'gpt-5.4' }); assert.equal(resolver(), 'selected-authority'); + return { openCommand: async () => ({ close: async () => { writeFileSync(${JSON.stringify(join(server, 'lease-closed'))}, 'ok'); } }) }; }`; + writeFileSync(integrity, moduleSource); + writeFileSync(join(drivers, 'qualified-profiles.js'), "export const resolveQualifiedAcpxProfile = (agent, model) => ({ agent, model });"); + const wrapper = join(consumer, 'node_modules/@openai/codex/bin'); mkdirSync(wrapper, { recursive: true }); + writeFileSync(join(wrapper, '../package.json'), JSON.stringify({ name: '@openai/codex', version: '0.160.0' })); + const index = join(vendor, 'index.mjs'), command = join(wrapper, 'codex.js'), probe = join(root, 'probe.mjs'); + const executable = version => writeFileSync(command, `#!/bin/sh\n[ "$1" = --version ] || exit 65\nprintf '%s\\n' 'codex-cli ${version}'\n`, { mode: 0o755 }); + writeFileSync(probe, installedCodexProbeSource(index, consumer, '0.160.0')); + const run = () => spawnSync(process.execPath, [probe], { encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin', NODE_PATH: '' } }); + writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(command)};`); + executable('0.160.0'); + let result = run(); assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).pinnedCodexCommandVerified, true); + assert.equal(JSON.parse(result.stdout).codexPlatformPackageSource, 'official npm'); + assert.equal(JSON.parse(result.stdout).codexConsumerPlatform, target); + assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, true); + assert.equal(JSON.parse(result.stdout).codexCommandLeaseVerified, true); + assert.equal(JSON.parse(result.stdout).codexNativeSandboxResourcesVerified, true); + assert.equal(readFileSync(join(server, 'lease-closed'), 'utf8'), 'ok'); + assert.equal(JSON.parse(result.stdout).providerCalls, 0); + const legacyPath = 'node_modules/legacy-codex-adapter/node_modules/@openai/codex-' + target; + const legacy = join(consumer, legacyPath); mkdirSync(legacy, { recursive: true }); + const legacyManifest = { ...manifest, version: '0.156.1-' + target }; + writeFileSync(join(legacy, 'package.json'), JSON.stringify(legacyManifest)); + lock.packages[legacyPath] = { ...entry, version: legacyManifest.version, + resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-' + legacyManifest.version + '.tgz' }; + writeFileSync(join(consumer, 'package-lock.json'), JSON.stringify(lock)); + result = run(); assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).codexPlatformPackages.length, 2); + assert.equal(JSON.parse(result.stdout).codexSelectedPlatformPath, platformPath); + assert.equal(JSON.parse(result.stdout).codexSelectedPlatformVersion, '0.160.0-' + target); + const legacyCommand = join(legacy, 'codex'); + writeFileSync(legacyCommand, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(legacyCommand)};`); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Selected Codex platform version must match the qualified pin/); + writeFileSync(index, `import { writeFileSync } from 'node:fs'; export const resolvePinnedCodexCommand = () => { + writeFileSync(${JSON.stringify(join(platform, 'package.json'))}, ${JSON.stringify(JSON.stringify({ ...manifest, altered: true }))}); + return ${JSON.stringify(command)}; };`); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /metadata must match its scanned provenance/); + writeFileSync(join(platform, 'package.json'), JSON.stringify(manifest)); + writeFileSync(index, "export const resolvePinnedCodexCommand = () => { throw new Error('Bundled Codex runtime version mismatch'); };"); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Bundled Codex runtime version mismatch/); + writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(command)};`); + writeFileSync(security, securitySource.replace(JSON.stringify(nativeVendor), '"/missing-vendor-root"')); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /vendor directory must be readable/); + writeFileSync(security, securitySource.replace(JSON.stringify(nativeVendor), `${JSON.stringify(nativeVendor)}, ${JSON.stringify(consumer)}`)); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /must not grant enclosing npm/); + writeFileSync(security, securitySource.replace("'=\"read\"'", "'=\"write\"'")); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /retain the selected native executable resources/); + writeFileSync(security, securitySource); + const emptySlot = join(server, 'node_modules/@openai/codex-' + target); + mkdirSync(emptySlot, { recursive: true }); + result = run(); assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).codexPlatformPackages.length, 2, + 'An empty nested npm optional slot must not obscure the qualified hoisted host package'); + writeFileSync(join(emptySlot, 'undeclared-native-payload'), 'native fixture; never executed'); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /empty regular directory; found.*undeclared-native-payload/); + rmSync(join(emptySlot, 'undeclared-native-payload')); + writeFileSync(join(emptySlot, 'package.json'), '{ malformed'); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /SyntaxError/); + rmSync(join(emptySlot, 'package.json')); + writeFileSync(integrity, moduleSource.replace('assert.deepEqual(profile,', "throw new Error('qualified executable digest mismatch'); assert.deepEqual(profile,")); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /qualified executable digest mismatch/); + writeFileSync(integrity, moduleSource.replace("writeFileSync(", "throw new Error('command lease close failed'); writeFileSync(")); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /command lease close failed/); + writeFileSync(integrity, moduleSource); + lock.packages[platformPath].inBundle = true; + writeFileSync(join(consumer, 'package-lock.json'), JSON.stringify(lock)); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /downloaded, not bundled/); + delete lock.packages[platformPath].inBundle; + writeFileSync(join(consumer, 'package-lock.json'), JSON.stringify(lock)); + const foreign = join(consumer, 'node_modules/@openai/codex-win32-arm64'); mkdirSync(foreign); + writeFileSync(join(foreign, 'package.json'), JSON.stringify({ name: '@openai/codex', version: '0.160.0-win32-arm64' })); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /only the host/); + rmSync(foreign, { recursive: true }); + executable('9.9.9'); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /qualified pin/); + rmSync(command); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /ENOENT/); + const outside = join(root, 'outside'); writeFileSync(outside, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(outside)};`); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /inside the installed consumer/); + writeFileSync(index, 'export const unrelated = true;'); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /must export the pinned Codex resolver/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('actual tar member checks allow Codex JavaScript but reject nested platform packages and native vendor payloads', () => { + const javascript = ['package/package.json', 'package/node_modules/@openai/codex/package.json', + 'package/node_modules/@openai/codex/bin/codex.js', 'package/dist/vendor/paperclip-runner/index.js']; + assert.doesNotThrow(() => assertNoBundledCodexPayloads(javascript)); + for (const payload of [ + 'package/node_modules/@openai/codex-linux-x64/package.json', + 'package/node_modules/bridge/node_modules/@openai/codex-darwin-arm64/package.json', + 'package/node_modules/@openai/codex-win32-x64/package.json', + 'package/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex', + 'package/dist/vendor/aarch64-apple-darwin/bin/codex', + 'package/dist/vendor/x86_64-pc-windows-msvc/bin/codex.exe', + ]) assert.throws(() => assertNoBundledCodexPayloads([...javascript, payload]), /must not bundle/); + assert.throws(() => assertNoBundledCodexPayloads(['package/../outside']), /inside the package/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.match(source, /assertNoBundledCodexPayloads\(run\('tar', \['-tzf', tarball\]\)/); +}); + +test('consumer provenance rejects bundled, foreign, inconsistent and non-official Codex payloads', () => { + const path = 'node_modules/@openai/codex-linux-x64'; + const manifest = { name: '@openai/codex', version: '0.160.0-linux-x64', os: ['linux'], cpu: ['x64'] }; + const entry = { version: manifest.version, resolved: `https://registry.npmjs.org/@openai/codex/-/codex-${manifest.version}.tgz`, + integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}` }; + const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, 'linux-x64'); + assert.equal(check().codexConsumerHostOnly, true); + const legacyManifest = { ...manifest, version: '0.156.1-linux-x64' }; + assert.equal(check({ ...entry, version: legacyManifest.version, + resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true); + assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: '^0.160.0-linux-x64' } }]), /exact host version/); + for (const change of [ + { inBundle: true }, { link: true }, { version: '0.156.1-linux-x64' }, + { resolved: 'https://example.com/@openai/codex/-/codex-0.160.0-linux-x64.tgz' }, + { resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, + { resolved: 'https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-linux-x64.tgz' }, + { integrity: undefined }, { integrity: 'sha512-AA==' }, + ]) assert.throws(() => check({ ...entry, ...change })); + assert.throws(() => check(entry, []), /must install its host/); + assert.throws(() => check(entry, [{ path: 'node_modules/@openai/codex-darwin-arm64', manifest }]), /only the host/); + assert.throws(() => check(entry, [{ path, manifest: { ...manifest, name: 'untrusted' } }]), /identity/); +}); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 293280c2d14..98717aa8afa 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -244,10 +244,10 @@ test("release verify workflow covers the same split test surface as stable PR ve assert.match(buildJob, /persist-credentials: false/); assert.doesNotMatch(buildJob, /cache: pnpm/); - for (const group of ["general-server-without-chat", "general-chat", "general-workspaces-a", "general-workspaces-b"]) { + for (const group of ["general-server-without-chat-or-native-runner", "general-chat", "general-workspaces-a", "general-workspaces-b"]) { assert.match(verifyWorkflow, new RegExp(`group: ${group}`)); } - for (const [group, count] of [["general-server-without-chat", 10], ["general-chat", 3]]) { + for (const [group, count] of [["general-server-without-chat-or-native-runner", 10], ["general-chat", 3]]) { const rows = [...verifyWorkflow.matchAll(new RegExp(`group: ${group}\\n\\s+group_label: [^\\n]+\\n\\s+shard_index: (\\d+)\\n\\s+shard_count: (\\d+)`, "g"))]; assert.deepEqual(rows.map((row) => [Number(row[1]), Number(row[2])]), Array.from({ length: count }, (_, index) => [index, count])); @@ -271,6 +271,30 @@ test("release verify workflow covers the same split test surface as stable PR ve assert.match(verifyWorkflow, /pnpm test:run:serialized -- --shard-index/); }); +test("release verification builds native test binaries in a required Rust-cached lane", () => { + const workflow = readWorkflow("release-verify.yml"); + const runnerJob = workflow.match(/ verify_paperclip_runner:\n[\s\S]*?(?=\n [A-Za-z0-9_-]+:|$)/)?.[0] ?? ""; + assert.equal((runnerJob.match(/- lane: server-integration/g) ?? []).length, 1); + assert.doesNotMatch(runnerJob, /continue-on-error/); + assert.match(runnerJob, /timeout-minutes: 20/); + assert.match(runnerJob, /shared-key: release-runner-v2/); + assert.match(runnerJob, /cache-workspace-crates: false/); + assert.match(runnerJob, /cache-bin: false/); + assert.match(runnerJob, /save-if: \$\{\{ matrix\.lane == 'rust'/); + assert.match(runnerJob, /Build native server test binaries\n\s+if: \$\{\{ matrix\.lane == 'server-integration' \}\}\n\s+timeout-minutes: 10/); + assert.match(runnerJob, /pnpm build:rust\n\s+cargo build --release --manifest-path runner\/Cargo\.toml --locked -p paperclip-runner-core --bin paperclip-runnerd --bin fake-codex-app-server/); + assert.match(runnerJob, /Run native server integration suites\n\s+if: \$\{\{ matrix\.lane == 'server-integration' \}\}\n\s+run: pnpm test:run:general -- --group general-server-native-runner/); + assert.ok(runnerJob.indexOf("Cache Runner Rust dependencies") < runnerJob.indexOf("Build native server test binaries")); + assert.ok(runnerJob.indexOf("Build native server test binaries") < runnerJob.indexOf("Run native server integration suites")); + + // The reusable workflow result includes every matrix child. Its caller must + // await that result without an override that can certify a failed native lane. + const cloud = readWorkflow("cloud-readiness.yml"); + assert.match(cloud, /verify:[\s\S]*?uses: \.\/\.github\/workflows\/release-verify\.yml/); + assert.match(cloud, /source_verified:[\s\S]*?needs: \[verify\]/); + assert.doesNotMatch(cloud, /continue-on-error|always\(\)/); +}); + test("Runner eval workflows pin actions and gate paid live execution", () => { const actionPinWorkflows = [ readWorkflow("release-verify.yml"), 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 00000000000..d5a9288ee3b --- /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/__tests__/run-vitest-stable-shard.test.mjs b/scripts/__tests__/run-vitest-stable-shard.test.mjs index d4fcb7a0d55..a044c67c151 100644 --- a/scripts/__tests__/run-vitest-stable-shard.test.mjs +++ b/scripts/__tests__/run-vitest-stable-shard.test.mjs @@ -319,9 +319,8 @@ test("12 PR without-chat shards plus the dedicated chat and native-runner lanes assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount); }); -// Mirrors release-verify.yml (10 shards, called by the Release and Cloud -// readiness workflows) and local runs: no Rust-cached vitest lane exists -// there, so the native-runner suite must stay in the server shards. +// The legacy group and local default must remain complete when a caller has +// not selected a separate native lane. for (const [caller, envOverrides] of [["Release", { GITHUB_WORKFLOW: "Release" }], ["no ambient workflow", {}]]) { test(`10 without-chat shards under ${caller} keep the native-runner suite and cover the server group with chat alone`, () => { const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], envOverrides); @@ -339,6 +338,23 @@ for (const [caller, envOverrides] of [["Release", { GITHUB_WORKFLOW: "Release" } }); } +// Release verification selects its dedicated lane explicitly. Do not infer it +// from the caller name: previews and future workflow callers need the same cover. +for (const caller of ["Release", "Cloud readiness", "another caller"]) { + test(`release server, chat, and native lanes cover every server suite once under ${caller}`, () => { + const env = { GITHUB_WORKFLOW: caller }; + const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], env); + const files = Array.from({ length: 10 }, (_, index) => dryRunJson([ + "--mode", "general", "--group", "general-server-without-chat-or-native-runner", + "--shard-index", String(index), "--shard-count", "10", + ], env)).flatMap((shard) => shard.selectedGeneralServerSuites); + const native = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"], env); + const combined = [...files, chatSuitePath, ...native.selectedGeneralServerSuites]; + assert.equal(new Set(combined).size, combined.length, "lanes must not overlap"); + assert.deepEqual(combined.sort(), full.selectedGeneralServerSuites.sort()); + }); +} + test("the native-runner lane runs exactly the cargo-dependent suites", () => { const lane = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"]); assert.deepEqual(lane.selectedGeneralServerSuites, [ diff --git a/scripts/acpx-patch-packaging.test.mjs b/scripts/acpx-patch-packaging.test.mjs index 7502350e168..fb7086946ef 100644 --- a/scripts/acpx-patch-packaging.test.mjs +++ b/scripts/acpx-patch-packaging.test.mjs @@ -6,7 +6,9 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { readFile } from "node:fs/promises"; @@ -20,11 +22,60 @@ import cliEsbuildConfig from "../cli/esbuild.config.mjs"; import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs"; import { createBundledInstallManifest, + configureBundledProviderOverrides, materializePublishManifest, selectBundledDependencyPatches, + stageBundledEsbuildOptionalDependencies, + stageBundledProviderOptionalDependencies, } from "./prepare-bundled-package.mjs"; const rootPackage = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); +const profileData = JSON.parse(await readFile(new URL("../packages/paperclip-runner/acpx-profiles.json", import.meta.url), "utf8")); +const nativeTargets = ["linux-x64", "darwin-arm64", "darwin-x64"]; +function esbuildFixture(t) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-bundled-esbuild-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const graph = join(directory, "node_modules"); + const writePackage = (location, metadata) => { + mkdirSync(location, { recursive: true }); + writeFileSync(join(location, "package.json"), JSON.stringify(metadata)); + return location; + }; + const acpx = writePackage(join(graph, "acpx"), { name: "acpx", version: "0.13.1" }); + const esbuild = writePackage(join(acpx, "node_modules/esbuild"), { + name: "esbuild", version: "0.28.2", scripts: { postinstall: "node install.js" }, + optionalDependencies: Object.fromEntries([...nativeTargets, "win32-x64"].map(target => [`@esbuild/${target}`, "0.28.2"])), + }); + const producerBinary = writePackage(join(graph, "@esbuild/linux-x64"), { + name: "@esbuild/linux-x64", version: "0.28.2", os: ["linux"], cpu: ["x64"], + }); + writePackage(join(graph, "unrelated-linux-x64"), { name: "unrelated-linux-x64", version: "1.0.0" }); + const publish = { name: "@paperclipai/adapter-utils", version: "0.3.1", bundleDependencies: ["acpx"], + dependencies: { acpx: "0.13.1" }, optionalDependencies: { unrelated: "1.0.0" } }; + return { directory, graph, esbuild, producerBinary, publish, writePackage }; +} +const codexTargets = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"]; +const codexOptional = Object.fromEntries(codexTargets.map(target => [`@openai/codex-${target}`, `npm:@openai/codex@0.160.0-${target}`])); +function codexFixture(t) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-bundled-codex-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const graph = join(directory, "node_modules"); + const writePackage = (location, metadata) => { + mkdirSync(location, { recursive: true }); writeFileSync(join(location, "package.json"), JSON.stringify(metadata)); return location; + }; + const bridge = writePackage(join(graph, "@agentclientprotocol/codex-acp"), { name: "@agentclientprotocol/codex-acp", version: "1.6.2", dependencies: { "@openai/codex": "0.160.0" } }); + writeFileSync(join(bridge, "index.js"), "export {};\n"); + const runtime = writePackage(join(graph, "@openai/codex"), { name: "@openai/codex", version: "0.160.0", bin: { codex: "bin/codex.js" }, optionalDependencies: codexOptional }); + mkdirSync(join(runtime, "bin")); writeFileSync(join(runtime, "bin/codex.js"), "// official JavaScript fixture, never executed\n"); + mkdirSync(join(runtime, "vendor")); writeFileSync(join(runtime, "vendor/codex"), "producer fallback binary fixture, never executed"); + const producer = writePackage(join(graph, "@openai/codex-linux-x64"), { name: "@openai/codex", version: "0.160.0-linux-x64", os: ["linux"], cpu: ["x64"] }); + mkdirSync(join(producer, "vendor")); writeFileSync(join(producer, "vendor/codex"), "producer optional binary fixture, never executed"); + const nested = writePackage(join(bridge, "node_modules/@openai/codex-darwin-arm64"), { name: "@openai/codex", version: "0.160.0-darwin-arm64", os: ["darwin"], cpu: ["arm64"] }); + writePackage(join(graph, "unrelated-linux-x64"), { name: "unrelated-linux-x64", version: "1.0.0" }); + const profiles = [{ agent: "codex", ...profileData.profiles.codex }]; + const publish = { name: "paperclip-codex-pack-fixture", version: "1.0.0", dependencies: { "@agentclientprotocol/codex-acp": "1.6.2" }, bundleDependencies: ["@agentclientprotocol/codex-acp"] }; + return { directory, graph, bridge, runtime, producer, nested, profiles, publish, writePackage }; +} const adapterUtilsPackage = JSON.parse( await readFile(new URL("../packages/adapter-utils/package.json", import.meta.url), "utf8"), ); @@ -40,6 +91,7 @@ const dbPackage = JSON.parse( const releaseScript = await readFile(new URL("./release.sh", import.meta.url), "utf8"); const releaseLib = await readFile(new URL("./release-lib.sh", import.meta.url), "utf8"); const buildNpmScript = await readFile(new URL("./build-npm.sh", import.meta.url), "utf8"); + const acpxRuntimePatch = await readFile( new URL("../patches/acpx@0.13.1.patch", import.meta.url), "utf8", @@ -124,6 +176,149 @@ test("published packages preserve the patched ACPX runtime", () => { assert.equal(cliEsbuildConfig.external.includes("acpx"), false); }); +test("published Codex bridge uses its qualified producer closure without consumer overrides", () => { + const published = materializePublishManifest(serverPackage); + const { installManifest, profiles } = configureBundledProviderOverrides( + createBundledInstallManifest(published, serverPackage.bundleDependencies), + serverPackage.bundleDependencies, rootPackage, profileData, + ); + assert.equal(profiles.length, 1); + for (const profile of profiles) { + assert.equal(published.dependencies[profile.agentServerPackage], profile.agentServerVersion); + assert.ok(published.bundleDependencies.includes(profile.agentServerPackage)); + assert.equal(installManifest.overrides[`${profile.agentServerPackage}@${profile.agentServerVersion}`][profile.agentRuntimePackage], profile.agentRuntimeVersion); + } + assert.equal(published.overrides, undefined); + assert.equal(installManifest.overrides.rollup, undefined); + const bad = structuredClone(rootPackage); + bad.pnpm.overrides["@agentclientprotocol/codex-acp@1.6.2>@openai/codex"] = "0.156.1"; + assert.throws(() => configureBundledProviderOverrides(installManifest, serverPackage.bundleDependencies, bad, profileData), /runtime override must match/); + const unpinned = createBundledInstallManifest(published, serverPackage.bundleDependencies); + unpinned.dependencies["@agentclientprotocol/codex-acp"] = "^1.6.2"; + assert.throws(() => configureBundledProviderOverrides(unpinned, serverPackage.bundleDependencies, rootPackage, profileData), /exact qualified profile/); + const conflict = createBundledInstallManifest(published, serverPackage.bundleDependencies); + conflict.overrides = { "@agentclientprotocol/codex-acp@1.6.2": { "@openai/codex": "0.156.1" } }; + assert.throws(() => configureBundledProviderOverrides(conflict, serverPackage.bundleDependencies, rootPackage, profileData), /conflicting producer override/); +}); + +test("Codex bundles retain pinned JavaScript and delegate official consumer platform aliases without native payloads", (t) => { + const { directory, graph, bridge, runtime, producer, nested, profiles, publish } = codexFixture(t); + const before = [bridge, runtime].map(path => readFileSync(join(path, "package.json"), "utf8")); + const staged = stageBundledProviderOptionalDependencies(directory, publish, profiles); + assert.deepEqual(staged.optionalDependencies, codexOptional); + assert.deepEqual(staged.bundleDependencies, publish.bundleDependencies); + assert.equal(staged.paperclipProviderArtifacts, undefined); + assert.equal(existsSync(producer), false); + assert.equal(existsSync(nested), false, "Transitive producer platform packages must also be removed"); + assert.equal(existsSync(join(runtime, "vendor")), false, "Wrapper fallback native payload must be removed"); + assert.equal(existsSync(join(graph, "unrelated-linux-x64")), true); + assert.equal(readFileSync(join(bridge, "package.json"), "utf8"), before[0], "The patched bridge metadata is unchanged"); + const originalRuntime = JSON.parse(before[1]); delete originalRuntime.optionalDependencies; + assert.deepEqual(JSON.parse(readFileSync(join(runtime, "package.json"), "utf8")), originalRuntime, + "Only the wrapper platform declarations move to the published authority root"); + assert.equal(readFileSync(join(runtime, "bin/codex.js"), "utf8"), "// official JavaScript fixture, never executed\n"); + assert.deepEqual(stageBundledProviderOptionalDependencies(directory, staged, profiles), staged); + writeFileSync(join(directory, "package.json"), JSON.stringify(staged)); + const packs = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--offline", "--json"], { + cwd: directory, encoding: "utf8", timeout: 30_000, + env: { ...process.env, HOME: directory, npm_config_cache: join(directory, "cache"), npm_config_update_notifier: "false" }, + })); + const paths = packs[0].files.map(file => file.path); + assert.ok(paths.some(path => path.endsWith("@openai/codex/bin/codex.js")), "The actual npm tarball retains its pinned JS runtime"); + assert.equal(paths.some(path => /@openai\/codex-(?:linux|darwin|win32)-|@openai\/codex\/vendor\//.test(path)), false, + "Actual npm pack must contain no native Codex payload on any platform"); +}); + +test("Codex bundle validation fails before deleting producer binaries for altered pins or optional declarations", (t) => { + const { directory, runtime, producer, profiles, publish } = codexFixture(t); + const file = join(runtime, "package.json"), original = JSON.parse(readFileSync(file, "utf8")); + writeFileSync(file, JSON.stringify({ ...original, version: "0.156.1" })); + assert.throws(() => stageBundledProviderOptionalDependencies(directory, publish, profiles), /runtime version mismatch/); + writeFileSync(file, JSON.stringify({ ...original, optionalDependencies: { ...codexOptional, "@openai/codex-untrusted": "0.160.0" } })); + assert.throws(() => stageBundledProviderOptionalDependencies(directory, publish, profiles), /not qualified/); + writeFileSync(file, JSON.stringify(original)); + const missing = { ...original }; delete missing.optionalDependencies; + writeFileSync(file, JSON.stringify(missing)); + assert.throws(() => stageBundledProviderOptionalDependencies(directory, publish, profiles), /not qualified/, + "Missing wrapper declarations require the exact published-root delegation mapping"); + writeFileSync(file, JSON.stringify(original)); + assert.throws(() => stageBundledProviderOptionalDependencies(directory, { ...publish, + optionalDependencies: { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.156.1-darwin-arm64" } }, profiles), /conflicts with published/); + assert.equal(existsSync(producer), true); + assert.equal(readFileSync(file, "utf8"), JSON.stringify(original), "Conflicting root declarations must not normalize metadata"); +}); + +test("Codex stripping rejects linked native payloads and wrapper fallback directories without touching external files", (t) => { + const { directory, runtime, producer, profiles, publish, writePackage } = codexFixture(t); + const outside = writePackage(join(directory, "outside"), { name: "@openai/codex", version: "0.160.0-linux-x64", os: ["linux"], cpu: ["x64"] }); + rmSync(producer, { recursive: true }); symlinkSync(outside, producer, "dir"); + assert.throws(() => stageBundledProviderOptionalDependencies(directory, publish, profiles), /escapes its producer graph/); + rmSync(producer); rmSync(join(runtime, "vendor"), { recursive: true }); symlinkSync(outside, join(runtime, "vendor"), "dir"); + assert.throws(() => stageBundledProviderOptionalDependencies(directory, publish, profiles), /escapes its producer graph/); + assert.equal(existsSync(join(outside, "package.json")), true); +}); + +test("Linux-produced esbuild bundles publish exact consumer platform dependencies and retain their hooks", (t) => { + const { directory, graph, esbuild, producerBinary, publish, writePackage } = esbuildFixture(t); + const original = readFileSync(join(esbuild, "package.json"), "utf8"); + const issuer = createRequire(join(esbuild, "package.json")); + // The original producer-only topology cannot resolve a Mac binary. The + // public optional declaration lets npm obtain it before offline hooks run. + assert.throws(() => issuer.resolve("@esbuild/darwin-arm64/bin/esbuild"), /Cannot find module/); + const staged = stageBundledEsbuildOptionalDependencies(directory, publish); + for (const target of [...nativeTargets, "win32-x64"]) assert.equal(staged.optionalDependencies[`@esbuild/${target}`], "0.28.2"); + assert.equal(staged.optionalDependencies.unrelated, "1.0.0"); + assert.deepEqual(staged.bundleDependencies, ["acpx"]); + assert.deepEqual(staged.dependencies, publish.dependencies); + assert.equal(publish.optionalDependencies["@esbuild/darwin-arm64"], undefined); + assert.equal(existsSync(producerBinary), false); + assert.equal(existsSync(join(graph, "unrelated-linux-x64")), true); + assert.equal(readFileSync(join(esbuild, "package.json"), "utf8"), original); + assert.deepEqual(stageBundledEsbuildOptionalDependencies(directory, staged), staged); + for (const target of ["darwin-arm64", "darwin-x64"]) { + const artifact = writePackage(join(graph, `@esbuild/${target}`), { + name: `@esbuild/${target}`, version: staged.optionalDependencies[`@esbuild/${target}`], os: ["darwin"], cpu: [target.slice(7)], + }); + mkdirSync(join(artifact, "bin")); writeFileSync(join(artifact, "bin/esbuild"), "consumer-selected fixture; never executed"); + assert.equal(issuer.resolve(`@esbuild/${target}/bin/esbuild`), join(artifact, "bin/esbuild")); + } +}); + +test("bundled esbuild rejects conflicting published or nested platform versions without removing the producer", (t) => { + const { directory, graph, producerBinary, publish, writePackage } = esbuildFixture(t); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, { + ...publish, optionalDependencies: { "@esbuild/darwin-arm64": "0.27.0" }, + }), /platform version conflicts/); + assert.equal(existsSync(producerBinary), true); + writePackage(join(graph, "esbuild"), { name: "esbuild", version: "0.27.0", + optionalDependencies: Object.fromEntries(nativeTargets.map(target => [`@esbuild/${target}`, "0.27.0"])) }); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /platform version conflicts|installed platform identity mismatch/); + assert.equal(existsSync(producerBinary), true); +}); + +test("bundled esbuild rejects missing targets, altered declarations and incorrect platform identity", (t) => { + const { directory, esbuild, producerBinary, publish } = esbuildFixture(t); + const path = join(esbuild, "package.json"), metadata = JSON.parse(readFileSync(path, "utf8")); + writeFileSync(path, JSON.stringify({ ...metadata, optionalDependencies: { "@esbuild/linux-x64": "0.28.2" } })); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /omitted a supported platform/); + writeFileSync(path, JSON.stringify({ ...metadata, optionalDependencies: { ...metadata.optionalDependencies, "@esbuild/win32-x64": "^0.28.2" } })); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /exact package version/); + writeFileSync(path, JSON.stringify(metadata)); + writeFileSync(join(producerBinary, "package.json"), JSON.stringify({ name: "@esbuild/linux-x64", version: "0.27.0" })); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /installed platform identity mismatch/); + assert.equal(existsSync(producerBinary), true); +}); + +test("bundled esbuild cannot follow a platform payload or package namespace outside its producer graph", (t) => { + const { directory, graph, producerBinary, publish, writePackage } = esbuildFixture(t); + const external = writePackage(join(directory, "outside"), { name: "@esbuild/linux-x64", version: "0.28.2" }); + rmSync(producerBinary, { recursive: true }); symlinkSync(external, producerBinary, "dir"); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /escapes its producer graph/); + assert.equal(readFileSync(join(external, "package.json"), "utf8").includes("0.28.2"), true); + rmSync(join(graph, "@esbuild"), { recursive: true }); symlinkSync(external, join(graph, "@esbuild"), "dir"); + assert.throws(() => stageBundledEsbuildOptionalDependencies(directory, publish), /escapes its producer graph/); +}); + test("Paperclip Runner pins the qualified ACPX host callbacks", () => { assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch"); assert.equal( @@ -285,11 +480,13 @@ test("bundled package patch selection rejects an unpatched installed version", ( }); test("server package staging applies every bundled runtime patch and preserves the vendored runner", (t) => { - const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-")); + const fixtureDir = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-"))); const sourceDir = join(fixtureDir, "source"); const destinationDir = join(fixtureDir, "destination"); const binDir = join(fixtureDir, "bin"); const callLog = join(fixtureDir, "calls.log"); + const fixtureSourceRoot = join(fixtureDir, "repo"); + const fixtureArtifacts = join(fixtureDir, "native-artifacts"); mkdirSync(sourceDir); mkdirSync(join(sourceDir, "dist")); writeFileSync(join(sourceDir, "dist", "index.js"), "export {};\n"); @@ -300,6 +497,18 @@ test("server package staging applies every bundled runtime patch and preserves t JSON.stringify({ ...serverPackage, files: ["dist"] }), ); writeFileSync(callLog, ""); + mkdirSync(fixtureArtifacts); + const nativePackage = join(fixtureArtifacts, "@openai/codex-linux-x64"); + mkdirSync(join(nativePackage, "vendor"), { recursive: true }); + writeFileSync(join(nativePackage, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.160.0-linux-x64", os: ["linux"], cpu: ["x64"] })); + writeFileSync(join(nativePackage, "vendor/codex"), "producer host fixture; never executed"); + mkdirSync(join(fixtureSourceRoot, "packages/paperclip-runner/src/drivers/acpx"), { recursive: true }); + writeFileSync(join(fixtureSourceRoot, "package.json"), JSON.stringify(rootPackage)); + writeFileSync(join(fixtureSourceRoot, "packages/paperclip-runner/acpx-profiles.json"), JSON.stringify(profileData)); + mkdirSync(join(fixtureSourceRoot, "patches")); + for (const patchPath of Object.values(rootPackage.pnpm.patchedDependencies)) { + writeFileSync(join(fixtureSourceRoot, patchPath), readFileSync(new URL(`../${patchPath}`, import.meta.url))); + } t.after(() => rmSync(fixtureDir, { recursive: true, force: true })); const writeExecutable = (name, body) => { @@ -321,8 +530,10 @@ mkdir -p "$destination/node_modules/.pnpm" set -euo pipefail printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG" [ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ] -node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); }' +node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); } for (const [bridge, runtimes] of Object.entries(pkg.overrides ?? {})) { for (const [name, version] of Object.entries(runtimes)) { const dir = "node_modules/" + name; fs.mkdirSync(dir, { recursive: true }); const native = name === "@openai/codex"; const optionalDependencies = native ? Object.fromEntries(["linux-x64","linux-arm64","darwin-x64","darwin-arm64","win32-x64","win32-arm64"].map(target => [name+"-"+target,"npm:"+name+"@"+version+"-"+target])) : undefined; fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version, optionalDependencies })); } }' +node -e 'const fs=require("node:fs");fs.cpSync(process.env.FAKE_NATIVE_ARTIFACTS+"/@openai/codex-linux-x64","node_modules/@openai/codex-linux-x64",{recursive:true});fs.mkdirSync("node_modules/@openai/codex/vendor",{recursive:true});fs.writeFileSync("node_modules/@openai/codex/vendor/codex","producer fallback fixture");' mkdir -p node_modules/acpx/dist +node -e 'const fs=require("node:fs");fs.mkdirSync("node_modules/esbuild",{recursive:true});fs.writeFileSync("node_modules/esbuild/package.json",JSON.stringify({name:"esbuild",version:"0.28.2",scripts:{postinstall:"node install.js"},optionalDependencies:Object.fromEntries(["linux-x64","darwin-arm64","darwin-x64"].map(target=>["@esbuild/"+target,"0.28.2"]))}));fs.mkdirSync("node_modules/@esbuild/linux-x64",{recursive:true});fs.writeFileSync("node_modules/@esbuild/linux-x64/package.json",JSON.stringify({name:"@esbuild/linux-x64",version:"0.28.2",os:["linux"],cpu:["x64"]}));' printf 'unpatched runtime\\n' > node_modules/acpx/dist/runtime.js printf '{"name":"acpx","version":"0.13.1"}\\n' > node_modules/acpx/package.json `, @@ -356,9 +567,11 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" execFileSync( process.execPath, [ - new URL("./prepare-bundled-package.mjs", import.meta.url).pathname, + "--input-type=module", "-e", + `import { prepareBundledPackage } from ${JSON.stringify(new URL("./prepare-bundled-package.mjs", import.meta.url).href)}; prepareBundledPackage(process.argv[1], process.argv[2], { sourceRoot: process.argv[3] });`, sourceDir, destinationDir, + fixtureSourceRoot, ], { env: { @@ -366,6 +579,7 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" PATH: `${binDir}:${process.env.PATH}`, FAKE_CALL_LOG: callLog, FAKE_SOURCE_PACKAGE: join(sourceDir, "package.json"), + FAKE_NATIVE_ARTIFACTS: fixtureArtifacts, }, stdio: "pipe", }, @@ -375,6 +589,19 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" assert.equal(lstatSync(stagedAcpxDir).isDirectory(), true); assert.equal(lstatSync(stagedAcpxDir).isSymbolicLink(), false); assert.equal(existsSync(join(destinationDir, "node_modules/.pnpm")), false); + const stagedManifest = JSON.parse(readFileSync(join(destinationDir, "package.json"), "utf8")); + for (const [name, version] of Object.entries(codexOptional)) { + assert.equal(stagedManifest.optionalDependencies[name], version); + assert.equal(existsSync(join(destinationDir, "node_modules", name)), false); + assert.equal(stagedManifest.bundleDependencies.includes(name), false); + } + assert.equal(existsSync(join(destinationDir, "node_modules/@openai/codex/vendor")), false); + assert.equal(stagedManifest.paperclipProviderArtifacts, undefined); + assert.equal(JSON.parse(readFileSync(join(destinationDir, "node_modules/@openai/codex/package.json"), "utf8")).optionalDependencies, undefined); + assert.equal(stagedManifest.optionalDependencies["@esbuild/darwin-arm64"], "0.28.2"); + assert.equal(stagedManifest.optionalDependencies["@esbuild/darwin-x64"], "0.28.2"); + assert.equal(existsSync(join(destinationDir, "node_modules/@esbuild/linux-x64")), false); + assert.equal(JSON.parse(readFileSync(join(destinationDir, "node_modules/esbuild/package.json"))).scripts.postinstall, "node install.js"); assert.match( readFileSync(join(stagedAcpxDir, "dist/runtime.js"), "utf8"), /spawnEnvironment/, @@ -383,13 +610,14 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" readFileSync(callLog, "utf8"), /patch -p1 --forward -d .*node_modules\/acpx/, ); + const patchedBundledDependencies = serverPackage.bundleDependencies.filter(name => rootPackage.pnpm.patchedDependencies[`${name}@${serverPackage.dependencies[name]}`]); assert.equal( readFileSync(callLog, "utf8") .split("\n") .filter((line) => line.startsWith("patch ")).length, - serverPackage.bundleDependencies.length, + patchedBundledDependencies.length, ); - for (const name of serverPackage.bundleDependencies) { + for (const name of patchedBundledDependencies) { const specifier = `${name}@${serverPackage.dependencies[name]}`; const patchPath = rootPackage.pnpm.patchedDependencies[specifier]; assert.equal( diff --git a/scripts/app-definition-overrides.test.mjs b/scripts/app-definition-overrides.test.mjs new file mode 100644 index 00000000000..cd24500d579 --- /dev/null +++ b/scripts/app-definition-overrides.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const makeFixture = () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-app-overrides-")); + for (const relative of [ + "scripts/ingest-app-definitions.mjs", + "scripts/app-definition-overrides", + "packages/shared/src/app-definitions", + "packages/shared/src/self-serve-mcp-research.json", + "doc/connections/tool-method-permission-reviews.json", + "ui/public/brands/apps/manifest.json", + ]) { + const source = path.join(repoRoot, relative); + const target = path.join(root, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.cpSync(source, target, { recursive: true }); + } + return root; +}; + +const runIngestion = (root) => + spawnSync(process.execPath, ["scripts/ingest-app-definitions.mjs", "--definitions-only"], { + cwd: root, + encoding: "utf8", + }); + +test("provider overrides keep manifest branding and pass normal review validation", (t) => { + const root = makeFixture(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const definitionPath = path.join(root, "packages/shared/src/app-definitions/github.json"); + const definition = JSON.parse(fs.readFileSync(definitionPath, "utf8")); + definition.name = "GitHub override fixture"; + definition.branding = { logoUrl: "https://untrusted.invalid/logo.svg" }; + fs.writeFileSync( + path.join(root, "scripts/app-definition-overrides/github.json"), + JSON.stringify(definition), + ); + + const result = runIngestion(root); + assert.equal(result.status, 0, result.stderr || result.stdout); + const emitted = JSON.parse(fs.readFileSync(definitionPath, "utf8")); + assert.equal(emitted.name, "GitHub override fixture"); + assert.deepEqual(emitted.branding, { + logoUrl: "/brands/apps/github.svg", + darkLogoUrl: "/brands/apps/github-dark.svg", + }); +}); + +test("provider overrides cannot add an unreviewed tool method", (t) => { + const root = makeFixture(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const definitionPath = path.join(root, "packages/shared/src/app-definitions/github.json"); + const definition = JSON.parse(fs.readFileSync(definitionPath, "utf8")); + definition.methods.push({ + key: "mcp-unreviewed-fixture", + transport: "mcp_remote", + auth: "api_key", + ownershipModes: ["customer"], + defaults: { serverUrl: "https://mcp.github.com" }, + credentialFields: [ + { key: "authorization", label: "Token", type: "password", required: true, placeholder: "token", secret: true }, + ], + keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " }, + }); + fs.writeFileSync( + path.join(root, "scripts/app-definition-overrides/github.json"), + JSON.stringify(definition), + ); + + const result = runIngestion(root); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /github\/mcp-unreviewed-fixture: permission review required/); +}); + +test("Tavily curated MCP methods opt into initialized sessions", (t) => { + const root = makeFixture(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const overridePath = path.join(root, "scripts/app-definition-overrides/tavily.json"); + const override = JSON.parse(fs.readFileSync(overridePath, "utf8")); + assert.equal(override.methods[0].defaults.mcpSessionRequired, true); + + const result = runIngestion(root); + assert.equal(result.status, 0, result.stderr || result.stdout); + const emittedPath = path.join(root, "packages/shared/src/app-definitions/tavily.json"); + const emitted = JSON.parse(fs.readFileSync(emittedPath, "utf8")); + assert.equal(emitted.methods[0].defaults.mcpSessionRequired, true); +}); diff --git a/scripts/app-definition-overrides/airtable.json b/scripts/app-definition-overrides/airtable.json new file mode 100644 index 00000000000..447d049fb0b --- /dev/null +++ b/scripts/app-definition-overrides/airtable.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": 1, + "slug": "airtable", + "name": "Airtable", + "description": "Connect Airtable's provider-hosted MCP server.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/airtable.svg" + }, + "urlPatterns": [ + "https://mcp.airtable.com/*" + ], + "docsUrl": "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "oauthClientRegistration": "dcr", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://mcp.airtable.com/mcp", + "scopesHint": [ + "data.records:read", + "data.records:write", + "schema.bases:read", + "schema.bases:write", + "data.recordComments:read", + "data.recordComments:write", + "workspacesAndBases:read" + ] + }, + "guidanceMd": "Connect Airtable in the browser. An Airtable account; enterprise administrators may need to allowlist the client.", + "riskTier": "S3", + "label": "Sign in with Airtable", + "consoleLinks": { + "docs": "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server" + }, + "warnings": [ + "An Airtable account; enterprise administrators may need to allowlist the client." + ] + }, + { + "key": "mcp-pat", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a personal access token when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://mcp.airtable.com/mcp" + }, + "guidanceMd": "Create an Airtable personal access token with the scopes and base resources required for the actions you intend to allow. MCP calls inherit your Airtable role and base permissions; organization allowlisting may still apply.", + "riskTier": "S3", + "label": "Use a personal access token", + "credentialFields": [ + { + "key": "authorization", + "label": "Airtable personal access token", + "type": "password", + "required": true, + "placeholder": "pat...", + "secret": true, + "helperMd": "Choose the scopes and base resources required for your supported read and write actions. Airtable permissions and selected base resources still apply; Paperclip cannot increase an issued token’s access." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "docs": "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server", + "keys": "https://airtable.com/create/tokens" + }, + "warnings": [ + "Use a disposable or development base for initial write qualification. Enterprise admins may need to allowlist MCP access." + ] + } + ] +} diff --git a/scripts/app-definition-overrides/calendly.json b/scripts/app-definition-overrides/calendly.json new file mode 100644 index 00000000000..29fb57ee124 --- /dev/null +++ b/scripts/app-definition-overrides/calendly.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "slug": "calendly", + "name": "Calendly", + "description": "Review Calendly event types and availability, and use supported scheduling actions.", + "categories": [ + "productivity" + ], + "branding": { + "logoUrl": "/brands/apps/calendly.png" + }, + "urlPatterns": [ + "https://mcp.calendly.com/*" + ], + "docsUrl": "https://developer.calendly.com/docs/mcp/calendly-mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Calendly", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Connect the provider-hosted Calendly MCP server with browser sign-in.", + "defaults": { + "serverUrl": "https://mcp.calendly.com", + "scopesHint": [ + "mcp:scheduling:read", + "mcp:scheduling:write" + ] + }, + "guidanceMd": "Check the connected account, event type, timezone, availability, and attendee details before using a scheduling write. Use a test scheduling artifact only when the discovered tool explicitly supports it.", + "warnings": [ + "Calendly MCP requires DCR and OAuth 2.1 authorization-code with PKCE S256. It does not support personal access tokens or manually provisioned static OAuth clients.", + "The provider advertises both scheduling read and write scopes for full access; verify the live tool catalog before enabling booking or link creation." + ], + "consoleLinks": { + "docs": "https://developer.calendly.com/docs/mcp/calendly-mcp-server" + }, + "riskTier": "S3" + } + ] +} diff --git a/scripts/app-definition-overrides/exa.json b/scripts/app-definition-overrides/exa.json new file mode 100644 index 00000000000..f5e9a384c03 --- /dev/null +++ b/scripts/app-definition-overrides/exa.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "slug": "exa", + "name": "Exa", + "description": "Search the public web and read source pages with Exa.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/exa.svg" + }, + "urlPatterns": [ + "https://mcp.exa.ai/*" + ], + "docsUrl": "https://exa.ai/docs/get-started/exa-mcp", + "methods": [ + { + "key": "public-search", + "label": "Use public Exa search", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use Exa’s public, keyless search MCP.", + "defaults": { + "serverUrl": "https://mcp.exa.ai/mcp" + }, + "guidanceMd": "The public MCP route provides web search and page fetching. Keep citations with results. Account-authenticated Agent and advanced tools are not included in this method.", + "riskTier": "S2" + } + ] +} diff --git a/scripts/app-definition-overrides/firecrawl.json b/scripts/app-definition-overrides/firecrawl.json new file mode 100644 index 00000000000..936f04c0c11 --- /dev/null +++ b/scripts/app-definition-overrides/firecrawl.json @@ -0,0 +1,92 @@ +{ + "schemaVersion": 1, + "slug": "firecrawl", + "name": "Firecrawl", + "description": "Search, scrape, and parse public web pages with Firecrawl.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/firecrawl.svg" + }, + "urlPatterns": [ + "https://mcp.firecrawl.dev/*" + ], + "docsUrl": "https://docs.firecrawl.dev/mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Firecrawl", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use interactive Firecrawl account authorization for the full hosted MCP tool surface.", + "defaults": { + "serverUrl": "https://mcp.firecrawl.dev/v2/mcp-oauth", + "scopesHint": [ + "firecrawl:global", + "offline_access" + ] + }, + "guidanceMd": "Sign in and choose the Firecrawl team whose usage should apply. This route exposes the account-authorized tool surface; review paid jobs and action policies before execution.", + "riskTier": "S4", + "warnings": [ + "Account authorization exposes Firecrawl’s broader hosted MCP surface, including job-based research tools that may use account credits.", + "Crawls and research jobs can use provider credits; review their tool actions and limits before execution." + ] + }, + { + "key": "mcp-api-key", + "label": "Use a Firecrawl API key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a customer-created Firecrawl API key in a Bearer header for unattended or higher-limit access.", + "defaults": { + "serverUrl": "https://mcp.firecrawl.dev/v2/mcp" + }, + "guidanceMd": "Create a Firecrawl API key for the intended team, then connect it with a Bearer header. The authenticated server may expose additional job tools and spend team credits.", + "riskTier": "S4", + "credentialFields": [ + { + "key": "authorization", + "label": "Use a Firecrawl API key", + "type": "password", + "required": true, + "placeholder": "Enter provider credential", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://docs.firecrawl.dev/mcp-server", + "docs": "https://docs.firecrawl.dev/mcp-server" + } + }, + { + "key": "keyless", + "label": "Try keyless access", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use the rate-limited keyless public web-search route without a Firecrawl credential.", + "defaults": { + "serverUrl": "https://mcp.firecrawl.dev/v2/mcp" + }, + "guidanceMd": "The keyless route currently exposes Search, Scrape, and Parse. Keep requests bounded; these calls can consume the provider’s keyless allowance.", + "riskTier": "S2" + } + ] +} diff --git a/scripts/app-definition-overrides/gsc-wizard.json b/scripts/app-definition-overrides/gsc-wizard.json new file mode 100644 index 00000000000..dde624161c8 --- /dev/null +++ b/scripts/app-definition-overrides/gsc-wizard.json @@ -0,0 +1,77 @@ +{ + "schemaVersion": 1, + "slug": "gsc-wizard", + "name": "GSC Wizard", + "description": "Read Google Search Console, Bing Webmaster, and analytics data for connected properties.", + "categories": [ + "analytics" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/gsc-wizard.png" + }, + "urlPatterns": [ + "https://mcp.gscwizard.com/*" + ], + "docsUrl": "https://www.gscwizard.com/claude-search-console-mcp.html", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with GSC Wizard", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use remote OAuth authorization for the GSC Wizard MCP.", + "defaults": { + "serverUrl": "https://mcp.gscwizard.com/mcp", + "scopesHint": [ + "mcp:read", + "mcp:write" + ] + }, + "guidanceMd": "Connect the GSC Wizard account and its authorized properties. Check site identity and date bounds before querying; review any URL submission or property-changing action separately.", + "riskTier": "S3", + "warnings": [ + "Some tools may submit URLs or otherwise change external indexing state; review action details before use.", + "GSC Wizard connects multiple search and analytics providers; confirm the authorized property and action policy for each tool." + ] + }, + { + "key": "mcp-api-key", + "label": "Use a GSC Wizard MCP key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a customer-created GSC Wizard MCP key in a Bearer header.", + "defaults": { + "serverUrl": "https://mcp.gscwizard.com/mcp" + }, + "guidanceMd": "Create a key for the intended account and select the appropriate read or write permission during key creation. Verify each connected property before querying; review URL submission actions separately.", + "riskTier": "S3", + "credentialFields": [ + { + "key": "authorization", + "label": "Use a GSC Wizard MCP key", + "type": "password", + "required": true, + "placeholder": "Enter provider credential", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://www.gscwizard.com/claude-search-console-mcp.html", + "docs": "https://www.gscwizard.com/claude-search-console-mcp.html" + } + } + ] +} diff --git a/scripts/app-definition-overrides/linear.json b/scripts/app-definition-overrides/linear.json new file mode 100644 index 00000000000..5f11a63742c --- /dev/null +++ b/scripts/app-definition-overrides/linear.json @@ -0,0 +1,104 @@ +{ + "schemaVersion": 1, + "slug": "linear", + "name": "Linear", + "description": "Create, update, and read Linear issues.", + "categories": [ + "productivity" + ], + "featured": true, + "branding": { + "logoUrl": "/brands/apps/linear.svg", + "darkLogoUrl": "/brands/apps/linear-dark.svg" + }, + "urlPatterns": [ + "https://mcp.linear.app/*" + ], + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr", + "customer" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://mcp.linear.app/mcp", + "authorizationEndpoint": "https://linear.app/oauth/authorize", + "tokenEndpoint": "https://api.linear.app/oauth/token", + "scopesHint": [ + "read", + "write" + ] + }, + "guidanceMd": "Connect Linear for issues and projects. Paperclip registers its own OAuth client with Linear MCP; no developer-console setup is needed.", + "riskTier": "S2", + "requiredResourceFilters": [ + "workspace", + "team", + "project" + ], + "credentialSources": { + "vercelConnect": { + "services": [ + "linear" + ], + "principalModes": [ + "user" + ], + "scopes": [ + "read", + "write" + ], + "header": { + "name": "Authorization", + "prefix": "Bearer " + } + } + }, + "label": "Sign in with Linear", + "consoleLinks": { + "docs": "https://linear.app/docs/mcp" + } + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a Linear API key when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://mcp.linear.app/mcp" + }, + "guidanceMd": "Create a Linear API key with the permissions needed for the actions you intend to allow. A key restricted to Read is supported for read-only use; workspace and role access still apply.", + "riskTier": "S2", + "label": "Use an API key", + "credentialFields": [ + { + "key": "authorization", + "label": "Linear API key", + "type": "password", + "required": true, + "secret": true, + "helperMd": "Grant Read and Write only when those actions are intended. A Read-only key is supported; the key cannot exceed the owning user\u2019s workspace access.", + "placeholder": "Paste your Linear API key" + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "docs": "https://linear.app/docs/api-and-webhooks" + }, + "warnings": [ + "Linear\u2019s standard MCP endpoint exposes read and write tools; review the discovered catalog and Paperclip action policies." + ] + } + ] +} diff --git a/scripts/app-definition-overrides/make.json b/scripts/app-definition-overrides/make.json new file mode 100644 index 00000000000..dff8f823a67 --- /dev/null +++ b/scripts/app-definition-overrides/make.json @@ -0,0 +1,94 @@ +{ + "schemaVersion": 1, + "slug": "make", + "name": "Make", + "description": "Connect Make's provider-hosted MCP server.", + "categories": [ + "productivity" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/make.svg", + "darkLogoUrl": "/brands/apps/make-dark.svg" + }, + "urlPatterns": [ + "https://mcp.make.com/*", + "https://*.make.com/mcp" + ], + "docsUrl": "https://developers.make.com/mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://mcp.make.com" + }, + "guidanceMd": "Connect Make in the browser. A Make account and access to the scenarios exposed to MCP.", + "riskTier": "S3", + "label": "Sign in with Make", + "consoleLinks": { + "docs": "https://developers.make.com/mcp-server" + }, + "warnings": [ + "A Make account and access to the scenarios exposed to MCP." + ] + }, + { + "key": "mcp-api-token", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a Make MCP token when browser sign-in is not suitable.", + "defaults": { + "serverUrlTemplate": "https://{zone}/mcp" + }, + "guidanceMd": "Create a Make API token with the mcp:use scope and any additional scopes required by the selected scenarios. Enter the zone host from your Make organization. The token is sent as a Bearer header and remains a secret.", + "riskTier": "S3", + "label": "Use an MCP token", + "tenantFields": [ + { + "key": "zone", + "label": "Make zone host", + "type": "text", + "required": true, + "placeholder": "eu2.make.com", + "helperMd": "Enter the zone host for your Make organization exactly as shown in the Make MCP client setup instructions. The server URL is https:///mcp.", + "validation": { + "pattern": "^[A-Za-z0-9-]+\\.make\\.com$", + "maxLength": 253 + } + } + ], + "credentialFields": [ + { + "key": "authorization", + "label": "Make MCP token", + "type": "password", + "required": true, + "placeholder": "Paste your MCP token", + "secret": true, + "helperMd": "Create a Make API token that includes the provider-documented mcp:use scope and only other scopes needed by the scenario tools you intend to use." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "docs": "https://developers.make.com/mcp-server/connect-using-mcp-token" + }, + "warnings": [ + "A token is a secret; do not place it in the server URL. Scenario availability depends on token scopes and scenario access controls." + ] + } + ] +} diff --git a/scripts/app-definition-overrides/parallel-search.json b/scripts/app-definition-overrides/parallel-search.json new file mode 100644 index 00000000000..cbc9843fb96 --- /dev/null +++ b/scripts/app-definition-overrides/parallel-search.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "slug": "parallel-search", + "name": "Parallel Search", + "description": "Search the public web and extract source pages with Parallel.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/parallel.svg", + "darkLogoUrl": "/brands/apps/parallel-dark.svg" + }, + "urlPatterns": [ + "https://search.parallel.ai/*" + ], + "docsUrl": "https://docs.parallel.ai/integrations/mcp/search-mcp", + "methods": [ + { + "key": "public-search", + "label": "Use public Parallel Search", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use Parallel Search’s public, keyless MCP route.", + "defaults": { + "serverUrl": "https://search.parallel.ai/mcp" + }, + "guidanceMd": "The public route exposes web_search and web_fetch. Keep source URLs, excerpts, and retrieval context in the result; this is separate from Parallel Task MCP.", + "riskTier": "S2" + } + ] +} diff --git a/scripts/app-definition-overrides/tavily.json b/scripts/app-definition-overrides/tavily.json new file mode 100644 index 00000000000..97dc1d5d6b3 --- /dev/null +++ b/scripts/app-definition-overrides/tavily.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "slug": "tavily", + "name": "Tavily", + "description": "Search the public web and extract page content with Tavily\u2019s hosted MCP server.", + "categories": [ + "analytics" + ], + "branding": { + "logoUrl": "/brands/apps/tavily.png" + }, + "urlPatterns": [ + "https://mcp.tavily.com/*" + ], + "docsUrl": "https://docs.tavily.com/documentation/mcp", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Tavily", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Connect the provider-hosted Tavily MCP server with browser sign-in.", + "defaults": { + "serverUrl": "https://mcp.tavily.com/mcp", + "scopesHint": [ + "openid", + "offline_access" + ], + "mcpSessionRequired": true + }, + "guidanceMd": "Browser sign-in requires a public HTTPS Paperclip callback; local HTTP callbacks are rejected by Tavily. Use Tavily for bounded public research. Set focused queries and relevant domains, cite the source URLs, and distinguish retrieved facts from inference. Research does not authorize contacting a company or submitting forms.", + "warnings": [ + "Tavily OAuth selects the account\u2019s `mcp_auth_default` key when configured; otherwise it may use the personal default or first available key. Review the chosen account and usage allowance.", + "Do not use the documented API-key-in-query alternative; OAuth keeps the key out of the server URL." + ], + "consoleLinks": { + "docs": "https://docs.tavily.com/documentation/mcp" + }, + "riskTier": "S2" + } + ] +} diff --git a/scripts/app-definition-overrides/windsor-ai.json b/scripts/app-definition-overrides/windsor-ai.json new file mode 100644 index 00000000000..70c6dde556c --- /dev/null +++ b/scripts/app-definition-overrides/windsor-ai.json @@ -0,0 +1,79 @@ +{ + "schemaVersion": 1, + "slug": "windsor-ai", + "name": "Windsor.ai", + "description": "Query connected marketing, analytics, CRM, and warehouse data through Windsor.ai.", + "categories": [ + "analytics" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/windsor-ai.png" + }, + "urlPatterns": [ + "https://mcp.windsor.ai/*" + ], + "docsUrl": "https://mcp.windsor.ai/", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "label": "Sign in with Windsor.ai", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser OAuth for Windsor.ai\u2019s hosted data MCP.", + "defaults": { + "serverUrl": "https://mcp.windsor.ai/", + "scopesHint": [ + "create", + "delete", + "offline_access" + ] + }, + "guidanceMd": "Browser sign-in requires a public HTTPS Paperclip callback; local HTTP callbacks are rejected by Windsor.ai. Connect Windsor.ai, then verify which advertising, analytics, CRM, or warehouse sources are linked. Keep source account and metric definitions with results. Review any action that changes campaigns, budgets, posts, or listings.", + "riskTier": "S4", + "warnings": [ + "Windsor.ai requires the provider-level delete scope to establish the MCP session, including for read-only use. This scope may expose destructive actions; do not approve it unless those provider permissions are acceptable.", + "The server includes actions across connected marketing platforms. A source connection does not imply authorization to modify budgets or publish content.", + "The server exposes write actions across some connected ad and commerce platforms." + ] + }, + { + "key": "mcp-api-key", + "label": "Use a Windsor.ai API key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a customer-created Windsor.ai API key as a Bearer credential.", + "defaults": { + "serverUrl": "https://mcp.windsor.ai/" + }, + "guidanceMd": "Create a Windsor.ai key for the intended account. Verify connected source accounts and action availability before use; broad tool exposure may include advertising changes.", + "riskTier": "S4", + "credentialFields": [ + { + "key": "authorization", + "label": "Use a Windsor.ai API key", + "type": "password", + "required": true, + "placeholder": "Enter provider credential", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://mcp.windsor.ai/", + "docs": "https://mcp.windsor.ai/" + } + } + ] +} diff --git a/scripts/create-runner-remote-companion.mjs b/scripts/create-runner-remote-companion.mjs new file mode 100644 index 00000000000..c3cb74e0a30 --- /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 07dc9207841..8ce7663810f 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -1,3 +1,5 @@ +import assert from 'node:assert/strict'; + // Keep public-package lifecycle code off the verification host. Resolve and // cache the public npm graph without scripts, then execute it offline. export const GROK_PUBLIC_INSTALL_IMAGE = @@ -7,18 +9,165 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; -export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMb = 256 }) { +// Inspect the actual npm archive, independently of the staging helper. +export function assertNoBundledCodexPayloads(entries) { + assert.ok(Array.isArray(entries) && entries.length > 0, 'Packed package must have tar members'); + for (const entry of entries) { + assert.equal(typeof entry, 'string'); + assert.ok(!entry.startsWith('/') && !entry.split('/').includes('..'), 'Tar member must remain inside the package'); + assert.doesNotMatch(entry, /(?:^|\/)node_modules\/@openai\/codex-(?:linux|darwin|win32)-(?:x64|arm64)(?:\/|$)/, + 'Paperclip tarballs must not bundle Codex platform packages'); + assert.doesNotMatch(entry, /(?:^|\/)node_modules\/@openai\/codex\/vendor(?:\/|$)/, + 'Paperclip tarballs must not bundle the Codex wrapper vendor payload'); + if (!entry.endsWith('/')) assert.doesNotMatch(entry, /(?:^|\/)codex(?:\.exe)?$/, + 'Paperclip tarballs must not bundle native Codex executables'); + } +} + +// Verify every host payload against its own official npm provenance, including +// independent legacy closures. The selected native runtime is pinned below. +export function assertInstalledCodexPlatformProvenance(lock, installed, target) { + assert.match(target, /^(linux|darwin|win32)-(x64|arm64)$/); + assert.ok(Array.isArray(installed) && installed.length > 0, 'Consumer must install its host Codex platform package'); + const alias = `@openai/codex-${target}`; + for (const { path, manifest } of installed) { + assert.ok(path.endsWith(`/node_modules/${alias}`) || path === `node_modules/${alias}`, + 'Consumer must contain only the host Codex platform payload'); + const version = manifest.version; + assert.ok(typeof version === 'string' && version.endsWith('-' + target) + && /^\d+\.\d+\.\d+$/.test(version.slice(0, -target.length - 1)), 'Installed Codex platform must declare an exact host version'); + assert.ok([alias, '@openai/codex'].includes(manifest.name), 'Installed Codex platform package identity must match its alias'); + const [os, cpu] = target.split('-'); + assert.deepEqual(manifest.os, [os]); + assert.deepEqual(manifest.cpu, [cpu]); + const entry = lock.packages?.[path]; + assert.ok(entry, 'Installed Codex platform must have a consumer lock entry'); + assert.notEqual(entry.inBundle, true, 'Codex platform package must be downloaded, not bundled'); + assert.notEqual(entry.link, true, 'Codex platform package must not be a local link'); + assert.equal(entry.version, version, 'Consumer lock must pin the host Codex platform version'); + const url = new URL(entry.resolved); + assert.equal(url.protocol, 'https:'); + assert.equal(url.host, 'registry.npmjs.org', 'Codex platform must resolve from official npm'); + assert.equal(url.username + url.password + url.search + url.hash, '', 'Codex npm URL must not have credentials or modifiers'); + assert.equal(decodeURIComponent(url.pathname), `/@openai/codex/-/codex-${version}.tgz`, 'Codex platform must use its exact official npm tarball'); + assert.match(entry.integrity ?? '', /^sha512-[A-Za-z0-9+/]+={0,2}$/, 'Codex platform must have npm integrity'); + assert.equal(Buffer.from(entry.integrity.slice(7), 'base64').length, 64, 'Codex npm integrity must be a SHA-512 digest'); + } + return { codexConsumerHostOnly: true, codexConsumerPlatform: target, codexPlatformPackageSource: 'official npm', + codexPlatformPackages: installed.map(({ path, manifest }) => ({ path, name: manifest.name, version: manifest.version, + resolved: lock.packages[path].resolved, integrity: lock.packages[path].integrity, inBundle: false })) }; +} + +// Execute this in the existing offline public consumer, never in the checkout. +// The public index and returned command must both come from the installed graph. +export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion) { + return ` + import assert from 'node:assert/strict'; + import { execFileSync } from 'node:child_process'; + import { createRequire } from 'node:module'; + import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; + import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; + import { pathToFileURL } from 'node:url'; + const root = realpathSync(${JSON.stringify(consumerRoot)}); + const contained = path => { const value = relative(root, realpathSync(path)); return value !== '' && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value); }; + const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8')); + const installed = [], pending = [join(root, 'node_modules')]; + let visited = 0; + while (pending.length) { + const directory = pending.pop(); + assert.ok(contained(directory), 'Installed dependency directory must remain in the consumer'); + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name.startsWith('.') || !(entry.isDirectory() || entry.isSymbolicLink())) continue; + const namespace = join(directory, entry.name); + const packages = entry.name.startsWith('@') ? readdirSync(namespace, { withFileTypes: true }) + .filter(value => value.isDirectory() || value.isSymbolicLink()).map(value => join(namespace, value.name)) : [namespace]; + for (const packageRoot of packages) { + assert.ok(++visited <= 10000 && contained(packageRoot), 'Installed dependency graph must be bounded and contained'); + const path = relative(root, packageRoot).split(sep).join('/'); + if (${/(?:^|\/)node_modules\/@openai\/codex-(?:linux|darwin|win32)-(?:x64|arm64)$/.toString()}.test(path)) { + let manifest; + try { manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); } + catch (error) { + if (error.code !== 'ENOENT') throw error; + const contents = readdirSync(packageRoot), slot = lstatSync(packageRoot); + assert.ok(slot.isDirectory() && !slot.isSymbolicLink() && contents.length === 0, + 'Codex optional slot without a manifest must be an empty regular directory; found ' + + JSON.stringify(contents.sort().slice(0, 16).map(name => name.slice(0, 256)))); + // npm can leave an empty nested optional slot while hoisting the + // actual host payload. Its real lock and manifest still qualify below. + continue; + } + installed.push({ path, manifest }); + } + const nested = join(packageRoot, 'node_modules'); + if (existsSync(nested)) pending.push(nested); + } + } + } + const provenance = (${assertInstalledCodexPlatformProvenance.toString()})(lock, installed, process.platform + '-' + process.arch); + const index = ${JSON.stringify(indexPath)}; + assert.ok(contained(index), 'Codex public index must be installed in the consumer'); + const { resolvePinnedCodexCommand } = await import(pathToFileURL(index).href); + assert.equal(typeof resolvePinnedCodexCommand, 'function', 'Installed public index must export the pinned Codex resolver'); + const command = resolvePinnedCodexCommand(); + assert.ok(isAbsolute(command) && contained(command), 'Pinned Codex must resolve inside the installed consumer'); + assert.ok(statSync(command).isFile(), 'Pinned Codex must be a regular executable'); + accessSync(command, constants.X_OK); + const selectedPlatformManifest = realpathSync(createRequire(command).resolve('@openai/codex-' + process.platform + '-' + process.arch + '/package.json')); + const selectedPlatform = installed.find(({ path }) => realpathSync(join(root, path, 'package.json')) === selectedPlatformManifest); + assert.ok(selectedPlatform, 'Codex wrapper must resolve an official host platform package from its installed graph'); + const selectedMetadata = JSON.parse(readFileSync(selectedPlatformManifest, 'utf8')); + assert.deepEqual(selectedMetadata, selectedPlatform.manifest, 'Selected Codex platform metadata must match its scanned provenance'); + assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch, + 'Selected Codex platform version must match the qualified pin'); + const version = execFileSync(command, ['--version'], { timeout: 30_000, maxBuffer: 128 * 1024, encoding: 'utf8', + env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', NODE_PATH: '' } }).trim(); + assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin'); + const vendor = dirname(index); + const integrityPath = join(vendor, 'drivers/acpx/installation-integrity.js'); + const profilesPath = join(vendor, 'drivers/acpx/qualified-profiles.js'); + const securityPath = join(vendor, 'drivers/codex/codex-security-config.js'); + assert.ok(contained(integrityPath) && contained(profilesPath) && contained(securityPath), 'Codex verification modules must come from the installed consumer'); + const { codexExecutableReadOnlyRoots, createIsolatedCodexAppServerArgs } = await import(pathToFileURL(securityPath).href); + const platformRoot = realpathSync(dirname(selectedPlatformManifest)); + const nativeVendor = realpathSync(join(platformRoot, 'vendor')); + assert.ok(contained(nativeVendor) && nativeVendor.startsWith(platformRoot + sep), 'Codex vendor resources must remain inside their installed platform package'); + const sandboxSource = { HOME: '/private-provider-home', PATH: '/usr/local/bin:/usr/bin:/bin' }; + const readRoots = codexExecutableReadOnlyRoots(sandboxSource, command); + assert.ok(readRoots.includes(nativeVendor), 'Selected native Codex vendor directory must be readable inside its sandbox'); + assert.ok(!readRoots.includes(platformRoot) && !readRoots.includes(root) && !readRoots.includes(sandboxSource.HOME), 'Codex sandbox must not grant enclosing npm or credential-home access'); + const sandboxArgs = createIsolatedCodexAppServerArgs(sandboxSource, readRoots).join('\\n'); + assert.ok(sandboxArgs.includes(JSON.stringify(nativeVendor) + '="read"'), 'Codex sandbox configuration must retain the selected native executable resources'); + const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href); + const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(profilesPath).href); + const serverRoot = resolve(vendor, '../../..'); + assert.ok(contained(serverRoot), 'Codex package authority must be the installed server'); + const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile('codex', 'gpt-5.4'), + createAcpxPackageJsonResolver(serverRoot, join(serverRoot, 'package.json'))); + const lease = await installation.openCommand(); + await lease.close(); + console.log(JSON.stringify({ pinnedCodexCommandVerified: true, pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, + ...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, codexNativeSandboxResourcesVerified: true, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true, providerCalls: 0 })); + `; +} + +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/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 42dbb080689..73151fa9125 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -185,7 +185,7 @@ const apps = [ key: "email-agent", label: "Email with an agent", purpose: "channel", provider: "agentmail", transport: "rest_api", auth: "api_key", ownershipModes: ["customer"], whenToUse: "Assign an inbox to an agent and manage email conversations in tasks.", credentialFields: [{ key: "apiKey", label: "AgentMail API key", type: "password", placeholder: "am_…", required: true, secret: true }], guidanceMd: "Connect an AgentMail API key, then create or select an inbox for your agent. WebSocket receiving works without a public URL.", - consoleLinks: { keys: "https://console.agentmail.to", docs: "https://docs.agentmail.to/inboxes" }, riskTier: "S3", requiredResourceFilters: ["inbox"] + consoleLinks: { keys: "https://console.agentmail.to/dashboard/api-keys", docs: "https://docs.agentmail.to/inboxes" }, riskTier: "S3", requiredResourceFilters: ["inbox"] }], [ "zapier", @@ -1017,6 +1017,7 @@ const categoryBySlug = { egnyte: "content", embat: "commerce", fireflies: "productivity", + gauge: "analytics", "hugging-face": "ai", jira: "productivity", kernel: "developer", @@ -1100,6 +1101,7 @@ const apiKeySpec = { prefix: "Bearer ", placeholder: "Paste your Coda API token", }, + gauge: { name: "Authorization", prefix: "Bearer ", placeholder: "Paste your Gauge API key" }, kernel: { name: "X-API-Key", prefix: null, @@ -1229,6 +1231,34 @@ const specialMethodsFor = (entry) => { }] } : {}), }), ]; + // Gauge's sign-in picks one organization during consent; an API key is bound + // to the organization that created it and carries no user identity. + if (entry.slug === "gauge") { + const warning = + "Gauge content tools can publish to your connected CMS, including live. Set publish actions to Ask first before agents run unattended."; + // The access step renders the capability profile before sign-in, so the + // publish warning is visible on the default browser path too. + const capabilityProfile = { + key: "write", + label: "Read and write", + description: `Read AI visibility, SEO and traffic data, and run content workflows. ${warning}`, + }; + return [ + oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, { + guidanceMd: + "Connect Gauge in the browser and choose the organization this connection may use. Write tools start enabled and remain governed by Paperclip's action policies.", + warnings: [entry.prerequisite, warning], + capabilityProfile, + }), + apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, { + whenToUse: "Use a Gauge organization API key when browser sign-in is not suitable.", + guidanceMd: + "In Gauge, open Settings → Integrations → API Keys, generate a key for Paperclip, and paste it below.", + warnings: [entry.prerequisite, warning], + capabilityProfile, + }), + ]; + } // Superagent's hosted server advertises protected-resource metadata, but its // authorization server publishes no OAuth metadata, so organization API keys // are the only working credential. @@ -1728,7 +1758,10 @@ for (const entry of researchManifest.entries) { } let methods = specialMethodsFor(entry); if (!methods) { - if (entry.authMode === "customer_oauth") + if (entry.authMode === "public") + methods = [method("public-mcp", "mcp_remote", "none", { serverUrl: entry.serverUrl }, entry.riskTier, + "Connect the provider's public MCP server without an account credential.", { label: "Connect public tools" })]; + else if (entry.authMode === "customer_oauth") methods = [customerOAuthMethodFor(entry)]; else if (entry.authMode === "api_key") methods = [apiKeyMethodFor(entry)]; else { @@ -1750,7 +1783,7 @@ for (const entry of researchManifest.entries) { schemaVersion: 1, slug: entry.slug, name: entry.name, - description: ({ neon: "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", superagent: "Review security findings, start red-team reports, and score content and packages before agents trust them.", mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers.", telem: "Search the web and read pages across many search providers with one API key." })[entry.slug] ?? (entry.slug === "fireflies" + description: ({ neon: "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", gauge: "Track how AI answers mention your brand, research keywords and traffic, and run content workflows.", superagent: "Review security findings, start red-team reports, and score content and packages before agents trust them.", mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers.", telem: "Search the web and read pages across many search providers with one API key." })[entry.slug] ?? (entry.slug === "fireflies" ? "Search meeting transcripts, read summaries and action items, and connect meeting-ready routines." : `Connect ${entry.name}'s provider-hosted MCP server.`), categories: [categoryBySlug[entry.slug] ?? "other"], @@ -1919,6 +1952,23 @@ for (const { slug, name, provider, subscription, envKey, url, description } of a // AI account flow; saved REST connections remain removable through Connections. app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")]; } +// Reviewed provider-specific source data stays separate from generated output. +// Apply it after legacy/research inputs, then run the same branding, permission, +// instruction-template and validation pipeline as every other definition. +const overridesDirectory = path.join(root, "scripts/app-definition-overrides"); +if (fs.existsSync(overridesDirectory)) { + for (const fileName of fs.readdirSync(overridesDirectory).filter(name => name.endsWith(".json")).sort()) { + const override = JSON.parse(fs.readFileSync(path.join(overridesDirectory, fileName), "utf8")); + if (!override.slug || fileName !== `${override.slug}.json` || !Array.isArray(override.methods)) { + throw new Error(`${fileName}: invalid provider definition source`); + } + override.branding = brandingFor(override.slug); + const existingIndex = apps.findIndex(app => app.slug === override.slug); + if (existingIndex === -1) apps.push(override); + else apps[existingIndex] = { ...apps[existingIndex], ...override }; + } +} + // Every tool method has a checked-in permission review. Discovery metadata is // evidence for reviewers, never a runtime instruction to request more scopes. const permissionReviews = JSON.parse(fs.readFileSync( diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs new file mode 100644 index 00000000000..663754fcbc8 --- /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/prepare-bundled-package.mjs b/scripts/prepare-bundled-package.mjs index aeb2bef0c5d..cdc03387905 100644 --- a/scripts/prepare-bundled-package.mjs +++ b/scripts/prepare-bundled-package.mjs @@ -1,11 +1,13 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const nativeTargets = ["linux-x64", "darwin-arm64", "darwin-x64"]; export function materializePublishManifest(pkg) { const publishConfig = pkg.publishConfig ?? {}; @@ -48,6 +50,222 @@ export function createBundledInstallManifest(publishManifest, bundledDependencie return installManifest; } +// npm consumers cannot inherit the workspace's pnpm overrides or patches. +// Materialize the already-qualified Codex bridge closure in the tarball, +// with overrides confined to the temporary, scripts-disabled producer graph. +export function configureBundledProviderOverrides(installManifest, bundledDependencies, rootPackage, profileData) { + const result = structuredClone(installManifest); + const selected = []; + for (const [agent, serverPackage, runtimePackage] of [ + ["codex", "@agentclientprotocol/codex-acp", "@openai/codex"], + ]) { + if (!bundledDependencies.includes(serverPackage)) continue; + const profile = profileData?.profiles?.[agent]; + if (profileData?.schema !== "paperclip.acpx-profiles.v1" || profile?.agentServerPackage !== serverPackage + || profile.agentRuntimePackage !== runtimePackage || !/^\d+\.\d+\.\d+$/.test(profile.agentServerVersion ?? "") + || !/^\d+\.\d+\.\d+$/.test(profile.agentRuntimeVersion ?? "") + || result.dependencies?.[serverPackage] !== profile.agentServerVersion) { + throw new Error(`Bundled ${agent} bridge must match its exact qualified profile`); + } + const selector = `${serverPackage}@${profile.agentServerVersion}>${runtimePackage}`; + if (rootPackage.pnpm?.overrides?.[selector] !== profile.agentRuntimeVersion) { + throw new Error(`Bundled ${agent} runtime override must match its qualified profile`); + } + const npmSelector = `${serverPackage}@${profile.agentServerVersion}`; + if (result.overrides?.[npmSelector] !== undefined) { + throw new Error(`Bundled ${agent} runtime has a conflicting producer override`); + } + const overrides = { [runtimePackage]: profile.agentRuntimeVersion }; + result.overrides = { ...result.overrides, [npmSelector]: overrides }; + selected.push({ agent, ...profile }); + } + return { installManifest: result, profiles: selected }; +} + +const inside = (root, candidate) => { + const value = relative(root, candidate); + return value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value); +}; + +// Bundle the pinned JavaScript closure, never the producer's Codex executable. +// npm installs the official optional package for the consumer's own platform. +export function stageBundledProviderOptionalDependencies(destinationDir, publishManifest, profiles) { + const graphRoot = resolve(destinationDir, "node_modules"); + if (realpathSync(graphRoot) !== graphRoot) throw new Error("Bundled Codex graph must be a canonical owned directory"); + const result = structuredClone(publishManifest), remove = new Set(), optional = {}, normalize = new Map(); + const profile = profiles.find(value => value.agent === "codex"); + if (profiles.length !== 1 || !profile) throw new Error("Bundled provider graph must contain its qualified Codex profile"); + const targets = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"]; + const declarations = Object.fromEntries(targets.map(target => [`${profile.agentRuntimePackage}-${target}`, + `npm:${profile.agentRuntimePackage}@${profile.agentRuntimeVersion}-${target}`])); + const ownedDirectory = directory => { + const stat = lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || !inside(graphRoot, realpathSync(directory))) { + throw new Error("Bundled Codex dependency escapes its producer graph"); + } + }; + const manifestAt = directory => { + const path = resolve(directory, "package.json"), stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256 * 1024) { + throw new Error("Bundled Codex dependency manifest must be a bounded regular file"); + } + return JSON.parse(readFileSync(path, "utf8")); + }; + const bridgeDirectory = resolve(graphRoot, profile.agentServerPackage); + ownedDirectory(bridgeDirectory); + const bridge = manifestAt(bridgeDirectory); + if (bridge.name !== profile.agentServerPackage || bridge.version !== profile.agentServerVersion) throw new Error("Bundled Codex bridge version mismatch"); + const issuer = createRequire(resolve(bridgeDirectory, "package.json")); + const runtimeManifest = realpathSync(issuer.resolve(`${profile.agentRuntimePackage}/package.json`)); + if (!inside(graphRoot, runtimeManifest)) throw new Error("Bundled Codex runtime escapes its producer graph"); + let runtimeCount = 0, packageCount = 0; + const pending = [{ directory: graphRoot, depth: 0 }]; + while (pending.length) { + const { directory, depth } = pending.pop(); + if (depth > 64) throw new Error("Bundled Codex graph exceeds its depth limit"); + const packages = []; + for (const entry of readdirSync(directory)) { + if (entry.startsWith(".")) continue; + const candidate = resolve(directory, entry); ownedDirectory(candidate); + if (entry.startsWith("@")) { + for (const child of readdirSync(candidate)) { + const scoped = resolve(candidate, child); ownedDirectory(scoped); packages.push(scoped); + } + } else packages.push(candidate); + } + for (const packageDirectory of packages) { + if (++packageCount > 20_000) throw new Error("Bundled Codex graph exceeds its package limit"); + const metadata = manifestAt(packageDirectory); + const binding = basename(dirname(packageDirectory)) === "@openai" ? `@openai/${basename(packageDirectory)}` : null; + if (binding?.startsWith("@openai/codex-")) { + const target = binding.slice("@openai/codex-".length), [os, cpu] = target.split("-"); + if (!Object.hasOwn(declarations, binding) || ![binding, profile.agentRuntimePackage].includes(metadata.name) + || metadata.version !== `${profile.agentRuntimeVersion}-${target}` || !metadata.os?.includes(os) || !metadata.cpu?.includes(cpu)) { + throw new Error("Bundled Codex installed platform identity mismatch"); + } + remove.add(packageDirectory); + continue; + } + const nested = resolve(packageDirectory, "node_modules"); + if (lstatExists(nested)) { ownedDirectory(nested); pending.push({ directory: nested, depth: depth + 1 }); } + if (binding !== profile.agentRuntimePackage) continue; + if (metadata.name !== profile.agentRuntimePackage || metadata.version !== profile.agentRuntimeVersion) throw new Error("Bundled Codex runtime version mismatch"); + runtimeCount++; + const delegated = metadata.optionalDependencies === undefined + && Object.entries(declarations).every(([name, specifier]) => result.optionalDependencies?.[name] === specifier); + if (!delegated && (Object.keys(metadata.optionalDependencies ?? {}).length !== targets.length + || targets.some(target => metadata.optionalDependencies?.[`${profile.agentRuntimePackage}-${target}`] !== declarations[`${profile.agentRuntimePackage}-${target}`]))) { + throw new Error("Bundled Codex platform declaration is not qualified"); + } + if (!delegated) { + const normalized = { ...metadata }; delete normalized.optionalDependencies; + normalize.set(resolve(packageDirectory, "package.json"), normalized); + } + const vendor = resolve(packageDirectory, "vendor"); + if (lstatExists(vendor)) { ownedDirectory(vendor); remove.add(vendor); } + } + } + if (!runtimeCount) throw new Error("Bundled Codex runtime omitted its platform declarations"); + for (const [name, specifier] of Object.entries(declarations)) { + if (result.optionalDependencies?.[name] !== undefined && result.optionalDependencies[name] !== specifier) throw new Error(`Bundled Codex platform conflicts with published dependency: ${name}`); + optional[name] = specifier; + } + // A bundled wrapper's optional edges make npm treat missing platform slots + // as bundled too. Delegate those exact declarations to the unbundled server + // root so normal npm can install the correct host version beside legacy deps. + // Validate the complete graph before changing metadata or deleting payloads. + for (const [path, metadata] of normalize) writeFileSync(path, `${JSON.stringify(metadata, null, 2)}\n`); + for (const directory of remove) rmSync(directory, { recursive: true, force: true }); + result.optionalDependencies = { ...result.optionalDependencies, ...optional }; + for (const field of ["bundleDependencies", "bundledDependencies"]) { + if (Array.isArray(result[field])) result[field] = result[field].filter(name => !Object.hasOwn(declarations, name)); + } + delete result.paperclipProviderArtifacts; + return result; +} + +function lstatExists(path) { + try { lstatSync(path); return true; } + catch (error) { if (error.code === "ENOENT") return false; throw error; } +} + +// A Linux-produced bundle contains esbuild's JavaScript but only the producer's +// optional executable. Expose its exact platform declarations to the consumer, +// just as embedded-postgres does below, before retaining the original hooks. +export function stageBundledEsbuildOptionalDependencies(destinationDir, publishManifest) { + const graphRoot = resolve(destinationDir, "node_modules"); + if (realpathSync(graphRoot) !== graphRoot) throw new Error("Bundled esbuild graph must be a canonical owned directory"); + const result = structuredClone(publishManifest), pending = [{ directory: graphRoot, depth: 0 }]; + const optional = {}, remove = new Set(); + let packageCount = 0; + const ownedDirectory = directory => { + const stat = lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || !inside(graphRoot, realpathSync(directory))) { + throw new Error("Bundled esbuild dependency escapes its producer graph"); + } + }; + const manifestAt = directory => { + const file = resolve(directory, "package.json"), stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256 * 1024) { + throw new Error("Bundled esbuild dependency manifest must be a bounded regular file"); + } + return JSON.parse(readFileSync(file, "utf8")); + }; + while (pending.length) { + const { directory, depth } = pending.pop(); + if (depth > 64) throw new Error("Bundled esbuild dependency graph exceeds its depth limit"); + const packages = []; + for (const entry of readdirSync(directory)) { + if (entry.startsWith(".")) continue; + const candidate = resolve(directory, entry); + ownedDirectory(candidate); + if (entry.startsWith("@")) { + for (const child of readdirSync(candidate)) { + const scoped = resolve(candidate, child); ownedDirectory(scoped); packages.push(scoped); + } + } else packages.push(candidate); + } + for (const packageDirectory of packages) { + if (++packageCount > 20_000) throw new Error("Bundled esbuild dependency graph exceeds its package limit"); + const metadata = manifestAt(packageDirectory); + const nested = resolve(packageDirectory, "node_modules"); + if (lstatExists(nested)) { ownedDirectory(nested); pending.push({ directory: nested, depth: depth + 1 }); } + if (basename(packageDirectory) !== "esbuild") continue; + if (metadata.name !== "esbuild" || !/^\d+\.\d+\.\d+$/.test(metadata.version ?? "")) { + throw new Error("Bundled esbuild package identity mismatch"); + } + const declarations = metadata.optionalDependencies ?? {}; + if (nativeTargets.some(target => declarations[`@esbuild/${target}`] !== metadata.version)) { + throw new Error("Bundled esbuild omitted a supported platform declaration"); + } + const issuer = createRequire(resolve(packageDirectory, "package.json")); + for (const [name, version] of Object.entries(declarations)) { + if (!/^@esbuild\/[a-z0-9]+-[a-z0-9]+$/.test(name) || version !== metadata.version) { + throw new Error("Bundled esbuild platform declaration must match its exact package version"); + } + if ((optional[name] !== undefined && optional[name] !== version) + || (result.optionalDependencies?.[name] !== undefined && result.optionalDependencies[name] !== version)) { + throw new Error(`Bundled esbuild platform version conflicts with published dependency: ${name}`); + } + optional[name] = version; + for (const lookup of issuer.resolve.paths(name) ?? []) { + const candidate = resolve(lookup, name); + if (!inside(graphRoot, candidate) || !lstatExists(candidate)) continue; + ownedDirectory(candidate); + const installed = manifestAt(candidate); + if (installed.name !== name || installed.version !== version) { + throw new Error(`Bundled esbuild installed platform identity mismatch: ${name}`); + } + remove.add(candidate); + } + } + } + } + for (const directory of remove) rmSync(directory, { recursive: true, force: true }); + if (Object.keys(optional).length) result.optionalDependencies = { ...result.optionalDependencies, ...optional }; + return result; +} + function patchedDependencyPackageName(specifier) { const versionSeparator = specifier.lastIndexOf("@"); const packageNameEnd = specifier.startsWith("@") ? specifier.indexOf("/") : 0; @@ -157,7 +375,12 @@ export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = const deployedPackagePath = resolve(destinationDir, "package.json"); const publishManifest = materializePublishManifest(sourcePackage); - const installManifest = createBundledInstallManifest(publishManifest, bundledDependencies); + const rootPackage = JSON.parse(readFileSync(resolve(sourceRoot, "package.json"), "utf8")); + const profileData = bundledDependencies.includes("@agentclientprotocol/codex-acp") + ? JSON.parse(readFileSync(resolve(sourceRoot, "packages/paperclip-runner/acpx-profiles.json"), "utf8")) : undefined; + const { installManifest, profiles } = configureBundledProviderOverrides( + createBundledInstallManifest(publishManifest, bundledDependencies), bundledDependencies, rootPackage, profileData, + ); writeFileSync(deployedPackagePath, `${JSON.stringify(installManifest, null, 2)}\n`); execFileSync( @@ -167,6 +390,7 @@ export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = ); writeFileSync(deployedPackagePath, `${JSON.stringify(publishManifest, null, 2)}\n`); applyBundledDependencyPatches(destinationDir, bundledDependencies, sourceRoot); + if (profiles.length) writeFileSync(deployedPackagePath, `${JSON.stringify(stageBundledProviderOptionalDependencies(destinationDir, publishManifest, profiles), null, 2)}\n`); if (bundledDependencies.includes("acpx")) { const acpxPackage = JSON.parse( @@ -210,6 +434,8 @@ export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = writeFileSync(deployedPackagePath, `${JSON.stringify(stagedPackage, null, 2)}\n`); rmSync(resolve(destinationDir, "node_modules/@embedded-postgres"), { recursive: true, force: true }); } + writeFileSync(deployedPackagePath, `${JSON.stringify(stageBundledEsbuildOptionalDependencies(destinationDir, + JSON.parse(readFileSync(deployedPackagePath, "utf8"))), null, 2)}\n`); } if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/scripts/retain-runner-qualification-packages.mjs b/scripts/retain-runner-qualification-packages.mjs new file mode 100644 index 00000000000..259293d40ed --- /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/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index 1d98b848af0..dd00f145cda 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -76,6 +76,7 @@ const generalModeName = "general"; const allModeName = "all"; const generalServerGroupName = "general-server"; const generalServerWithoutChatGroupName = "general-server-without-chat"; +const generalServerWithoutChatOrNativeRunnerGroupName = "general-server-without-chat-or-native-runner"; const generalChatGroupName = "general-chat"; const generalServerNativeRunnerGroupName = "general-server-native-runner"; const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts"; @@ -92,10 +93,9 @@ const nativeRunnerSuites = [ // In the PR workflow (pr.yml, the caller of pr-trusted.yml — reusable // workflows inherit the caller's GITHUB_WORKFLOW), the last Verify Paperclip // Runner vitest shard runs the native-runner group instead, because those -// lanes restore the shared release-runner-v1 Rust cache (see +// lanes restore the shared release-runner-v2 Rust cache (see // packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs). Every other -// caller — local runs, release-verify.yml under the Release and Cloud -// readiness workflows — keeps the suite in the server shards, so a renamed or +// caller of the same group keeps the suite in the server shards, so a renamed or // unknown workflow degrades to today's slower-but-covered behavior rather // than dropping the suite. const prWorkflowName = "PR"; @@ -103,6 +103,18 @@ const nativeRunnerSuiteRunsInRustCachedLane = process.env.GITHUB_WORKFLOW === pr const withoutChatExcludedSuites = nativeRunnerSuiteRunsInRustCachedLane ? [chatSuite, ...nativeRunnerSuites] : [chatSuite]; +// Release verification selects this explicit group and runs the omitted suites +// in its cached Runner job. Local callers keep the complete default group. +const generalServerGroups = [ + generalServerGroupName, + generalServerWithoutChatGroupName, + generalServerWithoutChatOrNativeRunnerGroupName, +]; +function excludedServerSuites(groupName) { + if (groupName === generalServerWithoutChatOrNativeRunnerGroupName) return [chatSuite, ...nativeRunnerSuites]; + if (groupName === generalServerWithoutChatGroupName) return withoutChatExcludedSuites; + return []; +} const generalWorkspacesAGroupName = "general-workspaces-a"; const generalWorkspacesBGroupName = "general-workspaces-b"; const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"]; @@ -111,6 +123,7 @@ const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, const allowedGeneralGroupNames = [ ...generalGroupNames, generalServerWithoutChatGroupName, + generalServerWithoutChatOrNativeRunnerGroupName, generalChatGroupName, generalServerNativeRunnerGroupName, ]; @@ -290,7 +303,7 @@ function parseCliOptions(argv) { const shardAllowed = mode === serializedModeName || (mode === generalModeName && - ([generalServerGroupName, generalServerWithoutChatGroupName, generalChatGroupName, generalWorkspacesAGroupName].includes(group))); + ([...generalServerGroups, generalChatGroupName, generalWorkspacesAGroupName].includes(group))); if (!shardAllowed && shardIndex !== null) { fail( "--shard-index/--shard-count are only valid with serialized mode or a shardable general server/chat/workspaces-a group.", @@ -448,14 +461,9 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount = ); return; } - if (groupName === generalServerGroupName || groupName === generalServerWithoutChatGroupName) { - // In the PR workflow the without-chat group also leaves the native-runner - // suite to the Rust-cached vitest lane; the full general-server group - // (local runs) keeps both. - const withoutChat = groupName === generalServerWithoutChatGroupName; - const files = withoutChat - ? generalServerTestFiles.filter((file) => !withoutChatExcludedSuites.includes(file)) - : generalServerTestFiles; + if (generalServerGroups.includes(groupName)) { + const excludedSuites = excludedServerSuites(groupName); + const files = generalServerTestFiles.filter((file) => !excludedSuites.includes(file)); if (shardCount !== null && shardCount > 1) { const shardFiles = selectGeneralServerShard( files, @@ -483,10 +491,8 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount = } const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); - if (withoutChat) { - for (const suite of withoutChatExcludedSuites) { - excludeRouteArgs.push("--exclude", suite.replace(/^server\//, "")); - } + for (const suite of excludedSuites) { + excludeRouteArgs.push("--exclude", suite.replace(/^server\//, "")); } runVitest( [ @@ -584,12 +590,10 @@ if (options.dryRun) { options.mode === generalModeName && options.group === generalServerNativeRunnerGroupName ? nativeRunnerSuites : options.mode === generalModeName && - [generalServerGroupName, generalServerWithoutChatGroupName].includes(options.group) && + generalServerGroups.includes(options.group) && options.shardCount !== null ? selectGeneralServerShard( - options.group === generalServerWithoutChatGroupName - ? generalServerTestFiles.filter((file) => !withoutChatExcludedSuites.includes(file)) - : generalServerTestFiles, + generalServerTestFiles.filter((file) => !excludedServerSuites(options.group).includes(file)), options.shardIndex, options.shardCount, generalServerShardDurations, diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index ee1788f9df5..b3c2db21d23 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -9,7 +9,8 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from './grok-public-install-sandbox.mjs'; +import { 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); @@ -57,7 +59,10 @@ try { } run('npm', ['pack', '--ignore-scripts', '--pack-destination', root], target); const packed = readdirSync(root).filter(f => f.endsWith('.tgz') && !tarballs.includes(join(root, f))); - assert.equal(packed.length, 1); tarballs.push(join(root, packed[0])); + assert.equal(packed.length, 1); + const tarball = join(root, packed[0]); + assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).toString().trim().split('\n')); + tarballs.push(tarball); } const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 }); const consumer = join(root, 'consumer'); mkdirSync(consumer); @@ -94,6 +99,13 @@ try { assert.equal(existsSync(prerequisite), false, 'npm must not provision Grok'); const server = join(consumer, 'node_modules/@paperclipai/server'); const installed = join(server, 'dist/vendor/paperclip-runner'); + const codexVersion = JSON.parse(readFileSync(join(repo, 'packages/paperclip-runner/acpx-profiles.json'), 'utf8')).profiles.codex.agentRuntimeVersion; + assert.match(codexVersion, /^\d+\.\d+\.\d+$/); + writeFileSync(join(assets, 'codex-probe.mjs'), installedCodexProbeSource( + '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/index.js', '/consumer', codexVersion), { mode: 0o644 }); + const codex = JSON.parse(isolated(['node', '/packages/codex-probe.mjs']).toString().trim()); + assert.equal(codex.codexConsumerHostOnly, true); + assert.equal(codex.codexPlatformPackageSource, 'official npm'); assert.ok(existsSync(join(installed, 'providers/grok/launcher.cjs'))); assert.equal(existsSync(join(consumer, 'node_modules/@paperclipai/grok-acp')), false); assert.equal(existsSync(join(installed, 'providers/grok/bin')), false); @@ -114,7 +126,30 @@ try { // Only the positive probe sees this file at the canonical sandbox path. run(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), prerequisite]); isolated(['node', '/packages/probe.mjs', 'present'], { prerequisite }); - console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 })); + 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/package.json b/server/package.json index 76c6fbc6459..f52206866bd 100644 --- a/server/package.json +++ b/server/package.json @@ -46,6 +46,7 @@ "postinstall": "node scripts/install-copilot-assets.mjs" }, "dependencies": { + "@agentclientprotocol/codex-acp": "1.6.2", "ai": "7.0.130", "@ai-sdk/openai": "4.0.86", "@openrouter/ai-sdk-provider": "3.1.0", @@ -57,7 +58,7 @@ "@chat-adapter/telegram": "4.39.0", "@discordjs/ws": "1.2.3", "@grpc/grpc-js": "1.14.5", - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.31.0", "@opentelemetry/api": "^1.9.0", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", @@ -109,6 +110,7 @@ "zod": "^4.4.3" }, "bundleDependencies": [ + "@agentclientprotocol/codex-acp", "@chat-adapter/discord", "@chat-adapter/github", "@chat-adapter/slack", diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 1990f9ca4bb..9185e900160 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,6 +1,6 @@ import { probeCopilotExecutionTarget } from "../services/copilot-connection-probe.js"; import { HttpError } from "../errors.js"; -import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation } from "../vendor/paperclip-runner/live/index.js"; +import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation, 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"; @@ -24,6 +24,7 @@ vi.mock("../services/copilot-connection-probe.js",()=>({probeCopilotExecutionTar 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), })); @@ -331,23 +332,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", @@ -359,9 +347,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("requires a saved Copilot token rather than ambient authentication",async()=>{ const prior=process.env.COPILOT_GITHUB_TOKEN;process.env.COPILOT_GITHUB_TOKEN="ambient-not-authority"; 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 00000000000..01a7b4b0236 --- /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 32e37089a3c..69fbb1c7d0e 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-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index c8fcab742b5..e1be98a92b0 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -8,7 +8,7 @@ import express from "express"; import request from "supertest"; import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { activityLog, agents, companies, companyMemberships, createDb, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db"; +import { activityLog, agents, companies, companyMemberships, createDb, closeRegisteredClients, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db"; import { type AiConnectionBinding, type AiConnectionPoolMember, type PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { agentRoutes } from "../routes/agents.js"; @@ -21,8 +21,16 @@ import { secretService } from "../services/secrets.js"; import { aiConnectionRouterService } from "../services/ai-connection-router.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { toolAccessService } from "../services/tool-access.js"; +import { waitForPendingRunFailureReports } from "../services/run-failure-report.js"; + +const captureRunFailure = vi.hoisted(() => vi.fn()); +vi.mock("../sentry.js", async (importOriginal) => ({ + ...await importOriginal(), + captureRunFailure, +})); let database: Awaited>; +const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim(); let db: ReturnType; let home: string; @@ -31,11 +39,17 @@ beforeAll(async () => { vi.stubEnv("PAPERCLIP_HOME", home); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "hire-ai"); vi.stubEnv("PAPERCLIP_IN_WORKTREE", "false"); - database = await startEmbeddedPostgresTestDatabase("paperclip-hire-ai-db-"); - db = createDb(database.connectionString); + if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl); + else { + database = await startEmbeddedPostgresTestDatabase("paperclip-hire-ai-db-"); + db = createDb(database.connectionString); + } }, 90_000); afterAll(async () => { + await waitForPendingRunFailureReports(); + if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl); + await db?.$client.end(); await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); @@ -488,10 +502,21 @@ describe("hired agents sharing a subscription", () => { expect(run).not.toBeNull(); await expect.poll(async () => (await heartbeat.getRun(run!.id))?.status, { timeout: 20_000 }).toBe("cancelled"); await heartbeat.drainActiveRunExecutions(); - expect(await heartbeat.getRun(run!.id)).toMatchObject({ errorCode: "ai_connection_busy", resultJson: { executionRecovery: { providerWorkStarted: false } } }); + const cancelled = await heartbeat.getRun(run!.id); + expect(cancelled).toMatchObject({ errorCode: "ai_connection_busy", resultJson: { + executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false }, + cancellation: { + source: "control_plane", expected: true, initiator: { type: "system" }, + reason: "Waiting for shared AI credentials", + recordedAt: cancelled!.finishedAt!.toISOString(), + }, + } }); + await waitForPendingRunFailureReports(); + expect(captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(0); const retries = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, run!.id)); expect(retries).toHaveLength(1); expect(retries[0]).toMatchObject({ status: "scheduled_retry", scheduledRetryReason: "ai_connection_busy" }); + expect(retries[0].resultJson?.cancellation).toBeUndefined(); const [savedIssue] = await db.select().from(issues).where(eq(issues.id, issue.id)); expect(savedIssue.status).not.toBe("blocked"); expect(savedIssue.executionRunId).toBe(retries[0].id); diff --git a/server/src/__tests__/agent-instruction-working-copies.test.ts b/server/src/__tests__/agent-instruction-working-copies.test.ts index 8cad9df070b..bd29eb5bc9b 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__/agent-profile-avatar.test.ts b/server/src/__tests__/agent-profile-avatar.test.ts new file mode 100644 index 00000000000..756cb9846e1 --- /dev/null +++ b/server/src/__tests__/agent-profile-avatar.test.ts @@ -0,0 +1,156 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import express, { type Request, type ErrorRequestHandler } from "express"; +import request from "supertest"; +import sharp from "sharp"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; +import { createDb, agents, companies, assets, activityLog, authUsers, companyMemberships, mcpOauthClients, mcpOauthGrants, dotAgentBindings, principalPermissionGrants } from "@paperclipai/db"; +import { appearanceForPalette, MAX_AGENT_AVATAR_BYTES } from "@paperclipai/shared"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js"; +import { createStorageService } from "../storage/service.js"; +import type { StorageService } from "../storage/types.js"; +import { normalizeAgentAvatar, setAgentProfileAvatar } from "../services/agent-profile-avatar.js"; +import { agentProfileAvatarRoutes } from "../routes/agent-profile-avatar.js"; +import { assetRoutes } from "../routes/assets.js"; +import { agentService } from "../services/agents.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { createDotRunnerMcpTools } from "../services/dot-runner-broker.js"; +import type { McpPrincipal } from "../services/public-mcp/oauth.js"; +import { isSecretSensitiveHttpRequest } from "../middleware/http-log-policy.js"; + +let storage: StorageService; +vi.mock("../storage/index.js", () => ({ getStorageService: () => storage })); +let temporary: Awaited>; +let db: ReturnType; +let root: string; +let imageBase64: string; +beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-profile-avatar-"); + db = createDb(temporary.connectionString); + root = await mkdtemp(join(tmpdir(), "profile-avatar-")); + storage = createStorageService(createLocalDiskStorageProvider(root)); + imageBase64 = (await sharp({ create: { width: 800, height: 600, channels: 3, background: "orange" } }).png().toBuffer()).toString("base64"); + await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true }); +}, 120000); +afterAll(async () => { await temporary?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); }); +async function fixture() { + const [company] = await db.insert(companies).values({ name: "Avatar test", issuePrefix: randomUUID().slice(0, 8) }).returning(); + const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", status: "active", adapterType: "paperclip_runner", appearance: appearanceForPalette("deep-tide") }).returning(); + const actor = { actorType: "agent" as const, actorId: agent!.id, agentId: agent!.id }; + return { company: company!, agent: agent!, actor }; +} +function appFor(actor: Request["actor"]) { + const app = express(); + app.use(express.json({ limit: "2mb" })); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api", agentProfileAvatarRoutes(db, storage), assetRoutes(db, storage)); + app.use(((error, _req, res, _next) => res.status(error.status ?? 400).json({ error: error.message })) as ErrorRequestHandler); + return app; +} + +it("uploads, normalizes, serves privately, retries without duplication, and resets the avatar", async () => { + const f = await fixture(); + const app = appFor({ type: "agent", agentId: f.agent.id, companyId: f.company.id }); + const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`; + const upload = await request(app).put(path).send({ imageBase64 }).expect(200); + expect(upload.body.appearance.paletteId).toBe(f.agent.appearance?.paletteId); + const retry = await request(app).put(path).send({ imageBase64 }).expect(200); + expect(retry.body).toEqual(upload.body); + expect(await db.select().from(assets).where(eq(assets.companyId, f.company.id))).toHaveLength(1); + const audit = await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id)); + expect(audit.filter(row => row.action === "agent.avatar_updated")).toHaveLength(1); + expect(JSON.stringify(audit)).not.toContain(imageBase64); + const content = await request(app).get(upload.body.avatarUrl).expect(200); + expect(content.headers["cache-control"]).toContain("private"); + expect(await sharp(content.body).metadata()).toMatchObject({ format: "png", width: 512, height: 384 }); + expect((await agentService(db).getById(f.agent.id))?.avatarUrl).toBe(upload.body.avatarUrl); + const reset = await request(app).put(path).send({ imageBase64: null }).expect(200); + expect(reset.body.appearance).toEqual(f.agent.appearance); + expect(reset.body.avatarUrl).toContain("/api/agent-avatars/"); +}); + +it("rejects other agents, companies, viewers, and foreign asset references", async () => { + const f = await fixture(); const other = await fixture(); + const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`; + for (const actor of [ + { type: "agent", agentId: other.agent.id, companyId: f.company.id }, + { type: "agent", agentId: other.agent.id, companyId: other.company.id }, + { type: "board", source: "session", companyIds: [f.company.id], memberships: [{ companyId: f.company.id, status: "active", membershipRole: "viewer" }] }, + ] satisfies Request["actor"][]) await request(appFor(actor)).put(path).send({ imageBase64 }).expect(403); + await request(appFor({ type: "none" })).put(path).send({ imageBase64 }).expect(401); + const foreign = await setAgentProfileAvatar(db, storage, other.company.id, other.agent.id, { imageBase64 }, other.actor); + await expect(agentService(db).update(f.agent.id, { appearance: foreign.appearance })).rejects.toThrow("avatar upload"); + await request(appFor({ type: "agent", agentId: f.agent.id, companyId: f.company.id })).get(foreign.avatarUrl).expect(404); +}); + +it("requires an agent configuration grant for board avatar changes", async () => { + const f = await fixture(); + const userId = randomUUID(); + await db.insert(authUsers).values({ id: userId, name: "Member", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "member", status: "active" }); + const app = appFor({ type: "board", source: "session", userId, companyIds: [f.company.id], memberships: [{ companyId: f.company.id, status: "active", membershipRole: "member" }] }); + const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`; + await request(app).put(path).send({ imageBase64 }).expect(403); + await request(app).put(path).send({ imageBase64: null }).expect(403); + expect(await db.select().from(assets).where(eq(assets.companyId, f.company.id))).toHaveLength(0); + await db.insert(principalPermissionGrants).values({ companyId: f.company.id, principalType: "user", principalId: userId, permissionKey: "agents:suggest-changes" }); + await request(app).put(path).send({ imageBase64 }).expect(403); + await db.insert(principalPermissionGrants).values({ companyId: f.company.id, principalType: "user", principalId: userId, permissionKey: "agents:configure" }); + await request(app).put(path).send({ imageBase64 }).expect(200); + await request(app).put(path).send({ imageBase64: null }).expect(200); +}); + +it.each([ + { kind: "task_bridge" as const, parentIssueId: randomUUID() }, + { kind: "skill_test" as const, issueId: randomUUID() }, +])("rejects profile changes by restricted %s credentials", async keyScope => { + const f = await fixture(); + const app = appFor({ type: "agent", agentId: f.agent.id, companyId: f.company.id, keyScope }); + const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`; + await request(app).put(path).send({ imageBase64 }).expect(403); + await request(app).put(path).send({ imageBase64: null }).expect(403); + expect(await db.select().from(assets).where(eq(assets.companyId, f.company.id))).toHaveLength(0); + expect(await db.select().from(activityLog).where(eq(activityLog.companyId, f.company.id))).toHaveLength(0); + expect((await agentService(db).getById(f.agent.id))?.appearance).toEqual(f.agent.appearance); +}); + +it("rejects URLs, SVG, corrupt and oversized images, unsupported fields and inactive agents", async () => { + for (const input of ["https://example.test/avatar.png", Buffer.from("").toString("base64"), Buffer.from("broken").toString("base64"), Buffer.alloc(MAX_AGENT_AVATAR_BYTES + 1).toString("base64")]) { + await expect(normalizeAgentAvatar(input)).rejects.toThrow(); + } + const tooManyPixels = await sharp({ create: { width: 5000, height: 5000, channels: 3, background: "white" } }).png().toBuffer(); + await expect(normalizeAgentAvatar(tooManyPixels.toString("base64"))).rejects.toThrow("16 megapixels"); + const f = await fixture(); + const app = appFor({ type: "agent", agentId: f.agent.id, companyId: f.company.id }); + await request(app).put(`/api/companies/${f.company.id}/agents/${f.agent.id}/avatar`).send({ imageBase64, agentId: randomUUID() }).expect(400); + await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agent.id)); + await expect(setAgentProfileAvatar(db, storage, f.company.id, f.agent.id, { imageBase64 }, f.actor)).rejects.toThrow("current state"); +}); + +it("lets a paired Dot upload without an assignment, with feature and current grant authorization", async () => { + const f = await fixture(); const userId = randomUUID(); const clientId = randomUUID(); + await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" }); + await db.insert(mcpOauthClients).values({ id: clientId, name: "Dot", redirectUris: [] }); + const [grant] = await db.insert(mcpOauthGrants).values({ companyId: f.company.id, userId, clientId, resource: "https://paperclip.example/mcp/runner", purpose: "agent", agentId: f.agent.id, scopes: ["paperclip:agent"] }).returning(); + await db.insert(dotAgentBindings).values({ companyId: f.company.id, agentId: f.agent.id, operatorId: userId, grantId: grant!.id, status: "connected" }); + const principal: McpPrincipal = { grant: grant!, company: f.company, actor: { type: "agent", companyId: f.company.id, agentId: f.agent.id } }; + const tools = createDotRunnerMcpTools(db); + expect((await tools.listTools(principal)).find(tool => tool.name === "paperclip_dot_set_avatar")?.annotations).toMatchObject({ readOnlyHint: false }); + expect(await tools.callTool(principal, "paperclip_dot_set_avatar", { imageBase64 })).toMatchObject({ outcome: "completed", result: { agentId: f.agent.id } }); + await expect(tools.callTool(principal, "paperclip_dot_set_avatar", { imageBase64, agentId: randomUUID() })).rejects.toThrow(); + await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: false }); + try { await expect(tools.callTool(principal, "paperclip_dot_set_avatar", { imageBase64 })).rejects.toThrow("disabled"); } + finally { await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: true }); } + await db.update(mcpOauthGrants).set({ revokedAt: new Date() }).where(eq(mcpOauthGrants.id, grant!.id)); + await expect(tools.callTool(principal, "paperclip_dot_set_avatar", { imageBase64 })).rejects.toThrow("authority"); +}); + +it("redacts HTTP diagnostics containing image bytes", () => { + expect(isSecretSensitiveHttpRequest("PUT", "/api/companies/company/agents/agent/avatar")).toBe(true); + expect(isSecretSensitiveHttpRequest("POST", "/mcp/runner")).toBe(true); +}); diff --git a/server/src/__tests__/ai-connection-router.test.ts b/server/src/__tests__/ai-connection-router.test.ts index b2e13a92e8a..1a1f3f29bd2 100644 --- a/server/src/__tests__/ai-connection-router.test.ts +++ b/server/src/__tests__/ai-connection-router.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { and, eq } from "drizzle-orm"; -import { createDb, companies, agents, companyMemberships, connectionGrants, plugins, aiConnectionRouterCursors, aiConnectionTaskPins, toolConnections, companySecrets, heartbeatRuns, issues } from "@paperclipai/db"; +import { createDb, closeRegisteredClients, companies, agents, companyMemberships, connectionGrants, plugins, aiConnectionRouterCursors, aiConnectionTaskPins, toolConnections, companySecrets, heartbeatRuns, issues } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres"; import type { AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { aiConnectionRouterService, AiConnectionPoolExhausted, poolMemberRuntimeConfig, applyAiConnectionRouterTaskSettings } from "../services/ai-connection-router.js"; @@ -24,8 +24,16 @@ import { heartbeatService, buildEffectiveRunSessionConfigMetadata, resolveTaskSe import { connectionIntentService } from "../services/connection-intents.js"; import { legacyExecutionNeedsReconciliation } from "../services/legacy-execution-recovery.js"; import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; +import { waitForPendingRunFailureReports } from "../services/run-failure-report.js"; + +const captureRunFailure = vi.hoisted(() => vi.fn()); +vi.mock("../sentry.js", async (importOriginal) => ({ + ...await importOriginal(), + captureRunFailure, +})); let database: Awaited>; +const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim(); let db: ReturnType; let home: string; const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID(), secondAgentId = randomUUID(), pluginId = randomUUID(); @@ -46,7 +54,8 @@ const makePool = async (config = {}) => service().save(pluginKey, { companyId, c beforeAll(async () => { home = await mkdtemp(path.join(os.tmpdir(), "paperclip-router-tests-")); vi.stubEnv("PAPERCLIP_HOME", home); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "router-fixture"); - database = await startEmbeddedPostgresTestDatabase("paperclip-router-db-"); db = createDb(database.connectionString); + if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl); + else { database = await startEmbeddedPostgresTestDatabase("paperclip-router-db-"); db = createDb(database.connectionString); } await db.insert(companies).values([{ id: companyId, name: "Router fixture", issuePrefix: "RTF" }, { id: otherCompanyId, name: "Other", issuePrefix: "RTO" }]); await db.insert(agents).values([agentId, secondAgentId].map(id => ({ id, companyId, name: "Router agent", adapterType: "paperclip_runner" }))); await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" }))); @@ -59,7 +68,12 @@ beforeAll(async () => { { id: randomUUID(), binding: { mode: "delegated", provider: "anthropic", method: "subscription", ...claude }, profile: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } }, ]; }, 90000); -afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); }); +afterAll(async () => { + await waitForPendingRunFailureReports(); + if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl); + await db?.$client.end(); + await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); +}); describe("durable, authorized connection routing", () => { it("defaults off and does not expose selectable pools before opt-in", async () => { @@ -321,16 +335,48 @@ describe("durable, authorized connection routing", () => { expect(run).not.toBeNull(); await expect.poll(async () => (await heartbeat.getRun(run!.id))?.status, { timeout: 20_000 }).toBe("cancelled"); const cancelled = await heartbeat.getRun(run!.id); expect(cancelled?.errorCode).toBe("ai_connection_pool_exhausted"); + expect(cancelled?.resultJson).toMatchObject({ + executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false }, + cancellation: { + source: "control_plane", expected: true, initiator: { type: "system" }, + reason: "Waiting for an AI connection pool account", + recordedAt: cancelled!.finishedAt!.toISOString(), + }, + }); expect(legacyExecutionNeedsReconciliation(cancelled!)).toBe(false); expect(legacyExecutionNeedsReconciliation({ ...cancelled!, resultJson: { executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: true } } })).toBe(true); await expect.poll(async () => (await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.agentId, agent!.id), eq(heartbeatRuns.scheduledRetryReason, "ai_connection_pool_wait")))).length, { timeout: 5000 }).toBe(1); const [retry] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.agentId, agent!.id), eq(heartbeatRuns.scheduledRetryReason, "ai_connection_pool_wait"))); + await waitForPendingRunFailureReports(); + expect(captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(0); + expect(retry?.resultJson?.cancellation).toBeUndefined(); expect(retry?.contextSnapshot?.aiRouterTaskKey).toBe(issue!.id); expect(retry?.contextSnapshot?.executionRetryAccounting).toMatchObject({ failureRetries: 0 }); expect(execute).not.toHaveBeenCalled(); const [pin] = await db.select().from(aiConnectionTaskPins).where(and(eq(aiConnectionTaskPins.poolId, pool.id), eq(aiConnectionTaskPins.agentId, agent!.id))); expect(pin?.selection).toEqual(pinned); const [cursor] = await db.select().from(aiConnectionRouterCursors).where(eq(aiConnectionRouterCursors.poolId, pool.id)); expect(cursor?.version).toBe(1); await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.agentId, agent!.id)); } finally { fetch.mockRestore(); proposal.mockReset(); proposal.mockImplementation(defaultProposal); unregisterServerAdapter("claude_local"); await heartbeat.drainActiveRunExecutions(); } }, 30_000); + it("keeps unexpected pool policy failures reportable before provider work", async () => { + await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true, enableWorktreeRunExecution: true }); + const pool = await makePool({ members: [members[1]!] }); + const [agent] = await db.insert(agents).values({ companyId, name: "Unexpected router failure", adapterType: "claude_local", adapterConfig: { cwd: home, engine: "cli" }, runtimeConfig: { aiConnection: { mode: "router", connectionId: pool.id }, heartbeat: { enabled: false } } }).returning(); + const [issue] = await db.insert(issues).values({ companyId, title: "Router failure fixture", status: "todo", assigneeAgentId: agent!.id, responsibleUserId: "alice", createdByUserId: "alice" }).returning(); + const execute = vi.fn(); registerServerAdapter({ ...getServerAdapter("claude_local"), execute }); + proposal.mockRejectedValueOnce(new Error("Synthetic unexpected router policy failure")); + const heartbeat = heartbeatService(db, { pluginWorkerManager: worker, runtimeEnv: {} }); + try { + const run = await heartbeat.wakeup(agent!.id, { source: "assignment", reason: "issue_assigned", payload: { issueId: issue!.id }, contextSnapshot: { issueId: issue!.id, wakeReason: "issue_assigned", responsibleUserId: "alice" }, triggerDetail: "system" }); + expect(run).not.toBeNull(); + await expect.poll(async () => (await heartbeat.getRun(run!.id))?.status, { timeout: 20_000 }).toBe("failed"); + const failed = await heartbeat.getRun(run!.id); + expect(failed?.errorCode).toBe("configuration_incomplete"); + expect(failed?.resultJson).toMatchObject({ configurationIncomplete: { reason: "ai_connection_unavailable" } }); + expect(failed?.resultJson?.cancellation).toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + await expect.poll(() => captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(1); + expect(captureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ runId: run!.id, runStatus: "failed", errorCode: "configuration_incomplete" })); + } finally { proposal.mockReset(); proposal.mockImplementation(defaultProposal); unregisterServerAdapter("claude_local"); await heartbeat.drainActiveRunExecutions(); } + }, 30_000); it("repairs the failed pool account without replacing the pool or adopting the configured provider", async () => { const pool = await makePool({ members: [members[1]!] }); const [agent] = await db.insert(agents).values({ companyId, name: "Pool repair", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, runtimeConfig: { aiConnection: { mode: "router", connectionId: pool.id } } }).returning(); diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 75b702c134f..6f2e685dd80 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -16,6 +16,7 @@ import { createDb, companies, agents, agentTaskSessions, heartbeatRuns, companyM import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres"; import { fetchCompanyQuotaWindows } from "../services/quota-windows.js"; import { quotaCredentialRecoveryPath, quotaCredentialRecovery, quotaCredentialHash } from "../services/quota-credential-recovery.js"; +import { readAiConnectionConfigurationFailure } from "../services/ai-connection-configuration-failure.js"; import { aiConnectionService } from "../services/ai-connections.js"; import { syncConnectionCredentialBindings } from "../services/connection-credential-bindings.js"; import * as codexAdapter from "@paperclipai/adapter-codex-local/server"; @@ -277,6 +278,25 @@ describe("managed AI connections", () => { await expect(prepareManagedAiRuntime(db, runInput)).rejects.toThrow(); }); + it("marks explicit AI selection rejections without changing their HTTP contract", async () => { + const owner = "selection-blocker-owner"; + await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); + const expectSelectionFailure = async (promise: Promise, reason: string) => { + const error = await promise.catch((error: unknown) => error); + expect(error).toMatchObject({ status: 422, details: { code: reason } }); + expect(readAiConnectionConfigurationFailure(error)).toBe(reason); + }; + await expectSelectionFailure(service.select({ ...input, userId: null }), "ai_connection_responsible_user_missing"); + await expectSelectionFailure(service.select({ ...input, userId: owner, adapterType: "codex_local" }), "ai_connection_incompatible"); + await expectSelectionFailure(service.select({ ...input, userId: owner }), "ai_connection_default_missing"); + await expectSelectionFailure(service.select({ ...input, userId: owner, binding: { + ...binding, mode: "delegated", connectionId: randomUUID(), grantId: randomUUID(), + } }), "ai_connection_missing"); + const account = await create(owner, "Selection blocker account"); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId)); + await expectSelectionFailure(service.select({ ...input, userId: owner }), "ai_connection_unavailable"); + }); + it("persists first-time recovery directories before consuming a single-use token", async () => { const owner = "quota-durable-path"; await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" }); @@ -1015,7 +1035,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" apiKey: "fixture", allAgents: true, agentIds: [], }, "github_pat_fixture_migration"); await service.setDefault(companyId, "migration-copilot", copilot.grantId); - const migration = await readFile(new URL("../../../packages/db/src/migrations/0319_copilot_defaults.sql", import.meta.url), "utf8"); + const migration = await readFile(new URL("../../../packages/db/src/migrations/0320_copilot_defaults.sql", import.meta.url), "utf8"); for (let attempt = 0; attempt < 2; attempt += 1) { await db.transaction(async (tx) => { for (const statement of migration.split("--> statement-breakpoint")) { diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 4d81ac5e1cc..cec993f591c 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( @@ -4138,9 +4192,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { for (const canary of [configToken, signingSecret, clientSecret, f.token]) expect(publicState).not.toContain(canary); await request(f.app).patch(`/api/chat-endpoints/${f.endpoint.id}`).send({ slackApp: appDetails }).expect(409); }); - it("uploads avatar bytes without fetching a tenant-session-protected URL, after app credentials are durable", async () => { + it.each([undefined, "22222222-2222-4222-8222-222222222222"])("uploads preset bytes after credentials are durable, even with private avatar %s", async customAvatarAssetId => { const f = await fixture(); - await db.update(agents).set({ appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "cherry-pop" } }).where(eq(agents.id, f.assignedAgentId)); + await db.update(agents).set({ appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "cherry-pop", customAvatarAssetId } }).where(eq(agents.id, f.assignedAgentId)); f.state.beforeIcon = async () => { const row = (await f.service.slackRegistration.registration(f.endpoint.id))!; expect(row).toMatchObject({ status: "install", appId: f.appId }); @@ -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__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 518adc8347e..1c4834b6dc0 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -91,7 +91,7 @@ async function createApp(actor: any, db: any = {} as any) { } describe("cli auth routes", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/authz.js"); @@ -99,6 +99,9 @@ describe("cli auth routes", () => { vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); + // Cold route transforms are fixture setup, not HTTP request latency. + // Use the hook budget while preserving the request tests' normal timeout. + await Promise.all([import("../routes/access.js"), import("../middleware/index.js")]); }); it("creates a CLI auth challenge with approval metadata", async () => { diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 74a6a08308d..461da04f268 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1727,20 +1727,30 @@ describe("company portability", () => { expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1); }); - it("round-trips the saved visual persona through the portable bundle", async () => { + it.each([undefined, "22222222-2222-4222-8222-222222222222"])("round-trips the preset without private avatar references (%s)", async customAvatarAssetId => { const appearance = { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "arctic-blue" }; const source = await agentSvc.list(); - agentSvc.list.mockResolvedValue(source.map((agent: Record) => ({ ...agent, appearance }))); + agentSvc.list.mockResolvedValue(source.map((agent: Record) => ({ ...agent, appearance: { ...appearance, ...(customAvatarAssetId ? { customAvatarAssetId } : {}) } }))); const portability = companyPortabilityService({} as any); const include = { company: true, agents: true, projects: false, issues: false, skills: false }; const exported = await portability.exportBundle("company-1", { include }); expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("arctic-blue"); + const extensionText = asTextFile(exported.files[".paperclip.yaml"]); + expect(extensionText).not.toContain("customAvatarAssetId"); + if (customAvatarAssetId) { + expect(exported.warnings.some(warning => warning.includes("uploaded avatar was omitted"))).toBe(true); + // Even a hand-edited package must not carry another agent's private asset into import. + exported.files[".paperclip.yaml"] = extensionText.replace(/^(\s*)paletteId:.*$/gm, + match => `${match}\n${match.match(/^\s*/)?.[0]}customAvatarAssetId: ${customAvatarAssetId}`); + expect(asTextFile(exported.files[".paperclip.yaml"])).toContain(customAvatarAssetId); + } + agentSvc.list.mockResolvedValue([]); agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ ...input, id: `imported-${input.name}` })); await portability.importBundle({ source: { type: "inline", files: exported.files, rootPath: exported.rootPath }, include, target: { mode: "new_company", newCompanyName: "Imported personas" }, collisionStrategy: "rename", agents: "all" }, "user-1"); expect(agentSvc.create).toHaveBeenCalled(); - for (const [, input] of agentSvc.create.mock.calls) expect(input).toMatchObject({ appearance }); + for (const [, input] of agentSvc.create.mock.calls) expect(input.appearance).toEqual(appearance); }); it("reads env inputs back from .paperclip.yaml during preview import", async () => { diff --git a/server/src/__tests__/dot-invitations.test.ts b/server/src/__tests__/dot-invitations.test.ts new file mode 100644 index 00000000000..0a31da0f2fd --- /dev/null +++ b/server/src/__tests__/dot-invitations.test.ts @@ -0,0 +1,104 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; +import { createDb, agents, approvals, principalPermissionGrants, authUsers, companies, companyMemberships, dotAgentBindings, activityLog } from "@paperclipai/db"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { dotInvitationService } from "../services/dot-invitations.js"; +import { dotRunnerBroker } from "../services/dot-runner-broker.js"; +import { dotRunnerRoutes } from "../routes/dot-runner.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { HttpError } from "../errors.js"; +let temporary: Awaited>; +let db: ReturnType; +let root: string; +beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("dot-invitations-"); + db = createDb(temporary.connectionString); + root = await mkdtemp(join(tmpdir(), "dot-invite-home-")); + vi.stubEnv("PAPERCLIP_HOME", root); + vi.stubEnv("PAPERCLIP_IN_WORKTREE", "false"); + await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: true, enablePublicMcp: true }); +}, 120000); +afterAll(async () => { await temporary?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); vi.unstubAllEnvs(); }); +async function fixture(requireApproval = false) { + const userId = randomUUID(); + await db.insert(authUsers).values({ id: userId, name: "Operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + const [company] = await db.insert(companies).values({ name: "Dot invitation", issuePrefix: "DI" + randomBytes(3).toString("hex"), requireBoardApprovalForNewAgents: requireApproval }).returning(); + await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" }); + await db.insert(principalPermissionGrants).values({ companyId: company!.id, principalType: "user", principalId: userId, permissionKey: "agents:create" }); + return { userId, companyId: company!.id }; +} +it("atomically resumes one pending invite across retries and never persists a pairing secret", async () => { + const f = await fixture(); + const service = dotInvitationService(db); + const [a, b] = await Promise.all([service.create(f.companyId, f.userId), service.create(f.companyId, f.userId)]); + expect(a.agent.id).toBe(b.agent.id); + expect(a.agent.status).toBe("idle"); + expect(await service.resume(f.companyId, f.userId)).toMatchObject({ agent: { id: a.agent.id } }); + expect(await service.resume(f.companyId, randomUUID())).toBeNull(); + expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1); + const code = await dotRunnerBroker(db).createPairing({ ...f, operatorId: f.userId, agentId: a.agent.id }); + const logs = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId)); + expect(JSON.stringify(logs)).not.toContain(code.pairingCode); + expect(JSON.stringify(await service.resume(f.companyId, f.userId))).not.toContain(code.pairingCode); +}); +it("honors hiring approval before issuing a pairing capability", async () => { + const f = await fixture(true); + const invite = await dotInvitationService(db).create(f.companyId, f.userId); + expect(invite.agent.status).toBe("pending_approval"); + expect(invite.approvalId).toBeTruthy(); + const [approval] = await db.select().from(approvals).where(eq(approvals.id, invite.approvalId!)); + expect(approval).toMatchObject({ status: "pending", type: "hire_agent", payload: { agentId: invite.agent.id } }); + await expect(dotRunnerBroker(db).createPairing({ ...f, operatorId: f.userId, agentId: invite.agent.id })).rejects.toThrow("approved"); + expect((await dotInvitationService(db).create(f.companyId, f.userId)).approvalId).toBe(invite.approvalId); +}); +it("replaces only the named pending capability and refuses to revoke a connected binding", async () => { + const f = await fixture(); const broker = dotRunnerBroker(db); + const invite = await dotInvitationService(db).create(f.companyId, f.userId); + const input = { companyId: f.companyId, agentId: invite.agent.id, operatorId: f.userId }; + const first = await broker.createPairing(input); + const second = await broker.createPairing({ ...input, replaceBindingId: first.bindingId }); + expect(second.bindingId).not.toBe(first.bindingId); + const [old] = await db.select().from(dotAgentBindings).where(eq(dotAgentBindings.id, first.bindingId)); + expect(old).toMatchObject({ status: "revoked", pairingCodeHash: null }); + await expect(broker.createPairing({ ...input, replaceBindingId: first.bindingId })).rejects.toThrow("changed"); + await db.update(dotAgentBindings).set({ status: "connected" }).where(eq(dotAgentBindings.id, second.bindingId)); + await expect(broker.createPairing({ ...input, replaceBindingId: second.bindingId })).rejects.toThrow("changed"); +}); +it("keeps invite routes company-scoped and denies viewers and agent actors", async () => { + const f = await fixture(); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { + const mode = req.headers["x-test-actor"]; + req.actor = mode === "agent" ? { type: "agent", agentId: randomUUID(), companyId: f.companyId } + : { type: "board", source: "session", userId: f.userId, companyIds: [f.companyId], + memberships: [{ companyId: f.companyId, membershipRole: mode === "viewer" ? "viewer" : "owner", status: "active" }] }; + next(); + }); + app.use(dotRunnerRoutes(db, "https://paperclip.example/mcp/runner")); + app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error instanceof HttpError ? error.status : 500).json({ error: error.message }); + }); + const path = `/companies/${f.companyId}/dot-invitations`; + for (const actor of ["viewer", "agent"]) expect((await request(app).post(path).set("x-test-actor", actor).send({})).status).toBe(403); + expect((await request(app).post(`/companies/${randomUUID()}/dot-invitations`).send({})).status).toBe(403); + const result = await request(app).post(path).send({}); + expect(result.status, JSON.stringify(result.body)).toBe(200); + expect(result.body.agent.id).toBeTruthy(); + expect((await request(app).get(path)).body.agent.id).toBe(result.body.agent.id); + expect(await db.select().from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.status, "idle")))).toHaveLength(1); +}); + +it("does not trust editable agent metadata as an invitation ownership receipt", async () => { + const f = await fixture(); + const [spoof] = await db.insert(agents).values({ companyId: f.companyId, name: "Unrelated agent", adapterType: "paperclip_runner", + adapterConfig: { provider: "openai_dot" }, metadata: { dotInvitation: { operatorId: f.userId } } }).returning(); + const service = dotInvitationService(db); + expect(await service.resume(f.companyId, f.userId)).toBeNull(); + expect((await service.create(f.companyId, f.userId)).agent.id).not.toBe(spoof!.id); +}); diff --git a/server/src/__tests__/dot-oauth-onboarding.test.ts b/server/src/__tests__/dot-oauth-onboarding.test.ts index e9ea01ccf3e..2f88ebeda2d 100644 --- a/server/src/__tests__/dot-oauth-onboarding.test.ts +++ b/server/src/__tests__/dot-oauth-onboarding.test.ts @@ -1,11 +1,12 @@ +import { readFile } from "node:fs/promises"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, mcpOauthGrants, mcpOauthRequests } from "@paperclipai/db"; +import { createDb, authUsers, companies, companyMemberships, agents, dotAgentBindings, mcpOauthGrants, mcpOauthRequests, mcpOauthTokens } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; -import { createPublicMcpOAuth, publicMcpConfig, DEVICE_GRANT } from "../services/public-mcp/oauth.js"; +import { createPublicMcpOAuth, publicMcpConfig, hashMcpSecret, DEVICE_GRANT } from "../services/public-mcp/oauth.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { dotRunnerBroker } from "../services/dot-runner-broker.js"; import { publicMcpManagementRoutes, publicMcpIngressRoutes } from "../routes/public-mcp.js"; @@ -26,7 +27,7 @@ describe("Dot onboarding with an operator-issued pairing capability", () => { await db.insert(authUsers).values({ id: userId, name: "Operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() }); const [company] = await db.insert(companies).values({ name: "Dot onboarding", issuePrefix: "DO" + randomBytes(3).toString("hex") }).returning(); await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" }); - const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", status: "active" }).returning(); + const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot" }, status: "active" }).returning(); const oauth = createPublicMcpOAuth(db, { ...config, ...(authorizationOrigin ? { authorizationOrigin } : {}) }); const client = await oauth.register({ client_name: "Dot", redirect_uris: [callback], grant_types: ["authorization_code", "refresh_token", DEVICE_GRANT] }, randomUUID()); const verifier = randomBytes(32).toString("base64url"); @@ -37,6 +38,72 @@ describe("Dot onboarding with an operator-issued pairing capability", () => { const id = await begin(); return { userId, company: company!, agent: agent!, oauth, client, verifier, input, pairing, id, begin }; } + async function connect(f: Awaited>) { + const consent = await f.oauth.consentDotPairing(f.id, f.pairing.pairingCode); + return f.oauth.token({ grant_type: "authorization_code", client_id: f.client.client_id, + redirect_uri: callback, resource: config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: f.verifier }); + } + it("refreshes a Dot connection after years of inactivity, still rotates and revokes on replay", async () => { + const f = await fixture(); + const tokens = await connect(f); + const [stored] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(tokens.refresh_token!))); + expect(stored!.expiresAt).toBeNull(); + const refresh = { grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: tokens.refresh_token }; + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date(Date.now() + 2 * 365 * 24 * 60 * 60_000)); + await expect(f.oauth.authenticate(tokens.access_token)).rejects.toThrow(); + const next = await f.oauth.token(refresh); + expect(next.refresh_token).not.toBe(tokens.refresh_token); + expect(next.expires_in).toBe(900); + expect((await f.oauth.authenticate(next.access_token)).actor).toMatchObject({ type: "agent", agentId: f.agent.id }); + const [rotated] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(next.refresh_token!))); + expect(rotated!.expiresAt).toBeNull(); + await expect(f.oauth.token(refresh)).rejects.toThrow(); + await expect(f.oauth.authenticate(next.access_token)).rejects.toThrow(); + expect(await dotRunnerBroker(db).bindingForAgent(f.company.id, f.agent.id)).toBeNull(); + } finally { vi.useRealTimers(); } + }); + it("upgrades valid Dot refresh tokens without reviving expired, used or revoked credentials", async () => { + const f = await fixture(); + const tokens = await connect(f); + const grant = (await f.oauth.authenticate(tokens.access_token)).grant; + const validExpiry = new Date(Date.now() + 30 * 24 * 60 * 60_000); + await db.update(mcpOauthTokens).set({ expiresAt: validExpiry }).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(tokens.refresh_token!))); + const stale = await fixture(); + const staleTokens = await connect(stale); + const expired = new Date(0); + await db.update(mcpOauthTokens).set({ expiresAt: expired }).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(staleTokens.refresh_token!))); + const personalId = randomUUID(); + const revokedId = randomUUID(); + const usedHash = randomUUID(); + await db.insert(mcpOauthGrants).values([ + { ...grant, id: personalId, purpose: "personal", agentId: null, resource: config.origin + "/mcp/paperclip" }, + { ...grant, id: revokedId, revokedAt: new Date() }, + ]); + await db.insert(mcpOauthTokens).values([ + { grantId: personalId, tokenHash: randomUUID(), kind: "refresh", expiresAt: validExpiry }, + { grantId: revokedId, tokenHash: randomUUID(), kind: "refresh", expiresAt: validExpiry }, + { grantId: grant.id, tokenHash: usedHash, kind: "refresh", expiresAt: validExpiry, usedAt: new Date() }, + ]); + const migration = await readFile(new URL("../../../packages/db/src/migrations/0319_heavy_captain_midlands.sql", import.meta.url), "utf8"); + for (let attempt = 0; attempt < 2; attempt++) { + for (const statement of migration.split("--> statement-breakpoint")) await db.execute(sql.raw(statement)); + } + const [personal] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.grantId, personalId)); + const [revoked] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.grantId, revokedId)); + const [used] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, usedHash)); + const [staleToken] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(staleTokens.refresh_token!))); + expect(personal!.expiresAt).toEqual(validExpiry); + expect(revoked!.expiresAt).toEqual(validExpiry); + expect(used!.expiresAt).toEqual(validExpiry); + expect(staleToken!.expiresAt).toEqual(expired); + await expect(stale.oauth.token({ grant_type: "refresh_token", client_id: stale.client.client_id, resource: config.resource, refresh_token: staleTokens.refresh_token })).rejects.toThrow(); + const next = await f.oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: tokens.refresh_token }); + expect((await f.oauth.authenticate(next.access_token)).actor).toMatchObject({ type: "agent", agentId: f.agent.id }); + await f.oauth.revokeToken(next.access_token, f.client.client_id); + await expect(f.oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, resource: config.resource, refresh_token: next.refresh_token })).rejects.toThrow(); + }); it("pins browser ingress without changing the issuer, resource or token endpoint", async () => { const browserOrigin = "https://paperclip-browser.example:10000"; const f = await fixture(browserOrigin); @@ -75,6 +142,7 @@ describe("Dot onboarding with an operator-issued pairing capability", () => { expect((await request(app).post(path).send({ pairingCode: f.pairing.pairingCode })).status).toBe(403); const preview = await request(app).post(path + "/preview").set("Origin", config.origin).send({ pairingCode: f.pairing.pairingCode }); expect(preview.status).toBe(200); + expect(preview.body.accessDuration).toBe("Ongoing until revoked. This connection does not expire from inactivity."); expect(preview.body).toMatchObject({ company: { id: f.company.id, name: f.company.name }, agent: { id: f.agent.id, name: f.agent.name } }); expect(JSON.stringify(preview.body)).not.toContain(f.pairing.pairingCode); expect(await db.select().from(mcpOauthGrants).where(eq(mcpOauthGrants.userId, f.userId))).toHaveLength(0); diff --git a/server/src/__tests__/dot-runner.test.ts b/server/src/__tests__/dot-runner.test.ts index bd105ab7848..fe7caa79c85 100644 --- a/server/src/__tests__/dot-runner.test.ts +++ b/server/src/__tests__/dot-runner.test.ts @@ -10,7 +10,7 @@ import express, { type Request } from "express"; import request from "supertest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations, - heartbeatRuns, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, workspaceOperations, workAssessments, statusDecisions, issueThreadInteractions } from "@paperclipai/db"; + heartbeatRuns, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, mcpEventSubscriptions, workspaceOperations, workAssessments, statusDecisions, issueThreadInteractions } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { createPublicMcpOAuth, DEVICE_GRANT } from "../services/public-mcp/oauth.js"; import { createPublicMcpExecutor } from "../services/public-mcp/capabilities.js"; @@ -37,7 +37,7 @@ describe("durable Dot Runner integration", () => { let root: string; beforeAll(async () => { const runnerRoot = fileURLToPath(new URL("../../../packages/paperclip-runner/", import.meta.url)); - execFileSync("cargo", ["build", "--release", "--locked", "--manifest-path", join(runnerRoot, "runner/Cargo.toml"), "-p", "paperclip-runner-core", "--bin", "paperclip-runnerd"], { cwd: runnerRoot, stdio: "pipe", timeout: 300000 }); + execFileSync("cargo", ["build", "--release", "--locked", "--manifest-path", join(runnerRoot, "runner/Cargo.toml"), "-p", "paperclip-runner-core", "--bin", "paperclip-runnerd"], { cwd: runnerRoot, stdio: "inherit", timeout: 300000 }); temporary = await startEmbeddedPostgresTestDatabase("paperclip-dot-runner-"); db = createDb(temporary.connectionString); root = await mkdtemp(join(tmpdir(), "paperclip-dot-state-")); @@ -47,7 +47,17 @@ describe("durable Dot Runner integration", () => { vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64")); await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: false }); }, 360000); - afterAll(async () => { await temporary?.cleanup(); await rm(root, { recursive: true, force: true }); vi.unstubAllEnvs(); }); + afterAll(async () => { + try { + await temporary?.cleanup(); + } finally { + try { + if (root) await rm(root, { recursive: true, force: true }); + } finally { + vi.unstubAllEnvs(); + } + } + }); it("requires each persisted prerequisite for pairing and new work without relying on the retired environment flag", async () => { const settings = instanceSettingsService(db); @@ -125,7 +135,7 @@ describe("durable Dot Runner integration", () => { const events = createPublicMcpEvents(db, oauth, async () => { throw new Error("personal API dispatch forbidden"); }, { enableDotRunner: true, fetch: fetcher }); const subscription = { name: "paperclip.dot.mailbox_updated", arguments: { companyId: company!.id, bindingId: pairing.bindingId }, delivery: { mode: "webhook", url: "https://example.com/dot-hook", secret } }; await events.subscribe(principal, subscription); - await broker.challenge(company!.id, agent!.id); await events.tick(); + await events.tick(); if (!received.length) throw new Error("Dot readiness delivery missing: " + JSON.stringify(await db.select({ outcome: mcpEventDeliveries.outcome, event: mcpEventDeliveries.event }).from(mcpEventDeliveries))); expect(received.at(-1)?.data.kind).toBe("readiness_challenge"); expect(received.at(-1)?.data).not.toHaveProperty("challenge"); @@ -168,6 +178,25 @@ describe("durable Dot Runner integration", () => { } }); + it.each(["revoked binding", "removed subscription"])("handles an automatic event-test race with a %s", async change => { + const f = await fixture(); + await db.update(dotAgentBindings).set({ readyAt: null, status: "connected" }).where(eq(dotAgentBindings.id, f.snapshot.bindingId)); + const readBinding = f.broker.bindingForAgent.bind(f.broker); + const read = vi.spyOn(f.broker, "bindingForAgent").mockImplementationOnce(async (companyId, agentId) => { + const state = await readBinding(companyId, agentId); + if (change === "revoked binding") await db.update(dotAgentBindings).set({ revokedAt: new Date() }).where(eq(dotAgentBindings.id, f.snapshot.bindingId)); + else await db.update(mcpEventSubscriptions).set({ stoppedAt: new Date() }).where(eq(mcpEventSubscriptions.bindingId, f.snapshot.bindingId)); + return state; + }); + try { + await expect(f.broker.challenge(f.company.id, f.agent.id, { automatic: true, bindingId: f.snapshot.bindingId })).resolves.toEqual({ status: "unavailable" }); + await expect(f.broker.challenge(f.company.id, f.agent.id)).rejects.toThrow("Connect and subscribe"); + } finally { + read.mockRestore(); + await f.events.unsubscribe(f.principal, f.subscription); await f.events.stop(); + } + }); + it("admits one durable idle intake without requiring a preassigned task", async () => { const f = await fixture(); await db.update(companies).set({ defaultResponsibleUserId: f.userId }).where(eq(companies.id, f.company.id)); diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 5cf2c2f9fd8..df72db38439 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 216e213666b..b683491a828 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 5167ae4037d..cfa81980bf7 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__/environment-sync-out-error.test.ts b/server/src/__tests__/environment-sync-out-error.test.ts index 36407d2453d..8ca1a0b7c77 100644 --- a/server/src/__tests__/environment-sync-out-error.test.ts +++ b/server/src/__tests__/environment-sync-out-error.test.ts @@ -3,6 +3,7 @@ import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; import { createRequest, definePlugin, isJsonRpcResponse, JsonRpcCallError, parseMessage, + readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncTransferStep, recordEnvironmentSyncError, PLUGIN_RPC_ERROR_CODES, serializeMessage, startWorkerRpcHost, type JsonRpcResponse, } from "@paperclipai/plugin-sdk"; import { @@ -53,8 +54,9 @@ describe("environment sync-out restore diagnostics", () => { await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => { throw preserveEnvironmentSyncOutErrorDiagnostic(error); }), async (line) => { logs.push(line); })).rejects.toBe(error); - expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "workspace", step: "workspace_transfer", errorCode: "ETIMEDOUT", httpStatus: 504 }); - expect(logs).toEqual(['[paperclip] Workspace restore diagnostic: {"phase":"workspace","step":"workspace_transfer","errorCode":"ETIMEDOUT","httpStatus":504}\n']); + expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "workspace", step: "workspace_transfer", errorCode: "ETIMEDOUT", httpStatus: 504, rpcCode: PLUGIN_RPC_ERROR_CODES.WORKER_ERROR }); + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0].slice(logs[0].indexOf("{")))).toEqual(getWorkspaceRestoreDiagnostic(error)); expect({ message: error.message, code: error.code, data: error.data }).toEqual(before); expect(error).not.toHaveProperty("cause"); }); @@ -76,3 +78,69 @@ describe("environment sync-out restore diagnostics", () => { expect(preserveEnvironmentSyncOutErrorDiagnostic(null)).toBeNull(); }); }); + + +it("isolates overlapping and sequential worker calls that throw the same object", async () => { + const original = Object.freeze(new Error("Transfer failed")); + const before = Object.getOwnPropertyDescriptors(original); + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const lines = createInterface({ input: stdout }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + const worker = startWorkerRpcHost({ stdin, stdout, plugin: definePlugin({ + async setup() {}, + async onEnvironmentSyncOut(params) { + if (params.operations?.[0]?.operationId === "first") { + return withEnvironmentSyncTransferStep("archive_create", async () => { + recordEnvironmentSyncError(original, { exitCode: 2, transferFailureKind: "command_failed" }); + started(); await gate; throw original; + }); + } + if (params.operations?.[0]?.operationId === "second") { + return withEnvironmentSyncTransferStep("file_download", async () => { throw original; }); + } + throw original; + }, + async onEnvironmentSyncIn() { throw original; }, + }) }); + const call = (id: string, method = "environmentSyncOut") => new Promise((resolve) => { + const listener = (wire: string) => { + const message = parseMessage(wire); + if (isJsonRpcResponse(message) && message.id === id && message.error) { + lines.off("line", listener); resolve(new JsonRpcCallError(message.error)); + } + }; + lines.on("line", listener); + stdin.write(serializeMessage(createRequest(method, { operations: [{ operationId: id, files: [] }] }, id))); + }); + try { + const first = call("first"); + await ready; + const second = await call("second"); + expect(readEnvironmentSyncErrorDiagnostic(second)).toEqual({ errorCode: "unknown", transferStep: "file_download" }); + release(); + expect(readEnvironmentSyncErrorDiagnostic(await first)).toEqual({ + errorCode: "unknown", transferStep: "archive_create", transferFailureKind: "command_failed", exitCode: 2, + }); + expect((await call("third")).data).toBeUndefined(); + expect((await call("unrelated", "environmentSyncIn")).data).toBeUndefined(); + expect(Object.getOwnPropertyDescriptors(original)).toEqual(before); + } finally { release(); worker.stop(); lines.close(); stdin.destroy(); stdout.destroy(); } +}); + +it.each([ + [new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, message: "private-rpc-message" }), PLUGIN_RPC_ERROR_CODES.TIMEOUT], + [new JsonRpcCallError({ code: 123, message: "private-rpc-message" }), undefined], + [Object.assign(new Error("private-rpc-message"), { code: PLUGIN_RPC_ERROR_CODES.TIMEOUT }), undefined], +])("records known typed RPC codes without interpreting arbitrary errors", async (error, rpcCode) => { + const logs: string[] = []; + await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => { + throw preserveEnvironmentSyncOutErrorDiagnostic(error); + }), async line => { logs.push(line); })).rejects.toBe(error); + expect(getWorkspaceRestoreDiagnostic(error)?.rpcCode).toBe(rpcCode); + expect(getWorkspaceRestoreDiagnostic(error)?.transferStep).toBeUndefined(); + expect(JSON.stringify(logs)).not.toContain("private-"); +}); diff --git a/server/src/__tests__/error-handler.test.ts b/server/src/__tests__/error-handler.test.ts index 52bf838559f..934cf7aa7c0 100644 --- a/server/src/__tests__/error-handler.test.ts +++ b/server/src/__tests__/error-handler.test.ts @@ -2,6 +2,7 @@ import type { NextFunction, Request, Response } from "express"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { HttpError } from "../errors.js"; import { errorHandler } from "../middleware/error-handler.js"; +import { mcpDiscoveryHttpFailure, retainMcpConnectionFailure } from "../services/mcp-connection-failure.js"; const recordResponsibleUserDenialOnActiveRunMock = vi.hoisted(() => vi.fn()); const captureExceptionMock = vi.hoisted(() => vi.fn()); @@ -121,6 +122,35 @@ describe("errorHandler", () => { expect(res.__errorContext?.error?.message).toBe("db exploded"); }); + it("keeps HTTP failures and existing telemetry for proven external MCP outages while skipping Sentry", () => { + const req = makeReq(); + const res = makeRes() as any; + const original = mcpDiscoveryHttpFailure(new Response(null, { status: 503 }), "Remote app returned HTTP 503"); + const err = retainMcpConnectionFailure(original, new HttpError(502, original.message)); + + errorHandler(err, req, res, vi.fn()); + + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith({ error: original.message }); + expect(res.err).toBe(err); + expect(res.__errorContext.error.message).toBe(original.message); + expect(telemetryMocks.trackErrorHandlerCrash).toHaveBeenCalledOnce(); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + + it("reports copied connection markers and unrelated database failures", () => { + for (const err of [ + Object.assign(new HttpError(502, "Remote app returned HTTP 503", { status: 503 }), { + connectionFailure: { schemaVersion: 1, provider: "mcp_http", operation: "discover_tools", reason: "remote_unavailable" }, + }), + new HttpError(500, "database unavailable"), + ]) { + errorHandler(err, makeReq(), makeRes(), vi.fn()); + } + expect(captureExceptionMock).toHaveBeenCalledTimes(2); + expect(telemetryMocks.trackErrorHandlerCrash).toHaveBeenCalledTimes(2); + }); + it("sanitizes chat setup errors before logs and crash reporting", () => { const req = { ...makeReq(), 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 6159af858d7..a694a093ec9 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__/fixtures/plugin-worker-idle.cjs b/server/src/__tests__/fixtures/plugin-worker-idle.cjs new file mode 100644 index 00000000000..4b989b447ea --- /dev/null +++ b/server/src/__tests__/fixtures/plugin-worker-idle.cjs @@ -0,0 +1,27 @@ +// Use the real SDK so host tests cross a process and the NDJSON boundary. +void Promise.all([ + import("../../../../packages/plugins/sdk/src/define-plugin.ts"), + import("../../../../packages/plugins/sdk/src/worker-rpc-host.ts"), +]).then(([{ definePlugin }, { startWorkerRpcHost }]) => { + let context; + startWorkerRpcHost({ plugin: definePlugin({ + async setup(ctx) { + context = ctx; + ctx.events.on("fixture.write", async () => { + await ctx.state.set({ scopeKind: "instance", stateKey: "event" }, { value: 1 }); + }); + }, + async onIdleDrain() { return "none"; }, + async onHealth() { + if (process.env.IDLE_TEST_WRITE === "1") { + await context.state.set({ scopeKind: "instance", stateKey: "fixture" }, { value: 1 }); + } + return { status: "ok" }; + }, + async onEnvironmentProbe(params) { + if (params.config.crash) process.exit(1); + await new Promise((resolve) => setTimeout(resolve, params.config.delayMs)); + return { ok: true, summary: "fixture complete" }; + }, + }) }); +}); diff --git a/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts b/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts index 279fb75cc4f..74c1b602e79 100644 --- a/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts +++ b/server/src/__tests__/heartbeat-managed-clone-credentials.test.ts @@ -8,6 +8,7 @@ import { ensureManagedProjectWorkspace, prepareProjectRepositoryWorkspaces } fro import { buildGitAuthInvocation, GIT_CREDENTIAL_TOKEN_ENV_KEY } from "../services/git-credentials.ts"; import { sanitizeRuntimeServiceBaseEnv } from "../services/workspace-runtime.ts"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts"; +import { readGitConnectionFailure } from "../services/git-connection-failure.js"; const execFile = promisify(execFileCallback); @@ -38,6 +39,79 @@ async function createLocalSourceRepo() { } describe("ensureManagedProjectWorkspace clone credentials", () => { + it.each([ + ["authentication_failed", "fatal: Authentication failed for 'https://example.test/team/repo.git/'"], + ["dns_failure", "ssh: Could not resolve hostname repository-alias.example.test: Name or service not known\nfatal: Could not read from remote repository."], + [null, "fatal: Authentication failed for 'https://example.test/team/repo.git/'\nfatal: bad config line 1 in file /tmp/config"], + [null, "fatal: corrupt loose object"], + ] as const)("preserves only proven outbound clone classification through the actionable wrapper: %s", async (reason, stderr) => { + const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fake-clone-")); + await fs.writeFile(path.join(bin, "git"), `#!/bin/sh +printf '%s\\n' "$FIXTURE_CLONE_STDERR" >&2 +exit 128 +`, { mode: 0o755 }); + try { + const failure = await ensureManagedProjectWorkspace({ + companyId: "remote-clone-fixture", projectId: path.basename(bin), + repoUrl: "https://fixture-user:fixture-password@example.test/team/repo.git", + resolveGitAuth: async () => ({ + configArgs: [], env: { PATH: bin, FIXTURE_CLONE_STDERR: stderr }, + source: "company_secret", secretName: "GITHUB_TOKEN", + }), + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("Failed to prepare managed checkout"); + expect((failure as Error).message).not.toContain("fixture-password"); + expect(readGitConnectionFailure(failure)).toEqual(reason ? { + schemaVersion: 1, provider: "git", operation: "clone", reason, + } : null); + const homeEntries = await fs.readdir(path.dirname(resolveManagedProjectWorkspaceDir({ + companyId: "remote-clone-fixture", projectId: path.basename(bin), repoName: "repo", + }))); + expect(homeEntries.some((name) => name.includes(".clone-"))).toBe(false); + } finally { + await fs.rm(bin, { recursive: true, force: true }); + } + }); + + it("does not classify a credential-provider exception that resembles a Git failure", async () => { + const failure = Object.assign(new Error("fatal: Authentication failed for 'https://example.test/repo.git'"), { + code: 128, stderr: "fatal: Authentication failed for 'https://example.test/repo.git'", + }); + const caught = await ensureManagedProjectWorkspace({ + companyId: "credential-provider-fixture", projectId: "project", repoUrl: "https://example.test/repo.git", + resolveGitAuth: async () => { throw failure; }, + }).catch((error: unknown) => error); + expect(caught).toBe(failure); + expect(readGitConnectionFailure(caught)).toBeNull(); + }); + + it("does not transfer clone classification to a later local checkout failure", async () => { + const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fake-checkout-")); + const previousPath = process.env.PATH; + await fs.writeFile(path.join(bin, "git"), `#!/bin/sh +if [ "$1" = clone ]; then + for argument in "$@"; do destination="$argument"; done + /bin/mkdir -p "$destination/.git" + exit 0 +fi +printf '%s\\n' "fatal: Authentication failed for 'https://example.test/repo.git'" >&2 +exit 128 +`, { mode: 0o755 }); + try { + process.env.PATH = bin; + const failure = await ensureManagedProjectWorkspace({ + companyId: "checkout-failure-fixture", projectId: "project", repoUrl: "https://example.test/repo.git", repoRef: "missing-branch", + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("Authentication failed"); + expect(readGitConnectionFailure(failure)).toBeNull(); + } finally { + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + await fs.rm(bin, { recursive: true, force: true }); + } + }); + it("materializes every repository-only project row inside the task workspace and reuses local edits", async () => { const first = await createLocalSourceRepo(); const second = await createLocalSourceRepo(); diff --git a/server/src/__tests__/heartbeat-native-runner-cancellation.test.ts b/server/src/__tests__/heartbeat-native-runner-cancellation.test.ts index 31749f638f1..1c2f877ec76 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 386f2a30ac3..f02983143ee 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,3 +1,9 @@ +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"; import * as environmentOrchestrator from "../services/environment-run-orchestrator.js"; import { remoteTerminationReceipt } from "../services/remote-execution-termination.js"; @@ -12,7 +18,7 @@ import { recordLegacyWorkspaceRestoreFailure, terminalizeLegacyExecution } from import { issueService } from "../services/issues.js"; import { getExecutionBlocker } from "../services/execution-blocker.js"; import { adapterExecutionControls, createAdapterExecutionControl } from "../services/adapter-execution-control.js"; -import { spawn, type ChildProcess } from "node:child_process"; +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -21,6 +27,7 @@ import { afterAll, afterEach, beforeAll, + beforeEach, describe, expect, it, @@ -44,6 +51,7 @@ import { chatExternalPrincipals, chatMessageLinks, chatPublications, + companyMemberships, companySecretBindings, companySecrets, companySkills, @@ -63,6 +71,7 @@ import { heartbeatRunEvents, heartbeatRuns, issueComments, + issueQuestionResponseDeliveries, issueApprovals, issueDocuments, issuePlanDecompositions, @@ -1522,6 +1531,53 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { return { companyId, agentId, runId, wakeupRequestId, issueId }; } + it.each(["missing_default", "database_error", "unmarked_http_error", "provider_error"] as const)( + "preserves AI configuration recovery and reports only unexpected causes: %s", async (cause) => { + const { companyId, agentId, issueId, runId, wakeupRequestId } = await seedQueuedIssueRunFixture(); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); + await db.update(agents).set({ runtimeConfig: { + heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 }, + aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" }, + } }).where(eq(agents.id, agentId)); + const prepare = vi.spyOn(aiConnectionRuntime, "prepareManagedAiRuntime"); + if (cause !== "missing_default") prepare.mockRejectedValueOnce( + cause === "unmarked_http_error" + ? unprocessable("Connect an account and choose your personal default", { code: "ai_connection_default_missing" }) + : new Error(cause), + ); + try { + const heartbeat = heartbeatService(db); + await heartbeat.resumeQueuedRuns(); + await waitForRunToSettle(heartbeat, runId, 5_000); + await heartbeat.waitForRunExecutionDrain(runId); + await waitForPendingRunFailureReports(); + expect(prepare).toHaveBeenCalled(); + const failed = await heartbeat.getRun(runId); + expect(failed).toMatchObject({ status: "failed", errorCode: "configuration_incomplete", + resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + }); + if (cause === "missing_default") { + expect(failed?.error).toBe("Connect an account and choose your personal default"); + expect(failed?.resultJson?.configurationIncomplete).toMatchObject({ selectionFailure: "ai_connection_default_missing" }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + } else { + expect(failed?.resultJson?.configurationIncomplete).not.toHaveProperty("selectionFailure"); + expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ runId, errorCode: "configuration_incomplete" })); + } + expect(mockAdapterExecute).not.toHaveBeenCalled(); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(issue).toMatchObject({ status: "blocked", executionRunId: null }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)); + expect(action).toMatchObject({ status: "active", kind: "configuration_validation", cause: "configuration_incomplete", ownerType: "board" }); + const [wakeup] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeupRequestId)); + expect(wakeup.status).toBe("failed"); + } finally { + prepare.mockRestore(); + await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId)); + } + }, + ); + it.each(["timeout", "upstream", "cleanup_pending", "cleanup_pending_edited", "cleanup_pending_exhausted", "edited", "exhausted", "new_message", "no_claim", "reassigned", "superseded", "stopped"])( "settles explicit retry admission through real executor cleanup: %s", async scenario => { const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture(); @@ -5591,6 +5647,179 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } }); + it.each([ + { name: "HTTPS authentication", failures: ["auth"], report: false }, + { name: "SSH alias DNS", failures: ["dns"], report: false }, + { name: "mixed recognized and unknown candidates", failures: ["auth", "unknown"], report: true }, + { name: "mixed local and authentication diagnostics", failures: ["mixed"], report: true }, + { name: "unknown clone failure", failures: ["unknown"], report: true }, + ])("preserves failed workspace recovery while classifying managed Git connections: $name", async ({ failures, report }) => { + const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture(); + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-connection-lifecycle-")); + const bin = path.join(home, "bin"); + const callsPath = path.join(home, "git-calls.jsonl"); + const projectId = randomUUID(); + const workspaceIds = failures.map(() => randomUUID()); + const remotes = failures.map((failure, index) => failure === "dns" + ? `git@clone-alias.example.test:team/repository-${index}.git` + : `https://clone.example.test/team/repository-${index}.git`); + const diagnostics = failures.map((failure) => failure === "dns" + ? "ssh: Could not resolve hostname clone-alias.example.test: Name or service not known\nfatal: Could not read from remote repository." + : failure === "unknown" ? "fatal: unexpected internal clone failure" + : "fatal: Authentication failed for 'https://clone.example.test/'" + + (failure === "mixed" ? "\nfatal: No space left on device" : "")); + await fs.mkdir(bin); + // A real child process supplies execFile's numeric exit/stderr evidence. + // Every command is intercepted, so no fixture can reach a remote Git host. + await fs.writeFile(path.join(bin, "git"), `#!${process.execPath}\n` + + `const fs = require('node:fs');\n` + + `const args = process.argv.slice(2);\n` + + `fs.appendFileSync(${JSON.stringify(callsPath)}, JSON.stringify(args) + '\\n');\n` + + `const remotes = ${JSON.stringify(remotes)};\n` + + `const diagnostics = ${JSON.stringify(diagnostics)};\n` + + `const index = remotes.indexOf(args.at(-2));\n` + + `if (!args.includes('clone') || index < 0) { process.stderr.write('Unexpected fixture Git command\\n'); process.exit(2); }\n` + + `process.stderr.write(diagnostics[index] + '\\n'); process.exitCode = 128;\n`, { mode: 0o755 }); + const heartbeat = heartbeatService(db); + const previousSettings = await instanceSettingsService(db).getExperimental(); + const previousPath = process.env.PATH; + const previousHome = process.env.PAPERCLIP_HOME; + try { + process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; + process.env.PAPERCLIP_HOME = home; + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + await db.insert(projects).values({ id: projectId, companyId, name: "Git connection fixture", status: "in_progress" }); + await db.insert(projectWorkspaces).values(workspaceIds.map((id, index) => ({ + id, companyId, projectId, name: `Repository ${index}`, sourceType: "git_repo", + repoUrl: remotes[index], cwd: null, isPrimary: index === 0, + }))); + await db.update(issues).set({ + projectId, projectWorkspaceId: workspaceIds[0], executionWorkspaceSettings: { mode: "isolated_workspace" }, + }).where(eq(issues.id, issueId)); + await db.update(agents).set({ adapterConfig: { workspaceStrategy: { type: "git_worktree" } } }).where(eq(agents.id, agentId)); + + await heartbeat.resumeQueuedRuns(); + await waitForRunToSettle(heartbeat, runId, 5_000); + await heartbeat.waitForRunExecutionDrain(runId); + await waitForPendingRunFailureReports(); + + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run).toMatchObject({ + status: "failed", errorCode: "workspace_validation_failed", processStartedAt: null, + resultJson: { + workspaceValidation: { reason: "git_worktree_base_materialization_failed" }, + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + }); + expect(run.error).toContain("checkout could not be prepared"); + const validation = run.resultJson!.workspaceValidation as Record; + const materializations = validation.materializationFailures as Array>; + expect(materializations).toHaveLength(failures.length); + expect(materializations.map((failure) => failure.connectionFailure)).toEqual(failures.map((failure) => + failure === "auth" || failure === "dns" ? { + schemaVersion: 1, provider: "git", operation: "clone", + reason: failure === "auth" ? "authentication_failed" : "dns_failure", + } : undefined)); + expect(run.resultJson!.connectionFailure).toEqual(report ? undefined : materializations[0].connectionFailure); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(issue).toMatchObject({ status: "blocked", executionRunId: null, projectId, projectWorkspaceId: workspaceIds[0] }); + const [action] = await db.select().from(issueRecoveryActions).where(and( + eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, issueId), + )); + expect(action).toMatchObject({ status: "active", kind: "workspace_validation", ownerType: "board" }); + expect(action.nextAction).toContain("workspace"); + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments.some((comment) => comment.body.includes("workspace failed validation"))).toBe(true); + expect(mockCaptureRunFailure.mock.calls.filter(([event]) => event.runId === runId)).toHaveLength(report ? 1 : 0); + const calls = (await fs.readFile(callsPath, "utf8")).trim().split("\n").map((line) => JSON.parse(line) as string[]); + expect(calls).toHaveLength(failures.length); + expect(calls.every((args) => args.includes("clone"))).toBe(true); + } finally { + await heartbeat.waitForRunExecutionDrain(runId); + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome; + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: previousSettings.enableIsolatedWorkspaces }); + await fs.rm(home, { recursive: true, force: true }); + } + }); + + it("keeps a direct additional-repository connection failure visible without reporting it to Sentry", async () => { + const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture(); + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-additional-git-connection-")); + const cwd = path.join(home, "primary"); + const bin = path.join(home, "bin"); + const cloneCalls = path.join(home, "clone-calls.jsonl"); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const repoUrl = "git@clone-alias.example.test:team/additional.git"; + const previousPath = process.env.PATH; + const previousHome = process.env.PAPERCLIP_HOME; + const heartbeat = heartbeatService(db); + await fs.mkdir(cwd); + await fs.mkdir(bin); + execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" }); + await fs.writeFile(path.join(cwd, "README.md"), "Keep this local repository intact.\n"); + execFileSync("git", ["add", "README.md"], { cwd, stdio: "ignore" }); + execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "Fixture"], { cwd, stdio: "ignore" }); + await fs.writeFile(path.join(bin, "git"), `#!${process.execPath}\n` + + `const fs = require('node:fs'); const { spawnSync } = require('node:child_process');\n` + + `const args = process.argv.slice(2);\n` + + `if (args.includes('clone')) {\n` + + ` fs.appendFileSync(${JSON.stringify(cloneCalls)}, JSON.stringify(args) + '\\n');\n` + + ` if (args.at(-2) !== ${JSON.stringify(repoUrl)}) process.exit(2);\n` + + ` process.stderr.write('ssh: Could not resolve hostname clone-alias.example.test: Name or service not known\\nfatal: Could not read from remote repository.\\n'); process.exit(128);\n` + + `}\n` + + `if (args.some(arg => ['fetch', 'pull', 'push', 'ls-remote', 'submodule'].includes(arg))) process.exit(2);\n` + + `const result = spawnSync('git', args, { stdio: 'inherit', env: { ...process.env, PATH: ${JSON.stringify(previousPath)} } });\n` + + `process.exit(result.status ?? 2);\n`, { mode: 0o755 }); + try { + process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ""}`; + process.env.PAPERCLIP_HOME = home; + await db.insert(projects).values({ id: projectId, companyId, name: "Additional repository fixture", status: "in_progress" }); + await db.insert(projectWorkspaces).values([ + { id: workspaceId, companyId, projectId, name: "Primary", sourceType: "local_path", cwd, isPrimary: true }, + { id: randomUUID(), companyId, projectId, name: "Additional", sourceType: "git_repo", repoUrl, cwd: null, isPrimary: false }, + ]); + await db.update(issues).set({ projectId, projectWorkspaceId: workspaceId, + executionWorkspaceSettings: { mode: "shared_workspace" }, + }).where(eq(issues.id, issueId)); + await db.update(agents).set({ adapterConfig: { cwd } }).where(eq(agents.id, agentId)); + + await heartbeat.resumeQueuedRuns(); + await waitForRunToSettle(heartbeat, runId, 5_000); + await heartbeat.waitForRunExecutionDrain(runId); + await waitForPendingRunFailureReports(); + + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run).toMatchObject({ status: "failed", errorCode: "setup_failed", executionStage: "preparing", processStartedAt: null, + resultJson: { + connectionFailure: { schemaVersion: 1, provider: "git", operation: "clone", reason: "dns_failure" }, + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + }); + expect(run.resultJson?.workspaceValidation).toBeUndefined(); + expect(run.error).toContain("Could not resolve hostname"); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(issue).toMatchObject({ status: "blocked", executionRunId: null, projectId, projectWorkspaceId: workspaceId }); + const [action] = await db.select().from(issueRecoveryActions).where(and( + eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, issueId), + )); + expect(action).toMatchObject({ status: "active", ownerType: "board" }); + expect(mockCaptureRunFailure.mock.calls.filter(([event]) => event.runId === runId)).toHaveLength(0); + const calls = (await fs.readFile(cloneCalls, "utf8")).trim().split("\n").map((line) => JSON.parse(line) as string[]); + expect(calls).toHaveLength(1); + expect(calls[0].at(-2)).toBe(repoUrl); + expect(await fs.readFile(path.join(cwd, "README.md"), "utf8")).toBe("Keep this local repository intact.\n"); + } finally { + await heartbeat.waitForRunExecutionDrain(runId); + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome; + await fs.rm(home, { recursive: true, force: true }); + } + }); + it.each(["throw", "result"])("redacts opaque environment-bound credentials from Sentry diagnostics: %s", async (mode) => { const { companyId, agentId, runId } = await seedQueuedIssueRunFixture(); const svc = secretService(db); @@ -7751,7 +7980,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); expect(next?.contextSnapshot?.wakeCommentIds).toEqual([pending!.id, go!.id]); expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, deferred!.id)))[0]).toMatchObject({ status: "coalesced", runId: next!.id }); - await vi.waitFor(async () => expect((await heartbeat.getRun(next!.id))?.status).not.toBe("running")); + await heartbeat.drainActiveRunExecutions(); + expect((await heartbeat.getRun(next!.id))?.status).not.toBe("running"); }); it.each(["dedicated deferred donor", "non-coalescing recipient", "persistent agent conversation"] as const)( @@ -16227,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__/heartbeat-workspace-busy.test.ts b/server/src/__tests__/heartbeat-workspace-busy.test.ts index 0d2fc3f1be1..af0d1f63c94 100644 --- a/server/src/__tests__/heartbeat-workspace-busy.test.ts +++ b/server/src/__tests__/heartbeat-workspace-busy.test.ts @@ -14,6 +14,7 @@ import { companies, companySkills, createDb, + closeRegisteredClients, environments, environmentLeases, executionWorkspaces, @@ -46,6 +47,13 @@ import { heartbeatService, } from "../services/heartbeat.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; +import { reportRunFailure, waitForPendingRunFailureReports } from "../services/run-failure-report.js"; + +const captureRunFailure = vi.hoisted(() => vi.fn()); +vi.mock("../sentry.js", async (importOriginal) => ({ + ...await importOriginal(), + captureRunFailure, +})); // Exercise the real SSH lease and heartbeat paths without connecting to a host. vi.mock("@paperclipai/adapter-utils/ssh", async (importOriginal) => ({ @@ -55,7 +63,8 @@ vi.mock("@paperclipai/adapter-utils/ssh", async (importOriginal) => ({ }), })); -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim(); +const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const WORKSPACE_BUSY_TEST_ADAPTER = "workspace_busy_test"; @@ -91,10 +100,14 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { let workspaceCwd!: string; const executedRunIds: string[] = []; const executedInputs = new Map(); + const failExecutionRunIds = new Set(); beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-workspace-busy-"); - db = createDb(tempDb.connectionString); + if (externalTestDatabaseUrl) db = createDb(externalTestDatabaseUrl); + else { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-workspace-busy-"); + db = createDb(tempDb.connectionString); + } heartbeat = heartbeatService(db); workspaceCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-busy-")); registerServerAdapter({ @@ -102,6 +115,15 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { execute: async (input) => { executedRunIds.push(input.runId); executedInputs.set(input.runId, input); + if (failExecutionRunIds.has(input.runId)) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "Synthetic unexpected adapter failure after workspace wait", + resultJson: {}, + }; + } return { exitCode: 0, signal: null, @@ -127,15 +149,20 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { .set({ status: "cancelled", finishedAt: new Date() }) .where(eq(heartbeatRuns.status, "running")); await drainHeartbeatRunsToQuiescence(db, heartbeat); + await waitForPendingRunFailureReports(); await cleanupFixture(); executedRunIds.length = 0; executedInputs.clear(); + failExecutionRunIds.clear(); + captureRunFailure.mockClear(); await instanceSettingsService(db).updateGeneral({ executionMode: "any" }); }); afterAll(async () => { unregisterServerAdapter(WORKSPACE_BUSY_TEST_ADAPTER); if (workspaceCwd) await fs.rm(workspaceCwd, { recursive: true, force: true }); + if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl); + await db.$client.end(); await tempDb?.cleanup(); }); @@ -547,6 +574,16 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { holderIssueId: fixture.holderIssueId, deferralAttempt: 0, }); + expect(finishedRun?.resultJson).toMatchObject({ + executionRecovery: { kind: "workspace_wait", providerWorkStarted: false }, + cancellation: { + source: "control_plane", + expected: true, + initiator: { type: "system" }, + reason: "Waiting for the shared project workspace", + recordedAt: finishedRun!.finishedAt!.toISOString(), + }, + }); // The deferred run's adapter never executed — the whole point of the gate. expect(executedRunIds).not.toContain(run!.id); @@ -557,6 +594,8 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { scheduledRetryAttempt: 1, scheduledRetryReason: WORKSPACE_BUSY_RETRY_REASON, }); + await waitForPendingRunFailureReports(); + expect(captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(0); expect((retryRun?.contextSnapshot as Record | null)?.wakeReason).toBe( WORKSPACE_BUSY_RETRY_WAKE_REASON, ); @@ -603,7 +642,7 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { } }); - it("executes the scheduled retry once the holder has finished", async () => { + it.each([false, true])("executes the scheduled retry without inheriting expected cancellation (adapter fails: %s)", async (adapterFails) => { const fixture = await seedWorkspaceFixture(); const run = await heartbeat.invoke( @@ -618,6 +657,8 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { const retryRun = await waitForRetryRun(run!.id); expect(retryRun?.status).toBe("scheduled_retry"); + expect(retryRun?.resultJson?.cancellation).toBeUndefined(); + if (adapterFails) failExecutionRunIds.add(retryRun!.id); // Holder finishes; the due retry promotes, queues, and executes. await db @@ -631,8 +672,50 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { await heartbeat.resumeQueuedRuns(); const finishedRetry = await waitForRunToLeaveActiveStates(retryRun!.id); - expect(finishedRetry?.status).toBe("succeeded"); + expect(finishedRetry?.status).toBe(adapterFails ? "failed" : "succeeded"); expect(executedRunIds).toContain(retryRun!.id); + expect(finishedRetry?.resultJson?.cancellation).toBeUndefined(); + if (adapterFails) { + await expect.poll(() => captureRunFailure.mock.calls.filter(([report]) => report.runId === retryRun!.id)).toHaveLength(1); + expect(captureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ + runId: retryRun!.id, + runStatus: "failed", + exitCode: 1, + })); + } + await waitForPendingRunFailureReports(); + expect(captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(0); + }); + + it("does not hide ambiguous, malformed, provider, or transport cancellations behind workspace_busy", async () => { + const fixture = await seedWorkspaceFixture(); + const run = await heartbeat.invoke(fixture.agentId, "assignment", { + issueId: fixture.issueId, wakeReason: "issue_assigned", + }, "system"); + expect(run).not.toBeNull(); + await waitForRunToLeaveActiveStates(run!.id); + await waitForRetryRun(run!.id); + const [deferred] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)); + expect(deferred?.status).toBe("cancelled"); + const receipt = deferred!.resultJson!.cancellation as Record; + for (const cancellation of [ + undefined, + { ...receipt, expected: "true" }, + { ...receipt, expected: false, source: "provider", initiator: { type: "provider" } }, + { ...receipt, expected: false, source: "transport" }, + ]) { + captureRunFailure.mockClear(); + const unexpected = { + ...deferred!, id: randomUUID(), + resultJson: { ...deferred!.resultJson, cancellation }, + }; + await reportRunFailure(db, unexpected); + expect(captureRunFailure).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + runId: unexpected.id, + runStatus: "cancelled", + errorCode: WORKSPACE_BUSY_ERROR_CODE, + })); + } }); it("preserves a legacy accepted mention run through workspace retry", async () => { diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index e57c34ff195..906a7d23c6e 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -462,6 +462,29 @@ describe("assertGitSensitiveAdapterWorkspaceValid", () => { }); describe("assertGitWorktreeBaseWorkspaceReady", () => { + it.each(["all_external", "mixed", "malformed"])("keeps materialization aggregation fail-closed: %s", async (kind) => { + const first = { schemaVersion: 1, provider: "git", operation: "clone", reason: "authentication_failed" } as const; + const second = kind === "all_external" ? { ...first, reason: "dns_failure" as const } : + kind === "malformed" ? { ...first, reason: "unknown" } : undefined; + const failures = [first, second].map((connectionFailure, index) => ({ + projectWorkspaceId: `workspace-${index}`, repoUrl: "https://example.test/team/repo.git", + error: "Managed checkout failed", ...(connectionFailure ? { connectionFailure } : {}), + })); + const error = await assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { id: "issue-1", identifier: "TEST-1", projectId: "project-1", projectWorkspaceId: "workspace-0" }, + base: { baseCwd: "/tmp/unused-fallback", source: "project_primary", projectId: "project-1", workspaceId: "workspace-0", repoUrl: "https://example.test/team/repo.git", repoRef: null }, + anchor: { baseCwdFallback: true, materializationFailures: failures as never }, + }).catch((error: unknown) => error); + expect(error).toMatchObject({ + code: "workspace_validation_failed", + resultJson: { workspaceValidation: { reason: "git_worktree_base_materialization_failed", materializationFailures: failures } }, + }); + expect((error as { resultJson: Record }).resultJson.connectionFailure) + .toEqual(kind === "all_external" ? first : undefined); + }); + it("rejects projectless isolated git worktrees that resolved to agent_home", async () => { const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1"); diff --git a/server/src/__tests__/idle-admission.test.ts b/server/src/__tests__/idle-admission.test.ts index 19f30ad08c4..4c8e86775f3 100644 --- a/server/src/__tests__/idle-admission.test.ts +++ b/server/src/__tests__/idle-admission.test.ts @@ -77,6 +77,33 @@ describe("idle admission", () => { done.resolve(); await new Promise(resolve => setImmediate(resolve)); expect(idleWorkSnapshot().active).toBe(0); + } finally { await vite.close(); } + }); + + it("tracks real Vite middleware without confusing Connect mount paths with Express routes", async () => { + const { createServer } = await import("vite"); + const vite = await createServer({ + configFile: false, + appType: "custom", + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const server = app(), done = deferred(); + vite.middlewares.use("/vite-work", async (_req, res) => { + res.statusCode = 202; + res.end(); + await done.promise; + }); + server.use(vite.middlewares); + try { + expect(() => trackIdleRequestHandlers(server)).not.toThrow(); + await request(server).get("/vite-work").expect(202); + expect(idleWorkSnapshot().active).toBe(1); + startTaskDrain({ purpose: "idle", ttlMs: 60_000 }); + await request(server).get("/vite-work").expect(503); + done.resolve(); + await new Promise(resolve => setImmediate(resolve)); + expect(idleWorkSnapshot().active).toBe(0); } finally { done.resolve(); await vite.close(); diff --git a/server/src/__tests__/idle-database-backup.test.ts b/server/src/__tests__/idle-database-backup.test.ts new file mode 100644 index 00000000000..07e794c9205 --- /dev/null +++ b/server/src/__tests__/idle-database-backup.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { sql } from "drizzle-orm"; +import { createDb, ensurePostgresDatabase, getEmbeddedPostgresTestSupport, runDatabaseBackup, runDatabaseRestore, startEmbeddedPostgresTestDatabase } from "@paperclipai/db"; +import { clearIdleBackupWakeRequired, idleBackupWakeMarker, markIdleBackupWakeRequired, verifyIdleDatabaseBackup } from "../services/idle-database-backup.js"; + +let directory: string; +beforeEach(async () => { directory = await mkdtemp(path.join(os.tmpdir(), "idle-backup-")); }); +afterEach(async () => { await rm(directory, { recursive: true, force: true }); }); + +describe("idle database backup persistence", () => { + it("keeps a restart marker until a fresh valid backup is complete", async () => { + const archive = path.join(directory, "backup.sql.gz"); + await markIdleBackupWakeRequired(directory); + await writeFile(archive, gzipSync("CREATE TABLE fixture (id integer);\n")); + await verifyIdleDatabaseBackup(archive); + // Verification on the sleep path must not remove the wake obligation. + expect(await readFile(idleBackupWakeMarker(directory), "utf8")).toContain("restarts"); + await clearIdleBackupWakeRequired(directory); + await expect(readFile(idleBackupWakeMarker(directory))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(archive)).toEqual(gzipSync("CREATE TABLE fixture (id integer);\n")); + }); + + it.each([ + ["empty file", Buffer.alloc(0)], ["empty SQL", gzipSync("")], + ["truncated gzip", gzipSync("SELECT 1;").subarray(0, -4)], + ["plain text", Buffer.from("SELECT 1;")], + ])("rejects %s and retains the restart obligation", async (_label, contents) => { + await markIdleBackupWakeRequired(directory); + const archive = path.join(directory, "invalid.sql.gz"); + await writeFile(archive, contents); + await expect(verifyIdleDatabaseBackup(archive)).rejects.toThrow(); + expect(await readFile(idleBackupWakeMarker(directory), "utf8")).toContain("restarts"); + }); + + it("rejects a missing archive and symlinked archive or marker", async () => { + const archive = path.join(directory, "backup.sql.gz"); + await expect(verifyIdleDatabaseBackup(archive)).rejects.toThrow(); + const target = path.join(directory, "target"); + await writeFile(target, gzipSync("SELECT 1;")); + await symlink(target, archive); + await expect(verifyIdleDatabaseBackup(archive)).rejects.toThrow(); + await symlink(target, idleBackupWakeMarker(directory)); + await expect(markIdleBackupWakeRequired(directory)).rejects.toThrow(); + expect(await readFile(target)).toEqual(gzipSync("SELECT 1;")); + }); +}); + +const support = await getEmbeddedPostgresTestSupport(); +describe.skipIf(!support.supported)("idle backup restore", () => { + it("restores the last pre-sleep state into a separate database", async () => { + const source = await startEmbeddedPostgresTestDatabase("idle-backup-restore-"); + const sourceDb = createDb(source.connectionString); + const targetUrl = new URL(source.connectionString); + targetUrl.pathname = "/idle_restore"; + try { + await sourceDb.execute(sql`CREATE TABLE backup_fixture (id integer PRIMARY KEY, value text NOT NULL)`); + await sourceDb.execute(sql`INSERT INTO backup_fixture VALUES (1, 'last committed state')`); + await markIdleBackupWakeRequired(directory); + const result = await runDatabaseBackup({ + connectionString: source.connectionString, backupDir: directory, + retention: { dailyDays: 14, weeklyWeeks: 4, monthlyMonths: 6 }, + verifyBeforePrune: verifyIdleDatabaseBackup, + }); + await verifyIdleDatabaseBackup(result.backupFile); + await ensurePostgresDatabase(source.connectionString, "idle_restore"); + await runDatabaseRestore({ connectionString: targetUrl.toString(), backupFile: result.backupFile }); + const target = createDb(targetUrl.toString()); + expect(await target.execute(sql`SELECT id, value FROM backup_fixture`)).toEqual([{ id: 1, value: "last committed state" }]); + const checkpointBytes = await readFile(result.backupFile); + expect(await readFile(idleBackupWakeMarker(directory), "utf8")).toContain("restarts"); + // A failed verification must not delete existing recovery points, even + // when their age would make them eligible for the configured pruning. + const history = path.join(directory, "paperclip-history.sql.gz"); + const historyBytes = gzipSync("SELECT 'historical recovery point';"); + await writeFile(history, historyBytes); + await utimes(history, new Date(0), new Date(0)); + await expect(runDatabaseBackup({ + connectionString: source.connectionString, backupDir: directory, + backupEngine: "javascript", retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, + verifyBeforePrune: async () => { throw new Error("verification failed"); }, + })).rejects.toThrow("verification failed"); + expect(await readFile(history)).toEqual(historyBytes); + expect(await readFile(result.backupFile)).toEqual(checkpointBytes); + } finally { + await source.cleanup(); + } + }, 60_000); +}); diff --git a/server/src/__tests__/idle-local-work.test.ts b/server/src/__tests__/idle-local-work.test.ts index 51e9dae011f..666c58d0b20 100644 --- a/server/src/__tests__/idle-local-work.test.ts +++ b/server/src/__tests__/idle-local-work.test.ts @@ -40,10 +40,12 @@ describe("idle cleanup inspection", () => { expect(await readIdleLocalWork()).toBe("none"); markIdleStartupComplete({ scheduledBackups: true }); expect(await readIdleLocalWork()).toBe("present"); + expect(await readIdleLocalWork({ backupCheckpoint: true })).toBe("none"); markIdleStartupComplete({ scheduledBackups: false }); await fs.mkdir(custom); await fs.writeFile(path.join(custom, "receipt.tmp"), "incomplete"); expect(await readIdleLocalWork()).toBe("present"); + expect(await readIdleLocalWork({ backupCheckpoint: true })).toBe("present"); await fs.rm(custom, { recursive: true }); expect(await readIdleLocalWork()).toBe("none"); }); diff --git a/server/src/__tests__/idle-sleep-safety.test.ts b/server/src/__tests__/idle-sleep-safety.test.ts index 1579247a494..66a023dd9b9 100644 --- a/server/src/__tests__/idle-sleep-safety.test.ts +++ b/server/src/__tests__/idle-sleep-safety.test.ts @@ -1,4 +1,4 @@ -import { beginIdleTrackedWork } from "../services/task-admission.js"; +import { beginIdleTrackedWork, idleWorkSnapshot } from "../services/task-admission.js"; import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; @@ -22,6 +22,73 @@ const ownerId = "d0b833f4-4098-42de-8420-1907f3aa4895"; const owned = () => ({ ...held(), ownerId }); const emptyLocal = async () => "none" as const; +describe("idle backup checkpoint", () => { + const database = (blocked = false) => { + const transaction = vi.fn(async (run: (tx: unknown) => Promise) => run({ execute: async () => [{ blocked }] })); + return { db: { transaction } as unknown as Db, transaction }; + }; + const backup = (work: () => Promise = async () => true) => vi.fn(async () => { + const finish = beginIdleTrackedWork(); + try { return await work(); } finally { finish(); } + }); + it("backs up after durable inspection and rechecks local work before authorizing sleep", async () => { + const { db, transaction } = database(); + const local = vi.fn(emptyLocal); + const checkpoint = backup(async () => { + expect(transaction).toHaveBeenCalledOnce(); + expect(idleWorkSnapshot().active).toBe(1); + return true; + }); + expect(await readIdleSleepSafety(db, owned, () => now, ownerId, local, undefined, checkpoint)).toEqual(none); + expect(checkpoint).toHaveBeenCalledOnce(); + expect(local).toHaveBeenCalledTimes(2); + }); + it.each(["present", "unknown"] as const)("avoids DB probes and backups for known local %s work", async state => { + const { db, transaction } = database(); + const checkpoint = backup(); + const plugins = vi.fn(); + expect(await readIdleSleepSafety(db, owned, () => now, ownerId, async () => state, plugins, checkpoint)) + .toEqual({ version: 1, backgroundWork: state }); + expect(transaction).not.toHaveBeenCalled(); + expect(plugins).not.toHaveBeenCalled(); + expect(checkpoint).not.toHaveBeenCalled(); + }); + it("does not start a backup for durable work or an unowned hold", async () => { + const checkpoint = backup(); + expect(await readIdleSleepSafety(database(true).db, owned, () => now, ownerId, emptyLocal, undefined, checkpoint)).toEqual(present); + expect(await readIdleSleepSafety(database().db, owned, () => now, "stale", emptyLocal, undefined, checkpoint)).toEqual(unknown); + expect(checkpoint).not.toHaveBeenCalled(); + }); + it.each(["failed", "threw", "untracked", "concurrent", "expired", "replaced"])("keeps the instance awake when the backup is %s", async kind => { + let clock = now; + let status = owned(); + const work = async () => { + if (kind === "threw") throw new Error("private archive path"); + if (kind === "concurrent") { const finish = beginIdleTrackedWork(); finish(); } + if (kind === "expired") clock += 60_000; + if (kind === "replaced") status = { ...status, ownerId: "replacement" }; + return kind !== "failed"; + }; + const checkpoint = kind === "untracked" ? work : backup(work); + expect(await readIdleSleepSafety(database().db, () => status, () => clock, ownerId, emptyLocal, undefined, checkpoint)).toEqual(unknown); + expect(idleWorkSnapshot().active).toBe(0); + }); + it("retains actual backup work after the owning hold expires", async () => { + let finishBackup!: () => void; + const pending = new Promise(resolve => { finishBackup = resolve; }); + const checkpoint = backup(async () => { await pending; return true; }); + let clock = now; + const scan = readIdleSleepSafety(database().db, owned, () => clock, ownerId, emptyLocal, undefined, checkpoint); + await vi.waitFor(() => expect(checkpoint).toHaveBeenCalledOnce()); + clock += 60_000; + expect(idleWorkSnapshot().active).toBe(1); + expect(await readIdleSleepSafety(database().db, owned, () => clock, ownerId, emptyLocal)).toEqual(unknown); + finishBackup(); + expect(await scan).toEqual(unknown); + expect(idleWorkSnapshot().active).toBe(0); + }); +}); + describe("idle sleep safety failure boundaries", () => { it.each([ @@ -36,7 +103,7 @@ describe("idle sleep safety failure boundaries", () => { it("fails closed when the database is unavailable", async () => { const db = { transaction: vi.fn().mockRejectedValue(new Error("private connection detail")) } as unknown as Db; - expect(await readIdleSleepSafety(db, held, () => now)).toEqual(unknown); + expect(await readIdleSleepSafety(db, held, () => now, undefined, emptyLocal)).toEqual(unknown); }); it.each([ @@ -46,14 +113,14 @@ describe("idle sleep safety failure boundaries", () => { const getStatus = vi.fn().mockReturnValueOnce(held()).mockReturnValue({ ...held(), ...change }); const execute = vi.fn().mockResolvedValue([{ blocked: true }]); const transaction = vi.fn(async (fn: (tx: unknown) => Promise) => fn({ execute })); - expect(await readIdleSleepSafety({ transaction } as unknown as Db, getStatus, () => now)).toEqual(unknown); + expect(await readIdleSleepSafety({ transaction } as unknown as Db, getStatus, () => now, undefined, emptyLocal)).toEqual(unknown); expect(transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: "repeatable read", accessMode: "read only" }); }); it("reports persisted work when the admission hold remains unchanged", async () => { const execute = vi.fn().mockResolvedValue([{ blocked: true }]); const transaction = vi.fn(async (fn: (tx: unknown) => Promise) => fn({ execute })); - expect(await readIdleSleepSafety({ transaction } as unknown as Db, held, () => now)).toEqual(present); + expect(await readIdleSleepSafety({ transaction } as unknown as Db, held, () => now, undefined, emptyLocal)).toEqual(present); }); }); @@ -245,6 +312,22 @@ if (!support.supported) console.warn(`Skipping idle sleep Postgres tests: ${supp expect(await read()).toEqual(none); }); + it("accepts only exact worker drain receipts and still checks durable work", async () => { + const [{ id }] = await db.execute<{ id: string }>(sql`INSERT INTO plugins (plugin_key, package_name, version, manifest_json) + VALUES ('demo.idle', 'demo-idle', '1.0.0', '{}'::jsonb) RETURNING id`); + const inspect = vi.fn(async () => ({ backgroundWork: "none" as const, pluginIds: [id!] })); + const scan = () => readIdleSleepSafety(db, owned, () => now, ownerId, emptyLocal, inspect); + expect(await scan()).toEqual(none); + expect(inspect).toHaveBeenCalledWith({ ownerId, expiresAt: owned().expiresAt!.getTime() }); + await db.execute(sql`INSERT INTO plugins (plugin_key, package_name, version, manifest_json) + VALUES ('demo.unknown', 'demo-unknown', '1.0.0', '{}'::jsonb)`); + expect(await scan()).toEqual(present); + await db.execute(sql`UPDATE plugins SET status = 'disabled' WHERE plugin_key = 'demo.unknown'`); + const { companyId } = await seed(); + await db.insert(issues).values({ companyId, title: "Still pending", status: "todo" }); + expect(await scan()).toEqual(present); + }); + it("fails closed when the installed schema is older than the report", async () => { await db.execute(sql`ALTER TABLE heartbeat_runs RENAME COLUMN cost_accounting_pending TO hidden_pending`); try { expect(await read()).toEqual(unknown); } diff --git a/server/src/__tests__/inbox-archive-routes.test.ts b/server/src/__tests__/inbox-archive-routes.test.ts index b33663e8cac..4b53f1f2736 100644 --- a/server/src/__tests__/inbox-archive-routes.test.ts +++ b/server/src/__tests__/inbox-archive-routes.test.ts @@ -20,6 +20,7 @@ import { import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; +import { issueService } from "../services/issues.js"; import { subscribeCompanyLiveEvents } from "../services/live-events.js"; import { getEmbeddedPostgresTestSupport, @@ -194,6 +195,168 @@ describeEmbeddedPostgres("inbox archive routes", () => { .expect(({ body }) => expect(body).toEqual({ ok: true, userId: seeded.responsibleUserId })); }); + it.each([false, true])("allows manual archive during human completion (existing archive: %s)", async (alreadyArchived) => { + const seeded = await seed(); + const svc = issueService(db); + if (alreadyArchived) { + await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId); + } + const app = appFor({ + type: "board", + source: "session", + userId: seeded.responsibleUserId, + companyIds: [seeded.companyId], + memberships: [{ companyId: seeded.companyId, membershipRole: "operator", status: "active" }], + isInstanceAdmin: false, + }); + let manualArchive: Promise<{ status: number }> | undefined; + let completed: Awaited>; + let manualStatus: number | undefined; + try { + completed = await db.transaction(async (tx) => { + await tx.execute(sql`set local statement_timeout = '5s'`); + // Human completion locks the issue before it writes the archive. + await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, seeded.issueId)).for("update"); + const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`); + manualArchive = request(app).post(`/api/issues/${seeded.issueId}/inbox-archive`).send({}).then((response) => response); + await expect.poll(async () => { + const blocked = await db.execute(sql` + select 1 from pg_stat_activity + where datname = current_database() and ${pid} = any(pg_blocking_pids(pid)) + `); + return blocked.length; + }, { timeout: 3_000 }).toBeGreaterThan(0); + + // The manual request is now waiting on our parent row. Previously it + // already owned the archive insert, so this completion formed a cycle. + return svc.update(seeded.issueId, { + status: "done", + actorUserId: seeded.responsibleUserId, + companyGuard: seeded.companyId, + }, tx, []); + }); + } finally { + // Do not leave an in-flight route writing into the next test's fixture. + if (manualArchive) manualStatus = (await manualArchive).status; + } + expect(manualStatus).toBe(200); + expect(completed!.status).toBe("done"); + const archives = await db.select().from(issueInboxArchives); + expect(archives).toHaveLength(1); + expect(archives[0]).toMatchObject({ + companyId: seeded.companyId, + issueId: seeded.issueId, + userId: seeded.responsibleUserId, + archivedByActorType: "user", + archivedByAgentId: null, + archivedByRunId: null, + }); + const audits = await db.select().from(activityLog).where(eq(activityLog.action, "issue.inbox_archived")); + expect(audits).toHaveLength(2); + expect(audits.some((audit) => audit.details?.source === "issue_status_done")).toBe(true); + expect(archives[0].archivedAt.getTime()).toBeGreaterThanOrEqual(completed!.completedAt!.getTime()); + // The status route records this activity after the completion transaction. + await db.insert(activityLog).values({ + companyId: seeded.companyId, + actorType: "user", + actorId: seeded.responsibleUserId, + action: "issue.updated", + entityType: "issue", + entityId: seeded.issueId, + details: { status: "done", _previous: { status: "todo" } }, + }); + await request(app).get(`/api/companies/${seeded.companyId}/issues`) + .query({ touchedByUserId: seeded.responsibleUserId, inboxArchivedByUserId: seeded.responsibleUserId, status: "done" }) + .expect(200) + .expect(({ body }) => expect(body.map((issue: { id: string }) => issue.id)).not.toContain(seeded.issueId)); + }); + + it("keeps caller rollback atomic without blocking a different user's archive", async () => { + const seeded = await seed(); + const svc = issueService(db); + const rollback = new Error("Roll back the first user's archive"); + await expect(db.transaction(async (tx) => { + await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, new Date(), undefined, tx); + // An exclusive parent lock would unnecessarily serialize these users. + await db.transaction(async (otherTx) => { + await otherTx.execute(sql`set local statement_timeout = '3s'`); + await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.targetUserId, new Date(), undefined, otherTx); + }); + throw rollback; + })).rejects.toBe(rollback); + const archives = await db.select().from(issueInboxArchives); + expect(archives).toHaveLength(1); + expect(archives[0].userId).toBe(seeded.targetUserId); + }); + + it.each([-1, 0, 1])("keeps the newest archive time and matching attribution (offset: %s)", async (offset) => { + const seeded = await seed(); + const svc = issueService(db); + const firstTime = new Date("2000-01-02T00:00:00.000Z"); + const nextTime = new Date(firstTime.getTime() + offset * 1_000); + const first = await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, firstTime, { + archivedByActorType: "agent", + archivedByAgentId: seeded.agentId, + archivedByRunId: seeded.runId, + }); + const next = await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, nextTime); + expect(next.id).toBe(first.id); + expect(next).toMatchObject(offset < 0 ? first : { + archivedAt: nextTime, + archivedByActorType: "user", + archivedByAgentId: null, + archivedByRunId: null, + }); + expect(await db.select().from(issueInboxArchives)).toEqual([next]); + + await svc.unarchiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId); + const afterUndo = await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, nextTime); + expect(afterUndo).toMatchObject({ archivedAt: nextTime, archivedByActorType: "user" }); + expect(afterUndo.id).not.toBe(first.id); + }); + + it("holds the issue company stable until its archive transaction ends", async () => { + const seeded = await seed(); + const otherCompany = await seed(); + const svc = issueService(db); + let move: Promise | undefined; + try { + await db.transaction(async (tx) => { + await svc.archiveInbox(seeded.companyId, seeded.issueId, seeded.responsibleUserId, new Date(), undefined, tx); + const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`); + move = db.update(issues).set({ companyId: otherCompany.companyId }) + .where(eq(issues.id, seeded.issueId)).then((rows) => rows); + await expect.poll(async () => { + const blocked = await db.execute(sql` + select 1 from pg_stat_activity + where datname = current_database() and ${pid} = any(pg_blocking_pids(pid)) + `); + return blocked.length; + }, { timeout: 3_000 }).toBeGreaterThan(0); + const [stillOwned] = await tx.select().from(issues).where(eq(issues.id, seeded.issueId)); + expect(stillOwned.companyId).toBe(seeded.companyId); + }); + } finally { + await move; + } + const [moved] = await db.select().from(issues).where(eq(issues.id, seeded.issueId)); + expect(moved.companyId).toBe(otherCompany.companyId); + }); + + it("rejects missing and cross-company issues before archiving", async () => { + const seeded = await seed(); + const svc = issueService(db); + const otherCompany = await seed(); + for (const [companyId, issueId] of [ + [otherCompany.companyId, seeded.issueId], + [seeded.companyId, randomUUID()], + ]) { + await expect(svc.archiveInbox(companyId, issueId, seeded.responsibleUserId)) + .rejects.toMatchObject({ status: 404, message: "Issue not found" }); + } + expect(await db.select().from(issueInboxArchives)).toHaveLength(0); + }); + it("silently archives an issue for the board user who moves it to done", async () => { const seeded = await seed(); const app = appFor({ diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 2483cd4a3e1..5d6cf9d0345 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -2,6 +2,7 @@ import express from "express"; import request from "supertest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { hoistModuleGraph } from "./helpers/hoist-module-graph.js"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(), @@ -76,7 +77,7 @@ describe("instance settings routes", () => { return { errorHandler, instanceSettingsRoutes }; }); - function createApp(actor: any) { + function createApp(actor: any, pluginWorkers?: PluginWorkerManager, prepareBackup?: () => Promise) { const { errorHandler, instanceSettingsRoutes } = routeModules.value; const app = express(); app.use(express.json()); @@ -84,7 +85,7 @@ describe("instance settings routes", () => { req.actor = actor; next(); }); - app.use("/api", instanceSettingsRoutes(mockDb as any)); + app.use("/api", instanceSettingsRoutes(mockDb as any, pluginWorkers, prepareBackup)); app.use(errorHandler); return app; } @@ -972,7 +973,7 @@ describe("instance settings routes", () => { await request(createApp(adminActor)).post("/api/instance/task-drain").send({ purpose: "idle", ttlMs: 60_000 }).expect(200); expect(mockHeartbeatService.computeTaskDrain).toHaveBeenCalledWith({ purpose: "idle", ttlMs: 60_000 }); await request(createApp(adminActor)).get(`/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`).expect(200); - expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId); + expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, expect.any(Function), undefined, undefined); }); it.each([{}, { ttlMs: null }, { ttlMs: 1 }, { ttlMs: 300_001 }])("rejects an unbounded or invalid idle TTL: %j", async body => { @@ -999,10 +1000,70 @@ describe("instance settings routes", () => { const res = await request(createApp(adminActor)).get("/api/instance/task-drain?idleSleepSafety=1"); expect(res.status).toBe(200); expect(res.body).toEqual({ ...idleStatus, idleSleepSafety: report }); - expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined); + expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, expect.any(Function), undefined, undefined); expect(mockReadIdleSleepSafety.mock.calls[0][1]()).toEqual(idleStatus); }); + it("uses the live worker manager for inspection and releases only the owned hold", async () => { + const inspect = vi.fn(async () => ({ backgroundWork: "none", pluginIds: [] })); + const release = vi.fn(); + const workers = { + inspectIdleSleep: inspect, + releaseIdleSleep: release, + } as unknown as PluginWorkerManager; + const ownerId = "fixture-idle-owner"; + const lease = { ownerId, expiresAt: Date.now() + 60_000 }; + mockHeartbeatService.getTaskDrainStatus.mockReturnValue({ ...idleStatus, draining: true, ownerId }); + mockReadIdleSleepSafety.mockImplementation(async (_db, _status, _now, owner, _local, inspectPlugins) => { + expect(owner).toBe(ownerId); + return { version: 1, backgroundWork: (await inspectPlugins(lease)).backgroundWork }; + }); + const app = createApp(adminActor, workers); + await request(app).get(`/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`).expect(200); + expect(inspect).toHaveBeenCalledWith(lease); + expect(inspect.mock.contexts[0]).toBe(workers); + await request(app).delete("/api/instance/task-drain?ownerId=stale-owner").expect(409); + expect(release).not.toHaveBeenCalled(); + await request(app).delete(`/api/instance/task-drain?ownerId=${ownerId}`).expect(200); + expect(release).toHaveBeenCalledOnce(); + }); + + it("passes the operator backup hook only through the protected safety report", async () => { + const backup = vi.fn(async () => true); + mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus); + const app = createApp(adminActor, undefined, backup); + await request(app).get("/api/instance/task-drain").expect(200); + expect(mockReadIdleSleepSafety).not.toHaveBeenCalled(); + await request(app).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200); + expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, + "fixture-owner", expect.any(Function), undefined, backup); + mockReadIdleSleepSafety.mockClear(); + await request(createApp(nonAdminActor, undefined, backup)).get("/api/instance/task-drain?idleSleepSafety=1").expect(403); + expect(mockReadIdleSleepSafety).not.toHaveBeenCalled(); + expect(backup).not.toHaveBeenCalled(); + }); + + it("reserves managed checkpoint creation for verified Cloud control actors", async () => { + const backup = vi.fn(async () => true); + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "fixture-managed-signal"; + mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus); + try { + for (const source of ["session", "cloud_tenant", "local_implicit"]) { + const tenantAdmin = createApp({ ...adminActor, source }, undefined, backup); + await request(tenantAdmin).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner") + .set("x-paperclip-cloud-control", "unverified-header").expect(403); + } + expect(mockReadIdleSleepSafety).not.toHaveBeenCalled(); + expect(backup).not.toHaveBeenCalled(); + const control = createApp({ ...adminActor, source: "cloud_control" }, undefined, backup); + await request(control).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200); + expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, + "fixture-owner", expect.any(Function), undefined, backup); + } finally { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + } + }); + it.each([ ["company member", nonAdminActor], ["agent", { type: "agent", agentId: "agent-1", companyId: "company-1" }], diff --git a/server/src/__tests__/invite-test-resolution-route.test.ts b/server/src/__tests__/invite-test-resolution-route.test.ts index 775905a8c62..38120e0d082 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-persistence.test.ts b/server/src/__tests__/issue-execution-policy-persistence.test.ts new file mode 100644 index 00000000000..743c248efc1 --- /dev/null +++ b/server/src/__tests__/issue-execution-policy-persistence.test.ts @@ -0,0 +1,45 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { companies, createDb, issues } from "@paperclipai/db"; +import { buildExecutionPolicy } from "../../../ui/src/lib/issue-execution-policy.ts"; +import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js"; +import { issueService } from "../services/issues.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("edited execution policy persistence", () => { + let temporary: Awaited>; + let db: ReturnType; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-policy-persistence-"); + db = createDb(temporary.connectionString); + }, 30_000); + afterAll(async () => { await temporary?.cleanup(); }); + + it.each(["reviewer", "monitor"] as const)("retains the independent review limit after removing the last %s", async (removed) => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const original = normalizeIssueExecutionPolicy({ + maxReviewRounds: 4, + ...(removed === "monitor" + ? { monitor: { nextCheckAt: "2099-01-01T00:00:00.000Z" } } + : { stages: [{ type: "review", participants: [{ type: "user", userId: "board-reviewer" }] }] }), + }); + await db.insert(companies).values({ id: companyId, name: "Policy fixture", issuePrefix: `P${companyId.slice(0, 6)}` }); + await db.insert(issues).values({ id: issueId, companyId, title: "Review limit fixture", executionPolicy: original }); + const edited = buildExecutionPolicy({ existingPolicy: original, reviewerValues: [], approverValues: [] })!; + // The monitor editor removes this key after building the unchanged stages. + const { monitor: _removedMonitor, ...withoutMonitor } = edited; + const normalized = normalizeIssueExecutionPolicy(removed === "monitor" ? withoutMonitor : edited); + const service = issueService(db); + await service.update(issueId, { executionPolicy: normalized, companyGuard: companyId }); + const saved = await service.getById(issueId); + expect(saved?.executionPolicy).toEqual({ mode: "normal", commentRequired: true, stages: [], maxReviewRounds: 4 }); + + const restored = normalizeIssueExecutionPolicy(buildExecutionPolicy({ + existingPolicy: saved?.executionPolicy, + reviewerValues: ["user:board-reviewer"], approverValues: [], + })); + expect(restored).toMatchObject({ maxReviewRounds: 4, stages: [{ type: "review" }] }); + }); +}); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index abc52ad4139..be3f8c36cb0 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__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index 8b9d560191c..c49890ba0b9 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts"; import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared"; +import { buildExecutionPolicy } from "../../../ui/src/lib/issue-execution-policy.ts"; const coderAgentId = "11111111-1111-4111-8111-111111111111"; const qaAgentId = "22222222-2222-4222-8222-222222222222"; @@ -43,6 +44,23 @@ describe("normalizeIssueExecutionPolicy", () => { expect(normalizeIssueExecutionPolicy({ stages: [] })).toBeNull(); }); + it("retains an independent review limit through removing and restoring the last reviewer", () => { + const original = normalizeIssueExecutionPolicy({ + maxReviewRounds: 4, + stages: [{ type: "review", participants: [{ type: "agent", agentId: qaAgentId }] }], + }); + const edited = buildExecutionPolicy({ existingPolicy: original, reviewerValues: [], approverValues: [] }); + const saved = normalizeIssueExecutionPolicy(edited); + expect(saved).toEqual({ mode: "normal", commentRequired: true, stages: [], maxReviewRounds: 4 }); + expect(applyIssueExecutionPolicyTransition({ + issue: { status: "in_progress", assigneeAgentId: coderAgentId }, + policy: saved, requestedStatus: "done", requestedAssigneePatch: {}, actor: { agentId: coderAgentId }, + })).toEqual({ patch: {} }); + expect(normalizeIssueExecutionPolicy(buildExecutionPolicy({ + existingPolicy: saved, reviewerValues: [`agent:${qaAgentId}`], approverValues: [], + }))).toMatchObject({ maxReviewRounds: 4, stages: [{ type: "review" }] }); + }); + it("throws when all participants are invalid (missing agentId)", () => { expect(() => normalizeIssueExecutionPolicy({ diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 30ab5acbb60..cd8cdded1bb 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -21,6 +21,7 @@ const apiPrefixes: Record = { "agents.ts": "/api", "agent-commentary.ts": "/api", "agent-avatars.ts": "/api", + "agent-profile-avatar.ts": "/api", "announcements.ts": "/api", "ai-connections.ts": "/api", "decision-models.ts": "/api", @@ -229,10 +230,13 @@ function loadActualRoutes() { } } if (file === "dot-runner.ts") { - const basePath = /const path = "([^"]+)"/.exec(source)?.[1]; - if (!basePath) throw new Error("Dot binding route prefix is missing"); - for (const match of source.matchAll(/router\.(get|post|delete)\(path(?: \+ "([^"]+)")?/g)) { - routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`); + for (const name of ["path", "invitePath"]) { + const basePath = new RegExp(`const ${name} = "([^"]+)"`).exec(source)?.[1]; + if (!basePath) throw new Error(`Dot ${name} route prefix is missing`); + const methods = new RegExp(`router\\.(get|post|delete)\\(${name}(?: \\+ "([^"]+)")?`, "g"); + for (const match of source.matchAll(methods)) { + routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`); + } } } @@ -858,6 +862,19 @@ describe("openapi routes", () => { expect(path.put.responses["422"]).toBeDefined(); }); + it("documents operator Dot invitations and current pairing and event-test fields", () => { + const { spec } = loadSpecRoutes(); + const invitations = spec.paths["/api/companies/{companyId}/dot-invitations"]; + for (const operation of [invitations.get, invitations.post]) { + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(operation.responses["200"]).toBeDefined(); + } + expect(invitations.post.requestBody.content["application/json"].schema.additionalProperties).toBe(false); + const base = "/api/companies/{companyId}/agents/{agentId}/dot-binding"; + expect(spec.paths[base].post.requestBody.content["application/json"].schema.properties.replaceBindingId).toMatchObject({ type: "string", format: "uuid" }); + expect(spec.paths[base + "/event-test"].post.requestBody.content["application/json"].schema.properties.bindingId).toMatchObject({ type: "string", format: "uuid" }); + }); + it("documents board-only repository discovery and selection", () => { const { spec } = loadSpecRoutes(); const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get; diff --git a/server/src/__tests__/plugin-idle-manager.test.ts b/server/src/__tests__/plugin-idle-manager.test.ts new file mode 100644 index 00000000000..1ea93bc172b --- /dev/null +++ b/server/src/__tests__/plugin-idle-manager.test.ts @@ -0,0 +1,98 @@ +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js"; +import { startTaskDrain, stopTaskDrain } from "../services/task-admission.js"; + +const manifest = { id: "test.idle", apiVersion: 1 as const, version: "1.0.0", displayName: "Idle fixture", + description: "Idle fixture", author: "Paperclip", categories: ["automation" as const], capabilities: [], entrypoints: { worker: "worker.cjs" } }; +function worker(write = false, hostHandler = async () => undefined) { + return createPluginWorkerHandle("test.idle", { + entrypointPath: fileURLToPath(new URL("./fixtures/plugin-worker-idle.cjs", import.meta.url)), + execArgv: ["--import", createRequire(import.meta.url).resolve("tsx")], + manifest, config: {}, instanceInfo: { instanceId: "fixture", hostVersion: "1.0.0" }, apiVersion: 1, + env: { IDLE_TEST_WRITE: write ? "1" : "0" }, + hostHandlers: { "state.set": hostHandler, "events.subscribe": async () => ({}) }, + }); +} +function hold() { + const lease = startTaskDrain({ purpose: "idle", ttlMs: 30_000 }); + return { ownerId: lease.ownerId!, expiresAt: lease.expiresAt!.getTime() }; +} + +describe("plugin manager idle receipts", () => { + afterEach(() => stopTaskDrain()); + it("retains timed-out worker operations and admits work after the owned hold releases", async () => { + const handle = worker(); + await handle.start(); + try { + await expect(handle.call("environmentProbe", { driverKey: "fixture", companyId: "fixture", environmentId: "fixture", config: { delayMs: 200 } }, 10)).rejects.toThrow("timed out"); + const lease = hold(); + expect(await handle.prepareIdleSleep!(lease)).toBe("present"); + await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none"); + await expect(handle.call("health", {})).rejects.toThrow("held"); + expect(await handle.prepareIdleSleep!({ ...lease, ownerId: "stale" })).toBe("unknown"); + stopTaskDrain(); handle.releaseIdleSleep!(); + expect(await handle.call("health", {})).toMatchObject({ status: "ok" }); + expect(await handle.prepareIdleSleep!(hold())).toBe("none"); + stopTaskDrain(); + // No intervening request or explicit worker release: the next controller + // attempt must discard the worker manager's previous ownership record. + expect(await handle.prepareIdleSleep!(hold())).toBe("none"); + } finally { stopTaskDrain(); await handle.stop(); } + }); + + it("keeps a host write counted after the caller stops waiting", async () => { + let finish!: () => void; + const handle = worker(true, () => new Promise((resolve) => { finish = resolve; })); + await handle.start(); + try { + await expect(handle.call("health", {}, 30)).rejects.toThrow("timed out"); + const lease = hold(); + expect(await handle.prepareIdleSleep!(lease)).toBe("present"); + finish(); + await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none"); + } finally { stopTaskDrain(); await handle.stop(); } + }); + + it("lets a queued notification finish its write while idle preparation is in flight", async () => { + let finish!: () => void; + let writes = 0; + const handle = worker(false, () => new Promise((resolve) => { + writes++; + finish = resolve; + })); + await handle.start(); + try { + // Finish setup and its subscription receipt before queuing the event. + await handle.call("health", {}); + handle.notify("onEvent", { event: { eventType: "fixture.write" } }); + // No await: the host has set its hold before it can read the event's + // worker-to-host write. The worker must still be allowed to complete it. + const lease = hold(); + const preparation = handle.prepareIdleSleep!(lease); + expect(await preparation).not.toBe("none"); + await expect.poll(() => writes).toBe(1); + expect(await handle.prepareIdleSleep!(lease)).toBe("present"); + finish(); + await expect.poll(() => handle.prepareIdleSleep!(lease)).toBe("none"); + } finally { finish?.(); stopTaskDrain(); await handle.stop(); } + }); + + it("resumes crash recovery after an idle hold without discarding the restart", async () => { + const handle = worker(); + await handle.start(); + handle.on("crash", () => { hold(); }); + try { + await expect(handle.call("environmentProbe", { driverKey: "fixture", companyId: "fixture", environmentId: "fixture", config: { crash: true } })).rejects.toThrow(); + await delay(1_600); + expect(handle.status).toBe("backoff"); + expect(handle.diagnostics().nextRestartAt).not.toBeNull(); + stopTaskDrain(); + await expect.poll(() => handle.status, { timeout: 5_000 }).toBe("running"); + // Recovery restores service, but lost completion receipts remain unsafe. + expect(await handle.prepareIdleSleep!(hold())).toBe("unknown"); + } finally { stopTaskDrain(); await handle.stop(); } + }); +}); diff --git a/server/src/__tests__/plugin-idle-rpc.test.ts b/server/src/__tests__/plugin-idle-rpc.test.ts new file mode 100644 index 00000000000..c1e626c1b88 --- /dev/null +++ b/server/src/__tests__/plugin-idle-rpc.test.ts @@ -0,0 +1,89 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { definePlugin, type PluginDefinition } from "../../../packages/plugins/sdk/src/define-plugin.js"; +import { startWorkerRpcHost } from "../../../packages/plugins/sdk/src/worker-rpc-host.js"; + +function fixture(definition: PluginDefinition, rpcTimeoutMs = 30_000) { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const worker = startWorkerRpcHost({ plugin: definePlugin(definition), stdin, stdout, rpcTimeoutMs }); + const messages: any[] = []; + stdout.on("data", (chunk) => { for (const line of String(chunk).trim().split("\n")) messages.push(JSON.parse(line)); }); + let id = 0; + async function call(method: string, params: unknown = {}) { + const callId = ++id; + stdin.write(JSON.stringify({ jsonrpc: "2.0", id: callId, method, params }) + "\n"); + await expect.poll(() => messages.find((message) => message.id === callId && !message.method)).toBeDefined(); + return messages.find((message) => message.id === callId && !message.method); + } + const initialize = () => call("initialize", { manifest: { id: "test.idle", apiVersion: 1 }, config: {}, apiVersion: 1 }); + return { call, initialize, messages, stdin, close: () => worker.stop() }; +} + +describe("plugin idle RPC", () => { + const hold = () => ({ ownerId: "idle-owner", expiresAt: Date.now() + 30_000 }); + + it("seals an idle worker, fences stale releases, and restores normal calls", async () => { + const f = fixture({ async setup() {}, async onIdleDrain() { return "none"; } }); + try { + expect((await f.initialize()).result.supportedMethods).toContain("prepareIdleSleep"); + const lease = hold(); + expect((await f.call("prepareIdleSleep", lease)).result).toEqual({ ...lease, backgroundWork: "none" }); + expect((await f.call("health")).error).toBeDefined(); + await f.call("releaseIdleSleep", { ownerId: "stale" }); + expect((await f.call("health")).error).toBeDefined(); + await f.call("releaseIdleSleep", lease); + expect((await f.call("health")).result.status).toBe("ok"); + } finally { f.close(); } + }); + + it("does not mistake a caller timeout for a completed worker handler", async () => { + let finish!: () => void; + const f = fixture({ async setup() {}, async onIdleDrain() { return "none"; }, + async onHealth() { await new Promise((resolve) => { finish = resolve; }); return { status: "ok" }; } }); + try { + await f.initialize(); + const accepted = f.call("health"); + await expect.poll(() => finish).toBeDefined(); + expect((await f.call("prepareIdleSleep", hold())).result.backgroundWork).toBe("present"); + finish(); await accepted; + expect((await f.call("prepareIdleSleep", hold())).result.backgroundWork).toBe("none"); + } finally { f.close(); } + }); + + it("retains timed-out worker-to-host writes until the host's late receipt", async () => { + let write!: () => Promise; + const f = fixture({ async setup(ctx) { write = () => ctx.state.set({ scopeKind: "instance", stateKey: "fixture" }, { value: 1 }); }, + async onIdleDrain() { return "none"; } }, 10); + try { + await f.initialize(); + await expect(write()).rejects.toThrow("timed out"); + const request = f.messages.find((message) => message.method === "state.set"); + expect((await f.call("prepareIdleSleep", hold())).result.backgroundWork).toBe("present"); + f.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: null }) + "\n"); + expect((await f.call("prepareIdleSleep", hold())).result.backgroundWork).toBe("none"); + } finally { f.close(); } + }); + + it.each([false, true])("logs session callback failures (async: %s)", async (asynchronous) => { + let subscribe!: () => Promise; + const f = fixture({ async setup(ctx) { + subscribe = () => ctx.agents.sessions.sendMessage("fixture-session", "fixture-company", { + prompt: "fixture", + onEvent: asynchronous + ? async () => { throw new Error("fixture callback failure"); } + : () => { throw new Error("fixture callback failure"); }, + }); + } }); + try { + await f.initialize(); + const subscription = subscribe(); + const request = f.messages.find((message) => message.method === "agents.sessions.sendMessage"); + f.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: {} }) + "\n"); + await subscription; + f.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "agents.sessions.event", params: { sessionId: "fixture-session" } }) + "\n"); + await expect.poll(() => f.messages.find((message) => message.method === "log" && + message.params.level === "error" && message.params.message.includes("fixture callback failure"))).toBeDefined(); + } finally { f.close(); } + }); +}); diff --git a/server/src/__tests__/public-mcp.test.ts b/server/src/__tests__/public-mcp.test.ts index 79debc06f17..5c8ff3d79d0 100644 --- a/server/src/__tests__/public-mcp.test.ts +++ b/server/src/__tests__/public-mcp.test.ts @@ -479,6 +479,15 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () => await expect(oauth.authenticate(next.access_token)).rejects.toThrow(); }); + it.each([new Date(0), null])("rejects personal refresh tokens with invalid expiry %s", async expiresAt => { + const f = await fixture(); + const [stored] = await db.select().from(mcpOauthTokens).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(f.tokens.refresh_token!))); + expect(stored!.expiresAt!.getTime() - Date.now()).toBeGreaterThan(29 * 24 * 60 * 60_000); + await db.update(mcpOauthTokens).set({ expiresAt }).where(eq(mcpOauthTokens.id, stored!.id)); + await expect(oauth.token({ grant_type: "refresh_token", client_id: f.client.client_id, + resource: config.resource, refresh_token: f.tokens.refresh_token })).rejects.toThrow(); + }); + it("rejects token expiry, revocation and membership loss on the next call", async () => { const f = await fixture(); await db.update(mcpOauthTokens).set({ expiresAt: new Date(0) }).where(eq(mcpOauthTokens.tokenHash, hashMcpSecret(f.tokens.access_token))); diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index ba5b19453e5..1075c05ea18 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-http-endpoint-guard.test.ts b/server/src/__tests__/remote-http-endpoint-guard.test.ts index 3602be470f4..2e23eaeec45 100644 --- a/server/src/__tests__/remote-http-endpoint-guard.test.ts +++ b/server/src/__tests__/remote-http-endpoint-guard.test.ts @@ -1,11 +1,33 @@ import { describe, expect, it } from "vitest"; import { assertPublicRemoteHttpEndpoint } from "../services/remote-http-endpoint-guard.js"; +import { readRemoteConnectionFailure } from "../services/remote-connection-failure.js"; function guardError(message: string, code: string) { return Object.assign(new Error(message), { code }); } describe("remote HTTP endpoint guard", () => { + it.each(["ENOTFOUND", "ENODATA", "EAI_AGAIN"])("retains proven DNS failure %s across the public wrapper", async (code) => { + const failure = await assertPublicRemoteHttpEndpoint(new URL("https://missing.example.test/mcp"), { + lookup: async () => { throw Object.assign(new Error("lookup failed"), { code }); }, + }, guardError).catch((error) => error); + expect(failure).toMatchObject({ code: "remote_http_dns_failed" }); + expect(readRemoteConnectionFailure(failure)).toBe("dns_failure"); + }); + + it("marks its exact DNS deadline without trusting unknown lookup exceptions", async () => { + const timedOut = await assertPublicRemoteHttpEndpoint(new URL("https://silent.example.test/mcp"), { + lookup: async () => new Promise(() => {}), dnsTimeoutMs: 1, + }, guardError).catch((error) => error); + expect(timedOut).toMatchObject({ code: "remote_http_dns_failed" }); + expect(readRemoteConnectionFailure(timedOut)).toBe("connection_timeout"); + const unknown = await assertPublicRemoteHttpEndpoint(new URL("https://broken.example.test/mcp"), { + lookup: async () => { throw new TypeError("internal lookup bug: ENOTFOUND"); }, + }, guardError).catch((error) => error); + expect(unknown).toMatchObject({ code: "remote_http_dns_failed" }); + expect(readRemoteConnectionFailure(unknown)).toBeNull(); + }); + it("blocks hostnames that resolve to private network addresses", async () => { await expect(assertPublicRemoteHttpEndpoint( new URL("https://metadata.example/mcp"), diff --git a/server/src/__tests__/remote-http-rebinding.test.ts b/server/src/__tests__/remote-http-rebinding.test.ts index 6d9b834977a..7730086e184 100644 --- a/server/src/__tests__/remote-http-rebinding.test.ts +++ b/server/src/__tests__/remote-http-rebinding.test.ts @@ -1,9 +1,13 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { connect as netConnect, createServer as netCreateServer, type AddressInfo, type Socket } from "node:net"; +import { connect as netConnect, createServer as netCreateServer, Socket, type AddressInfo } from "node:net"; import { gzipSync } from "node:zlib"; import { afterEach, describe, expect, it } from "vitest"; import { guardedRemoteHttpFetch, type RemoteHttpSocketFactory } from "../services/remote-http-fetch.js"; +import { readRemoteConnectionFailure } from "../services/remote-connection-failure.js"; +import { isExpectedMcpConnectionFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "../services/mcp-connection-failure.js"; +import { HttpError } from "../errors.js"; +import { initializeMcpHttpSession } from "../services/mcp-http.js"; /** * PAP-17098 — DNS-rebinding regression coverage for outbound MCP/OAuth calls. @@ -147,14 +151,16 @@ describe("guarded remote HTTP fetch (PAP-17098 DNS rebinding)", () => { throw Object.assign(new TypeError("fetch failed"), { cause }); }) as typeof fetch; - await expect(guardedRemoteHttpFetch("https://8.8.8.8/mcp", {}, { + const failure = await guardedRemoteHttpFetch("https://8.8.8.8/mcp", {}, { allowPrivateNetwork: true, unpinnedFetch, error: guardError, - })).rejects.toMatchObject({ + }).catch((error) => error); + expect(failure).toMatchObject({ code: "remote_http_dns_failed", message: "Remote MCP connection hostname could not be resolved", }); + expect(readRemoteConnectionFailure(failure)).toBe("dns_failure"); }); it("pins the connection to the approved address so a rebind never reaches loopback", async () => { @@ -352,13 +358,15 @@ describe("guarded remote HTTP fetch (PAP-17098 DNS rebinding)", () => { }); const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); - await expect(guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { + const failure = await guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { allowPrivateNetwork: false, lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], socketFactory: network.factory, responseTimeoutMs: 150, error: guardError, - })).rejects.toMatchObject({ code: "remote_http_response_timeout" }); + }).catch((error) => error); + expect(failure).toMatchObject({ code: "remote_http_response_timeout" }); + expect(readRemoteConnectionFailure(failure)).toBe("connection_timeout"); // The deadline has to hand back the socket, not just the request handler: // a bounded call that still leaks a descriptor per silent peer is the same @@ -416,11 +424,69 @@ describe("guarded remote HTTP fetch (PAP-17098 DNS rebinding)", () => { }); expect(response.status).toBe(200); - await expect(response.text()).rejects.toMatchObject({ code: "remote_http_response_timeout" }); + const failure = await response.text().catch((error) => error); + expect(failure).toMatchObject({ code: "remote_http_response_timeout" }); + expect(readRemoteConnectionFailure(failure)).toBe("connection_timeout"); await flush(); expect(network.sockets.map((socket) => socket.destroyed)).toEqual([true]); }); + it("preserves the owned body timeout through the MCP initialization wrapper", async () => { + const upstream = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.write('{"jsonrpc":"2.0",'); + }); + const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); + const failure = await initializeMcpHttpSession({ + requestId: "owned-deadline", + send: (init) => withMcpConnectionFailure(() => guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, init, { + allowPrivateNetwork: false, + lookup: async () => [{ address: PUBLIC_ADDRESS, family: 4 }], + socketFactory: network.factory, + responseTimeoutMs: 150, + error: guardError, + })), + }).catch((error) => error); + expect(failure).toMatchObject({ name: "McpHttpInitializationError", stage: "initialize", status: null, + message: "Remote MCP initialization returned an invalid response" }); + expect(failure.response).toBeUndefined(); + expect(isExpectedMcpConnectionFailure(retainMcpConnectionFailure(failure, new HttpError(502, failure.message)))).toBe(true); + await flush(); + expect(network.sockets.map((socket) => socket.destroyed)).toEqual([true]); + }); + + it.each([ + ["EMFILE", "ECONNREFUSED", false], + ["ERR_SSL_UNKNOWN_PROTOCOL", "deadline", false], + ["ECONNRESET", "ECONNREFUSED", true], + ["ETIMEDOUT", "deadline", true], + ] as const)("keeps every failed address relevant to reporting: %s then %s", async (firstCode, lastCode, expected) => { + const first = Object.assign(new Error("first address failed"), { code: firstCode }); + const last = Object.assign(new Error("last address failed"), { code: lastCode }); + const addresses = ["93.184.216.35", PUBLIC_ADDRESS]; + const dialled: string[] = []; + const failure = await withMcpConnectionFailure(() => guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { + allowPrivateNetwork: false, + lookup: async () => addresses.map((address) => ({ address, family: 4 })), + socketFactory: ({ address }) => { + dialled.push(address); + const socket = new Socket(); + openSockets.push(socket); + if (dialled.length === 1 || lastCode !== "deadline") { + queueMicrotask(() => socket.emit("error", dialled.length === 1 ? first : last)); + } + return socket; + }, + connectTimeoutMs: 5, + error: guardError, + })).catch((error) => error); + expect(dialled).toEqual(addresses); + if (lastCode === "deadline") expect(failure.message).toBe("Timed out trying to connect to the remote MCP endpoint"); + else expect(failure).toBe(last); + expect(isExpectedMcpConnectionFailure(retainMcpConnectionFailure(failure, new HttpError(502, failure.message)))).toBe(expected); + expect(openSockets.every((socket) => socket.destroyed)).toBe(true); + }); + it("falls over to the next approved address when the first is unreachable", async () => { // Pinning must not cost the failover `fetch` gave a multi-homed host: one // dead A record should not take the connection down with it. @@ -447,6 +513,29 @@ describe("guarded remote HTTP fetch (PAP-17098 DNS rebinding)", () => { expect(network.dialled).toEqual(["93.184.216.35", PUBLIC_ADDRESS]); }); + it("keeps a successful fallback unchanged even after an unknown first-address failure", async () => { + const upstream = await startServer(); + const network = routingSocketFactory({ [PUBLIC_ADDRESS]: upstream.port }); + const dialled: string[] = []; + const response = await withMcpConnectionFailure(() => guardedRemoteHttpFetch(`http://${REBIND_HOST}/mcp`, {}, { + allowPrivateNetwork: false, + lookup: async () => [{ address: "93.184.216.35", family: 4 }, { address: PUBLIC_ADDRESS, family: 4 }], + socketFactory: (target) => { + dialled.push(target.address); + if (target.address === PUBLIC_ADDRESS) return network.factory(target); + const socket = new Socket(); + openSockets.push(socket); + queueMicrotask(() => socket.emit("error", Object.assign(new Error("local limit"), { code: "EMFILE" }))); + return socket; + }, + error: guardError, + })); + expect(dialled).toEqual(["93.184.216.35", PUBLIC_ADDRESS]); + await expect(response.json()).resolves.toEqual({ ok: true }); + expect(readRemoteConnectionFailure(response)).toBeNull(); + expect(upstream.requests).toHaveLength(1); + }); + it("fails closed instead of falling over when a peer fails the address check", async () => { // Failover covers unreachable addresses only. A peer that answers from an // unapproved address is the rebinding defence firing, and must not be diff --git a/server/src/__tests__/remote-mcp-connectors.test.ts b/server/src/__tests__/remote-mcp-connectors.test.ts index 1004790d832..2b5970f0ae1 100644 --- a/server/src/__tests__/remote-mcp-connectors.test.ts +++ b/server/src/__tests__/remote-mcp-connectors.test.ts @@ -50,6 +50,49 @@ describe("remote connector lifecycle", () => { }); return { service, requests, add: () => { added = true; }, remove: () => { removed = true; }, restore: () => { removed = false; } }; } + it("advertises the initiating OAuth callback and preserves explicitly configured origins", async () => { + const org = await company(); + const app = express(); + app.use((req, _res, next) => { req.actor = { type: "board", userId: actor.actorId, source: "local_implicit", isInstanceAdmin: true }; next(); }); + app.use("/api", toolAccessRoutes(db, { paperclipCloudConnector: null })); + + const publicBaseUrlEnv = process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL; + const otherPublicUrlNames = ["BETTER_AUTH_URL", "BETTER_AUTH_BASE_URL", "PAPERCLIP_PUBLIC_URL", "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL"] as const; + const otherPublicUrlEnv = Object.fromEntries(otherPublicUrlNames.map((name) => [name, process.env[name]])); + try { + for (const name of otherPublicUrlNames) process.env[name] = ""; + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = "https://paperclip.example.test"; + const configured = await request(app).get(`/api/companies/${org.id}/tools/gallery`); + expect(configured.status).toBe(200); + expect(configured.body.oauthCallbackUrl).toBe("https://paperclip.example.test/api/tools/oauth/callback"); + + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = "http://127.0.0.1:3105"; + const configuredLoopback = await request(app).get(`/api/companies/${org.id}/tools/gallery`); + expect(configuredLoopback.status).toBe(200); + expect(configuredLoopback.body.oauthCallbackUrl).toBe("http://127.0.0.1:3105/api/tools/oauth/callback"); + + delete process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL; + const inferredLoopback = await request(app) + .get(`/api/companies/${org.id}/tools/gallery`) + .set("host", "127.0.0.1:3105"); + expect(inferredLoopback.status).toBe(200); + expect(inferredLoopback.body.oauthCallbackUrl).toBe("http://localhost:3105/api/tools/oauth/callback"); + + const unsupported = await request(app) + .get(`/api/companies/${org.id}/tools/gallery`) + .set("host", "paperclip.internal.test"); + expect(unsupported.status).toBe(200); + expect(unsupported.body).not.toHaveProperty("oauthCallbackUrl"); + } finally { + if (publicBaseUrlEnv === undefined) delete process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL; + else process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = publicBaseUrlEnv; + for (const name of otherPublicUrlNames) { + const value = otherPublicUrlEnv[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + }); it.each([undefined, false])("allows all aggregator setup with stored setting %s", async (legacyValue) => { await db.delete(instanceSettings); if (legacyValue !== undefined) await db.insert(instanceSettings).values({ experimental: { enableMcpAggregators: legacyValue } }); 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 00000000000..4ad3b4869f4 --- /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 5d92575459d..7b568806cca 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__/run-failure-sentry-real-sdk.test.ts b/server/src/__tests__/run-failure-sentry-real-sdk.test.ts index f92e0a42bc0..5f5e2c180eb 100644 --- a/server/src/__tests__/run-failure-sentry-real-sdk.test.ts +++ b/server/src/__tests__/run-failure-sentry-real-sdk.test.ts @@ -378,4 +378,64 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK", expect(unrelated).not.toHaveProperty("contexts.run_failure"); expect(unrelated).not.toHaveProperty("tags.error_code"); }); + it("sends only validated transfer and RPC evidence, isolated from unrelated captures", async () => { + const Sentry = sentryPackage!; + const events: Array> = []; + vi.stubEnv("SENTRY_DSN_BACKEND", "https://public@example.invalid/1"); + vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: true }) })); + vi.doMock("@sentry/node", () => ({ ...Sentry, + init: (options: Record) => Sentry.init({ ...options, + transport: () => ({ send: async () => ({}), flush: async () => true }), + beforeSend: (event: Record) => { events.push(event); return event; }, + }), + })); + vi.resetModules(); + const { sentryReady, captureRunFailure, captureException } = await import("../sentry.js"); + await sentryReady; + for (const known of [true, false]) { + const error = Object.freeze(new Error("private-transfer-message")); + // Worker/typed-host propagation has its own real RPC round-trip test. + // This narrow contract exercises the host receipt through the real Sentry + // transport without requiring plugin builds or dependency lifecycle scripts. + const evidence = { + transferStep: known ? "archive_create" : "private-transfer-path", + transferFailureKind: known ? "command_failed" : "private-transfer-command", + rpcCode: known ? -32002 : -12345, + cause: { token: "private-transfer-token" }, output: "private-transfer-output", + }; + const logs: string[] = []; + const restore = createWorkspaceRestoreTeardown({ stagedRuntime: { + restoreWorkspace: progress => withWorkspaceRestoreDiagnostics("workspace", () => + withWorkspaceRestoreStep("workspace_transfer", async () => { throw preserveWorkspaceRestoreErrorDiagnostic(error, { exitCode: 2 }, evidence); }), progress), + }, onLog: async (_stream, line) => { logs.push(line); }, startMessage: "Restoring workspace\n", failurePrefix: "Workspace restore failed" }); + const outcome = await restore(); + expect(outcome.ok).toBe(false); + if (outcome.ok) throw new Error("Expected transfer fixture failure"); + expect(outcome.diagnostic?.rpcCode).toBe(known ? -32002 : undefined); + expect(JSON.stringify({ outcome, logs })).not.toContain("private-transfer-"); + const diagnostics = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics({ resultJson: { + workspaceRestoreFailure: outcome.code, workspaceRestoreDiagnostic: { ...outcome.diagnostic, + message: "private-transfer-persisted", cause: { token: "private-transfer-token" }, + }, + } } as unknown as typeof heartbeatRuns.$inferSelect, {})); + captureRunFailure({ taskId: "11111111-1111-4111-8111-111111111111", runId: "22222222-2222-4222-8222-222222222222", + errorMessage: "Workspace restore failed. Workspace files need recovery.", errorCode: "workspace_restore_failed", + agentAdapter: "fixture-adapter", runStatus: "failed", exitCode: 1, signal: null, diagnostics, + }); + await Sentry.flush(2000); + const execution = (events.at(-1)?.contexts as Record>).run_execution; + expect(execution.workspaceRestoreTransferStep).toBe(known ? "archive_create" : undefined); + expect(execution.workspaceRestoreTransferFailureKind).toBe(known ? "command_failed" : undefined); + expect(execution.workspaceRestoreRpcCode).toBe(known ? -32002 : undefined); + expect(execution.workspaceRestoreExitCode).toBe(2); + } + captureException(new Error("Unrelated transfer fixture")); + await Sentry.flush(2000); + expect(events).toHaveLength(3); + expect(events[2]).not.toHaveProperty("contexts.run_execution"); + expect(events[2]).not.toHaveProperty("contexts.run_failure"); + expect(events[2]).not.toHaveProperty("tags.error_code"); + expect(JSON.stringify(events)).not.toContain("private-transfer-"); + }); + }); 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 00000000000..04d7c20b7e3 --- /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__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 7b89d44dd9a..449d8d5b5dd 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -1,9 +1,12 @@ import { idleWorkSnapshot } from "../services/task-admission.js"; import { readIdleSleepSafety } from "../services/idle-sleep-safety.js"; +import * as idleLocalWork from "../services/idle-local-work.js"; +import { idleBackupWakeMarker, markIdleBackupWakeRequired } from "../services/idle-database-backup.js"; import { reconcileBuiltInAgentsOnStartup, reconcileCodexLocalManagedHomesOnStartup, reconcilePersistedRuntimeServicesOnStartup } from "../services/index.js"; import { runDatabaseBackup } from "@paperclipai/db"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { gzipSync } from "node:zlib"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -441,6 +444,69 @@ describe("startServer feedback export wiring", () => { process.env.BETTER_AUTH_SECRET = "test-secret"; }); + it.each([false, true].flatMap(checkpointMode => [false, true].map(fails => ({ checkpointMode, fails })))) + ("finishes restart backup before idle eligibility (checkpoint=$checkpointMode, failure=$fails)", async ({ checkpointMode, fails }) => { + const directory = mkdtempSync(path.join(tmpdir(), "startup-idle-backup-")); + const previousFlag = process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED; + process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED = checkpointMode ? "1" : "0"; + loadConfigMock.mockReturnValue(buildTestConfig({ databaseBackupEnabled: true, databaseBackupDir: directory })); + const eligible = vi.spyOn(idleLocalWork, "markIdleStartupComplete"); + let retry: (() => void) | undefined; + const timers = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, ms: number) => { + if (ms === 60 * 60 * 1000) retry = callback; + return 1 as unknown as ReturnType; + }) as typeof setInterval); + let finish!: () => void; + const pending = new Promise(resolve => { finish = resolve; }); + const writeBackup = async (options: Parameters[0]) => { + const backupFile = path.join(directory, "paperclip-restart.sql.gz"); + const bytes = gzipSync("SELECT 'restart recovery';\n"); + writeFileSync(backupFile, bytes); + expect(options.retention).toEqual({ dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }); + expect(options.verifyBeforePrune).toEqual(expect.any(Function)); + await options.verifyBeforePrune!(backupFile); + return { backupFile, sizeBytes: bytes.length, prunedCount: 0 }; + }; + vi.mocked(runDatabaseBackup).mockImplementationOnce(async options => { + await pending; + if (fails) throw new Error("fixture restart backup failed"); + return writeBackup(options); + }); + let starting: ReturnType | undefined; + try { + await markIdleBackupWakeRequired(directory); + starting = startServer(); + await vi.waitFor(() => expect(runDatabaseBackup).toHaveBeenCalledOnce()); + expect(eligible).not.toHaveBeenCalled(); + expect(idleWorkSnapshot().active).toBeGreaterThan(0); + finish(); + await starting; + expect(eligible).toHaveBeenCalledWith({ scheduledBackups: true }); + const options = createAppMock.mock.calls[0]?.[1] as unknown as { prepareIdleDatabaseBackup?: unknown }; + expect(Boolean(options.prepareIdleDatabaseBackup)).toBe(checkpointMode); + expect(existsSync(idleBackupWakeMarker(directory))).toBe(fails); + if (fails) { + vi.mocked(runDatabaseBackup).mockImplementationOnce(writeBackup); + expect(retry).toEqual(expect.any(Function)); + const activeBeforeRetry = idleWorkSnapshot().active; + retry!(); + await vi.waitFor(() => { + expect(existsSync(idleBackupWakeMarker(directory))).toBe(false); + // Marker removal precedes directory fsync and release of the work receipt. + expect(idleWorkSnapshot().active).toBe(activeBeforeRetry); + }); + expect(runDatabaseBackup).toHaveBeenCalledTimes(2); + } + } finally { + finish(); + await starting; + timers.mockRestore(); eligible.mockRestore(); + if (previousFlag === undefined) delete process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED; + else process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED = previousFlag; + rmSync(directory, { recursive: true, force: true }); + } + }); + it.each(([ ["built-in agents", reconcileBuiltInAgentsOnStartup], ["managed homes", reconcileCodexLocalManagedHomesOnStartup], diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 27b8c6d8b4f..fac6f6ec09f 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -52,6 +52,7 @@ import { } from "@paperclipai/db"; import { and, eq, inArray, sql } from "drizzle-orm"; import { + APP_DEFINITIONS, APP_STORE_HIDDEN_SLUGS, GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS, @@ -87,6 +88,7 @@ import { import { toolAccessRoutes } from "../routes/tool-access.js"; import { errorHandler } from "../middleware/index.js"; import * as sentry from "../sentry.js"; +import { HttpError } from "../errors.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { invalidatePaperclipCloudConnectorCapabilities, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; @@ -111,6 +113,17 @@ function createTestToolAccessService( }); } +function optIntoAirtableDcrRegistration() { + const method = APP_DEFINITIONS.find((app) => app.slug === "airtable")!.methods + .find((entry) => entry.key === "mcp-oauth")!; + const original = method.oauthClientRegistration; + method.oauthClientRegistration = "dcr"; + return () => { + if (original === undefined) delete method.oauthClientRegistration; + else method.oauthClientRegistration = original; + }; +} + function fakeGoogleWorkspaceConnector( companyId: string, userId: string, @@ -2965,6 +2978,35 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it("reports persistence failure after a recognized MCP outage instead of suppressing the database error", async () => { + const company = await createCompany(db); + const [application] = await db.insert(toolApplications).values({ + companyId: company.id, applicationKey: `audit-failure-${randomUUID()}`, + name: "MCP audit fixture", type: "mcp_http", status: "active", + }).returning(); + const [connection] = await db.insert(toolConnections).values({ + companyId: company.id, applicationId: application!.id, name: "MCP audit fixture", + uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "draft", enabled: false, + config: { url: "https://audit-failure.example.test/mcp" }, + transportConfig: { url: "https://audit-failure.example.test/mcp" }, + }).returning(); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 503 })); + const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {}); + await db.execute(sql`CREATE FUNCTION test_mcp_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'synthetic MCP audit write failure'; END $$`); + await db.execute(sql`CREATE TRIGGER test_mcp_audit_failure BEFORE INSERT ON tool_access_audit_events + FOR EACH ROW EXECUTE FUNCTION test_mcp_audit_failure()`); + try { + const response = await request(createRouteApp(db)).post(`/api/tool-connections/${connection!.id}/catalog/refresh`); + expect(response.status).toBe(500); + expect(capture).toHaveBeenCalledOnce(); + expect(capture.mock.calls[0]?.[0]).toMatchObject({ cause: expect.objectContaining({ message: "synthetic MCP audit write failure" }) }); + } finally { + await db.execute(sql`DROP TRIGGER test_mcp_audit_failure ON tool_access_audit_events`); + await db.execute(sql`DROP FUNCTION test_mcp_audit_failure()`); + } + }); + it.each([ [ "local_trusted", @@ -5184,7 +5226,18 @@ describeEmbeddedPostgres("tool access service", () => { "telem", ]), ); - expect(res.body.apps).toHaveLength(68); + expect(res.body.apps.length).toBeGreaterThanOrEqual(69); + expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual( + expect.arrayContaining([ + "calendly", + "exa", + "firecrawl", + "gsc-wizard", + "parallel-search", + "tavily", + "windsor-ai", + ]), + ); for (const slug of ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) { expect(res.body.apps.find((app: { slug: string }) => app.slug === slug).tags).toContain("model-provider"); } @@ -11043,6 +11096,204 @@ describeEmbeddedPostgres("tool access service", () => { }); }); + it("uses Airtable DCR when both DCR and CIMD are advertised", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_SECRET", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); + const restoreMethod = optIntoAirtableDcrRegistration(); + try { + const company = await createCompany(db); + let metadataLookups = 0; + let registrations = 0; + const service = createTestToolAccessService(db, { + oauthClientMetadataLookup: async () => { + metadataLookups++; + return [{ address: "93.184.216.34", family: 4 }]; + }, + }); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "airtable", connectionMethodKey: "mcp-oauth", name: "Airtable DCR", + }); + const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const href = String(url); + if (href === "https://mcp.airtable.com/.well-known/oauth-protected-resource/mcp") { + return mcpHttpResponse({ + resource: "https://mcp.airtable.com", + authorization_servers: ["https://airtable.com/oauth2/v1"], + scopes_supported: ["data.records:read", "data.records:write"], + }); + } + if (href === "https://airtable.com/.well-known/oauth-authorization-server/oauth2/v1") { + return mcpHttpResponse({ + issuer: "https://airtable.com/oauth2/v1", + authorization_endpoint: "https://airtable.com/oauth2/v1/authorize", + token_endpoint: "https://airtable.com/oauth2/v1/token", + registration_endpoint: "https://airtable.com/oauth2/v1/register", + client_id_metadata_document_supported: true, + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); + } + if (href === "https://airtable.com/oauth2/v1/register") { + registrations++; + expect(JSON.parse(String(init?.body))).toMatchObject({ + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + application_type: "web", + }); + return mcpHttpResponse({ + client_id: "airtable-dcr-client", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }); + } + throw new Error(`unexpected fetch ${href}`); + }); + + const start = await service.startOAuth(company.id, connected.connectionId, { + redirectUri, actor: { actorType: "user", actorId: "board" }, + }); + + expect(start.registrationSource).toBe("dcr"); + expect(new URL(start.authorizationUrl).searchParams.get("client_id")).toBe("airtable-dcr-client"); + expect(registrations).toBe(1); + expect(metadataLookups).toBe(0); + } finally { + restoreMethod(); + } + }); + + it("rebinds an Airtable CIMD client through DCR when the curated method opts in", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_SECRET", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); + const company = await createCompany(db); + const service = createTestToolAccessService(db, { + oauthClientMetadataLookup: async () => [{ address: "93.184.216.34", family: 4 }], + }); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "airtable", connectionMethodKey: "mcp-oauth", name: "Airtable migration", + }); + const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; + let registrations = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + const href = String(url); + if (href === "https://mcp.airtable.com/.well-known/oauth-protected-resource/mcp") { + return mcpHttpResponse({ + resource: "https://mcp.airtable.com", + authorization_servers: ["https://airtable.com/oauth2/v1"], + }); + } + if (href === "https://airtable.com/.well-known/oauth-authorization-server/oauth2/v1") { + return mcpHttpResponse({ + issuer: "https://airtable.com/oauth2/v1", + authorization_endpoint: "https://airtable.com/oauth2/v1/authorize", + token_endpoint: "https://airtable.com/oauth2/v1/token", + registration_endpoint: "https://airtable.com/oauth2/v1/register", + client_id_metadata_document_supported: true, + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); + } + if (href === "https://airtable.com/oauth2/v1/register") { + registrations++; + return mcpHttpResponse({ + client_id: "airtable-rebound-dcr-client", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }); + } + throw new Error(`unexpected fetch ${href}`); + }); + + const airtableMethod = APP_DEFINITIONS.find((app) => app.slug === "airtable")!.methods + .find((entry) => entry.key === "mcp-oauth")!; + const originalRegistration = airtableMethod.oauthClientRegistration; + try { + // Generated Airtable metadata now opts into DCR. Remove that curated + // flag here to model an existing client binding created before the opt-in. + delete airtableMethod.oauthClientRegistration; + const cimdStart = await service.startOAuth(company.id, connected.connectionId, { + redirectUri, actor: { actorType: "user", actorId: "board" }, + }); + expect(cimdStart.registrationSource).toBe("cimd"); + + airtableMethod.oauthClientRegistration = "dcr"; + const dcrStart = await service.startOAuth(company.id, connected.connectionId, { + redirectUri, actor: { actorType: "user", actorId: "board" }, + }); + expect(dcrStart.registrationSource).toBe("dcr"); + expect(new URL(dcrStart.authorizationUrl).searchParams.get("client_id")).toBe("airtable-rebound-dcr-client"); + expect(registrations).toBe(1); + } finally { + if (originalRegistration === undefined) delete airtableMethod.oauthClientRegistration; + else airtableMethod.oauthClientRegistration = originalRegistration; + } + }); + + it("does not fall back to Airtable CIMD when DCR is required but unavailable", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_AIRTABLE_CLIENT_SECRET", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); + const restoreMethod = optIntoAirtableDcrRegistration(); + try { + const company = await createCompany(db); + let metadataLookups = 0; + const service = createTestToolAccessService(db, { + oauthClientMetadataLookup: async () => { + metadataLookups++; + return [{ address: "93.184.216.34", family: 4 }]; + }, + }); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "airtable", connectionMethodKey: "mcp-oauth", name: "Airtable DCR unavailable", + }); + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + const href = String(url); + if (href === "https://mcp.airtable.com/.well-known/oauth-protected-resource/mcp") { + return mcpHttpResponse({ + resource: "https://mcp.airtable.com", + authorization_servers: ["https://airtable.com/oauth2/v1"], + }); + } + if (href === "https://airtable.com/.well-known/oauth-authorization-server/oauth2/v1") { + return mcpHttpResponse({ + issuer: "https://airtable.com/oauth2/v1", + authorization_endpoint: "https://airtable.com/oauth2/v1/authorize", + token_endpoint: "https://airtable.com/oauth2/v1/token", + client_id_metadata_document_supported: true, + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); + } + throw new Error(`unexpected fetch ${href}`); + }); + + await expect(service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + })).rejects.toMatchObject({ details: { code: "oauth_dcr_not_supported" } }); + expect(metadataLookups).toBe(0); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + expect((connection.config.oauth as Record | undefined)?.clientId).toBeUndefined(); + } finally { + restoreMethod(); + } + }); + it("registers Linear against its MCP authorization server instead of the pinned console endpoints", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_LINEAR_CLIENT_ID", ""); @@ -11091,7 +11342,7 @@ describeEmbeddedPostgres("tool access service", () => { const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) - .send({ galleryKey: "linear", name: "Linear", grantKind: "user" }) + .send({ galleryKey: "linear", connectionMethodKey: "mcp-oauth", name: "Linear", grantKind: "user" }) .expect(201); const startUrl = new URL(connectRes.body.auth.startUrl); @@ -11101,6 +11352,52 @@ describeEmbeddedPostgres("tool access service", () => { expect(fetched.some((href) => href.startsWith("https://linear.app/"))).toBe(false); }); + it("registers Calendly with a provider-compatible DCR client name", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "calendly", connectionMethodKey: "mcp-oauth", name: "Calendly", + }); + const redirectUri = "http://localhost:3105/api/tools/oauth/callback"; + let registrationBody: Record | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const href = String(url); + if (href.includes("oauth-protected-resource")) return mcpHttpResponse({ + resource: "https://mcp.calendly.com/", authorization_servers: ["https://calendly.com/"], + scopes_supported: ["mcp:scheduling:read", "mcp:scheduling:write"], + }); + if (href.includes("oauth-authorization-server")) return mcpHttpResponse({ + issuer: "https://calendly.com/", authorization_endpoint: "https://calendly.com/oauth/authorize", + token_endpoint: "https://calendly.com/oauth/token", registration_endpoint: "https://calendly.com/oauth/register", + code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], + }); + if (href === "https://calendly.com/oauth/register") { + registrationBody = JSON.parse(String(init?.body)) as Record; + return mcpHttpResponse({ + client_id: "calendly-public-client", redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], + token_endpoint_auth_method: "none", + }); + } + throw new Error(`unexpected fetch ${href}`); + }); + + const result = await service.startOAuth(company.id, connected.connectionId, { + redirectUri, actor: { actorType: "user", actorId: "board" }, + }); + + expect(registrationBody).toMatchObject({ + client_name: "Paperclip localhost-3105", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + application_type: "web", + }); + expect(registrationBody?.client_name).toMatch(/^[A-Za-z0-9 -]+$/); + expect(new URL(result.authorizationUrl).searchParams.get("client_id")).toBe("calendly-public-client"); + }); + it("returns a pre-scoped personal Notion callback directly to Permissions", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", ""); @@ -12008,7 +12305,7 @@ describeEmbeddedPostgres("tool access service", () => { ).toBe("notion-dcr-client"); expect(registrationBodies).toEqual([ { - client_name: "Paperclip (paperclip-dev.tail29c1aa.ts.net)", + client_name: "Paperclip paperclip-dev-tail29c1aa-ts-net", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], @@ -15297,15 +15594,81 @@ describeEmbeddedPostgres("tool access service", () => { }, ); + it.each( + (["catalog", "catalog/refresh", "health-check"] as const).flatMap((path) => + ([401, 403] as const).map((upstreamStatus) => ({ path, upstreamStatus })), + ), + )( + "preserves reconnect guidance and degraded health for explicit OAuth scope failure on $path (HTTP $upstreamStatus)", + async ({ path, upstreamStatus }) => { + const company = await createCompany(db); + const [application] = await db.insert(toolApplications).values({ + companyId: company.id, + applicationKey: `scope-status-${randomUUID()}`, + name: "OAuth scope fixture", + type: "mcp_http", + status: "active", + }).returning(); + const [connection] = await db.insert(toolConnections).values({ + companyId: company.id, + applicationId: application!.id, + name: "OAuth scope fixture", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", + status: "draft", + enabled: false, + config: { url: "https://scope-status.example.test/mcp" }, + transportConfig: { url: "https://scope-status.example.test/mcp" }, + }).returning(); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response( + JSON.stringify({ error: "private provider details" }), + { status: upstreamStatus, headers: { "www-authenticate": 'Bearer error="insufficient_scope", scope="tools:read"' } }, + )); + const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {}); + const app = createRouteApp(db); + const url = `/api/tool-connections/${connection!.id}/${path}`; + const response = await (path === "catalog" ? request(app).get(url) : request(app).post(url)); + + expect(response.status).toBe(422); + expect(response.body).toMatchObject({ + code: "oauth_insufficient_scope", + error: expect.stringContaining("Reconnect this connection"), + details: { + code: "oauth_insufficient_scope", + setupUrl: expect.any(String), + reconnectUrl: expect.any(String), + }, + }); + expect(JSON.stringify(response.body)).not.toContain("private provider details"); + expect(capture).not.toHaveBeenCalled(); + const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id)); + expect(updated?.healthStatus).toBe("degraded"); + expect(updated?.healthMessage).toBe(response.body.error); + await expect(db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, connection!.id))) + .resolves.toEqual(expect.arrayContaining([expect.objectContaining({ + action: path === "health-check" ? "tool_connection.health_check" : "tool_connection.catalog_refresh", + outcome: "failure", + reasonCode: "oauth_insufficient_scope", + })])); + await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connection!.id))) + .resolves.toHaveLength(0); + }, + ); + it.each([ ["catalog", 401, 'Bearer realm="app"', 422, false], ["catalog/refresh", 401, 'Bearer realm="app"', 422, false], ["catalog", 400, null, 502, true], ["catalog/refresh", 400, null, 502, true], - ["catalog", 503, null, 502, true], - ["catalog/refresh", 503, null, 502, true], + ["catalog", 503, null, 502, false], + ["catalog/refresh", 503, null, 502, false], + ["catalog/refresh", 403, null, 502, false], + ["health-check", 503, 'Bearer error="insufficient_scope"', 502, false], + ["catalog/refresh", 404, null, 502, false], + ["catalog/refresh", 429, null, 502, false], + ["health-check", 500, null, 502, false], ] as const)( - "classifies %s upstream HTTP %i without hiding provider failures", + "classifies %s upstream HTTP %i while retaining the failed response", async (path, upstreamStatus, challenge, expectedStatus, reportable) => { const company = await createCompany(db); const [application] = await db @@ -15349,7 +15712,7 @@ describeEmbeddedPostgres("tool access service", () => { : request(app).post(url)); expect(res.status).toBe(expectedStatus); - if (challenge) { + if (challenge && upstreamStatus === 401) { expect(res.body).toMatchObject({ error: "This app needs you to sign in.", code: "oauth_challenge", @@ -15361,6 +15724,9 @@ describeEmbeddedPostgres("tool access service", () => { }); } expect(capture).toHaveBeenCalledTimes(reportable ? 1 : 0); + const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id)); + expect(updated?.healthStatus).toBe("error"); + expect(updated?.healthMessage).toBe(res.body.error); await expect( db.select().from(toolCatalogEntries) .where(eq(toolCatalogEntries.connectionId, connection!.id)), @@ -15368,6 +15734,92 @@ describeEmbeddedPostgres("tool access service", () => { }, ); + it.each([ + ["initialize reset", false], + ["initialize parser", true], + ["initialize internal reader", true], + ["notification 503", false], + ["notification 400", true], + ] as const)("preserves session-required MCP failure reporting: %s", async (scenario, reportable) => { + const company = await createCompany(db); + const [application] = await db.insert(toolApplications).values({ + companyId: company.id, applicationKey: `session-status-${randomUUID()}`, + name: "MCP session fixture", type: "mcp_http", status: "active", + }).returning(); + const config = { url: "https://session-status.example.test/mcp", mcpSessionRequired: true }; + const [connection] = await db.insert(toolConnections).values({ + companyId: company.id, applicationId: application!.id, name: "MCP session fixture", + uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "draft", enabled: false, + config, transportConfig: config, + }).returning(); + const methods: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { + const request = JSON.parse(String(init?.body)); + methods.push(request.method); + if (request.method === "notifications/initialized") return new Response(null, { status: scenario === "notification 503" ? 503 : 400 }); + expect(request.method).toBe("initialize"); + if (scenario === "initialize parser") return new Response("not JSON"); + if (scenario === "initialize reset" || scenario === "initialize internal reader") { + const error = scenario === "initialize reset" + ? new TypeError("body terminated", { cause: { code: "ECONNRESET" } }) + : new TypeError("internal reader failed"); + return new Response(new ReadableStream({ start(controller) { controller.error(error); } })); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2025-06-18" } })); + }); + const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {}); + const response = await request(createRouteApp(db)).post(`/api/tool-connections/${connection!.id}/catalog/refresh`); + expect(response.status).toBe(502); + expect(response.body.error).toBeTruthy(); + expect(capture).toHaveBeenCalledTimes(reportable ? 1 : 0); + expect(methods).toEqual(scenario.startsWith("notification") ? ["initialize", "notifications/initialized"] : ["initialize"]); + const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id)); + expect(updated?.healthStatus).toBe("error"); + expect(updated?.healthMessage).toBe(response.body.error); + await expect(db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, connection!.id))) + .resolves.toEqual(expect.arrayContaining([expect.objectContaining({ outcome: "failure", action: "tool_connection.catalog_refresh" })])); + }); + + it.each([ + ["known transport", () => { throw new TypeError("fetch failed", { cause: { code: "ECONNREFUSED" } }); }, false], + ["direct transport", () => { throw Object.assign(new Error("connection reset"), { code: "ECONNRESET" }); }, false], + ["unknown transport", () => { throw new TypeError("ECONNREFUSED"); }, true], + ["database code", () => { throw Object.assign(new Error("query failed"), { code: "42P01" }); }, true], + ["mixed transport", () => { throw new TypeError("fetch failed", { cause: { errors: [{ code: "ECONNREFUSED" }, { code: "ERR_INTERNAL_ASSERTION" }] } }); }, true], + ["forged classification", () => { throw new HttpError(502, "Remote app returned HTTP 503", { status: 503, connectionFailure: { schemaVersion: 1, provider: "mcp_http", operation: "discover_tools", reason: "remote_unavailable" } }); }, true], + ["malformed response", () => new Response("not JSON"), true], + ["invalid catalog", () => new Response(JSON.stringify({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: {} })), true], + ] as const)( + "preserves MCP diagnostics and reports only unclassified failure: %s", + async (_name, reply, reportable) => { + const company = await createCompany(db); + const [application] = await db.insert(toolApplications).values({ + companyId: company.id, applicationKey: `transport-status-${randomUUID()}`, + name: "MCP transport fixture", type: "mcp_http", status: "active", + }).returning(); + const [connection] = await db.insert(toolConnections).values({ + companyId: company.id, applicationId: application!.id, name: "MCP transport fixture", + uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "draft", enabled: false, + config: { url: "https://transport-status.example.test/mcp" }, + transportConfig: { url: "https://transport-status.example.test/mcp" }, + }).returning(); + vi.spyOn(globalThis, "fetch").mockImplementation(async () => reply()); + const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {}); + const response = await request(createRouteApp(db)).post(`/api/tool-connections/${connection!.id}/catalog/refresh`); + + expect(response.status).toBe(502); + expect(response.body.error).toBeTruthy(); + expect(capture).toHaveBeenCalledTimes(reportable ? 1 : 0); + const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id)); + expect(updated?.healthStatus).toBe("error"); + expect(updated?.healthMessage).toBe(response.body.error); + await expect(db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, connection!.id))) + .resolves.toEqual(expect.arrayContaining([expect.objectContaining({ outcome: "failure", action: "tool_connection.catalog_refresh" })])); + await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connection!.id))) + .resolves.toHaveLength(0); + }, + ); + it.each([ [ "local_trusted", diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 2f0774be90c..3e9b984672d 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -1275,11 +1275,19 @@ describeEmbeddedPostgres("tool gateway service", () => { } }); - it("initializes a fresh Streamable HTTP session before a stateful tools/call", async () => { + it.each([ + ["persisted session preference", true], + ["retained connection curated default", false], + ] as const)("initializes Tavily's session before its first tools/call from the %s", async (_caseName, persistPreference) => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); await db.update(toolConnections).set({ - config: { url: "https://example.invalid/mcp", mcpSessionRequired: true }, + config: { + url: "https://mcp.tavily.com/mcp", + sourceTemplateKey: "tavily", + connectionMethodKey: "mcp-oauth", + ...(persistPreference ? { mcpSessionRequired: true } : {}), + }, }).where(eq(toolConnections.id, connection.id)); await db.insert(toolPolicies).values({ companyId: company.id, @@ -1341,6 +1349,36 @@ describeEmbeddedPostgres("tool gateway service", () => { ]); }); + it("keeps direct tools/call for generic connections without the curated session preference", async () => { + const { company, agent, run } = await createRunFixture(db); + const { connection } = await createRemoteMcpToolFixture(db, company.id); + await db.insert(toolPolicies).values({ + companyId: company.id, + name: "Allow read tools", + policyType: "allow", + selectors: { riskLevel: "read" }, + }); + const requests: string[] = []; + const gateway = createTestToolGatewayService(db, { + remoteHttpRequest: async (_url, init) => { + const payload = JSON.parse(String(init.body)) as { method?: string; id?: string }; + requests.push(payload.method ?? ""); + return Response.json({ + jsonrpc: "2.0", + id: payload.id, + result: { content: [{ type: "text", text: "generic ok" }] }, + }); + }, + }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)) + .find((candidate) => candidate.providerType === "mcp_remote_http"); + + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {} })) + .resolves.toMatchObject({ status: "completed" }); + expect(requests).toEqual(["tools/call"]); + }); + it("hides cached Chat unread filters and blocks agent and board requests before provider dispatch", async () => { const { company, agent, run } = await createRunFixture(db); const { connection, catalogEntry } = await createRemoteMcpToolFixture(db, company.id); @@ -1449,7 +1487,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }); }); - it("injects Vercel tokens at dispatch and refreshes exactly once after an upstream 401", async () => { + it("refreshes Vercel tokens after an upstream 401 without replaying the tool call", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); await db.update(toolConnections).set({ @@ -1511,17 +1549,16 @@ describeEmbeddedPostgres("tool gateway service", () => { const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); - const result = await gateway.executeTool({ + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {}, + })).rejects.toMatchObject({ + status: 409, + reasonCode: "oauth_refreshed_retry_required", }); - expect(result.status).toBe("completed"); - expect(authorizationHeaders).toEqual([ - "Bearer stale-provider-bearer", - "Bearer fresh-provider-bearer", - ]); + expect(authorizationHeaders).toEqual(["Bearer stale-provider-bearer"]); expect(getToken).toHaveBeenCalledTimes(2); expect(getToken.mock.calls[1]?.[1]).toEqual({ forceRefresh: true }); expect(evict).toHaveBeenCalledTimes(1); @@ -1639,7 +1676,7 @@ describeEmbeddedPostgres("tool gateway service", () => { expect(refreshed).toEqual(upstreamFailure ? [grants[1]!.id] : [grants[1]!.id, grants[0]!.id]); }); - it("refreshes a customer OAuth grant once and retries after an upstream 401", async () => { + it("refreshes customer OAuth after a session 401 without replaying the call, then uses a fresh session on explicit retry", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); const accessSecret = await secretService(db).create(company.id, { @@ -1661,6 +1698,7 @@ describeEmbeddedPostgres("tool gateway service", () => { credentialRefs: [{ name: "oauth.access_token", placement: "header", key: "Authorization", prefix: "Bearer ", secretId: accessSecret.id, versionSelector: "latest" }], config: { url: "https://example.invalid/mcp", + mcpSessionRequired: true, oauth: { provider: "fixture", tokenUrl: "https://example.invalid/oauth/token", @@ -1701,13 +1739,37 @@ describeEmbeddedPostgres("tool gateway service", () => { return db.select().from(connectionGrants).where(eq(connectionGrants.id, input.grantId)) .then((rows) => rows[0]!); }); - const authorizationHeaders: string[] = []; + const requests: Array<{ method: string; authorization: string | null; sessionId: string | null; protocolVersion: string | null }> = []; + let callDispatches = 0; const gateway = createTestToolGatewayService(db, { oauthGrantRefresher, remoteHttpRequest: async (_url, init) => { - authorizationHeaders.push(new Headers(init.headers).get("authorization") ?? ""); - if (authorizationHeaders.length === 1) return new Response(null, { status: 401 }); - const requestBody = JSON.parse(String(init.body)) as { id: string }; + const requestBody = JSON.parse(String(init.body)) as { method: string; id?: string }; + const requestHeaders = new Headers(init.headers); + requests.push({ + method: requestBody.method, + authorization: requestHeaders.get("authorization"), + sessionId: requestHeaders.get("mcp-session-id"), + protocolVersion: requestHeaders.get("mcp-protocol-version"), + }); + if (requestBody.method === "initialize") { + const fresh = requestHeaders.get("authorization") === "Bearer fresh-customer-token"; + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: requestBody.id, + result: { + protocolVersion: "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "oauth-session", version: "1" }, + }, + }), { status: 200, headers: { + "content-type": "application/json", + "mcp-session-id": fresh ? "fresh-session" : "stale-session", + } }); + } + if (requestBody.method === "notifications/initialized") return new Response(null, { status: 202 }); + callDispatches += 1; + if (callDispatches === 1) return new Response(null, { status: 401 }); return new Response(JSON.stringify({ jsonrpc: "2.0", id: requestBody.id, @@ -1719,22 +1781,37 @@ describeEmbeddedPostgres("tool gateway service", () => { const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); - const result = await gateway.executeTool({ + const execute = () => gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {}, }); - expect(result.status).toBe("completed"); - expect(authorizationHeaders).toEqual([ - "Bearer stale-customer-token", - "Bearer fresh-customer-token", + await expect(execute()).rejects.toMatchObject({ + status: 409, + reasonCode: "oauth_refreshed_retry_required", + }); + expect(callDispatches).toBe(1); + expect(requests.map(({ method }) => method)).toEqual([ + "initialize", "notifications/initialized", "tools/call", ]); - expect(oauthGrantRefresher).toHaveBeenCalledTimes(2); - expect(vi.mocked(oauthGrantRefresher).mock.calls[1]?.[0]).toMatchObject({ forceRefresh: true }); + expect(requests.every((request) => request.authorization === "Bearer stale-customer-token")).toBe(true); + expect(requests[2]).toMatchObject({ sessionId: "stale-session", protocolVersion: "2025-06-18" }); + + const result = await execute(); + expect(result.status).toBe("completed"); + expect(callDispatches).toBe(2); + expect(requests.map(({ method }) => method)).toEqual([ + "initialize", "notifications/initialized", "tools/call", + "initialize", "notifications/initialized", "tools/call", + ]); + expect(requests.slice(3).every((request) => request.authorization === "Bearer fresh-customer-token")).toBe(true); + expect(requests[5]).toMatchObject({ sessionId: "fresh-session", protocolVersion: "2025-06-18" }); + expect(oauthGrantRefresher).toHaveBeenCalledTimes(3); + expect(vi.mocked(oauthGrantRefresher).mock.calls.some(([input]) => input.forceRefresh === true)).toBe(true); }); - it("marks a managed OAuth grant reconnect-required after one rejected refresh retry", async () => { + it("refreshes a managed OAuth grant after 401 without replaying the call", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); const accessSecret = await secretService(db).create(company.id, { @@ -1811,16 +1888,13 @@ describeEmbeddedPostgres("tool gateway service", () => { sessionToken: session.token, tool: tool!.name, parameters: {}, - })).rejects.toMatchObject({ reasonCode: "mcp_remote_status" }); + })).rejects.toMatchObject({ reasonCode: "oauth_refreshed_retry_required" }); - expect(authorizationHeaders).toEqual([ - "Bearer stale-managed-token", - "Bearer fresh-managed-token", - ]); + expect(authorizationHeaders).toEqual(["Bearer stale-managed-token"]); expect(oauthGrantRefresher).toHaveBeenCalledTimes(2); expect(vi.mocked(oauthGrantRefresher).mock.calls[1]?.[0]).toMatchObject({ forceRefresh: true }); const [storedGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)); - expect(storedGrant?.status).toBe("needs_reauthorization"); + expect(storedGrant?.status).toBe("active"); }); it("fails clearly when remote MCP elicitation has no issue interaction path", async () => { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 86a3538c06d..9f4e1ef118b 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 76f9cc8149c..d86b0144217 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("../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 0dbd1fa5949..f987594436a 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -411,15 +411,6 @@ 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 (profile.acpxAgent === "pi") { - // The profile resolver already validated the isolated host's exact - // qualification pair. Do not report a production readiness pass. - return { - adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), - checks: [{ code: "acpx_candidate_qualification_only", level: "warn" as const, - message: "This exact candidate and model are admitted for operator-controlled qualification only. Verified runtime installation, bound credentials, and model access are checked before execution; production support remains pending." }], - }; - } try { if (profile.acpxAgent === "copilot") { const configured = context.config.env; @@ -435,7 +426,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { checks: [{ code: "copilot_metadata_verified", level: "info" as const, message: "The verified Copilot runtime authenticated this account and accepted the selected model. No model prompt was sent." }] }; } - 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( @@ -453,8 +444,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.` }], @@ -548,7 +539,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 requires a saved GitHub token and an explicitly selected available model. Pi is awaiting local and Daytona qualification and is 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 requires a saved GitHub token and an explicitly selected available model. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", getConfigSchema: () => ({ fields: [ { @@ -592,6 +583,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/app.ts b/server/src/app.ts index c4ca14b6db4..c99670385ce 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -8,6 +8,7 @@ import { browserUseService } from "./services/browser-use.js"; import { slackToolRoutes } from "./routes/slack-tools.js"; import { createPublicMcpOAuth, publicMcpConfig } from "./services/public-mcp/oauth.js"; import { createDotRunnerMcpTools } from "./services/dot-runner-broker.js"; +import { agentProfileAvatarRoutes } from "./routes/agent-profile-avatar.js"; import { dotRunnerRoutes } from "./routes/dot-runner.js"; import { createPublicMcpTransfers } from "./services/public-mcp/file-transfers.js"; import { createMcpApiDispatch, createPublicMcpExecutor } from "./services/public-mcp/capabilities.js"; @@ -490,6 +491,7 @@ export async function createApp( }): Promise; }; databaseBackupService?: InstanceDatabaseBackupService; + prepareIdleDatabaseBackup?: () => Promise; databaseBackupHealth?: InspectDatabaseBackupHealthOptions; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; @@ -781,6 +783,7 @@ export async function createApp( }), ); api.use(assetRoutes(db, opts.storageService)); + api.use(agentProfileAvatarRoutes(db, opts.storageService)); api.use(projectToolRoutes(db)); api.use(projectRoutes(db)); api.use(caseRoutes(db, opts.storageService)); @@ -834,7 +837,7 @@ export async function createApp( api.use(resourceMembershipRoutes(db)); api.use(primaryAgentRoutes(db)); api.use(inboxDismissalRoutes(db)); - api.use(instanceSettingsRoutes(db)); + api.use(instanceSettingsRoutes(db, workerManager, opts.prepareIdleDatabaseBackup)); if (opts.databaseBackupService) { api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService)); } diff --git a/server/src/index.ts b/server/src/index.ts index 0c79e723e62..ce72014df01 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,6 @@ import { isIdleTaskDrainActive, beginIdleTrackedWork, trackIdleWork } from "./services/task-admission.js"; import { markIdleStartupComplete } from "./services/idle-local-work.js"; +import { clearIdleBackupWakeRequired, idleBackupWakeMarker, markIdleBackupWakeRequired, verifyIdleDatabaseBackup } from "./services/idle-database-backup.js"; import { cloudWarmStandbyServerOptions } from "./middleware/cloud-warm-standby.js"; import { createCloudWarmStandby } from "./services/cloud-warm-standby.js"; import { subscribeAllCompanyLiveEvents } from "./services/live-events.js"; @@ -819,6 +820,8 @@ async function startServerWithDatabaseTeardown( resolve(config.databaseBackupDir, "..", "db-backup-to-s3.failure"), ]; let databaseBackupInFlight = false; + const idleDatabaseBackupEnabled = config.databaseBackupEnabled + && process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED === "1"; const runServerDatabaseBackup = async ( trigger: InstanceDatabaseBackupTrigger, ): Promise => { @@ -835,19 +838,25 @@ async function startServerWithDatabaseTeardown( const finishIdleBackup = beginIdleTrackedWork(); const startedAt = new Date(); const startedAtMs = Date.now(); - const label = trigger === "scheduled" ? "Automatic" : "Manual"; + const label = trigger === "manual" ? "Manual" : trigger === "idle" ? "Pre-sleep" : "Automatic"; try { + if (trigger === "idle") await markIdleBackupWakeRequired(config.databaseBackupDir); logger.info({ backupDir: config.databaseBackupDir, trigger }, `${label} database backup starting`); // Read retention from Instance Settings (DB) so changes take effect without restart. const generalSettings = await backupSettingsSvc.getGeneral(); const retention = generalSettings.backupRetention; + const verifyCheckpoint = trigger === "idle" || existsSync(idleBackupWakeMarker(config.databaseBackupDir)); const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, retention, filenamePrefix: "paperclip", + verifyBeforePrune: verifyCheckpoint ? verifyIdleDatabaseBackup : undefined, }); + if (verifyCheckpoint) { + if (trigger !== "idle") await clearIdleBackupWakeRequired(config.databaseBackupDir); + } const finishedAt = new Date(); const response: InstanceDatabaseBackupRunResult = { ...result, @@ -892,6 +901,9 @@ async function startServerWithDatabaseTeardown( // self-hosted: createApp falls back to its built-in kubernetes-only default. const managedPluginAutoInstall = managedConfig?.plugins.autoInstall ?? null; const app = await createApp(db as any, { + prepareIdleDatabaseBackup: idleDatabaseBackupEnabled + ? async () => (await runServerDatabaseBackup("idle")) !== null + : undefined, cloudWarmStandby: isWarmStandby, uiMode, serverPort: listenPort, @@ -1872,6 +1884,12 @@ async function startServerWithDatabaseTeardown( }, "Automatic database backups enabled", ); + // The final pre-sleep dump remains on the volume throughout sleep. Take + // a fresh backup on restart before idle eligibility is enabled, including + // after rollback of the opt-in flag. Failure retains the marker for retry. + if (!isWarmStandby() && existsSync(idleBackupWakeMarker(config.databaseBackupDir))) { + await runServerDatabaseBackup("scheduled").catch(() => {}); + } setInterval(() => { if (isWarmStandby() || isIdleTaskDrainActive()) return; void runServerDatabaseBackup("scheduled").catch(() => { diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 31dd8535e62..23103185c79 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -13,6 +13,7 @@ import { redactSensitiveValueOccurrences, } from "./redact-sensitive.js"; import { recordResponsibleUserDenialOnActiveRun } from "../services/responsible-user-denial-run-outcomes.js"; +import { isExpectedMcpConnectionFailure } from "../services/mcp-connection-failure.js"; export interface ErrorContext { error: { @@ -85,10 +86,10 @@ function sanitizeSecretSensitiveResponse( } /** Report a server-side crash to every error sink. */ -function reportCrash(error: Error): void { +function reportCrash(error: Error, reportToSentry = true): void { const tc = getTelemetryClient(); if (tc) trackErrorHandlerCrash(tc, { errorCode: error.name }); - captureException(error); + if (reportToSentry) captureException(error); } function getPaperclipDb(req: Request): Db | null { @@ -176,7 +177,7 @@ export function errorHandler( }, reportableError, ); - reportCrash(reportableError); + reportCrash(reportableError, !isExpectedMcpConnectionFailure(err)); } const secretSensitiveServerError = err.status >= 500 && diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index 451121b6132..7b2c4bd7ba3 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -89,6 +89,8 @@ export function isSecretSensitiveHttpRequest( if (isPrivateWebhookHttpRequest(method, url)) return true; if (url && normalizePath(url).startsWith("/mcp/files/")) return true; if (isPrivateAgentCommentaryHttpRequest(url)) return true; + // Avatar uploads and dedicated Dot MCP calls may contain private image bytes. + if (url && /^(?:\/mcp\/runner(?:\/|$)|\/api\/companies\/[^/]+\/agents\/[^/]+\/avatar(?:\/|$))/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!method || !url) return false; if (/^\/api\/chat-slack\/oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (/^\/api\/chat-endpoints\/[^/]+\/slack(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; diff --git a/server/src/middleware/idle-admission.ts b/server/src/middleware/idle-admission.ts index 92b1e36aa94..afd56508bef 100644 --- a/server/src/middleware/idle-admission.ts +++ b/server/src/middleware/idle-admission.ts @@ -53,14 +53,15 @@ export const idleAdmissionMiddleware: RequestHandler = (req, res, next) => { // preserving router objects and error-handler arity. Waiting only for finish // loses async work after res.json(), including the disconnected-client case. // Keep this adapter isolated and exercise it against real Express in tests. -type Layer = { handle: Function & { stack?: Layer[] }; route?: { stack: Layer[] } }; +type Layer = { handle: Function & { stack?: Layer[] }; route?: { stack: Layer[] } | string }; export function trackIdleRequestHandlers(app: Application): void { const seen = new Set(); const visit = (stack: Layer[], route = false) => { for (const layer of stack) { if (seen.has(layer)) continue; seen.add(layer); - if (layer.route && Array.isArray(layer.route.stack)) { visit(layer.route.stack, true); continue; } + // Connect/Vite mount paths are strings; Express Route stacks are arrays. + if (layer.route && typeof layer.route !== "string" && Array.isArray(layer.route.stack)) { visit(layer.route.stack, true); continue; } if (Array.isArray(layer.handle.stack)) { visit(layer.handle.stack); continue; } const original = layer.handle; if (original === idleAdmissionMiddleware) continue; diff --git a/server/src/routes/agent-profile-avatar.ts b/server/src/routes/agent-profile-avatar.ts new file mode 100644 index 00000000000..cb984836360 --- /dev/null +++ b/server/src/routes/agent-profile-avatar.ts @@ -0,0 +1,36 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { setAgentAvatarSchema } from "@paperclipai/shared"; +import { z } from "zod"; +import type { StorageService } from "../storage/types.js"; +import { forbidden } from "../errors.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { accessService } from "../services/access.js"; +import { setAgentProfileAvatar } from "../services/agent-profile-avatar.js"; + +export function agentProfileAvatarRoutes(db: Db, storage: StorageService) { + const router = Router(); + router.put("/companies/:companyId/agents/:agentId/avatar", async (req, res) => { + const companyId = z.uuid().parse(req.params.companyId); + const agentId = z.uuid().parse(req.params.agentId); + assertCompanyAccess(req, companyId); + if (req.actor.type === "agent") { + if (req.actor.keyScope?.kind === "task_bridge" || req.actor.keyScope?.kind === "skill_test") { + throw forbidden("Task-scoped credentials cannot update agent profiles"); + } + if (req.actor.agentId !== agentId) throw forbidden("Agents can update only their own avatar"); + } else if (req.actor.type !== "board" || req.actor.memberships?.find(m => m.companyId === companyId)?.membershipRole === "viewer") { + throw forbidden("Operator or agent access required"); + } + if (req.actor.type === "board") { + const decision = await accessService(db).decide({ + actor: req.actor, action: "agent_config:update", + resource: { type: "agent", companyId, agentId }, + }); + if (!decision.allowed) throw forbidden(decision.explanation); + } + const actor = getActorInfo(req); + res.json(await setAgentProfileAvatar(db, storage, companyId, agentId, setAgentAvatarSchema.parse(req.body), actor)); + }); + return router; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 56ed7c939fe..a90a2e6950c 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/routes/dot-runner.ts b/server/src/routes/dot-runner.ts index 11b8c8a1fd2..928806548c1 100644 --- a/server/src/routes/dot-runner.ts +++ b/server/src/routes/dot-runner.ts @@ -4,34 +4,61 @@ import type { Db } from "@paperclipai/db"; import { assertBoard, assertCompanyAccess } from "./authz.js"; import { forbidden, HttpError } from "../errors.js"; import { publicMcpConfig, McpOAuthError } from "../services/public-mcp/oauth.js"; +import { dotInvitationService } from "../services/dot-invitations.js"; +import { agentService } from "../services/agents.js"; +import { notFound } from "../errors.js"; +import { accessService } from "../services/access.js"; import { dotRunnerBroker } from "../services/dot-runner-broker.js"; export function dotRunnerRoutes(db: Db, resourceUrl?: string) { const router = Router(); const broker = dotRunnerBroker(db); const path = "/companies/:companyId/agents/:agentId/dot-binding"; - function scope(req: Request, write = false) { + function companyScope(req: Request, write = false) { const companyId = z.uuid().parse(req.params.companyId); - const agentId = z.uuid().parse(req.params.agentId); assertBoard(req); assertCompanyAccess(req, companyId); if (write && (!req.actor.userId || !["session", "cloud_tenant"].includes(req.actor.source ?? "") || req.actor.memberships?.find(m => m.companyId === companyId)?.membershipRole === "viewer")) { throw forbidden("Sign in as an operator to connect a Dot agent."); } - return { companyId, agentId, operatorId: req.actor.userId! }; + return { companyId, operatorId: req.actor.userId! }; } + function scope(req: Request, write = false) { + return { ...companyScope(req, write), agentId: z.uuid().parse(req.params.agentId) }; + } + const invitations = dotInvitationService(db); + const invitePath = "/companies/:companyId/dot-invitations"; + async function inviteScope(req: Request) { + const input = companyScope(req, true); + const decision = await accessService(db).decide({ actor: req.actor, action: "agents:create", resource: { type: "company", companyId: input.companyId } }); + if (!decision.allowed) throw forbidden(decision.explanation); + return input; + } + router.get(invitePath, async (req, res) => { + const { companyId, operatorId } = await inviteScope(req); + res.set("Cache-Control", "no-store").json(await invitations.resume(companyId, operatorId)); + }); + router.post(invitePath, async (req, res) => { + const { companyId, operatorId } = await inviteScope(req); + z.object({}).strict().parse(req.body ?? {}); + res.set("Cache-Control", "no-store").json(await invitations.create(companyId, operatorId)); + }); router.get(path, async (req, res) => { const input = scope(req); + const agent = await agentService(db).getById(input.agentId); + if (!agent || agent.companyId !== input.companyId) throw notFound("Agent not found"); const config = resourceUrl ? null : publicMcpConfig(process.env); - res.json({ enabled: await broker.enabled(), resourceUrl: resourceUrl ?? (config ? config.origin + "/mcp/runner" : null), binding: await broker.bindingForAgent(input.companyId, input.agentId) }); + res.json({ agentStatus: agent.status, enabled: await broker.enabled(), resourceUrl: resourceUrl ?? (config ? config.origin + "/mcp/runner" : null), binding: await broker.bindingForAgent(input.companyId, input.agentId) }); }); router.post(path, async (req, res) => { const input = scope(req, true); - const body = z.object({ dotUrl: z.string().max(2048).optional() }).strict().parse(req.body ?? {}); - res.status(201).json(await broker.createPairing({ ...input, ...body })); + const body = z.object({ dotUrl: z.string().max(2048).optional(), replaceBindingId: z.uuid().optional() }).strict().parse(req.body ?? {}); + res.set("Cache-Control", "no-store").status(201).json(await broker.createPairing({ ...input, ...body })); }); router.post(path + "/event-test", async (req, res) => { - const input = scope(req, true); res.json(await broker.challenge(input.companyId, input.agentId)); + const input = scope(req, true); + const body = z.object({ bindingId: z.uuid().optional() }).strict().parse(req.body ?? {}); + res.json(await broker.challenge(input.companyId, input.agentId, { bindingId: body.bindingId })); }); router.delete(path, async (req, res) => { const input = scope(req, true); await broker.revoke(input.companyId, input.agentId, input.operatorId); res.status(204).end(); diff --git a/server/src/routes/instance-database-backups.ts b/server/src/routes/instance-database-backups.ts index f05e3361c2c..9635a1cb31b 100644 --- a/server/src/routes/instance-database-backups.ts +++ b/server/src/routes/instance-database-backups.ts @@ -4,7 +4,7 @@ import { forbidden } from "../errors.js"; import { isCloudManagedInstance } from "../services/cloud-instance.js"; import { assertInstanceAdmin } from "./authz.js"; -export type InstanceDatabaseBackupTrigger = "manual" | "scheduled"; +export type InstanceDatabaseBackupTrigger = "manual" | "scheduled" | "idle"; export type InstanceDatabaseBackupRunResult = RunDatabaseBackupResult & { trigger: InstanceDatabaseBackupTrigger; diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 1e1fd26c41b..0c126b729e1 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -15,6 +15,8 @@ import { } from "../services/cloud-instance.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; import { readIdleSleepSafety } from "../services/idle-sleep-safety.js"; +import { readIdleLocalWork } from "../services/idle-local-work.js"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { validate } from "../middleware/validate.js"; import { logger } from "../middleware/logger.js"; import { @@ -119,7 +121,7 @@ function withTaskDrainTransition(run: () => Promise): Promise { return turn; } -export function instanceSettingsRoutes(db: Db) { +export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManager, prepareIdleDatabaseBackup?: () => Promise) { const router = Router(); const svc = instanceSettingsService(db); const environments = environmentService(db); @@ -303,8 +305,18 @@ export function instanceSettingsRoutes(db: Db) { // The report covers every company in this process. Ordinary company // members may read process counters, but not instance-wide work state. assertCanManageInstanceSettings(req); + // A checkpoint performs platform-owned backup work. Tenant owners can + // have instance-admin elevation, but that must not grant the manual + // backup authority which managed instances deliberately withhold. + if (prepareIdleDatabaseBackup && isCloudManagedInstance() && req.actor.source !== "cloud_control") { + throw forbidden("Backup checkpoints are platform-managed on cloud-managed instances", { + code: "database_backups_platform_managed", + }); + } const idleSleepSafety = await readIdleSleepSafety(db, () => heartbeat.getTaskDrainStatus(), Date.now, - typeof req.query.ownerId === "string" ? req.query.ownerId : undefined); + typeof req.query.ownerId === "string" ? req.query.ownerId : undefined, + () => readIdleLocalWork({ backupCheckpoint: Boolean(prepareIdleDatabaseBackup) }), + pluginWorkers?.inspectIdleSleep?.bind(pluginWorkers), prepareIdleDatabaseBackup); res.json({ ...heartbeat.getTaskDrainStatus(), idleSleepSafety }); return; } @@ -418,6 +430,7 @@ export function instanceSettingsRoutes(db: Db) { ), ); heartbeat.stopTaskDrain(); + pluginWorkers?.releaseIdleSleep?.(); // See the POST handler above for why a publish failure here is // swallowed instead of failing the route: the audit record already // committed, so a publish failure here must not undo a drain-stop diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 81b0b5e2c4a..915e28ce507 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -30,6 +30,7 @@ import { AGENT_AVATAR_SIZES, CHARACTER_STATES, agentAppearanceSchema, + setAgentAvatarSchema, createAgentSchema, createAgentHireSchema, updateAgentSchema, @@ -1714,6 +1715,7 @@ function resolveOperationAuthLevel( || key === "POST /api/mcp/requests/{id}/dot-pairing/preview") return "public"; if (path === "/api/mcp/setup" || path === "/api/mcp/device/consent" || path.startsWith("/api/mcp/requests/") || path.startsWith("/api/mcp/connections")) return "board"; if (/^\/api\/companies\/\{companyId\}\/agents\/\{agentId\}\/dot-binding(?:\/event-test)?$/.test(path)) return "board"; + if (path === "/api/companies/{companyId}/dot-invitations") return "board"; if (PUBLIC_OPERATIONS.has(key)) return "public"; if (key === "POST /api/companies/{companyId}/agent-commentary") return "agent_heartbeat"; if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run"; @@ -2978,6 +2980,26 @@ for (const route of [ // ─── Agents ────────────────────────────────────────────────────────────────── registry.register("AgentAppearance", agentAppearanceSchema); +registry.registerPath({ + method: "put", + path: "/api/companies/{companyId}/agents/{agentId}/avatar", + tags: ["agents"], + summary: "Upload or reset an agent profile avatar", + description: "Board users require agent_config:update permission to change company agent avatars. Agents can update only their own avatar. Task bridge and skill test credentials cannot change profiles. Upload a base64 raster image, or null to restore the preset portrait. Stored avatars use company-scoped private assets.", + request: { + params: z.object({ companyId: z.string().uuid(), agentId: z.string().uuid() }), + body: jsonBody(setAgentAvatarSchema), + }, + responses: { + 200: r.ok(z.object({ agentId: z.string().uuid(), appearance: agentAppearanceSchema, avatarUrl: z.string() })), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + }, +}); + registry.registerPath({ method: "get", path: "/api/agent-avatars/{version}/{palette}/{file}", @@ -11733,6 +11755,27 @@ registerCurrentRoute({ }, }); +const dotInvitationResponseSchema = z.object({ + agent: z.object({ id: z.string().uuid(), name: z.string(), status: z.string() }), + approvalId: z.string().uuid().nullable(), + binding: z.object({ + id: z.string().uuid(), status: z.string(), connected: z.boolean(), + subscriptionVerified: z.boolean(), hasPendingChallenge: z.boolean(), + pairingExpiresAt: z.string().datetime().nullable(), challengeExpiresAt: z.string().datetime().nullable(), + }).nullable(), +}); +registerCurrentRoute({ + method: "get", path: "/api/companies/{companyId}/dot-invitations", tags: ["agents"], + summary: "Resume the signed-in operator's unfinished Dot invitation", + responses: { 200: r.ok(dotInvitationResponseSchema.nullable()), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); +registerCurrentRoute({ + method: "post", path: "/api/companies/{companyId}/dot-invitations", tags: ["agents"], + summary: "Create or resume a Dot invitation with company hire approval gates", + body: z.object({}).strict(), + responses: { 200: r.ok(dotInvitationResponseSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict }, +}); + registerCurrentRoute({ method: "get", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding", tags: ["agents"], summary: "Read the experimental Dot agent connection and event readiness", @@ -11741,13 +11784,14 @@ registerCurrentRoute({ registerCurrentRoute({ method: "post", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding", tags: ["agents"], summary: "Create a one-use Dot pairing code as a company operator", - body: z.object({ dotUrl: z.string().max(2048).optional() }).strict(), + body: z.object({ dotUrl: z.string().max(2048).optional(), replaceBindingId: z.string().uuid().optional() }).strict(), responses: { 201: r.ok(z.object({ bindingId: z.string().uuid(), pairingCode: z.string(), expiresAt: z.string().datetime(), instructions: z.string() })), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, }); registerCurrentRoute({ method: "post", path: "/api/companies/{companyId}/agents/{agentId}/dot-binding/event-test", tags: ["agents"], summary: "Request a harmless Dot readiness challenge as a company operator", + body: z.object({ bindingId: z.string().uuid().optional() }).strict(), responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, }); registerCurrentRoute({ diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index a3574824a85..093cc1630d6 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -327,7 +327,7 @@ export function toolAccessRoutes( } } - function requestLoopbackBaseUrl(req: Request) { + function requestLoopbackBaseUrl(req: Request, forInitiation = false) { const host = req.get("host")?.trim(); if (!host) return null; try { @@ -346,8 +346,7 @@ export function toolAccessRoutes( // interoperable spelling and retain the browser's exact origin as OAuth // state for popup postMessage below. if ( - req.method !== "GET" - && req.method !== "HEAD" + (forInitiation || (req.method !== "GET" && req.method !== "HEAD")) && (options.deploymentMode ?? "local_trusted") === "local_trusted" && parsed.protocol === "http:" && parsed.hostname !== "localhost" @@ -414,11 +413,11 @@ export function toolAccessRoutes( return null; } - function oauthRedirectUri(req: Request) { + function oauthRedirectUri(req: Request, forInitiation = false) { const baseUrl = configuredPublicBaseUrl() ?? trustedBrowserBaseUrl(req) ?? enrolledConnectorBaseUrl(req) - ?? requestLoopbackBaseUrl(req); + ?? requestLoopbackBaseUrl(req, forInitiation); if (!baseUrl) { throw unprocessable( "This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.", @@ -821,7 +820,22 @@ function connectorEnrollmentPrincipal(req: Request): string { : []; const vercelConnect = vercelConnectIntegrationStatus(); const { enableMemoryConnectors } = await instanceSettingsService(db).getExperimental(); + let oauthCallbackUrl: string | undefined; + try { + // Setup must display the callback used by its initiating POST. Provider + // callbacks themselves retain their exact request-derived redirect URI. + oauthCallbackUrl = oauthRedirectUri(req, true); + } catch (error) { + const details = error instanceof HttpError + && error.details + && typeof error.details === "object" + && !Array.isArray(error.details) + ? error.details as Record + : null; + if (details?.code !== "oauth_redirect_origin_unsupported") throw error; + } res.json({ + ...(oauthCallbackUrl ? { oauthCallbackUrl } : {}), capabilities: await describeConnectionCreateCapabilities(req, companyId), credentialSources: { vercelConnect: { diff --git a/server/src/services/__tests__/git-connection-failure.test.ts b/server/src/services/__tests__/git-connection-failure.test.ts new file mode 100644 index 00000000000..17692dfe407 --- /dev/null +++ b/server/src/services/__tests__/git-connection-failure.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; +import type { GitConnectionFailure } from "@paperclipai/adapter-utils/connection-failure"; +import { + classifyGitCloneFailure, + GitConnectionFailureError, + readGitConnectionFailure, +} from "../git-connection-failure.js"; + +const httpsRemote = "https://example.test/team/repository.git"; +const sshRemote = "git@example.test:team/repository.git"; +const authenticationStderr = `fatal: Authentication failed for '${httpsRemote}/'`; + +function childFailure(stderr: string, overrides: Record = {}) { + return Object.assign(new Error("Git clone subprocess failed"), { + code: 128, + stderr, + signal: null, + killed: false, + ...overrides, + }); +} + +function classification(reason: GitConnectionFailure["reason"]): GitConnectionFailure { + return { schemaVersion: 1, provider: "git", operation: "clone", reason }; +} + +describe("Git clone connection failure classification", () => { + it.each([ + ["HTTPS authentication", httpsRemote, authenticationStderr, "authentication_failed"], + ["disabled username prompt", httpsRemote, + "fatal: could not read Username for 'https://example.test': terminal prompts disabled", "authentication_failed"], + ["disabled password prompt", httpsRemote, + "fatal: could not read Password for 'https://git@example.test': terminal prompts disabled", "authentication_failed"], + ["SSH public key", sshRemote, + "git@example.test: Permission denied (publickey).\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.", "authentication_failed"], + ["private repository", httpsRemote, + `remote: Repository not found.\nfatal: repository '${httpsRemote}/' not found`, "repository_unavailable"], + ["HTTP unauthorized", httpsRemote, + `fatal: unable to access '${httpsRemote}/': The requested URL returned error: 401`, "authentication_failed"], + ["HTTP forbidden", httpsRemote, + `fatal: unable to access '${httpsRemote}/': The requested URL returned error: 403`, "authentication_failed"], + ["HTTP missing repository", httpsRemote, + `fatal: unable to access '${httpsRemote}/': The requested URL returned error: 404`, "repository_unavailable"], + ["invalid HTTP port", "https://example.test:invalid/team/repository.git", + "fatal: unable to access 'https://example.test:invalid/team/repository.git/': Port number was not a decimal number between 0 and 65535", "invalid_remote"], + ["invalid HTTP URL", "https://example.test:invalid/team/repository.git", + "fatal: unable to access 'https://example.test:invalid/team/repository.git/': URL rejected: Port number was not a decimal number between 0 and 65535", "invalid_remote"], + ["SSH alias DNS", "git@repo-alias.example.test:team/repository.git", + "ssh: Could not resolve hostname repo-alias.example.test: Name or service not known\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.", "dns_failure"], + ["HTTPS DNS", httpsRemote, + `fatal: unable to access '${httpsRemote}/': Could not resolve host: example.test`, "dns_failure"], + ["SSH refusal", sshRemote, + "ssh: connect to host example.test port 22: Connection refused\nfatal: Could not read from remote repository.", "connection_refused"], + ["HTTPS refusal", httpsRemote, + `fatal: unable to access '${httpsRemote}/': Failed to connect to example.test: Connection refused`, "connection_refused"], + ["SSH unreachable network", "ssh://git@example.test/team/repository.git", + "ssh: connect to host example.test port 22: Network is unreachable\nfatal: Could not read from remote repository.", "network_unreachable"], + ["SSH missing route", sshRemote, + "ssh: connect to host example.test port 22: No route to host\nfatal: Could not read from remote repository.", "network_unreachable"], + ["HTTPS reset", httpsRemote, + `fatal: unable to access '${httpsRemote}/': Recv failure: Connection was reset`, "connection_reset"], + ["SSH reset", sshRemote, + "Connection reset by example.test port 22\nfatal: Could not read from remote repository.", "connection_reset"], + ["SSH timeout", sshRemote, + "ssh: connect to host example.test port 22: Connection timed out\nfatal: Could not read from remote repository.", "connection_timeout"], + ["HTTPS timeout", httpsRemote, + `fatal: unable to access '${httpsRemote}/': Operation timed out after 30000 milliseconds with 0 bytes received`, "connection_timeout"], + ["HTTPS certificate", httpsRemote, + `fatal: unable to access '${httpsRemote}/': SSL certificate problem: unable to get local issuer certificate`, "tls_failure"], + ["HTTPS rate limit", httpsRemote, + `fatal: unable to access '${httpsRemote}/': The requested URL returned error: 429`, "rate_limited"], + ["HTTPS service unavailable", httpsRemote, + `fatal: unable to access '${httpsRemote}/': The requested URL returned error: 503`, "remote_unavailable"], + ["HTTPS bad gateway", httpsRemote, + `fatal: unable to access '${httpsRemote}/': The requested URL returned error: 502`, "remote_unavailable"], + ] as const)("classifies the actual remote clone diagnostic: %s", (_name, remote, stderr, reason) => { + expect(classifyGitCloneFailure(remote, childFailure(`Cloning into 'repository'...\n${stderr}\n`))) + .toEqual(classification(reason)); + }); + + it.each([ + "/tmp/repository", "./repository", "../repository", "repository", "~/repository", + "C:\\repository", "C:/repository", "file:///tmp/repository", "file://example.test/repository", + "ftp://example.test/repository", "gopher://example.test/repository", "ext::example.test/repository", + "https::example.test/repository", "", "example.test/repository", + ])("keeps local paths and unsupported transports reportable: %s", (remote) => { + expect(classifyGitCloneFailure(remote, childFailure(authenticationStderr))).toBeNull(); + }); + + it.each([ + ["missing Git executable", { code: "ENOENT" }], + ["string exit code", { code: "128" }], + ["successful exit", { code: 0 }], + ["non-fatal exit", { code: 1 }], + ["command error exit", { code: 2 }], + ["usage exit", { code: 129 }], + ["negative exit", { code: -1 }], + ["fractional exit", { code: 128.5 }], + ["unknown exit", { code: undefined }], + ["nonfinite exit", { code: Infinity }], + ["killed child", { killed: true }], + ["terminated child", { signal: "SIGTERM" }], + ["missing stderr", { stderr: undefined }], + ["buffer stderr", { stderr: Buffer.from(authenticationStderr) }], + ["object stderr", { stderr: { message: authenticationStderr } }], + ] as const)("requires unambiguous child-process failure evidence: %s", (_name, overrides) => { + expect(classifyGitCloneFailure(httpsRemote, childFailure(authenticationStderr, overrides))).toBeNull(); + }); + + it.each([ + "fatal: No space left on device", + "fatal: Read-only file system", + "fatal: Input/output error", + "fatal: Too many open files", + "fatal: Cannot allocate memory", + "fatal: could not create work tree dir 'repository': Permission denied", + "fatal: cannot open file 'repository/.git/config': Permission denied", + "fatal: bad object HEAD", + "fatal: corrupt loose object", + "fatal: invalid index", + "fatal: not a git repository", + "fatal: detected dubious ownership in repository", + "fatal: unexpected internal clone failure", + "error: unrelated local helper failed", + "warning: unrelated local configuration failure", + ])("does not hide a mixed local or unknown diagnostic: %s", (otherDiagnostic) => { + for (const stderr of [ + `${authenticationStderr}\n${otherDiagnostic}`, + `${otherDiagnostic}\n${authenticationStderr}`, + ]) { + expect(classifyGitCloneFailure(httpsRemote, childFailure(stderr))).toBeNull(); + } + }); + + it.each([ + "", "fatal: Could not read from remote repository.", + "fatal: destination path 'repository' already exists and is not an empty directory.", + "fatal: unable to access 'https://example.test/team/repository.git/': The requested URL returned error: 418", + "fatal: invalid object name 'The requested URL returned error: 503'", + "fatal: cannot open file 'Permission denied (publickey)': Permission denied", + "error: local helper reported fatal: Authentication failed before crashing", + ])("does not infer a remote cause from ambiguous stderr: %s", (stderr) => { + const error = childFailure(stderr); + error.message = authenticationStderr; + expect(classifyGitCloneFailure(httpsRemote, error)).toBeNull(); + }); + + it("does not classify messages or serialized child-error lookalikes", () => { + for (const error of [ + authenticationStderr, + new Error(authenticationStderr), + { message: authenticationStderr, code: 128, stderr: authenticationStderr }, + null, + ]) { + expect(classifyGitCloneFailure(httpsRemote, error)).toBeNull(); + } + }); +}); + +describe("producer-owned Git connection failure errors", () => { + it("preserves the existing error message and returns only the bounded classification", () => { + const diagnostic = classifyGitCloneFailure(httpsRemote, childFailure(authenticationStderr)); + expect(diagnostic).toEqual(classification("authentication_failed")); + const error = new GitConnectionFailureError("Unable to prepare project repository", diagnostic!); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe("GitConnectionFailureError"); + expect(error.message).toBe("Unable to prepare project repository"); + expect(readGitConnectionFailure(error)).toEqual(classification("authentication_failed")); + expect(JSON.stringify(readGitConnectionFailure(error))).not.toContain("example.test"); + expect(error).not.toHaveProperty("connectionFailure"); + expect(error).not.toHaveProperty("stderr"); + }); + + it("does not let caller mutations change retained failure evidence", () => { + const input = classification("authentication_failed"); + const error = new GitConnectionFailureError("Clone failed", input); + input.reason = "remote_unavailable"; + const firstRead = readGitConnectionFailure(error)!; + expect(firstRead).toEqual(classification("authentication_failed")); + firstRead.reason = "dns_failure"; + expect(readGitConnectionFailure(error)).toEqual(classification("authentication_failed")); + expect(readGitConnectionFailure(error)).not.toBe(firstRead); + }); + + it("rejects spoofed markers, prototype lookalikes, and serialized errors", () => { + const diagnostic = classification("authentication_failed"); + const real = new GitConnectionFailureError("Clone failed", diagnostic); + const spoofed = Object.assign(new Error("Clone failed"), { + name: "GitConnectionFailureError", connectionFailure: diagnostic, + }); + for (const value of [ + null, "Clone failed", diagnostic, { connectionFailure: diagnostic }, spoofed, + Object.assign(Object.create(GitConnectionFailureError.prototype), spoofed), + JSON.parse(JSON.stringify(real)), + new Error("Wrapper", { cause: real }), + ]) { + expect(readGitConnectionFailure(value)).toBeNull(); + } + expect(readGitConnectionFailure(real)).toEqual(diagnostic); + }); +}); diff --git a/server/src/services/__tests__/mcp-connection-failure.test.ts b/server/src/services/__tests__/mcp-connection-failure.test.ts new file mode 100644 index 00000000000..6a422a1af31 --- /dev/null +++ b/server/src/services/__tests__/mcp-connection-failure.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { HttpError } from "../../errors.js"; +import { isExpectedMcpConnectionFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "../mcp-connection-failure.js"; +import { assertPublicRemoteHttpEndpoint } from "../remote-http-endpoint-guard.js"; +import { classifyRemoteConnectionError, markRemoteConnectionFailure, readRemoteConnectionFailure, recordRemoteConnectionAttempts } from "../remote-connection-failure.js"; + +describe("MCP outbound failure provenance", () => { + it("lets an unknown address override existing MCP and owned-timeout receipts on the final error", async () => { + const last = markRemoteConnectionFailure(Object.assign(new HttpError(502, "last address timed out"), { code: "ETIMEDOUT" }), "connection_timeout"); + await expect(withMcpConnectionFailure(async () => { throw last; })).rejects.toBe(last); + expect(isExpectedMcpConnectionFailure(last)).toBe(true); + expect(isExpectedMcpConnectionFailure(retainMcpConnectionFailure(last, new HttpError(502, last.message)))).toBe(true); + const unknown = Object.assign(new Error("first address exhausted file descriptors"), { code: "EMFILE" }); + expect(recordRemoteConnectionAttempts(last, [unknown, last])).toBe(last); + expect(readRemoteConnectionFailure(last)).toBeNull(); + expect(classifyRemoteConnectionError(last)).toBeNull(); + expect(classifyRemoteConnectionError(new TypeError("wrapped", { cause: last }))).toBeNull(); + expect(isExpectedMcpConnectionFailure(last)).toBe(false); + expect(isExpectedMcpConnectionFailure(retainMcpConnectionFailure(last, new HttpError(502, last.message)))).toBe(false); + await expect(withMcpConnectionFailure(async () => { throw last; })).rejects.toBe(last); + expect(isExpectedMcpConnectionFailure(retainMcpConnectionFailure(last, new HttpError(502, last.message)))).toBe(false); + }); + + it.each([ + "ECONNREFUSED", "ENOTFOUND", "ENODATA", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", + "ECONNRESET", "EPIPE", "UND_ERR_SOCKET", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "CERT_HAS_EXPIRED", "CERT_NOT_YET_VALID", + "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "ERR_TLS_CERT_ALTNAME_INVALID", + ])("recognizes the structured transport failure %s without changing the thrown error", async (code) => { + const original = new TypeError("request failed", { cause: { code } }); + await expect(withMcpConnectionFailure(async () => { throw original; })).rejects.toBe(original); + const wrapped = retainMcpConnectionFailure(original, new HttpError(502, original.message)); + expect(isExpectedMcpConnectionFailure(wrapped)).toBe(true); + expect(Object.keys(original)).not.toContain("connectionFailure"); + }); + + it.each([ + new TypeError("ECONNREFUSED"), + new DOMException("request expired", "TimeoutError"), + Object.assign(new Error("cancelled", { cause: { code: "ECONNRESET" } }), { name: "AbortError" }), + Object.assign(new Error("too many files"), { code: "EMFILE" }), + new TypeError("request failed", { cause: { code: "toString" } }), + new TypeError("request failed", { cause: { code: "ECONNRESET", errors: [] } }), + new TypeError("request failed", { cause: { errors: [{ code: "ECONNRESET" }, { code: "ERR_INTERNAL_ASSERTION" }] } }), + ])("keeps unknown, local, cancellation, and mixed failures visible: %s", async (original) => { + await expect(withMcpConnectionFailure(async () => { throw original; })).rejects.toBe(original); + expect(isExpectedMcpConnectionFailure(retainMcpConnectionFailure(original, new HttpError(502, original.message)))) + .toBe(false); + }); + + it("does not carry provenance onto a subsequent independent error", async () => { + const transport = Object.assign(new Error("connection reset"), { code: "ECONNRESET" }); + await expect(withMcpConnectionFailure(async () => { throw transport; })).rejects.toBe(transport); + const persistence = new Error("database write failed", { cause: transport }); + expect(isExpectedMcpConnectionFailure(retainMcpConnectionFailure(persistence, new HttpError(502, persistence.message)))) + .toBe(false); + }); + + it.each(["known DNS", "DNS deadline", "unknown lookup"])("retains only producer-proven %s across discovery wrapping", async (kind) => { + const original = await assertPublicRemoteHttpEndpoint(new URL("https://missing.example.test/mcp"), { + dnsTimeoutMs: 1, + lookup: async () => { + if (kind === "known DNS") throw Object.assign(new Error("lookup failed"), { code: "ENOTFOUND" }); + if (kind === "unknown lookup") throw new TypeError("lookup implementation failed"); + return new Promise(() => {}); + }, + }, (message, code) => new HttpError(400, message, { code })).catch((error) => error); + expect(original).toBeInstanceOf(HttpError); + const wrapped = retainMcpConnectionFailure(original, new HttpError(502, original.message)); + expect(isExpectedMcpConnectionFailure(wrapped)).toBe(kind !== "unknown lookup"); + const unrelated = new Error("database failed", { cause: original }); + expect(isExpectedMcpConnectionFailure(retainMcpConnectionFailure(unrelated, new HttpError(502, unrelated.message)))) + .toBe(false); + }); +}); diff --git a/server/src/services/__tests__/run-failure-diagnostics.test.ts b/server/src/services/__tests__/run-failure-diagnostics.test.ts index 0bf0d2a9eb8..868e1841e1a 100644 --- a/server/src/services/__tests__/run-failure-diagnostics.test.ts +++ b/server/src/services/__tests__/run-failure-diagnostics.test.ts @@ -342,3 +342,33 @@ describe("run failure diagnostics", () => { expect(collectRunFailureDiagnostics(run({ startedAt: new Date(NaN), finishedAt: new Date() }), {}).execution).not.toHaveProperty("durationMs"); }); }); + + +it("revalidates persisted transfer evidence and excludes arbitrary nested payloads", () => { + for (const known of [true, false]) { + const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: { + workspaceRestoreFailure: "restore_failed", workspaceRestoreDiagnostic: { + phase: "workspace", step: "workspace_transfer", errorCode: "unknown", + transferStep: known ? "archive_create" : "private-command", + transferFailureKind: known ? "command_failed" : "private-output", rpcCode: known ? -32003 : -12345, + command: "private-command", cause: { message: "private-nested-message" }, + }, + } }), {})); + expect(result.execution.workspaceRestoreTransferStep).toBe(known ? "archive_create" : undefined); + expect(result.execution.workspaceRestoreTransferFailureKind).toBe(known ? "command_failed" : undefined); + expect(result.execution.workspaceRestoreRpcCode).toBe(known ? -32003 : undefined); + expect(JSON.stringify(result)).not.toContain("private-"); + } +}); + +it("does not project transfer fields into an unrelated restore step", () => { + const result = collectRunFailureDiagnostics(run({ resultJson: { + workspaceRestoreFailure: "restore_failed", workspaceRestoreDiagnostic: { + phase: "workspace", step: "git_import", errorCode: "unknown", + transferStep: "archive_create", transferFailureKind: "command_failed", rpcCode: -32003, + }, + } }), {}); + expect(result.execution).not.toHaveProperty("workspaceRestoreTransferStep"); + expect(result.execution).not.toHaveProperty("workspaceRestoreTransferFailureKind"); + expect(result.execution).not.toHaveProperty("workspaceRestoreRpcCode"); +}); diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index 6271b6f520c..2b2120e99f9 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import os from "node:os"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { agents, companies, createDb, heartbeatRuns, type Db } from "@paperclipai/db"; +import { agents, applyPendingMigrations, closeRegisteredClients, companies, createDb, heartbeatRuns, type Db } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -30,7 +30,8 @@ vi.mock("../../log-redaction.js", async (importOriginal) => { import { reportRunFailure, waitForPendingRunFailureReports } from "../run-failure-report.js"; import { REDACTED_EVENT_VALUE } from "../../redaction.js"; -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const testDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL; +const embeddedPostgresSupport = testDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; describeEmbeddedPostgres("reportRunFailure", () => { @@ -41,8 +42,13 @@ describeEmbeddedPostgres("reportRunFailure", () => { let inheritedEnv: NodeJS.ProcessEnv; beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-failure-report-"); - db = createDb(tempDb.connectionString); + if (testDatabaseUrl) { + await applyPendingMigrations(testDatabaseUrl); + db = createDb(testDatabaseUrl); + } else { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-failure-report-"); + db = createDb(tempDb.connectionString); + } }, 20_000); beforeEach(() => { @@ -66,6 +72,7 @@ describeEmbeddedPostgres("reportRunFailure", () => { }); afterAll(async () => { + if (testDatabaseUrl) await closeRegisteredClients(testDatabaseUrl); await tempDb?.cleanup(); }); @@ -110,6 +117,231 @@ describeEmbeddedPostgres("reportRunFailure", () => { } as unknown as typeof heartbeatRuns.$inferSelect; } + const gitConnection = { schemaVersion: 1, provider: "git", operation: "clone", reason: "authentication_failed" }; + const hermesConnection = { schemaVersion: 1, provider: "hermes_gateway", operation: "create_run", reason: "connection_refused" }; + + function gitConnectionRun(overrides: Partial = {}) { + return buildRun({ + errorCode: "setup_failed", executionStage: "preparing", exitCode: null, signal: null, + resultJson: { connectionFailure: gitConnection, + executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + ...overrides, + }); + } + + function hermesConnectionRun(overrides: Partial = {}) { + return buildRun({ + errorCode: "hermes_gateway_connect_failed", executionStage: "executing", exitCode: 1, signal: null, + resultJson: { connectionFailure: hermesConnection }, ...overrides, + }); + } + + it("keeps a proven Git clone failure and its persisted task error unchanged", async () => { + await seedCompanyAndAgent(); + const input = gitConnectionRun(); + const [run] = await db.insert(heartbeatRuns).values(input).returning(); + const before = structuredClone(run); + await reportRunFailure(db, run!, { phase: "setup" }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(run).toEqual(before); + expect(await db.select().from(heartbeatRuns)).toEqual([before]); + }); + + it("keeps an all-external Git materialization aggregate local even across different external causes", async () => { + await seedCompanyAndAgent(); + const run = gitConnectionRun({ errorCode: "workspace_validation_failed" }); + run.resultJson!.workspaceValidation = { reason: "git_worktree_base_materialization_failed", materializationFailures: [ + { connectionFailure: gitConnection }, + { connectionFailure: { ...gitConnection, reason: "repository_unavailable" } }, + ] }; + const before = structuredClone(run); + await reportRunFailure(db, run, { phase: "setup" }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(run).toEqual(before); + }); + + it.each([ + { status: "timed_out" }, { executionStage: "executing" }, { executionStage: null }, + { errorCode: "adapter_failed" }, { exitCode: 1 }, { exitCode: 0 }, { signal: "SIGTERM" }, + { resultJson: {} }, + { resultJson: { connectionFailure: { ...gitConnection, schemaVersion: 2 }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }, + { resultJson: { connectionFailure: hermesConnection, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }, + ])("reports a Git marker without its matching setup outcome: %j", async (overrides) => { + await seedCompanyAndAgent(); + await reportRunFailure(db, gitConnectionRun(overrides), { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it.each([undefined, "execute"] as const)("reports Git failures outside setup: %s", async (phase) => { + await seedCompanyAndAgent(); + await reportRunFailure(db, gitConnectionRun(), { phase }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it.each([undefined, { kind: "bootstrap" }, { kind: "bootstrap", providerWorkStarted: true }, + { kind: "runtime", providerWorkStarted: false }])("reports Git failures without unstarted bootstrap proof: %j", async (recovery) => { + await seedCompanyAndAgent(); + const run = gitConnectionRun(); + run.resultJson!.executionRecovery = recovery; + await reportRunFailure(db, run, { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it.each([ + [], [null], [{ connectionFailure: gitConnection }, {}], + [{ connectionFailure: gitConnection }, { connectionFailure: hermesConnection }], + [{ connectionFailure: gitConnection }, { connectionFailure: { ...gitConnection, reason: "unknown" } }], + [{ connectionFailure: { ...gitConnection, reason: "repository_unavailable" } }], + ].map((failures) => ({ failures })))("reports mixed, malformed, or inconsistent Git materialization evidence: %j", async ({ failures }) => { + await seedCompanyAndAgent(); + const run = gitConnectionRun({ errorCode: "workspace_validation_failed" }); + run.resultJson!.workspaceValidation = { reason: "git_worktree_base_materialization_failed", materializationFailures: failures }; + await reportRunFailure(db, run, { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it.each([ + { reason: "git_worktree_base_materialization_failed", materializationFailures: [{ connectionFailure: gitConnection }] }, + { reason: "git_worktree_not_reusable" }, + ])("does not use setup_failed to bypass separate workspace validation evidence: %j", async (validation) => { + await seedCompanyAndAgent(); + const run = gitConnectionRun(); + run.resultJson!.workspaceValidation = validation; + await reportRunFailure(db, run, { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it.each([ + ["configuration", "endpoint_missing", "hermes_gateway_api_base_url_missing"], + ["configuration", "endpoint_invalid", "hermes_gateway_api_base_url_invalid"], + ["configuration", "insecure_transport", "hermes_gateway_plain_http_remote_denied"], + ["configuration", "credentials_missing", "hermes_gateway_api_key_missing"], + ["create_run", "authentication_failed", "hermes_gateway_auth_failed"], + ["create_run", "endpoint_not_found", "hermes_gateway_runs_unsupported"], + ["create_run", "rate_limited", "hermes_gateway_rate_limited"], + ["create_run", "remote_unavailable", "hermes_gateway_upstream_error"], + ...["connection_refused", "dns_failure", "network_unreachable", "connection_reset", "connection_timeout", "tls_failure"] + .flatMap((reason) => ["hermes_gateway_connect_failed", "hermes_gateway_protocol_error"].map((code) => ["create_run", reason, code])), + ])("keeps the matching Hermes %s/%s task error local", async (operation, reason, errorCode) => { + await seedCompanyAndAgent({ adapterType: "hermes_gateway" }); + const run = hermesConnectionRun({ errorCode, resultJson: { connectionFailure: { ...hermesConnection, operation, reason } } }); + const before = structuredClone(run); + await reportRunFailure(db, run, { phase: "execute" }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(run).toEqual(before); + }); + + it.each([undefined, { kind: "bootstrap", providerWorkStarted: true }, { kind: "runtime", providerWorkStarted: true }])( + "does not infer remote nonacceptance from a classified Hermes connection failure: %j", async (recovery) => { + await seedCompanyAndAgent({ adapterType: "hermes_gateway" }); + const input = hermesConnectionRun(); + input.resultJson!.executionRecovery = recovery; + const [run] = await db.insert(heartbeatRuns).values(input).returning(); + const before = structuredClone(run); + await reportRunFailure(db, run!); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(await db.select().from(heartbeatRuns)).toEqual([before]); + }, + ); + + it.each([ + { status: "timed_out" }, { status: "cancelled", startedAt: new Date(0) }, + { exitCode: 0 }, { exitCode: null }, { signal: "SIGTERM" }, + { errorCode: "hermes_gateway_timeout" }, { errorCode: "hermes_gateway_run_failed" }, + { errorCode: "hermes_gateway_auth_failed" }, + { resultJson: {} }, + { resultJson: { connectionFailure: { ...hermesConnection, reason: "unknown" } } }, + { resultJson: { connectionFailure: { ...hermesConnection, privateUrl: "https://private.example.test" } } }, + { resultJson: { connectionFailure: gitConnection } }, + { resultJson: { connectionFailure: { ...hermesConnection, operation: "configuration", reason: "credentials_missing" } } }, + ])("reports an unproven or mismatched Hermes outcome: %j", async (overrides) => { + await seedCompanyAndAgent({ adapterType: "hermes_gateway" }); + const run = hermesConnectionRun(overrides); + const before = structuredClone(run); + await reportRunFailure(db, run, { phase: "execute" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + expect(run).toEqual(before); + }); + + it("keeps setup errors reportable even when they carry a matching Hermes marker", async () => { + await seedCompanyAndAgent({ adapterType: "hermes_gateway" }); + await reportRunFailure(db, hermesConnectionRun(), { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it.each(["codex_local", "hermes_local"])("requires the actual database adapter, not a Hermes marker on %s", async (adapterType) => { + await seedCompanyAndAgent({ adapterType }); + const run = hermesConnectionRun({ contextSnapshot: { adapterType: "hermes_gateway" } }); + await reportRunFailure(db, run); + expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: adapterType })); + }); + + it.each(["missing", "other_company"])("does not suppress Hermes when its database agent is %s", async (kind) => { + await seedCompanyAndAgent({ adapterType: "hermes_gateway" }); + const run = hermesConnectionRun(kind === "missing" ? { agentId: randomUUID() } : { companyId: randomUUID() }); + await reportRunFailure(db, run); + expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: "unknown" })); + }); + + function aiSelectionRun(overrides: Partial = {}) { + return buildRun({ + errorCode: "configuration_incomplete", + executionStage: "preparing", + resultJson: { + configurationIncomplete: { + reason: "ai_connection_unavailable", + selectionFailure: "ai_connection_default_missing", + }, + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + ...overrides, + }); + } + + it.each([ + "ai_connection_responsible_user_missing", "ai_connection_default_missing", + "ai_connection_missing", "ai_connection_incompatible", "ai_connection_unavailable", + ])("keeps the proven AI selection blocker local: %s", async (selectionFailure) => { + await seedCompanyAndAgent(); + const run = aiSelectionRun(); + (run.resultJson!.configurationIncomplete as Record).selectionFailure = selectionFailure; + const saved = structuredClone(run); + await reportRunFailure(db, run, { phase: "setup" }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(run).toEqual(saved); + }); + + it.each([undefined, null, {}, [], "", "database_error", "ai_connection_busy"])( + "reports AI connection wrappers without a known selection cause: %j", async (selectionFailure) => { + await seedCompanyAndAgent(); + const run = aiSelectionRun(); + (run.resultJson!.configurationIncomplete as Record).selectionFailure = selectionFailure; + await reportRunFailure(db, run, { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); + }, + ); + + it.each([ + { runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: {} } }, + { status: "timed_out" }, { errorCode: "setup_failed" }, { executionStage: "executing" }, + { executionStage: null }, { exitCode: 1 }, { exitCode: 0 }, { signal: "SIGTERM" }, + { resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable", selectionFailure: "ai_connection_default_missing" } } }, + { resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable", selectionFailure: "ai_connection_default_missing" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } }, + { resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable", selectionFailure: "ai_connection_default_missing" }, executionRecovery: { kind: "resume", providerWorkStarted: false } } }, + { resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable", selectionFailure: "ai_connection_default_missing" }, executionRecovery: { kind: "bootstrap" } } }, + { resultJson: { configurationIncomplete: { reason: "controller_environment_unsupported", selectionFailure: "ai_connection_default_missing" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }, + ])("reports AI configuration without pre-provider selection proof: %j", async (overrides) => { + await seedCompanyAndAgent(); + await reportRunFailure(db, aiSelectionRun(overrides), { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, "execute"] as const)("reports AI selection failures outside setup: %s", async (phase) => { + await seedCompanyAndAgent(); + await reportRunFailure(db, aiSelectionRun(), { phase }); + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); + }); + function missingSecretRun(overrides: Partial = {}) { return buildRun({ errorCode: "configuration_incomplete", diff --git a/server/src/services/agent-directory-probe.ts b/server/src/services/agent-directory-probe.ts new file mode 100644 index 00000000000..97b3385fe0d --- /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 869a497e97b..8bbead8531f 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 c88c0537729..6eb2c974b6e 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/agent-profile-avatar.ts b/server/src/services/agent-profile-avatar.ts new file mode 100644 index 00000000000..64d0b7d3d9c --- /dev/null +++ b/server/src/services/agent-profile-avatar.ts @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import sharp from "sharp"; +import { agents, assets, companies, type Db } from "@paperclipai/db"; +import { MAX_AGENT_AVATAR_BYTES, resolveAgentAppearance, agentAvatarUrl, setAgentAvatarSchema, type SetAgentAvatarInput } from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; +import type { StorageService } from "../storage/types.js"; +import { agentService } from "./agents.js"; +import { logActivity, publishActivity, type ActivityPublication, type LogActivityInput } from "./activity-log.js"; + +/** Decode and re-encode: exclude SVG, animation, metadata and unbounded pixel counts. No URL fetching. */ +export async function normalizeAgentAvatar(input: string) { + const bytes = Buffer.from(input, "base64"); + if (!bytes.length || bytes.length > MAX_AGENT_AVATAR_BYTES || bytes.toString("base64") !== input) { + throw unprocessable("Avatar must contain at most 512 KiB of base64-encoded image bytes"); + } + const rasterSignature = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + || (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) + || (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP"); + if (!rasterSignature) throw unprocessable("Use a PNG, JPEG, or WebP image"); + try { + const image = sharp(bytes, { limitInputPixels: 16_777_216, failOn: "warning" }); + const metadata = await image.metadata(); + if (!["png", "jpeg", "webp"].includes(metadata.format ?? "") || (metadata.pages ?? 1) !== 1) throw new Error("Unsupported image"); + return await image.rotate().resize(512, 512, { fit: "inside", withoutEnlargement: true }).png().toBuffer(); + } catch { + throw unprocessable("Use a valid static PNG, JPEG, or WebP image with at most 16 megapixels"); + } +} + +type AvatarActor = Pick; +export async function setAgentProfileAvatar(db: Db, storage: StorageService, companyId: string, agentId: string, input: SetAgentAvatarInput, actor: AvatarActor) { + const parsed = setAgentAvatarSchema.parse(input); + // Authorization belongs to the REST/MCP boundary. Validate scope and eligibility before decoding. + const [target] = await db.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))); + if (!target) throw notFound("Agent not found"); + if (["terminated", "pending_approval"].includes(target.status)) throw conflict("Agent avatar cannot be updated in its current state"); + const png = parsed.imageBase64 === null ? null : await normalizeAgentAvatar(parsed.imageBase64); + const digest = png ? createHash("sha256").update(png).digest("hex") : null; + let newObjectKey: string | undefined; + const publications: ActivityPublication[] = []; + try { + const result = await db.transaction(async tx => { + const txDb = tx as unknown as Db; + const [agent] = await tx.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).for("update"); + const [company] = await tx.select().from(companies).where(eq(companies.id, companyId)); + if (!agent || company?.status !== "active" || ["terminated", "pending_approval"].includes(agent.status)) throw conflict("Agent avatar cannot be updated in its current state"); + const appearance = resolveAgentAppearance(agent.appearance, agent.id); + const current = appearance.customAvatarAssetId + ? (await tx.select().from(assets).where(and(eq(assets.id, appearance.customAvatarAssetId), eq(assets.companyId, companyId))))[0] + : null; + if ((png && current?.sha256 === digest) || (!png && !appearance.customAvatarAssetId)) { + return { agentId, appearance, avatarUrl: agentAvatarUrl(appearance) }; + } + delete appearance.customAvatarAssetId; + if (png) { + const stored = await storage.putFile({ companyId, namespace: `agent-avatars/${agentId}`, originalFilename: "avatar.png", contentType: "image/png", body: png }); + newObjectKey = stored.objectKey; + const [asset] = await tx.insert(assets).values({ ...stored, companyId, createdByAgentId: agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null }).returning(); + appearance.customAvatarAssetId = asset!.id; + } + await agentService(txDb).update(agentId, { appearance }, { recordRevision: { + createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, source: "avatar-upload", + } }); + await logActivity(txDb, { ...actor, companyId, action: "agent.avatar_updated", entityType: "agent", entityId: agentId, + details: { assetId: appearance.customAvatarAssetId ?? null, byteSize: png?.length ?? 0 } }, publications); + return { agentId, appearance, avatarUrl: agentAvatarUrl(appearance) }; + }); + publications.forEach(publishActivity); + return result; + } catch (error) { + if (newObjectKey) await storage.deleteObject(companyId, newObjectKey).catch(() => {}); + throw error; + } +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index f47a20ce612..59e3a905123 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -7,6 +7,7 @@ import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, + assets, toolConnectionInstalls, agentConfigRevisions, agentApiKeys, @@ -755,6 +756,15 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { assertBuiltInAgentMetadataMutationAllowed(existing.metadata, data.metadata, options); } + if (data.appearance?.customAvatarAssetId) { + const [asset] = await db.select().from(assets).where(and( + eq(assets.id, data.appearance.customAvatarAssetId), eq(assets.companyId, existing.companyId), + eq(assets.createdByAgentId, id), + )); + if (!asset || asset.contentType !== "image/png" || !asset.objectKey.startsWith(`${existing.companyId}/agent-avatars/${id}/`)) { + throw unprocessable("Use the avatar upload endpoint to set this agent's image"); + } + } const normalizedPatch = { ...data } as Partial; if (data.permissions !== undefined) { normalizedPatch.permissions = normalizeAgentPermissions(data.permissions); @@ -913,6 +923,7 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { getById, create: async (companyId: string, data: Omit, options?: CreateAgentOptions) => { + if (data.appearance?.customAvatarAssetId) throw unprocessable("Create the agent before uploading its avatar"); assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options); if (data.reportsTo) { await ensureManager(companyId, data.reportsTo); diff --git a/server/src/services/ai-connection-configuration-failure.test.ts b/server/src/services/ai-connection-configuration-failure.test.ts new file mode 100644 index 00000000000..50e4a6a68f8 --- /dev/null +++ b/server/src/services/ai-connection-configuration-failure.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { unprocessable } from "../errors.js"; +import { + aiConnectionConfigurationFailure, + isAiConnectionConfigurationReason, + readAiConnectionConfigurationFailure, +} from "./ai-connection-configuration-failure.js"; + +describe("AI selection failure provenance", () => { + it("preserves HTTP behavior while retaining only an owned bounded reason", () => { + const error = aiConnectionConfigurationFailure("ai_connection_default_missing", "Choose an account", { connectionId: "synthetic-connection" }); + expect(error).toMatchObject({ status: 422, message: "Choose an account", details: { + code: "ai_connection_default_missing", connectionId: "synthetic-connection", + } }); + expect(readAiConnectionConfigurationFailure(error)).toBe("ai_connection_default_missing"); + expect(Object.keys(error)).toEqual(["status", "details"]); + }); + + it.each([ + new Error("Connect an account and choose your personal default"), + unprocessable("Choose an account", { code: "ai_connection_default_missing" }), + { status: 422, details: { code: "ai_connection_default_missing" } }, + Object.assign(new Error("database failed"), { code: "configuration_incomplete" }), + null, + ])("does not infer provenance from an error name, message, or HTTP shape: %j", (error) => { + expect(readAiConnectionConfigurationFailure(error)).toBeNull(); + }); + + it.each([null, undefined, {}, [], "ai_connection_busy", "provider_error", "toString"])( + "rejects unknown or malformed persisted reasons: %j", (value) => { + expect(isAiConnectionConfigurationReason(value)).toBe(false); + }, + ); +}); diff --git a/server/src/services/ai-connection-configuration-failure.ts b/server/src/services/ai-connection-configuration-failure.ts new file mode 100644 index 00000000000..01053c24203 --- /dev/null +++ b/server/src/services/ai-connection-configuration-failure.ts @@ -0,0 +1,31 @@ +import { unprocessable } from "../errors.js"; + +const reasons = [ + "ai_connection_responsible_user_missing", + "ai_connection_default_missing", + "ai_connection_missing", + "ai_connection_incompatible", + "ai_connection_unavailable", +] as const; +type AiConnectionConfigurationReason = typeof reasons[number]; +const failures = new WeakMap(); + +/** Preserve the existing HTTP error while recording an explicit selection rejection. */ +export function aiConnectionConfigurationFailure( + reason: AiConnectionConfigurationReason, + message: string, + details: Record = {}, +) { + const error = unprocessable(message, { ...details, code: reason }); + failures.set(error, reason); + return error; +} + +/** Only owned producers supply this evidence; matching names or HTTP codes do not. */ +export function readAiConnectionConfigurationFailure(error: unknown): AiConnectionConfigurationReason | null { + return error instanceof Error ? failures.get(error) ?? null : null; +} + +export function isAiConnectionConfigurationReason(value: unknown): value is AiConnectionConfigurationReason { + return typeof value === "string" && reasons.some((reason) => reason === value); +} diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts index 46494d580a9..e73c0c64fd7 100644 --- a/server/src/services/ai-connections.ts +++ b/server/src/services/ai-connections.ts @@ -39,6 +39,7 @@ import { type AiConnectionLoginIntent, } from "@paperclipai/shared"; import { forbidden, notFound, unprocessable } from "../errors.js"; +import { aiConnectionConfigurationFailure } from "./ai-connection-configuration-failure.js"; import { quotaCredentialRecovery, quotaCredentialHash } from "./quota-credential-recovery.js"; import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; @@ -266,15 +267,9 @@ export function aiConnectionService(db: Db) { input.acpxAgent, ) ) - throw unprocessable( - "Select an AI connection compatible with this harness and model", - { code: "ai_connection_incompatible" }, - ); + throw aiConnectionConfigurationFailure("ai_connection_incompatible", "Select an AI connection compatible with this harness and model"); if (binding.mode === "responsible_user" && !userId) - throw unprocessable( - "This run needs a responsible user to select an AI connection", - { code: "ai_connection_responsible_user_missing" }, - ); + throw aiConnectionConfigurationFailure("ai_connection_responsible_user_missing", "This run needs a responsible user to select an AI connection"); if (userId && !(await membership(companyId, userId))) throw forbidden("The responsible user is not an active company member"); const defaultRow = @@ -298,10 +293,7 @@ export function aiConnectionService(db: Db) { ? defaultRow?.grantId : binding.grantId; if (!grantId) - throw unprocessable( - "Connect an account and choose your personal default", - { code: "ai_connection_default_missing" }, - ); + throw aiConnectionConfigurationFailure("ai_connection_default_missing", "Connect an account and choose your personal default"); const row = (await rows(companyId)).find( (r) => r.grant.id === grantId && @@ -309,9 +301,7 @@ export function aiConnectionService(db: Db) { r.connection.id === binding.connectionId), ); if (!row) - throw unprocessable("The selected AI connection is unavailable", { - code: "ai_connection_missing", - }); + throw aiConnectionConfigurationFailure("ai_connection_missing", "The selected AI connection is unavailable"); const { connection, grant } = row; const metadata = aiConnectionMetadataSchema.safeParse(connection.config.ai); if ( @@ -320,13 +310,9 @@ export function aiConnectionService(db: Db) { (binding.mode !== "responsible_user" && metadata.data.method !== binding.method) || !isAiConnectionCompatible(metadata.data, input.adapterType, input.model, input.runnerProvider, input.acpxAgent) ) - throw unprocessable("The selected AI connection is incompatible", { - code: "ai_connection_incompatible", - }); + throw aiConnectionConfigurationFailure("ai_connection_incompatible", "The selected AI connection is incompatible"); if (aiSubscriptionNeedsIsolatedLogin(connection.config)) - throw unprocessable("Reconnect this subscription with a separate sign-in to protect your existing terminal login.", { - code: "ai_connection_unavailable", connectionId: connection.id, - }); + throw aiConnectionConfigurationFailure("ai_connection_unavailable", "Reconnect this subscription with a separate sign-in to protect your existing terminal login.", { connectionId: connection.id }); if ( grant.status !== "active" || !connection.enabled || @@ -337,10 +323,7 @@ export function aiConnectionService(db: Db) { connection.config.aiLegacyAdoption === true )) ) - throw unprocessable("Reconnect or validate the selected AI account", { - code: "ai_connection_unavailable", - connectionId: connection.id, - }); + throw aiConnectionConfigurationFailure("ai_connection_unavailable", "Reconnect or validate the selected AI account", { connectionId: connection.id }); if ( grant.kind === "user" && !(await membership(companyId, grant.subjectUserId)) diff --git a/server/src/services/chat-slack-registration.ts b/server/src/services/chat-slack-registration.ts index 212bc4054a7..a42909e7ae2 100644 --- a/server/src/services/chat-slack-registration.ts +++ b/server/src/services/chat-slack-registration.ts @@ -218,7 +218,9 @@ export function slackChatRegistrationService(db: Db, options: { // Cloud ingress requires a tenant session. Send the preset PNG bytes so // Slack does not need to fetch an authenticated Paperclip URL. const render = options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render; - const png = await render({ appearance: resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId), + const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; + delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. + const png = await render({ appearance, size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); await endpoint(endpointId, actor); await lease.assertOwned(); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 7d2824afa40..ad438f88298 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -3083,6 +3083,16 @@ function readAgentSkillRefs(frontmatter: Record) { )); } +/** Uploaded avatars belong to one company/agent; portable packages retain the preset fallback. */ +function portableAgentAppearance(value: unknown, warnings: string[], slug: string) { + const appearance = agentAppearanceSchema.parse(value); + if (appearance.customAvatarAssetId) { + delete appearance.customAvatarAssetId; + warnings.push(`Agent ${slug} uploaded avatar was omitted because image assets are instance-local. Upload it again after import.`); + } + return appearance; +} + function buildManifestFromPackageFiles( files: Record, opts?: { sourceLabel?: { companyId: string; companyName: string } | null }, @@ -3241,7 +3251,7 @@ function buildManifestFromPackageFiles( role: asString(extension.role) ?? asString(frontmatter.role) ?? "agent", title, icon: asString(extension.icon), - appearance: extension.appearance == null ? undefined : agentAppearanceSchema.parse(extension.appearance), + appearance: extension.appearance == null ? undefined : portableAgentAppearance(extension.appearance, warnings, slug), capabilities: asString(extension.capabilities), reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo), reportsToExistingAgentId: asString(extension.reportsToExistingAgentId), @@ -4262,7 +4272,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const extension = stripEmptyValues({ role: agent.role !== "agent" ? agent.role : undefined, icon: agent.icon ?? null, - appearance: agent.appearance, + appearance: agent.appearance == null ? undefined : portableAgentAppearance(agent.appearance, warnings, slug), capabilities: agent.capabilities ?? null, adapter: { type: agent.adapterType, @@ -5609,7 +5619,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { role: manifestAgent.role, title: manifestAgent.title, icon: manifestAgent.icon, - ...(manifestAgent.appearance ? { appearance: manifestAgent.appearance } : {}), + ...(manifestAgent.appearance ? { appearance: portableAgentAppearance(manifestAgent.appearance, warnings, manifestAgent.slug) } : {}), capabilities: manifestAgent.capabilities, reportsTo: null, adapterType: normalizedAdapter.adapterType, diff --git a/server/src/services/dot-invitations.ts b/server/src/services/dot-invitations.ts new file mode 100644 index 00000000000..975e7b0bc40 --- /dev/null +++ b/server/src/services/dot-invitations.ts @@ -0,0 +1,78 @@ +import { and, desc, eq, isNull, ne, sql } from "drizzle-orm"; +import { activityLog, agents, companies, dotAgentBindings, type Db } from "@paperclipai/db"; +import { agentService } from "./agents.js"; +import { approvalService } from "./approvals.js"; +import { accessService } from "./access.js"; +import { agentInstructionsService } from "./agent-instructions.js"; +import { loadDefaultAgentInstructionsBundle } from "./default-agent-instructions.js"; +import { logActivity } from "./activity-log.js"; +import { dotRunnerBroker } from "./dot-runner-broker.js"; +import { notFound, unprocessable } from "../errors.js"; + +/** One unfinished invitation per operator/company. Only non-secret references are persisted. */ +export function dotInvitationService(db: Db) { + async function pending(database: Db, companyId: string, operatorId: string) { + const rows = await database.select({ agent: agents, binding: dotAgentBindings }) + .from(agents).leftJoin(dotAgentBindings, and(eq(dotAgentBindings.agentId, agents.id), isNull(dotAgentBindings.revokedAt))) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"), + eq(agents.adapterType, "paperclip_runner"), sql`${agents.adapterConfig}->>'provider' = 'openai_dot'`, + sql`exists (select 1 from ${activityLog} where ${activityLog.companyId} = ${agents.companyId} + and ${activityLog.entityId} = ${agents.id}::text and ${activityLog.entityType} = 'agent' + and ${activityLog.actorType} = 'user' and ${activityLog.actorId} = ${operatorId} + and ${activityLog.action} = 'agent.hire_created' and ${activityLog.details}->>'source' = 'dot-invitation')`)).orderBy(desc(agents.createdAt)); + return rows.find(row => row.binding?.status !== "ready")?.agent ?? null; + } + async function describe(agent: typeof agents.$inferSelect) { + const approval = await approvalService(db).findOpenHireApprovalForAgent(agent.companyId, agent.id); + return { agent: { id: agent.id, name: agent.name, status: agent.status }, approvalId: approval?.id ?? null, + binding: await dotRunnerBroker(db).bindingForAgent(agent.companyId, agent.id) }; + } + return { + async resume(companyId: string, operatorId: string) { + const agent = await pending(db, companyId, operatorId); + return agent ? describe(agent) : null; + }, + async create(companyId: string, operatorId: string) { + if (!await dotRunnerBroker(db).enabled()) throw unprocessable("Enable OpenAI Dot and Assistant connections (MCP) in experimental settings."); + const agent = await db.transaction(async tx => { + const database = tx as unknown as Db; + // SQL locking also serializes retries on different control-plane replicas. + const [company] = await tx.select().from(companies).where(eq(companies.id, companyId)).for("update"); + if (!company) throw notFound("Company not found"); + if (company.status !== "active") throw unprocessable("Activate this company before inviting Dot."); + const existing = await pending(database, companyId, operatorId); + if (existing) return existing; + const svc = agentService(database); + let created = await svc.create(companyId, { + name: "Dot", role: "general", adapterType: "paperclip_runner", + adapterConfig: { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true, + dotWorkspaceAccess: false, dotAttachmentAccess: false }, + runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } }, + status: company.requireBoardApprovalForNewAgents ? "pending_approval" : "idle", + metadata: { dotInvitation: { operatorId } }, + }, { createdByUserId: operatorId }); + const bundle = await agentInstructionsService(database).materializeManagedBundle(created, + await loadDefaultAgentInstructionsBundle("default"), { entryFile: "AGENTS.md", replaceExisting: false }); + created = (await svc.update(created.id, { adapterConfig: bundle.adapterConfig }, { allowPendingApprovalConfigUpdate: true }))!; + const access = accessService(database); + await access.ensureMembership(companyId, "agent", created.id, "member", "active"); + await access.setPrincipalPermission(companyId, "agent", created.id, "tasks:assign", true, operatorId); + const approval = company.requireBoardApprovalForNewAgents ? await approvalService(database).create(companyId, { + type: "hire_agent", status: "pending", requestedByUserId: operatorId, + payload: { agentId: created.id, name: created.name, role: created.role, adapterType: created.adapterType, + adapterConfig: created.adapterConfig, runtimeConfig: created.runtimeConfig, metadata: created.metadata, + budgetMonthlyCents: created.budgetMonthlyCents, requestedConfigurationSnapshot: { + adapterType: created.adapterType, runtimeConfig: created.runtimeConfig, adapterConfig: created.adapterConfig, + } }, + }) : null; + await logActivity(database, { companyId, actorType: "user", actorId: operatorId, action: "agent.hire_created", + entityType: "agent", entityId: created.id, details: { name: created.name, role: created.role, + requiresApproval: !!approval, approvalId: approval?.id ?? null, source: "dot-invitation" } }); + if (approval) await logActivity(database, { companyId, actorType: "user", actorId: operatorId, action: "approval.created", + entityType: "approval", entityId: approval.id, details: { type: "hire_agent", agentId: created.id } }); + return created; + }); + return describe(agent); + }, + }; +} diff --git a/server/src/services/dot-runner-broker.ts b/server/src/services/dot-runner-broker.ts index 4edda023c0d..a65107e51d5 100644 --- a/server/src/services/dot-runner-broker.ts +++ b/server/src/services/dot-runner-broker.ts @@ -1,6 +1,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { and, asc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { z } from "zod"; +import { setAgentAvatarSchema, type SetAgentAvatarInput } from "@paperclipai/shared"; +import { setAgentProfileAvatar } from "./agent-profile-avatar.js"; +import { getStorageService } from "../storage/index.js"; import { agents, companies, heartbeatRuns, issues, agentWakeupRequests, nativeRunFinalizations, mcpOauthGrants, mcpEventSubscriptions, dotAgentBindings as bindings, dotRunnerAssignments as assignments, dotRunnerOperations as operations, dotMailboxItems as mailbox, type Db } from "@paperclipai/db"; @@ -30,7 +33,7 @@ function createBroker(db: Db) { const experimental = await settings.getExperimental(); return experimental.enableOpenAiDot && experimental.enablePublicMcp; } - const ports = new Map Promise; revoke?: () => Promise }>(); + const ports = new Map Promise; revoke?: () => Promise }>(); async function principalBinding(principal: McpPrincipal, requireReady = true, allowPaused = false) { if (principal.grant.purpose !== "agent" || !principal.grant.scopes.includes("paperclip:agent")) throw fail("Use the dedicated Dot agent connection."); const [binding] = await db.select().from(bindings).where(and(eq(bindings.grantId, principal.grant.id), @@ -85,6 +88,9 @@ function createBroker(db: Db) { const [a] = await db.select().from(assignments).where(eq(assignments.id, row.assignmentId)); const port = a && ports.get(a.runId); if (!port || !["reserved", "admitted"].includes(row.status)) return; + const [owner] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, a.runId)); + if (!owner || owner.controllerGeneration !== port.controllerGeneration || a.controllerGeneration !== port.controllerGeneration + || !owner.leaseExpiresAt || owner.leaseExpiresAt <= new Date()) return; // Sending the same stable command is safe after a lost ACK. The Runner's // command/semantic receipts decide whether it was already admitted. if (!a || a.status === "fenced" || a.expiresAt <= new Date()) return; @@ -103,22 +109,31 @@ function createBroker(db: Db) { return { enabled, - async createPairing(input: { companyId: string; agentId: string; operatorId: string; dotUrl?: string }) { + async createPairing(input: { companyId: string; agentId: string; operatorId: string; dotUrl?: string; replaceBindingId?: string }) { if (!await enabled()) throw fail("Enable OpenAI Dot and Assistant connections (MCP) in experimental settings."); if (input.dotUrl && !/^https:\/\/chatgpt\.com\/dots\/[A-Za-z0-9-]+$/.test(input.dotUrl)) throw fail("Use the Dot's ChatGPT URL."); const code = randomBytes(24).toString("base64url"); const binding = await db.transaction(async tx => { const [agent] = await tx.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))).for("update"); - if (!agent || agent.adapterType !== "paperclip_runner" || agent.status === "pending_approval" || agent.status === "terminated") throw fail("Choose an approved Paperclip Runner agent."); + if (!agent || agent.adapterType !== "paperclip_runner" || agent.adapterConfig.provider !== "openai_dot" || ["pending_approval", "terminated", "paused"].includes(agent.status)) throw fail("Choose an approved Paperclip Runner agent."); const [existing] = await tx.select().from(bindings).where(and(eq(bindings.companyId, input.companyId), eq(bindings.agentId, input.agentId), isNull(bindings.revokedAt))); - if (existing) throw fail("Revoke the existing connection before creating another pairing code."); - const [created] = await tx.insert(bindings).values({ ...input, pairingCodeHash: hash(code), pairingExpiresAt: new Date(Date.now() + 15 * 60_000) }).returning(); + if (existing) { + if (existing.id !== input.replaceBindingId || existing.status !== "pairing" || existing.operatorId !== input.operatorId) { + throw fail("This invitation changed. Refresh before creating a new prompt."); + } + // Consume only the pending binding named by the caller. An OAuth race + // cannot revoke a connection that has already been established. + const [replaced] = await tx.update(bindings).set({ revokedAt: new Date(), status: "revoked", pairingCodeHash: null, updatedAt: new Date() }) + .where(and(eq(bindings.id, existing.id), eq(bindings.status, "pairing"), isNull(bindings.revokedAt))).returning(); + if (!replaced) throw fail("Dot connected while this prompt was being replaced. Refresh the connection."); + } else if (input.replaceBindingId) throw fail("This invitation changed. Refresh before creating a new prompt."); + const [created] = await tx.insert(bindings).values({ companyId: input.companyId, agentId: input.agentId, operatorId: input.operatorId, dotUrl: input.dotUrl, pairingCodeHash: hash(code), pairingExpiresAt: new Date(Date.now() + 15 * 60_000) }).returning(); await logActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "user", actorId: input.operatorId, action: "dot.pairing_created", entityType: "agent", entityId: input.agentId, details: { bindingId: created!.id } }); return created!; }); return { bindingId: binding.id, pairingCode: code, expiresAt: binding.pairingExpiresAt, - instructions: "Connect the private Paperclip Dot plugin at /mcp/runner, approve agent access, then call paperclip_dot_pair with this code. Subscribe to paperclip.dot.mailbox_updated for the returned binding. Run the event test before assigning work." }; + instructions: "Connect the private Paperclip Dot plugin at /mcp/runner, approve agent access, then call paperclip_dot_pair with this code. Subscribe to paperclip.dot.mailbox_updated for the returned binding. Paperclip sends the event test automatically before work can be assigned." }; }, async pair(principal: McpPrincipal, code: string) { if (!await enabled() || principal.grant.purpose !== "agent" || !principal.grant.scopes.includes("paperclip:agent")) throw fail("A dedicated Dot agent grant is required."); @@ -149,7 +164,7 @@ function createBroker(db: Db) { const [active] = await db.select({ id: assignments.id, runId: assignments.runId, status: assignments.status, lastActivityAt: assignments.lastActivityAt }).from(assignments) .where(and(eq(assignments.bindingId, b.id), inArray(assignments.status, ["offered", "accepted"]))); return { id: b.id, companyId: b.companyId, agentId: b.agentId, generation: b.generation, status: b.status, dotUrl: b.dotUrl, - readyAt: b.readyAt, connected: !!b.grantId, subscriptionVerified: activeSubscriptions.length > 0, + readyAt: b.readyAt, pairingExpiresAt: b.pairingExpiresAt, challengeExpiresAt: b.challengeExpiresAt, connected: !!b.grantId, subscriptionVerified: activeSubscriptions.length > 0, hasPendingChallenge: !!b.challengeHash && !!b.challengeExpiresAt && b.challengeExpiresAt > new Date(), assignment: active ? { ...active, attentionRequired: active.status === "accepted" && active.lastActivityAt.getTime() < Date.now() - 15 * 60_000 } : null }; }, @@ -228,6 +243,12 @@ function createBroker(db: Db) { return db.select({ id: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status }).from(issues) .where(and(eq(issues.companyId, b.companyId), eq(issues.assigneeAgentId, b.agentId), inArray(issues.status, ["todo", "in_progress", "blocked"]), after ? gt(issues.id, after) : undefined)).orderBy(asc(issues.id)).limit(50); }, + async setAvatar(principal: McpPrincipal, input: SetAgentAvatarInput) { + if (!await enabled()) throw fail("Dot is disabled in experimental settings."); + const b = await principalBinding(principal, false); + return setAgentProfileAvatar(db, getStorageService(), b.companyId, b.agentId, input, + { actorType: "agent", actorId: b.agentId, agentId: b.agentId }); + }, async capabilities(principal: McpPrincipal) { const b = await principalBinding(principal, false); const [agent] = await db.select().from(agents).where(eq(agents.id, b.agentId)); @@ -238,6 +259,7 @@ function createBroker(db: Db) { permissions: agent!.permissions, ready: state?.status === "ready" && state.subscriptionVerified, assignment: state?.assignment ?? null, idle: { read: ["paperclip_dot_capabilities", "paperclip_dot_tasks", "paperclip_dot_inbox"], + profile: "paperclip_dot_set_avatar", start: "paperclip_dot_request_turn", instruction: "You can start work without an existing task. Call paperclip_dot_request_turn with the user's request and a stable UUID. Drain the inbox, read and accept the assignment, then use its full catalog through paperclip_dot_tool. Task tools run as this agent under normal permissions, never as the owner." }, runtime: { skills: "pinned_read", mcp: "assigned_gateway", taskAttachments: agent!.adapterConfig?.dotAttachmentAccess === true ? "assigned_task_read" : "disabled", attachmentPrerequisite: "Enable task attachment reading on this Dot agent to send verified contents of its current assigned task files to OpenAI. This does not enable workspace commands.", workspace: agent!.adapterConfig?.dotWorkspaceAccess === true ? "sandboxed_tool_bridge" : "disabled", @@ -355,19 +377,35 @@ function createBroker(db: Db) { return outcome; }); }, - async challenge(companyId: string, agentId: string) { + async challenge(companyId: string, agentId: string, options: { automatic?: boolean; bindingId?: string } = {}) { const state = await this.bindingForAgent(companyId, agentId); - if (!await enabled() || !state || !state.subscriptionVerified) throw fail("Connect and subscribe the Dot first."); + if (!await enabled() || !state || !state.subscriptionVerified || (options.bindingId && options.bindingId !== state.id)) { + if (options.automatic) return { status: "unavailable" }; + throw fail("Connect and subscribe the Dot first."); + } const nonce = randomBytes(24).toString("base64url"); - await db.transaction(async tx => { + const available = await db.transaction(async tx => { const [b] = await tx.select().from(bindings).where(eq(bindings.id, state.id)).for("update"); - if (!b || b.revokedAt || b.challengeHash && b.challengeExpiresAt && b.challengeExpiresAt > new Date()) throw fail("An event test is already pending."); + if (!b || b.revokedAt || b.generation !== state.generation) { + if (options.automatic) return false; + throw fail("Binding authority changed."); + } + if (options.automatic && (b.challengeHash || b.readyAt)) return; + if (b.challengeHash && b.challengeExpiresAt && b.challengeExpiresAt > new Date()) return; + const [subscription] = await tx.select({ id: mcpEventSubscriptions.id }).from(mcpEventSubscriptions).where(and( + eq(mcpEventSubscriptions.bindingId, b.id), eq(mcpEventSubscriptions.grantId, b.grantId!), + isNull(mcpEventSubscriptions.stoppedAt), gt(mcpEventSubscriptions.expiresAt, new Date()))); + if (!subscription) { + if (options.automatic) return false; + throw fail("The event subscription is no longer verified."); + } await tx.update(bindings).set({ challengeHash: hash(nonce), challengeExpiresAt: new Date(Date.now() + 10 * 60_000), updatedAt: new Date() }).where(eq(bindings.id, b.id)); await tx.insert(mailbox).values({ companyId, bindingId: b.id, bindingGeneration: b.generation, kind: "readiness_challenge", sourceEventId: "challenge_" + randomUUID(), references: { challenge: nonce } }); await logActivity(tx as unknown as Db, { companyId, actorType: "user", actorId: b.operatorId, action: "dot.event_test_requested", entityType: "agent", entityId: agentId, details: { bindingId: b.id } }); }); + if (available === false) return { status: "unavailable" }; return { status: "pending", message: "Waiting for the Dot to receive the event, read its mailbox and complete the harmless challenge." }; }, async confirmChallenge(principal: McpPrincipal, nonce: string) { @@ -375,7 +413,7 @@ function createBroker(db: Db) { const connection = await this.bindingForAgent(b.companyId, b.agentId); if (!connection?.subscriptionVerified) throw fail("The event subscription is no longer verified."); const [updated] = await db.update(bindings).set({ status: "ready", readyAt: new Date(), challengeHash: null, challengeExpiresAt: null, updatedAt: new Date() }) - .where(and(eq(bindings.id, b.id), eq(bindings.generation, b.generation), eq(bindings.challengeHash, hash(nonce)), gt(bindings.challengeExpiresAt, new Date()))).returning(); + .where(and(eq(bindings.id, b.id), eq(bindings.generation, b.generation), isNull(bindings.revokedAt), eq(bindings.grantId, principal.grant.id), eq(bindings.challengeHash, hash(nonce)), gt(bindings.challengeExpiresAt, new Date()))).returning(); if (!updated) throw fail("Event test expired or does not match."); await logActivity(db, { companyId: b.companyId, actorType: "agent", actorId: b.agentId, action: "dot.event_test_completed", entityType: "agent", entityId: b.agentId, details: { bindingId: b.id } }); @@ -422,14 +460,34 @@ function createBroker(db: Db) { if (existing && hasProviderCheckpoint !== true) throw fail("Dot bridge checkpoint missing; reconcile external work before continuing."); const token = Symbol(runId); if (ports.has(runId)) throw fail("A Dot run controller is already attached."); - ports.set(runId, { token, send, revoke }); const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)); - if (!coordinator) { ports.delete(runId); throw fail("Run controller ownership is unavailable."); } - await db.update(assignments).set({ controllerGeneration: coordinator.controllerGeneration }).where(and(eq(assignments.runId, runId), eq(assignments.bindingGeneration, execution.provider.binding.bindingGeneration))); - const rows = await db.select({ operation: operations }).from(operations).innerJoin(assignments, eq(assignments.id, operations.assignmentId)) - .where(and(eq(assignments.runId, runId), inArray(operations.status, ["reserved", "admitted"]))).orderBy(asc(operations.createdAt)); - for (const { operation } of rows) void forward(operation).catch(() => {}); - return async () => { if (ports.get(runId)?.token === token) ports.delete(runId); }; + if (!coordinator?.leaseExpiresAt || coordinator.leaseExpiresAt <= new Date()) throw fail("Run controller ownership is unavailable."); + // Reserve synchronously after the ownership read; another attachment + // may have completed while that read was in flight. + if (ports.has(runId)) throw fail("A Dot run controller is already attached."); + ports.set(runId, { token, send, revoke, controllerGeneration: coordinator.controllerGeneration }); + try { + await db.update(assignments).set({ controllerGeneration: coordinator.controllerGeneration }).where(and(eq(assignments.runId, runId), + eq(assignments.bindingGeneration, execution.provider.binding.bindingGeneration), sql`${assignments.controllerGeneration} <= ${coordinator.controllerGeneration}`)); + } catch (error) { if (ports.get(runId)?.token === token) ports.delete(runId); throw error; } + // Any HTTP replica can reserve an operation. Only this run's current + // controller drains it. No Runner/sandbox is needed for an idle binding. + let timer: NodeJS.Timeout | undefined; + const drain = async () => { + try { + if (ports.get(runId)?.token !== token) return; + const rows = await db.select({ operation: operations }).from(operations).innerJoin(assignments, eq(assignments.id, operations.assignmentId)) + .where(and(eq(assignments.runId, runId), inArray(operations.status, ["reserved", "admitted"]))).orderBy(asc(operations.createdAt)).limit(32); + for (const { operation } of rows) { + if (ports.get(runId)?.token !== token) break; + await forward(operation).catch(() => {}); // durable rows remain retryable + } + } finally { + if (ports.get(runId)?.token === token) { timer = setTimeout(() => { void drain().catch(() => {}); }, 2000); timer.unref(); } + } + }; + void drain().catch(() => {}); + return async () => { if (timer) clearTimeout(timer); if (ports.get(runId)?.token === token) ports.delete(runId); }; }, dispatch: async event => { const p = event.payload; @@ -499,6 +557,7 @@ export function createDotRunnerMcpTools(db: Db): PublicMcpToolExtension { const broker = dotRunnerBroker(db); const request = { assignmentId: z.uuid(), requestId: z.uuid() }; const definitions = [ + { name: "paperclip_dot_set_avatar", description: "Update only your bound Paperclip agent's avatar, including while idle after pairing. If you can obtain your own avatar, send its PNG/JPEG/WebP bytes as raw base64 (max 512 KiB, static image). Do not send a URL or invent an image. Use imageBase64: null to restore the Paperclip character. Repeating the same image is safe. No active assignment is needed.", schema: setAgentAvatarSchema }, { name: "paperclip_dot_capabilities", description: "Discover your agent identity, responsible person, configured permissions, enforcement limits, idle entry point, runtime capabilities, and prerequisites. Use this before concluding you cannot act.", schema: z.object({}).strict() }, { name: "paperclip_dot_request_turn", description: "Start a governed Runner turn for a request from your Dot conversation, even when no task is assigned. Creates one auditable intake task using normal permissions and admission. Retry the exact prompt with the same UUID.", schema: z.object({ prompt: z.string().trim().min(1).max(20_000), requestId: z.uuid() }).strict() }, { name: "paperclip_dot_renew", description: "Renew an accepted assignment before expiry. Choose an expiry no more than two hours ahead or 24 hours after admission. Cannot revive expired, stopped, or revoked work.", schema: z.object({ ...request, expiresAtUnixMs: z.number().int().positive() }).strict() }, @@ -523,7 +582,8 @@ export function createDotRunnerMcpTools(db: Db): PublicMcpToolExtension { const definition = definitions.find(d => d.name === name); if (!definition) throw fail("Unknown Dot tool."); const input = definition.schema.parse(raw) as Record; let result: unknown; - if (name === "paperclip_dot_capabilities") result = await broker.capabilities(principal); + if (name === "paperclip_dot_set_avatar") result = await broker.setAvatar(principal, setAgentAvatarSchema.parse(input)); + else if (name === "paperclip_dot_capabilities") result = await broker.capabilities(principal); else if (name === "paperclip_dot_request_turn") result = await broker.requestTurn(principal, String(input.prompt), String(input.requestId)); else if (name === "paperclip_dot_tasks") result = await broker.tasks(principal, input.after ? String(input.after) : null); else if (name === "paperclip_dot_request_work") result = await broker.requestWork(principal, String(input.issueId), String(input.requestId)); diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 91f887ac776..0bfcd69effe 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 d28e072a2e8..e0cd734df47 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/environment-sync-out-error.ts b/server/src/services/environment-sync-out-error.ts index f9574f696a7..64a4aa75a23 100644 --- a/server/src/services/environment-sync-out-error.ts +++ b/server/src/services/environment-sync-out-error.ts @@ -1,15 +1,17 @@ -import { readEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk"; +import { readEnvironmentSyncErrorDiagnostic, JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; import { preserveWorkspaceRestoreErrorDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics"; /** Keep the original RPC failure and policy fields; add only private diagnostic evidence. */ export function preserveEnvironmentSyncOutErrorDiagnostic(error: unknown): unknown { const diagnostic = readEnvironmentSyncErrorDiagnostic(error); - if (diagnostic && error && typeof error === "object") { + const rpcCode = error instanceof JsonRpcCallError && (Object.values(PLUGIN_RPC_ERROR_CODES) as number[]).includes(error.code) + ? error.code : undefined; + if ((diagnostic || rpcCode !== undefined) && error && typeof error === "object") { preserveWorkspaceRestoreErrorDiagnostic(error, { - code: diagnostic.errorCode, - status: diagnostic.httpStatus, - exitCode: diagnostic.exitCode, - }); + code: diagnostic?.errorCode, + status: diagnostic?.httpStatus, + exitCode: diagnostic?.exitCode, + }, { ...diagnostic, rpcCode }); } return error; } diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index c63a3974adb..4170332cc30 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/git-connection-failure.ts b/server/src/services/git-connection-failure.ts new file mode 100644 index 00000000000..f8b7cac02de --- /dev/null +++ b/server/src/services/git-connection-failure.ts @@ -0,0 +1,90 @@ +import type { GitConnectionFailure } from "@paperclipai/adapter-utils/connection-failure"; + +const classifiedErrors = new WeakMap(); + +/** Only the outbound Git call may create this reporting classification. */ +export class GitConnectionFailureError extends Error { + constructor(message: string, failure: GitConnectionFailure) { + super(message); + this.name = "GitConnectionFailureError"; + classifiedErrors.set(this, { ...failure }); + } +} + +export function readGitConnectionFailure(error: unknown): GitConnectionFailure | null { + const failure = error instanceof Error ? classifiedErrors.get(error) : undefined; + return failure ? { ...failure } : null; +} + +function isNetworkRemote(remote: string): boolean { + // Local paths/file remotes and arbitrary remote helpers are not connections. + // Keep malformed HTTP/SSH URLs eligible only for Git's explicit URL error. + if (/^(?:https?|ssh|git):\/\//i.test(remote)) return true; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(remote) || remote.includes("::")) return false; + return /^(?:[^\s/@:]+@)?[a-z0-9][a-z0-9.-]+:[^:\s][^\s]*$/i.test(remote) && !/^[a-z]:[\\/]/i.test(remote); +} + +/** + * Read the actual failed clone subprocess, not a wrapped error message. These + * diagnostics belong to the remote transport; filesystem/repository damage + * and mixed failures remain application errors even if auth text is present. + */ +export function classifyGitCloneFailure(remote: string, error: unknown): GitConnectionFailure | null { + if (!isNetworkRemote(remote) || !(error instanceof Error)) return null; + const failure = error as Error & { code?: unknown; stderr?: unknown; signal?: unknown; killed?: unknown }; + if (failure.code !== 128 || failure.signal != null || failure.killed === true || typeof failure.stderr !== "string") return null; + const lines = failure.stderr.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + let reason: GitConnectionFailure["reason"] | undefined; + for (const line of lines) { + const classified = classifyTransportLine(line); + if (classified) { + reason ??= classified; + continue; + } + // Git and SSH append these fixed progress/help lines to recognized + // transport errors. Unknown diagnostics, including a second local failure, + // keep the whole operation reportable. The boilerplate alone proves nothing. + if (/^Cloning into ['"].+['"]\.\.\.$/.test(line) || + /^(?:remote: )?(?:Enumerating|Counting|Compressing|Receiving|Resolving|Updating) (?:objects|deltas|files):/.test(line) || + /^remote: Total \d+ /.test(line) || + /^fatal: Could not read from remote repository\.$/.test(line) || + /^Please make sure you have the correct access rights$/.test(line) || + /^and the repository exists\.$/.test(line) || + /^remote: Please see https:\/\/[^\s]+ for information/.test(line)) continue; + return null; + } + return reason ? { schemaVersion: 1, provider: "git", operation: "clone", reason } : null; +} + +function classifyTransportLine(line: string): GitConnectionFailure["reason"] | undefined { + // Parse the transport's own diagnostic prefix before examining its detail. + // A local error may quote exactly the same words in a path or object name. + const http = /^fatal: unable to access ['"][^'"\r\n]+['"]: (.+)$/i.exec(line)?.[1]; + if (http) { + const status = /^The requested URL returned error: (\d{3})$/i.exec(http)?.[1]; + if (status === "401" || status === "403") return "authentication_failed"; + if (status === "404") return "repository_unavailable"; + if (status === "429") return "rate_limited"; + if (status && /^5\d\d$/.test(status)) return "remote_unavailable"; + if (/^(?:URL (?:using bad\/illegal format|rejected)|Port number was not a decimal number)/i.test(http)) return "invalid_remote"; + if (/^Could not resolve host: [^\s]+$/i.test(http)) return "dns_failure"; + if (/^Failed to connect to .+: Connection refused$/i.test(http)) return "connection_refused"; + if (/^Failed to connect to .+: (?:Network is unreachable|No route to host)$/i.test(http)) return "network_unreachable"; + if (/^Recv failure: Connection was reset$/i.test(http)) return "connection_reset"; + if (/^Failed to connect to .+: Connection timed out$|^Operation timed out(?: after \d+ milliseconds with \d+ bytes received)?$/i.test(http)) return "connection_timeout"; + if (/^(?:SSL certificate problem|server certificate verification failed|SSL peer certificate|gnutls_handshake\(\) failed)/i.test(http)) return "tls_failure"; + return undefined; + } + if (/^fatal: Authentication failed for ['"][^'"\r\n]+['"]$/i.test(line) || + /^fatal: could not read (?:Username|Password) for ['"][^'"\r\n]+['"]: terminal prompts disabled$/i.test(line) || + /^remote: (?:Invalid username or (?:password|token)|Permission [^\r\n]+ denied|Support for password authentication was removed|Password authentication is not supported)/i.test(line) || + /^(?:[^\s:@]+@)?[a-z0-9][a-z0-9.-]*: Permission denied \((?:publickey|password|keyboard-interactive)(?:,[a-z-]+)*\)\.?$/i.test(line)) return "authentication_failed"; + if (/^remote: Repository not found\.?$|^fatal: repository ['"][^'"\r\n]+['"] not found$/i.test(line)) return "repository_unavailable"; + if (/^ssh: Could not resolve hostname [^\s]+: (?:Name or service not known|nodename nor servname provided, or not known|Temporary failure in name resolution|No such host is known\.?)$/i.test(line)) return "dns_failure"; + const ssh = /^ssh: connect to host [^\s]+ port \d+: (.+)$/i.exec(line)?.[1]; + if (ssh === "Connection refused") return "connection_refused"; + if (ssh === "Network is unreachable" || ssh === "No route to host") return "network_unreachable"; + if (ssh === "Connection timed out") return "connection_timeout"; + if (/^Connection reset by (?:peer|[^\s]+ port \d+)$/i.test(line)) return "connection_reset"; + return undefined; +} diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index d87c5ebbc82..917c26c55a6 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -473,12 +473,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 406b32c1e54..d0a31431c1f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,7 +1,126 @@ +import { + ChatControlRecoveryUnresolvedError, + shouldDeferFollowupWakeForSameIssue, + shouldQueueFollowupForRunningIssueWake, + DEFERRED_WAKE_CONTEXT_KEY, + EXECUTION_PATH_HEARTBEAT_RUN_STATUSES, + shouldRequireIssueCommentForWake, + createHeartbeatQueue, +} from "./heartbeat/queue.js"; +export { + shouldDeferFollowupWakeForSameIssue, + shouldQueueFollowupForRunningIssueWake, +} from "./heartbeat/queue.js"; +import { + HEARTBEAT_RUN_TERMINAL_STATUSES, + DETACHED_PROCESS_ERROR_CODE, + isProcessAlive, + CANCELLABLE_HEARTBEAT_RUN_STATUSES, + createHeartbeatRecovery, +} from "./heartbeat/recovery.js"; +import { + BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, + normalizeAgentNameKey, + WorkspaceBusyDeferral, + readTransientRecoveryContractFromRun, + readHeartbeatRunErrorFamily, + isWorkspaceBusyDeferral, + isTransientWorkspaceGitScanCode, + createHeartbeatRetries, +} from "./heartbeat/retries.js"; +export { + BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS, + WORKSPACE_BUSY_RETRY_WAKE_REASON, + WORKSPACE_BUSY_ERROR_CODE, + WORKSPACE_BUSY_RETRY_BASE_DELAY_MS, + WORKSPACE_BUSY_RETRY_JITTER_MS, + WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS, + type SharedWorkspaceHolder, + WorkspaceBusyDeferral, + computeWorkspaceBusyRetryDelayMs, + computeBoundedTransientHeartbeatRetrySchedule, +} from "./heartbeat/retries.js"; +import { + type RunSessionOutcome, + EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON, + deriveTaskKeyWithHeartbeatFallback, + type UsageTotals, + getAdapterSessionCodec, + shouldResetTaskSessionForWake, + normalizeSessionParams, + normalizeResumeParamsForAdapter, + truncateDisplayId, + resolveLedgerScopeForRun, + describeSessionResetReason, + isCanonicalSessionIdForAdapter, + requiresCanonicalSessionIds, + resolveNextSessionState, + normalizeUsageTotals, + resolveCacheAdjustedCostUsd, + resolveLedgerBiller, + resolveLedgerCostStatus, + normalizeLedgerBillingType, + createHeartbeatRunState, +} from "./heartbeat/run-state.js"; +export { + summarizeHeartbeatRunContextSnapshot, + summarizeHeartbeatRunListResultJson, + normalizeBilledCostCents, + resolveLedgerCostStatus, + resolveCacheAdjustedCostUsd, + resolveLedgerScopeForRun, + buildExplicitResumeSessionOverride, + normalizeAdapterRunUsage, + parseSessionCompactionPolicy, + deriveTaskKeyWithHeartbeatFallback, + shouldResetTaskSessionForWake, + describeSessionResetReason, + normalizeSessionParams, + resolveNextSessionState, +} from "./heartbeat/run-state.js"; +import { readGitConnectionFailure } from "./git-connection-failure.js"; +import { + CONFIGURATION_INCOMPLETE_FAILURE_CODE, + ConfigurationIncompleteFailure, + deriveTaskKey, + type WakeupOptions, + mergeCoalescedContextSnapshot, + PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY, + PAPERCLIP_HARNESS_CHECKOUT_KEY, + attestReviewedExternalChatRun, + resolveAcceptedPlanWakeRoutingDecision, + clearInteractionContinuationWakeContext, + buildPaperclipWakePayload, + PAPERCLIP_WAKE_PAYLOAD_KEY, + resolveRunScopedMentionedSkillKeys, + resolveExecutionRunAdapterConfig, + applyRunScopedMentionedSkillKeys, + MANAGED_GITHUB_TOKEN_KEYS, + configuredPaperclipApiBaseUrl, + buildPaperclipRuntimeMcpServers, + createAdapterRuntimeToolAccess, + paperclipApiBaseUrl, + createAdapterRuntimeMcpAccess, + createManagedMcpRunConfig, + revokeHeartbeatRunGatewayTokens, + createHeartbeatRunPreparation, +} from "./heartbeat/run-preparation.js"; +export { + ConfigurationIncompleteFailure, + resolveExecutionRunAdapterConfig, + extractMentionedSkillIdsFromSources, + applyRunScopedMentionedSkillKeys, + revokeHeartbeatRunGatewayTokens, + buildPaperclipRuntimeMcpServers, + createManagedMcpRunConfig, + mergeCoalescedContextSnapshot, + resolveExternalChatWakeProvider, + attestReviewedExternalChatRun, + buildPaperclipWakePayload, +} from "./heartbeat/run-preparation.js"; import { WORKSPACE_VALIDATION_FAILURE_CODE, isWorkspaceValidationFailedRun, - readWorkspaceValidationPayloadFromRun, resolveNativeRecoveryExecutionWorkspaceBinding, resolveExecutionWorkspaceReuseRequestForIssue, buildExecutionWorkspaceConfigSnapshot, @@ -107,13 +226,15 @@ export { buildPaperclipTaskMarkdown } from "./heartbeat/task-markdown.js"; import { preserveWorkspaceRestoreRecoveryMetadataSql } from "./legacy-workspace-restore-recovery.js"; import { preserveWorkspaceRestoreRecoveryMetadata } from "./workspace-restore-recovery-state.js"; import { recordLegacyWorkspaceRestoreFailure } from "./legacy-execution-recovery.js"; -import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironmentProjection } from "../vendor/paperclip-runner/index.js"; +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"; -import { retryIdempotentDatabaseOperation } from "../database-retry.js"; + import { prepareConnectionInstructionDelivery } from "./connection-instructions.js"; import { resolveAssignedConnectionInstructionsForRun } from "./native-runtime/assigned-mcp-tools.js"; import { externalObjectService } from "./external-objects.js"; @@ -121,12 +242,15 @@ import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { dotRunnerBroker } from "./dot-runner-broker.js"; import { isAiAuthenticationBlocked } from "./ai-auth-failure.js"; import { nativeRetryCancellationCommitCondition, rethrowNativeCancellationLockConflict, claimCancellationRequest, startupCancellationFence } from "./native-runtime/native-cancellation-request.js"; -import { CHAT_COMPLETION_WAKE_REASON, prepareChatCompletionTurn, chatCompletionInstruction, isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js"; +import { + prepareChatCompletionTurn, + chatCompletionInstruction, +} from "./chat-completion-delivery.js"; import { AgentDirectoryReuseInvalidatedError, isAgentDirectoryCopy } from "./agent-directory-working-copies.js"; import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils"; import { restoreNativeWorkspaceBestEffort } from "./native-runtime/native-workspace-best-effort.js"; -import { ASSIGNED_MCP_SERVER_NAME } from "./mcp-tool-names.js"; + import { cancellationResultJson, canContinueCancelledRun, readRunCancellation, requestedRunCancellation } from "./run-cancellation.js"; import { withNativeWorkspaceFinalizationOwnership, @@ -134,29 +258,49 @@ import { NativeWorkspaceFinalizationOwnershipLostError, type NativeWorkspaceFinalizationOwnership, } from "./native-runtime/native-workspace-finalization-ownership.js"; -import { hasStopOnlyCleanup, settleStopOnlyCleanup } from "./sandbox-stop-and-retain.js"; + import { reserveRunBudget } from "./budget-reservations.js"; import { accountRunCost, createCostAccountingReconciler } from "./run-cost-accounting.js"; import { createRunUsageRecorder } from "./usage-receipts.js"; import { applyWorkspaceRestoreFailure } from "@paperclipai/adapter-utils/workspace-restore-result"; -import { compareCents, hasWorkspaceRestoreFailure } from "@paperclipai/shared"; -import { externalConversationStateSql, nonIdleSlackIssueCondition } from "./slack-conversation-state.js"; +import { + hasWorkspaceRestoreFailure, +} from "@paperclipai/shared"; + import { settleSlackConversation } from "./slack-conversation-lifecycle.js"; import { toolActionDeliveryService } from "./tool-action-delivery.js"; -import { githubBotConnectionIdsForRun } from "./chat-github-tools.js"; -import { isBrowserUseConnection } from "./browser-use-client.js"; + + import { readQueuedInteractionResponse } from "./queued-interaction-response.js"; -import { isConversation, isConversationExecutionWake, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.js"; +import { + isConversation, + isWaitingConversation, + prepareConversationTurn, + settleConversationTurn, +} from "./agent-conversations.js"; import { withAdapterExecutionPhase } from "@paperclipai/adapter-utils/execution-phase"; import { getConversationConfirmationContext } from "./conversation-confirmation-context.js"; import { PROCESS_IDENTITY_RECORDED, recordNativeLocalProcessStop } from "./native-local-process-stop.js"; import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent, isAcknowledgedNativeStop, acknowledgedNativeStopExecutionHasStopped } from "./acknowledged-native-stop.js"; -import { legacyControllerBootId, legacyControllerClaim, renewLegacyControllerLease, hasLiveLegacyController, revokeExpiredLegacyController, watchLegacyControllerLease } from "./legacy-controller-lease.js"; +import { + legacyControllerBootId, + renewLegacyControllerLease, + watchLegacyControllerLease, +} from "./legacy-controller-lease.js"; import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js"; -import { hasRemoteTerminationReceipt, remoteExecutionHasStopped, remoteTerminationReceipt, stoppedRemoteCleanupScopes } from "./remote-execution-termination.js"; +import { + hasRemoteTerminationReceipt, + remoteExecutionHasStopped, + stoppedRemoteCleanupScopes, +} from "./remote-execution-termination.js"; import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js"; -import { admitExplicitNativeContinuation, admitExplicitContinuationRetry, undeliveredLegacyUserCommentIds } from "./explicit-native-continuation.js"; -import { canRetryStoppedRun, isCancelledNativeStartup } from "./cancelled-native-startup.js"; +import { + admitExplicitNativeContinuation, + undeliveredLegacyUserCommentIds, +} from "./explicit-native-continuation.js"; +import { + isCancelledNativeStartup, +} from "./cancelled-native-startup.js"; import { connectionIntentService } from "./connection-intents.js"; import { prepareManagedAiRuntime, @@ -171,9 +315,9 @@ import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnection import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "./ai-connection-session.js"; import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js"; import { CONVERSATION_CONTINUATION_POLICY, claimedAdapterType, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js"; -import { recordExecutionWait } from "./execution-wait.js"; + import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-runtime/native-review-participant.js"; -import { claimQueuedNativeReviewRun } from "./native-runtime/native-review-dispatch.js"; + import { buildNativeReviewRequest } from "./native-runtime/native-review-prompt.js"; import { legacyExecutionNeedsReconciliationWithEvidence, @@ -187,25 +331,19 @@ import { registerAdapterExecutionControl, waitForAdapterStop, } from "./adapter-execution-control.js"; -import { executionFailureRetryCount, executionRetryAttemptCount, accountingForScheduledRetry } from "./execution-recovery-attempt.js"; +import { executionFailureRetryCount } from "./execution-recovery-attempt.js"; import { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js"; export { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js"; import { buildExecutionContinuation, StaleExecutionContinuationError } from "./execution-continuation.js"; import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; -import { - isWorkspaceGitScanError, - WORKSPACE_GIT_SCAN_ERROR_CODES, -} from "./workspace-git-operation-scheduler.js"; +import { isWorkspaceGitScanError } from "./workspace-git-operation-scheduler.js"; -import { initializeRunIdentity, explicitOperatorRunIdentity } from "./run-identity.js"; import { - assertDurableChatWakeupReceipt, - assertDurableChatWakeupRequest, + initializeRunIdentity, +} from "./run-identity.js"; +import { authorizeFailedChatRunRetryWake, - FailedChatRunRetryAuthorizationError, - unadmittedChatWakeupCondition, - type DurableChatWakeupRequest, } from "./durable-chat-wakeup.js"; import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js"; import { @@ -213,9 +351,10 @@ import { prepareGitHubExecutionEnvironment, startAdapterExecutionTargetPaperclipBridge, } from "@paperclipai/adapter-utils/execution-target"; -import { agentService } from "./agents.js"; -import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js"; -import { normalizeLegacyRunnerProvider, resolveManagedOpenAiBilling } from "@paperclipai/adapter-utils"; +import { agentInstructionWorkingCopyService, collectStoppedInstructionCopyWithRetries, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js"; +import { + resolveManagedOpenAiBilling, +} from "@paperclipai/adapter-utils"; import fs from "node:fs/promises"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; @@ -242,19 +381,11 @@ import { } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { - AGENT_DEFAULT_MAX_CONCURRENT_RUNS, CHAT_PROVIDERS, - CONNECTION_INTENT_AGENT_GUIDANCE, - CONNECTION_RUNTIME_TOOL_NAMES, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, ISSUE_DISPOSITION_REPAIR_RETRY_REASON, PROVIDER_QUOTA_MONITOR_SERVICE_NAME, - envBindingSchema, isEnvironmentDriverSupportedForAdapter, - isToolConnectionAttentionHealth, - type BillingType, - type ChatProvider, - type CostStatus, type EnvironmentLeaseStatus, type ExecutionWorkspace, type HeartbeatRunStatusPhase, @@ -262,9 +393,7 @@ import { type IssueExecutionMonitorPolicy, type IssueExecutionMonitorRecoveryPolicy, type RequestConfirmationResult, - type RoutineRevisionSnapshotV1, type RunLivenessState, - type SourceTrustMetadata, } from "@paperclipai/shared"; import { agents, @@ -275,31 +404,17 @@ import { agentWakeupRequests, activityLog, approvals, - assets, chatActions, - chatConversations, - chatDeliveries, - chatEndpoints, - chatMessageLinks, - companyMemberships, companySkillTestRuns, - companySkillVersions, - companySkills as companySkillsTable, companies, completionContracts, - costEvents, - documentAnnotationComments, - documentAnnotationThreads, documentRevisions, environmentLeases, issueDocuments, - executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issueApprovals, - issueAttachments, issueComments, - issuePlanDecompositions, issueRecoveryActions, issueRelations, issueThreadInteractions, @@ -308,16 +423,7 @@ import { nativeRunFinalizations, projects, projectWorkspaces, - routineRevisions, - routineRuns, routines, - toolMcpGateways, - toolMcpGatewayTokens, - toolCatalogEntries, - toolConnectionInstalls, - toolConnections, - toolProfileEntries, - toolProfiles, workspaceOperations, } from "@paperclipai/db"; import { conflict, HttpError, notFound } from "../errors.js"; @@ -331,7 +437,6 @@ import { logger } from "../middleware/logger.js"; import { createGitRemoteAuthProvider, resolveManagedGitHubIdentitySelection, - filterResolvedGitHubConnectionsForRun, scrubGitCredentialText, } from "./git-credentials.js"; // Re-exported because heartbeat's workspace surface exposed the scrubber before the @@ -339,7 +444,6 @@ import { export { scrubGitCredentialText }; import { publishLiveEvent } from "./live-events.js"; import { - allocateHeartbeatRunEventSeq, appendHeartbeatRunEvent, type AppendHeartbeatRunEventInput, } from "./heartbeat-run-events.js"; @@ -359,13 +463,11 @@ import { buildNativeExecutionWithCheckpoint, buildNativeRuntimeContext, cancelNativeSession, - claimNativeRestartRecoveries, + claimWarmNativeInstructionCopy, + nativeSessionWorkspaceScope, closeWarmNativeSessionsForEnvironment, - closeIdleWarmNativeSessionsForRestart, reserveWarmNativeInstructionDirectory, - currentNativeControllerIdentity, dispatchNativeSessionResumptions, - detachNativeSessionsForRestart, ensureNativeCompletionContract, executePaperclipNativeSession, finalizeNativeRun, @@ -388,9 +490,6 @@ import { recordNativeFinalizationFailure, type NativeRestartRecoveryClaim, rebindNativeSessionCheckpoint, - reconcileNativeFinalizations, - reconcileRetainedNativeSessionCleanup, - reconcileRetainedNativeSessionCleanups, resolveHeartbeatNativeRuntimeMode, } from "./native-runtime/index.js"; import { @@ -407,7 +506,6 @@ import { type NativeRunHistoricalSpan, } from "./native-runtime/native-run-trace.js"; import { - drainRetainedRunnerdMaintenanceOperations, describeRunnerdNativeSessionBackend, parseNativeExecutionInput, type NativeExecutionInput, @@ -426,11 +524,6 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterRuntimeEvent, - AdapterRuntimeMcpAccess, - AdapterRuntimeMcpServer, - AdapterRuntimeToolAccess, - AdapterSessionCodec, - UsageSummary, } from "../adapters/index.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { createRuntimeToolsToken } from "../runtime-tools-token.js"; @@ -439,13 +532,9 @@ import { asBoolean, asNumber, } from "../adapters/utils.js"; -import { - authorizeChatConversationForBoundRun, - isExternalChatWaitAuthorizationContention, -} from "./native-runtime/chat-attachment-reuse.js"; + import { EXTERNAL_CHAT_QUESTION_RESPONSE_KEY, - resolveExternalChatQuestionResponse, } from "./native-runtime/external-chat-question-response.js"; import { materializeExternalChatQuestionResponseInput } from "./native-runtime/external-chat-question-response-input.js"; import { @@ -467,13 +556,15 @@ import { emitAgentTaskRun, emitAgentTaskRunById, } from "./agent-task-run-telemetry.js"; +import { readAiConnectionConfigurationFailure } from "./ai-connection-configuration-failure.js"; import { reportRunFailure } from "./run-failure-report.js"; -import { performance } from "node:perf_hooks"; -import { buildProcessLossDiagnostic } from "./process-loss-diagnostics.js"; + import { collectRunFailureSecretValues, type RunFailureReportOptions } from "./run-failure-diagnostics.js"; import { companySkillService } from "./company-skills.js"; import { budgetService, withCurrentBudgetEnforcement, type BudgetEnforcementScope } from "./budgets.js"; -import { secretService, type MissingRuntimeBinding } from "./secrets.js"; +import { + secretService, +} from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir, } from "../home-paths.js"; @@ -481,16 +572,12 @@ import { buildHeartbeatRunIssueComment, CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, findHeartbeatRunCompletionComment, - HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS, - HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS, - HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES, hasAcceptedSemanticResult, isExternalChatPresentationContext, mergeHeartbeatRunResultJson, readCompletedAssistantMessageCandidate, resolveHeartbeatRunResponse, selectHeartbeatRunFinalAgentMessage, - summarizeRunErrorForModel, type RunPresentationDecision, } from "./heartbeat-run-summary.js"; import { @@ -500,25 +587,13 @@ import { } from "./heartbeat-stop-metadata.js"; import { CHAT_CONTROL_RECOVERY_ADMISSION_KEY, - CHAT_CONTROL_RECOVERY_STOP_CODE, CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE, - chatControlRecoveryAdmission, - readChatControlRecoveryAdmission, - readChatControlNativeParent, - readChatControlRecoveryStop, } from "./chat-control-recovery-stop.js"; import { classifyRunLiveness, type RunLivenessClassificationInput, } from "./run-liveness.js"; -import { - ISSUE_NEW_INPUT_ACTIVITY_ACTIONS, - ISSUE_PROGRESS_ACTIVITY_ACTIONS, - ISSUE_REWAKE_LOOKBACK_MS, - ISSUE_REWAKE_RUN_SAMPLE_LIMIT, - evaluateIssueRewakeThrottle, - isThrottleCandidateIssueRewake, -} from "./issue-rewake-throttle.js"; + import { logActivity, publishPluginDomainEvent, @@ -558,10 +633,8 @@ import { } from "./runner-goals.js"; import { resolveChatRunPresentationAuthorizationReason } from "./chat-run-publications.js"; -import { createToolGatewayService } from "./tool-gateway.js"; -import { toolAccessService } from "./tool-access.js"; import { visibleIssueCondition } from "./issue-visibility.js"; -import { ISSUE_BLOCKERS_RESOLVED_WAKE_REASON } from "./issue-dependency-wakeups.js"; + import { buildIssueMonitorClearedPatch, buildIssueMonitorTriggeredPatch, @@ -569,8 +642,6 @@ import { parseIssueExecutionState, } from "./issue-execution-policy.js"; import { - ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS, - isVerifiedIssueTreeControlInteractionWake, issueTreeControlService, } from "./issue-tree-control.js"; import { @@ -579,7 +650,6 @@ import { refreshIssueContinuationSummary, } from "./issue-continuation-summary.js"; import { - buildDocumentReviewContext, buildPlanReviewContext, } from "./plan-review-context.js"; import { @@ -607,7 +677,6 @@ import { buildExecutionWorkspaceAdapterConfig, gateProjectExecutionWorkspacePolicy, issueExecutionWorkspaceModeForPersistedWorkspace, - isUnrunnableWorktreeCombo, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveEffectiveWorkspaceStrategyType, @@ -615,9 +684,6 @@ import { resolveExecutionWorkspaceMode, resolveSharedWorkspaceConcurrency, selectEnvironmentExecutionWorkspaceSettings, - WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, - WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, - WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, } from "./execution-workspace-policy.js"; import { instanceSettingsService, @@ -662,11 +728,9 @@ import { AI_CONNECTION_POOL_WAIT_RETRY_REASON, INTERACTION_CONTINUATION_INFRA_RETRY_REASON, INTERACTION_CONTINUATION_INFRA_WAKE_REASON, - WAKE_COMMENT_IDS_KEY, isNonAssigneeWorkspaceBusyRetry, extractWakeCommentIds, deriveCommentId, - allowsIssueInteractionWake, isResolvedInteractionContinuationWakeContext, } from "../modules/run-dispatch/index.js"; import { @@ -687,17 +751,14 @@ import { } from "./recovery/review-path-recovery.js"; import { resolveRequiredSuccessfulRunHandoffOnValidPath } from "./successful-run-handoff-state.js"; import { taskWatchdogService } from "./task-watchdogs.js"; -import { withAgentStartLock } from "./agent-start-lock.js"; + import { evaluateAgentInvokability, evaluateAgentInvokabilityFromDb, - shouldCancelRunsForNonInvokableAgent, DIRECT_NON_INVOKABLE_STATUSES, - type AgentOrgRow, } from "./agent-invokability.js"; -import { isHeartbeatWakeOnDemandEnabled } from "./heartbeat-policy.js"; + import { - isLowTrustQuarantined, redactQuarantinedBodyForHigherTrust, sanitizeQuarantinedCommentForHigherTrust, } from "./source-trust.js"; @@ -709,31 +770,29 @@ import { import { redactEventPayload, redactSensitiveText } from "../redaction.js"; import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js"; import { - hasSessionCompactionThresholds, resolvePaperclipRunnerIdleTimeoutMs, - resolveSessionCompactionPolicy, type RuntimeStatusUpdate, - type SessionCompactionPolicy, } from "@paperclipai/adapter-utils"; import { readPaperclipSkillSyncPreference, selectPaperclipTaskMarkdown, UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, UNMANAGED_BACKGROUND_TASK_STOP_REASON, - writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; -import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared"; -import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-local/server"; +import { + isUuidLike, +} from "@paperclipai/shared"; + import { environmentService } from "./environments.js"; import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js"; -import { retryChatControlAdmission } from "./chat-control-admission-retry.js"; + import { environmentRuntimeService, type ProviderResourceDisposition, } from "./environment-runtime.js"; import { skillVersionSelectionMap } from "./runtime-skill-selections.js"; import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; -import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js"; + import { clearHeartbeatRunRuntimeStatus, getHeartbeatRunRuntimeStatus, @@ -744,24 +803,16 @@ import { touchHeartbeatRunRuntimeStatus, } from "./heartbeat-run-runtime-status.js"; import { - findMissingHotRestartSnapshotRunIds, - readHotRestartIntent, readProcessStartedAt, - removeHotRestartIntent, - shouldHonorHotRestartIntentForProcess, - writeHotRestartReport, - writeHotRestartShutdownSnapshot, - type HotRestartIntentRun, - type HotRestartReportRun, } from "./hot-restart.js"; import { assertLowTrustRuntimeServicesAllowed, } from "./low-trust-runtime-containment.js"; -import type { TrustPresetResolution } from "./trust-preset-resolver.js"; + import { resolveAndRetainRunTrustPreset } from "./run-trust-preset.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; -import { serverVersion } from "../version.js"; + import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; @@ -787,140 +838,21 @@ export function redactSuccessfulRunHandoffEvidence( ); } -const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; -const HEARTBEAT_MAX_CONCURRENT_RUNS_MIN = 1; -const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 50; const LIVENESS_BOOKKEEPING_ACTIVITY_ACTIONS = [ "environment.lease_acquired", "environment.lease_released", "cost.reported", ]; -const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; -const EXTERNAL_ATTACHMENT_OMISSIONS_KEY = "externalAttachmentOmissions"; -const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake"; const ACCEPTED_PLAN_CONVERSION_SKILL_KEY = "paperclipai/paperclip/paperclip-converting-plans-to-tasks"; -const PAPERCLIP_AGENT_MESSAGE_KEY = "paperclipAgentMessage"; -const PAPERCLIP_HARNESS_CHECKOUT_KEY = "paperclipHarnessCheckedOut"; -const PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY = - "paperclipExternalChatExecutionBound"; -const DETACHED_PROCESS_ERROR_CODE = "process_detached"; -const NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE = - "Native execution ownership could not be verified; automatic recovery is blocked"; -// The reaper sweeps at most this many pending_cleanup leases per tick. -const PENDING_CLEANUP_SWEEP_PAGE_SIZE = 20; -const pendingCleanupAttemptsInFlight = new Set(); -// Escalate and slow cleanup after this many attempts; never abandon a live lease. -const PENDING_CLEANUP_SWEEP_ATTEMPT_CAP = 5; -// The reaper stores its retry state under these keys in the lease metadata. -const PENDING_CLEANUP_ATTEMPTS_METADATA_KEY = "pendingCleanupRetryAttempts"; -const PENDING_CLEANUP_CAP_WARNED_METADATA_KEY = "pendingCleanupRetryCapWarned"; -// The reaper sweeps at most this many orphaned active leases per tick. -const ORPHANED_ACTIVE_LEASE_SWEEP_PAGE_SIZE = 20; -// A provider or plugin destroy rejection can carry a bearer credential, a -// signed URL, or provider response detail in its name, code, message, cause, or -// stack. The exception fields cross the server boundary, so they are not a -// trusted enum. The pending_cleanup sweep logs never read the exception. Each -// catch site logs a constant, locally generated `errorKind` instead. -const PENDING_CLEANUP_RETRY_ERROR_KIND = "destroy_failed"; -const PENDING_CLEANUP_SWEEP_ERROR_KIND = "sweep_failed"; -const ORPHANED_ACTIVE_LEASE_SWEEP_ERROR_KIND = "orphaned_active_lease_sweep_failed"; - -// Read the stored retry attempt count as a safe value, directly in SQL. A -// provider can write a malformed value under the attempts key. The type guard -// makes any non-number value read as zero. The reader computes as numeric and -// never casts to int, so a finite number outside the 32-bit range (for example -// 1e300) never throws. The reader clamps a negative value to zero and a positive -// value to the attempt cap. One malformed lease therefore never aborts the page -// sweep. This matches the TypeScript reader `readPendingCleanupRetryAttempts`, -// which clamps to the same range. The claim predicate compares the two readers, -// so both must yield the same value for every input. -function pendingCleanupAttemptsSql() { - return sql` - case - when jsonb_typeof(${environmentLeases.metadata} -> ${PENDING_CLEANUP_ATTEMPTS_METADATA_KEY}) = 'number' - then least( - greatest( - floor((${environmentLeases.metadata} ->> ${PENDING_CLEANUP_ATTEMPTS_METADATA_KEY})::numeric), - 0 - ), - ${PENDING_CLEANUP_SWEEP_ATTEMPT_CAP} - ) - else 0 - end`; -} - -function pendingCleanupRetryDueSql(explicitRetry = false) { - // Ownership renewal and retry cooldown are separate clocks. A late renewal - // cannot change when a completed attempt may retry. Older claims used the - // retry field for both clocks, so retain that fallback until they settle. - const deadline = sql`case - when ${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true' - and ${environmentLeases.metadata} ? 'pendingCleanupLeaseExpiresAtMs' - then ${environmentLeases.metadata}->'pendingCleanupLeaseExpiresAtMs' - else ${environmentLeases.metadata}->'pendingCleanupRetryAfterMs' end`; - return sql`case - when ${explicitRetry} and coalesce(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') != 'true' then true - when jsonb_typeof(${deadline}) = 'number' - then (${deadline})::numeric <= ${Date.now()} - or (${deadline})::numeric > ${Date.now() + 30 * 60_000 + 1_000} - else true end`; -} - -// Choose the `jsonb_set` target root. A provider can write a scalar or array -// metadata root. `jsonb_set` fails on a non-object root, so the reader uses the -// stored metadata only when its root is an object. A NULL, scalar, or array root -// reads as an empty object. `jsonb_typeof(NULL)` is NULL, so the else branch also -// covers a NULL root. -function pendingCleanupMetadataObjectSql() { - return sql`case when jsonb_typeof(${environmentLeases.metadata}) = 'object' then ${environmentLeases.metadata} else '{}'::jsonb end`; -} - -// Read the stored cap-warned flag as a safe boolean, directly in SQL. A -// malformed value reads as false, so the boolean cast never throws. -function pendingCleanupCapWarnedSql() { - return sql`coalesce( - case - when jsonb_typeof(${environmentLeases.metadata} -> ${PENDING_CLEANUP_CAP_WARNED_METADATA_KEY}) = 'boolean' - then (${environmentLeases.metadata} ->> ${PENDING_CLEANUP_CAP_WARNED_METADATA_KEY})::boolean - else false - end, - false - )`; -} - -const MAX_INLINE_WAKE_COMMENTS = 8; -const MAX_INLINE_WAKE_ATTACHMENTS = 20; -const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; -const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; -const MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS = 12_000; -const MAX_AGENT_SESSION_MESSAGE_CHARS = 12_000; const execFile = promisify(execFileCallback); -const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = [ - "queued", - "running", - "scheduled_retry", -] as const; -const CANCELLABLE_HEARTBEAT_RUN_STATUSES = [ - "queued", - "running", - "scheduled_retry", -] as const; -const NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY = "nativeQuestionCancellation"; -const HEARTBEAT_RUN_TERMINAL_STATUSES = [ - "succeeded", - "interrupted", - "failed", - "cancelled", - "timed_out", -] as const; const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = [ "failed", "cancelled", "timed_out", ] as const; -const TIMER_ACTIONABLE_ISSUE_STATUSES = ["todo", "in_progress"] as const; + export { ACTIVE_RUN_OUTPUT_CONTINUE_REARM_MS, ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS, @@ -928,30 +860,17 @@ export { } from "./recovery/service.js"; export const ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS = 60 * 1000; export const ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS = 5 * 1000; -export const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS = [ - 30_000, 30_000, -] as const; -const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO = 0; -const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON = "transient_failure"; -const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry"; -function isTransientWorkspaceGitScanCode(code: string | null | undefined): boolean { - return code === WORKSPACE_GIT_SCAN_ERROR_CODES.timeout || code === WORKSPACE_GIT_SCAN_ERROR_CODES.saturated; -} -const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = - BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length; + export { INTERACTION_CONTINUATION_INFRA_RETRY_REASON, INTERACTION_CONTINUATION_INFRA_WAKE_REASON, }; -const INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS = 2; - const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed"; -const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete"; + const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete"; const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON = "execution_review_participant_recovery"; -const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = - "execution_review_participant_recovery"; + const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE = "execution_review_participant_recovery"; @@ -988,50 +907,14 @@ function nonRetryablePreflightFailureCode(error: unknown): string | null { const code = readNonEmptyString(parseObject(error.details).code); return code && NON_RETRYABLE_PREFLIGHT_FAILURE_CODES.has(code) ? code : null; } -class ChatControlRecoveryUnresolvedError extends Error { - constructor() { - super( - "Run admission could not acquire its database locks after bounded retries. No provider work started. Review database contention and send a fresh request; this attempt will not automatically retry.", - ); - } -} - export { MAX_TURN_CONTINUATION_RETRY_REASON }; export const MAX_TURN_CONTINUATION_WAKE_REASON = "max_turns_continuation_retry"; const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2; const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10; const MAX_TURN_CONTINUATION_DEFAULT_DELAY_MS = 1_000; const MAX_TURN_CONTINUATION_MAX_DELAY_MS = 5 * 60 * 1000; -const MAX_TURN_CONTINUATION_LIVE_RUN_STATUSES = [ - "scheduled_retry", - "queued", - "running", -] as const; + export { WORKSPACE_BUSY_RETRY_REASON }; -export const WORKSPACE_BUSY_RETRY_WAKE_REASON = "workspace_busy_retry"; -export const WORKSPACE_BUSY_ERROR_CODE = "workspace_busy"; -export const WORKSPACE_BUSY_RETRY_BASE_DELAY_MS = 60 * 1000; -export const WORKSPACE_BUSY_RETRY_JITTER_MS = 60 * 1000; -// Preserve the one-hour shared-workspace holder cutoff independently of the -// informational output-silence warnings. Warning sooner must not let another -// run overtake a quiet holder and mutate its shared workspace. -export const WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS = 60 * 60 * 1000; -// Issue-level executionWorkspaceSettings.mode values that unambiguously opt an -// issue's runs out of the shared project workspace, and therefore out of -// shared-workspace serialization ("isolated" is the legacy alias -// parseIssueExecutionWorkspaceSettings normalizes to isolated_workspace). Any -// other value — including agent_default and an absent mode — may still resolve -// to the shared workspace and counts as a holder. -const ISOLATED_EXECUTION_WORKSPACE_MODES = [ - "isolated_workspace", - "operator_branch", - "isolated", -] as const; -type CodexTransientFallbackMode = - | "same_session" - | "safer_invocation" - | "fresh_session" - | "fresh_session_safer_invocation"; interface MaxTurnContinuationPolicy { enabled: boolean; @@ -1039,20 +922,6 @@ interface MaxTurnContinuationPolicy { delayMs: number; } -// Pre-dispatch gate outcome: required secret/env bindings are missing, so the -// run must not be dispatched. Surfaced as a configuration-incomplete blocker -// routed to a human owner instead of N opaque dispatched-then-failed runs. -export class ConfigurationIncompleteFailure extends Error { - code = CONFIGURATION_INCOMPLETE_FAILURE_CODE; - resultJson: Record; - - constructor(message: string, resultJson: Record) { - super(message); - this.name = "ConfigurationIncompleteFailure"; - this.resultJson = resultJson; - } -} - // Build the configuration-incomplete result payload for a workspace base ref // that never resolved to a commit. The setup catch maps this to errorCode // `configuration_incomplete`, so the recovery path routes it to a human owner @@ -1108,90 +977,8 @@ function buildUnresolvedWorkspaceBaseRefResultJson( }; } -export interface SharedWorkspaceHolder { - runId: string; - agentId: string; - issueId: string; - issueIdentifier: string | null; -} - -// Pre-dispatch gate outcome: another running run currently holds the issue's -// shared project workspace. Not a failure — the run is parked as a bounded -// scheduled retry and re-attempted once the holder finishes, so two agents -// never mutate the same working tree concurrently. -export class WorkspaceBusyDeferral extends Error { - code = WORKSPACE_BUSY_ERROR_CODE; - holder: SharedWorkspaceHolder; - projectWorkspaceId: string; - deferralAttempt: number; - wasIssueAssignee: boolean; - - constructor(input: { - holder: SharedWorkspaceHolder; - projectWorkspaceId: string; - deferralAttempt: number; - wasIssueAssignee: boolean; - }) { - super( - `Shared project workspace is busy: run ${input.holder.runId} (issue ${ - input.holder.issueIdentifier ?? input.holder.issueId - }) is still running`, - ); - this.name = "WorkspaceBusyDeferral"; - this.holder = input.holder; - this.projectWorkspaceId = input.projectWorkspaceId; - this.deferralAttempt = input.deferralAttempt; - this.wasIssueAssignee = input.wasIssueAssignee; - } -} - -function isWorkspaceBusyDeferral( - error: unknown, -): error is WorkspaceBusyDeferral { - return error instanceof WorkspaceBusyDeferral; -} - -export function computeWorkspaceBusyRetryDelayMs( - random: () => number = Math.random, -) { - const jitter = Math.min(Math.max(random(), 0), 1); - return ( - WORKSPACE_BUSY_RETRY_BASE_DELAY_MS + - Math.floor(jitter * WORKSPACE_BUSY_RETRY_JITTER_MS) - ); -} - export { isNonAssigneeWorkspaceBusyRetry }; -function resolveCodexTransientFallbackMode( - attempt: number, -): CodexTransientFallbackMode { - if (attempt <= 1) return "same_session"; - if (attempt === 2) return "safer_invocation"; - if (attempt === 3) return "fresh_session"; - return "fresh_session_safer_invocation"; -} - -function readHeartbeatRunErrorFamily( - run: Pick, -) { - const resultJson = parseObject(run.resultJson); - const persistedFamily = readNonEmptyString(resultJson.errorFamily); - if (persistedFamily) return persistedFamily; - - if (run.errorCode === "provider_quota") { - return "provider_quota"; - } - if ( - run.errorCode === "codex_transient_upstream" || - run.errorCode === "claude_transient_upstream" || - run.errorCode === "codex_harness_crash" - ) { - return "transient_upstream"; - } - return null; -} - function isMaxTurnExhaustionRun( run: Pick, ) { @@ -1202,63 +989,6 @@ function isMaxTurnExhaustionRun( ); } -function readTransientRetryNotBeforeFromRun( - run: Pick, -) { - const resultJson = parseObject(run.resultJson); - const value = resultJson.retryNotBefore ?? resultJson.transientRetryNotBefore; - if (!( - typeof value === "string" || - typeof value === "number" || - value instanceof Date - )) { - return null; - } - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -function readTransientRecoveryContractFromRun( - run: Pick, -) { - const errorFamily = readHeartbeatRunErrorFamily(run); - return errorFamily === "transient_upstream" || - errorFamily === "provider_quota" - ? { - errorFamily, - retryNotBefore: readTransientRetryNotBeforeFromRun(run), - } - : null; -} - -function isSpawnLikeFailureMessage(value: unknown) { - if (typeof value !== "string") return false; - return /failed to start command|spawn\b|\bENOENT\b/i.test(value); -} - -// A sandbox provider plugin's worker can be briefly down during its own -// restart window (e.g. a rolling deploy of the plugin worker process). Lease -// acquisition fails immediately in that window, but the condition is -// transient and self-healing, so it must be treated as retryable -// infrastructure rather than a terminal setup failure. See -// resolveSandboxProviderPlugin's "worker_unavailable" message in -// environment-runtime.ts (":808"), e.g. 'Sandbox provider "kubernetes" is -// installed via plugin "acme.kubernetes-sandbox-provider", but its worker is -// not running.' -// -// This is anchored on both "is installed via plugin" and "but its worker is -// not running" so it does not also match plugin-environment-driver.ts's -// unrelated, permanent "provider not installed" message ('Sandbox provider -// "X" is not installed or its plugin worker is not running.'), which -// coincidentally contains the same "worker is not running" substring but -// describes a terminal condition that must not be retried. -function isSandboxProviderWorkerUnavailableFailureMessage(value: unknown) { - if (typeof value !== "string") return false; - return /sandbox provider .* is installed via plugin .* but its worker is not running/i.test( - value, - ); -} - // environment-runtime.ts's resolveSandboxProviderPlugin "not_ready" message, // e.g. 'Sandbox provider "kubernetes" is installed via plugin // "acme.kubernetes-sandbox-provider", but that plugin is currently error.' @@ -1286,33 +1016,6 @@ export function parseSandboxProviderPluginNotReadyFailureMessage( }; } -function isRetryableInteractionContinuationInfrastructureFailure( - run: Pick< - typeof heartbeatRuns.$inferSelect, - "error" | "errorCode" | "resultJson" - >, -) { - if ( - run.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE || - run.errorCode === "process_lost" - ) { - return true; - } - - if (run.errorCode !== "adapter_failed" && run.errorCode !== "setup_failed") - return false; - - const resultJson = parseObject(run.resultJson); - return ( - isSpawnLikeFailureMessage(run.error) || - isSpawnLikeFailureMessage(resultJson.errorMessage) || - isSpawnLikeFailureMessage(resultJson.message) || - isSandboxProviderWorkerUnavailableFailureMessage(run.error) || - isSandboxProviderWorkerUnavailableFailureMessage(resultJson.errorMessage) || - isSandboxProviderWorkerUnavailableFailureMessage(resultJson.message) - ); -} - function mergeAdapterRecoveryMetadata(input: { resultJson: Record | null | undefined; errorFamily?: string | null; @@ -1337,38 +1040,6 @@ function mergeAdapterRecoveryMetadata(input: { : {}), }; } -const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set([ - CHAT_COMPLETION_WAKE_REASON, - "approval_approved", - ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, - "issue_recovery_action_restored", -]); -const ISSUE_RESPONSIBLE_USER_WAKE_REASONS = new Set([ - "issue_assigned", - "issue_checked_out", - "issue_commented", - "issue_comment_mentioned", - "issue_reopened_via_comment", - "issue_blockers_resolved", - "issue_children_completed", - "issue_status_changed", - "issue_tree_restored", - "issue_recovery_action_restored", - "execution_review_requested", - "execution_approval_requested", - "execution_changes_requested", - "approval_approved", -]); -const SESSIONED_LOCAL_ADAPTERS = new Set([ - "claude_local", - "codex_local", - "cursor", - "gemini_local", - "hermes_local", - "kimi_local", - "opencode_local", - "pi_local", -]); // Routes and the scheduler construct separate heartbeatService instances, but // they must agree on in-process adapter executions when reaping stale runs. const activeRunExecutions = new Set(); @@ -1444,631 +1115,6 @@ export function getTaskDrainStatus(): { }; } -type RuntimeConfigSecretResolver = Pick< - ReturnType, - | "resolveAdapterConfigForRuntime" - | "resolveEnvBindings" - | "collectMissingRuntimeBindings" - | "collectMissingAdapterConfigRuntimeBindings" ->; - -function formatMissingBindingForOperator( - missing: MissingRuntimeBinding, -): string { - if (missing.bindingType === "user_secret_ref") { - const definitionLabel = missing.userSecretDefinitionName - ? `"${missing.userSecretDefinitionName}"` - : missing.userSecretDefinitionKey - ? `"${missing.userSecretDefinitionKey}"` - : "declared user secret"; - const ownerLabel = missing.responsibleUserId - ? ` for responsible user ${missing.responsibleUserId}` - : ""; - return `user secret ${definitionLabel}${ownerLabel} not available at ${missing.consumerType} ${missing.configPath}`; - } - const secretLabel = missing.secretName - ? `"${missing.secretName}"` - : (missing.secretId ?? "unknown"); - return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`; -} - -function isConfiguredEnvBindingValue(binding: unknown) { - const parsed = envBindingSchema.safeParse(binding); - if (!parsed.success) return false; - const value = parsed.data; - if (typeof value === "string") return value.trim().length > 0; - if (value.type === "plain") return value.value.trim().length > 0; - return true; -} - -const LOW_TRUST_SENSITIVE_ENV_KEY_RE = - /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; - -// PAPERCLIP_* env binding policy: -// 1. PAPERCLIP_API_KEY is never accepted from user/adapter/project/routine -// config — the harness-minted run token is the only source. -// 2. A PAPERCLIP_* runtime var the harness assigns for the run (RUN_ID, -// AGENT_ID, wake/workspace vars, ...) always wins over a same-named -// binding; adapters enforce this at env-merge time. -// 3. Any other PAPERCLIP_*-named binding is user data and flows through to -// the run env like any non-prefixed binding. -const FORBIDDEN_ENV_BINDING_KEYS = new Set([ - CONFIGURED_ENVIRONMENT_KEYS, - "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", - "PAPERCLIP_RUNNER_NETWORK_ACCESS", - "PAPERCLIP_RUNNER_NETWORK_ROOTS", - "PAPERCLIP_API_KEY", - "PAPERCLIP_GITHUB_AUTH_MODE", - "PAPERCLIP_GITHUB_HOST_HOME", - "PAPERCLIP_GIT_METADATA_ROOTS", - "PAPERCLIP_GITHUB_BROKER_TOKEN", - "PAPERCLIP_GITHUB_BROKER_URL", - "PAPERCLIP_GITHUB_BRIDGE_TOKEN", - "PAPERCLIP_GITHUB_LAUNCHER_DIR", -]); -const MANAGED_GITHUB_TOKEN_KEYS = new Set([ - "GH_TOKEN", - "GITHUB_TOKEN", - "GH_ENTERPRISE_TOKEN", - "GITHUB_ENTERPRISE_TOKEN", - "PAPERCLIP_GIT_TOKEN", -]); - -function stripForbiddenEnvBindings( - envValue: unknown, - managedGitHubCredentials = false, -): Record | null { - const record = parseObject(envValue); - const filtered = Object.fromEntries( - Object.entries(record).filter( - ([key]) => - !FORBIDDEN_ENV_BINDING_KEYS.has(key.toUpperCase()) && - !(managedGitHubCredentials && MANAGED_GITHUB_TOKEN_KEYS.has(key)), - ), - ); - return Object.keys(filtered).length > 0 ? filtered : null; -} - -function stripForbiddenEnvFromAdapterConfig( - config: Record, - managedGitHubCredentials = false, -): Record { - if (!Object.prototype.hasOwnProperty.call(config, "env")) return config; - return { - ...config, - env: stripForbiddenEnvBindings(config.env, managedGitHubCredentials) ?? {}, - }; -} - -function assertLowTrustEnvConfigAllowed(envValue: unknown, source: string) { - const record = stripForbiddenEnvBindings(envValue); - if (!record) return; - for (const [key, rawBinding] of Object.entries(record)) { - const parsed = envBindingSchema.safeParse(rawBinding); - if (!parsed.success) continue; - const binding = parsed.data; - const isPlainBinding = - typeof binding === "string" || - (typeof binding === "object" && - binding !== null && - binding.type === "plain"); - if (isPlainBinding && LOW_TRUST_SENSITIVE_ENV_KEY_RE.test(key)) { - throw new HttpError( - 422, - `Low-trust execution cannot use inline sensitive env value ${source}.${key}`, - { - code: "low_trust_inline_sensitive_env_denied", - }, - ); - } - } -} - -export async function resolveExecutionRunAdapterConfig(input: { - managedAiCredentials?: boolean; - companyId: string; - agentId?: string | null; - adapterType?: string | null; - issueId?: string | null; - heartbeatRunId?: string | null; - responsibleUserId?: string | null; - environmentId?: string | null; - environmentEnv?: unknown; - environmentDriver?: string | null; - projectId?: string | null; - routineId?: string | null; - executionRunConfig: Record; - projectEnv: unknown; - routineEnv?: unknown; - secretsSvc: RuntimeConfigSecretResolver; - trustPreset?: TrustPresetResolution; - requiredScopedEnvBinding?: { - keys: string[]; - consumerScopes: Array<"agent" | "project">; - reason: string; - remediation: string; - }; - /** Managed GitHub tokens are resolved only when operations start. */ - managedGitHubCredentials?: boolean; - /** Audited class-3 values resolved by an internal credential broker. */ - trustedEnvProjection?: Record; - trustedEnvSecretKeys?: string[]; -}) { - const executionRunConfig = stripForbiddenEnvFromAdapterConfig( - input.executionRunConfig, - input.managedGitHubCredentials, - ); - const environmentEnv = stripForbiddenEnvBindings( - input.environmentEnv, - input.managedGitHubCredentials, - ); - const projectEnv = stripForbiddenEnvBindings( - input.projectEnv, - input.managedGitHubCredentials, - ); - const routineEnv = stripForbiddenEnvBindings( - input.routineEnv, - input.managedGitHubCredentials, - ); - const agentEnv = parseObject(executionRunConfig.env); - const lowTrustAllowedBindingIds = - input.trustPreset?.kind === "low_trust_review" - ? (input.trustPreset.boundary.allowedSecretBindingIds ?? []) - : undefined; - const allowTrustedEnvProjection = - input.trustPreset?.kind !== "low_trust_review"; - if (input.trustPreset?.kind === "low_trust_review") { - assertLowTrustEnvConfigAllowed(environmentEnv, "environment.env"); - assertLowTrustEnvConfigAllowed(executionRunConfig.env, "agent.env"); - assertLowTrustEnvConfigAllowed(projectEnv, "project.env"); - assertLowTrustEnvConfigAllowed(routineEnv, "routine.env"); - } - const requiredScopedEnvBinding = input.requiredScopedEnvBinding ?? null; - const requiredScopedBindingsConfigured = requiredScopedEnvBinding - ? requiredScopedEnvBinding.keys.some( - (key) => - (allowTrustedEnvProjection && - typeof input.trustedEnvProjection?.[key] === "string") || - (requiredScopedEnvBinding.consumerScopes.includes("agent") && - isConfiguredEnvBindingValue(agentEnv[key])) || - (requiredScopedEnvBinding.consumerScopes.includes("project") && - isConfiguredEnvBindingValue(projectEnv?.[key])), - ) - : false; - if (requiredScopedEnvBinding && !requiredScopedBindingsConfigured) { - throw new ConfigurationIncompleteFailure( - `configuration incomplete: ${requiredScopedEnvBinding.remediation}`, - { - configurationIncomplete: { - reason: requiredScopedEnvBinding.reason, - companyId: input.companyId, - agentId: input.agentId ?? null, - issueId: input.issueId ?? null, - projectId: input.projectId ?? null, - routineId: input.routineId ?? null, - requiredEnvKeys: requiredScopedEnvBinding.keys, - requiredScopes: requiredScopedEnvBinding.consumerScopes, - missingBindings: [], - }, - }, - ); - } - // Pre-dispatch binding-validation gate: detect declared secret refs that have - // no binding before resolving any secret value. Missing bindings short-circuit - // to a configuration-incomplete blocker routed to a human owner instead of a - // dispatched-then-failed run (which previously surfaced as opaque setup_failed). - if (typeof input.secretsSvc.collectMissingRuntimeBindings === "function") { - const missingBindings: MissingRuntimeBinding[] = []; - if (environmentEnv && input.environmentId) { - missingBindings.push( - ...(await input.secretsSvc.collectMissingRuntimeBindings( - input.companyId, - environmentEnv, - { - consumerType: "environment", - consumerId: input.environmentId, - responsibleUserId: input.responsibleUserId ?? null, - }, - )), - ); - } - if (input.agentId) { - missingBindings.push( - ...(await input.secretsSvc.collectMissingRuntimeBindings( - input.companyId, - parseObject(executionRunConfig.env), - { - consumerType: "agent", - consumerId: input.agentId, - responsibleUserId: input.responsibleUserId ?? null, - }, - )), - ); - if ( - typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === - "function" - ) { - missingBindings.push( - ...(await input.secretsSvc.collectMissingAdapterConfigRuntimeBindings( - input.companyId, - executionRunConfig, - input.adapterType ?? null, - { - consumerType: "agent", - consumerId: input.agentId, - responsibleUserId: input.responsibleUserId ?? null, - }, - )), - ); - } - } - if (projectEnv && input.projectId) { - missingBindings.push( - ...(await input.secretsSvc.collectMissingRuntimeBindings( - input.companyId, - projectEnv, - { - consumerType: "project", - consumerId: input.projectId, - responsibleUserId: input.responsibleUserId ?? null, - }, - )), - ); - } - if (routineEnv && input.routineId) { - missingBindings.push( - ...(await input.secretsSvc.collectMissingRuntimeBindings( - input.companyId, - routineEnv, - { - consumerType: "routine", - consumerId: input.routineId, - responsibleUserId: input.responsibleUserId ?? null, - }, - )), - ); - } - if (requiredScopedEnvBinding) { - const requiredEnvKeys = new Set(requiredScopedEnvBinding.keys); - const requiredScopes = new Set(requiredScopedEnvBinding.consumerScopes); - const requiredMissingBindings = missingBindings.filter( - (binding) => - requiredScopes.has(binding.consumerType as "agent" | "project") && - requiredEnvKeys.has(binding.envKey), - ); - if (requiredMissingBindings.length > 0) { - const detail = requiredMissingBindings - .map(formatMissingBindingForOperator) - .join("; "); - throw new ConfigurationIncompleteFailure( - `configuration incomplete: ${requiredScopedEnvBinding.remediation}; ${detail}`, - { - configurationIncomplete: { - reason: requiredScopedEnvBinding.reason, - companyId: input.companyId, - agentId: input.agentId ?? null, - issueId: input.issueId ?? null, - projectId: input.projectId ?? null, - routineId: input.routineId ?? null, - requiredEnvKeys: requiredScopedEnvBinding.keys, - requiredScopes: requiredScopedEnvBinding.consumerScopes, - missingBindings: requiredMissingBindings, - }, - }, - ); - } - } - if (missingBindings.length > 0) { - const detail = missingBindings - .map(formatMissingBindingForOperator) - .join("; "); - throw new ConfigurationIncompleteFailure( - `configuration incomplete: ${detail}`, - { - configurationIncomplete: { - reason: "secret_binding_missing", - companyId: input.companyId, - agentId: input.agentId ?? null, - issueId: input.issueId ?? null, - projectId: input.projectId ?? null, - routineId: input.routineId ?? null, - missingBindings, - }, - }, - ); - } - } - const environmentEnvResolution = environmentEnv - ? await input.secretsSvc.resolveEnvBindings( - input.companyId, - environmentEnv, - input.environmentId - ? { - consumerType: "environment", - consumerId: input.environmentId, - actorType: "agent", - actorId: input.agentId ?? null, - responsibleUserId: input.responsibleUserId ?? null, - issueId: input.issueId ?? null, - heartbeatRunId: input.heartbeatRunId ?? null, - ...(lowTrustAllowedBindingIds !== undefined - ? { allowedBindingIds: lowTrustAllowedBindingIds } - : {}), - } - : undefined, - ) - : { env: {}, secretKeys: new Set(), manifest: [] }; - const { - config: resolvedConfig, - secretKeys, - manifest, - } = await input.secretsSvc.resolveAdapterConfigForRuntime( - input.companyId, - executionRunConfig, - input.agentId - ? { - consumerType: "agent", - consumerId: input.agentId, - actorType: "agent", - actorId: input.agentId, - responsibleUserId: input.responsibleUserId ?? null, - issueId: input.issueId ?? null, - heartbeatRunId: input.heartbeatRunId ?? null, - ...(lowTrustAllowedBindingIds !== undefined - ? { allowedBindingIds: lowTrustAllowedBindingIds } - : {}), - } - : undefined, - { adapterType: input.adapterType ?? null }, - ); - if (Object.keys(environmentEnvResolution.env).length > 0) { - resolvedConfig.env = { - ...environmentEnvResolution.env, - ...parseObject(resolvedConfig.env), - }; - for (const key of environmentEnvResolution.secretKeys) { - secretKeys.add(key); - } - } - const projectEnvResolution = projectEnv - ? await input.secretsSvc.resolveEnvBindings( - input.companyId, - projectEnv, - input.projectId - ? { - consumerType: "project", - consumerId: input.projectId, - actorType: "agent", - actorId: input.agentId ?? null, - responsibleUserId: input.responsibleUserId ?? null, - issueId: input.issueId ?? null, - heartbeatRunId: input.heartbeatRunId ?? null, - ...(lowTrustAllowedBindingIds !== undefined - ? { allowedBindingIds: lowTrustAllowedBindingIds } - : {}), - } - : undefined, - ) - : { env: {}, secretKeys: new Set(), manifest: [] }; - if (Object.keys(projectEnvResolution.env).length > 0) { - resolvedConfig.env = { - ...parseObject(resolvedConfig.env), - ...projectEnvResolution.env, - }; - for (const key of projectEnvResolution.secretKeys) { - secretKeys.add(key); - } - } - const routineEnvResolution = routineEnv - ? await input.secretsSvc.resolveEnvBindings( - input.companyId, - routineEnv, - input.routineId - ? { - consumerType: "routine", - consumerId: input.routineId, - actorType: "agent", - actorId: input.agentId ?? null, - responsibleUserId: input.responsibleUserId ?? null, - issueId: input.issueId ?? null, - heartbeatRunId: input.heartbeatRunId ?? null, - ...(lowTrustAllowedBindingIds !== undefined - ? { allowedBindingIds: lowTrustAllowedBindingIds } - : {}), - } - : undefined, - ) - : { env: {}, secretKeys: new Set(), manifest: [] }; - if (Object.keys(routineEnvResolution.env).length > 0) { - resolvedConfig.env = { - ...parseObject(resolvedConfig.env), - ...routineEnvResolution.env, - }; - for (const key of routineEnvResolution.secretKeys) { - secretKeys.add(key); - } - } - if ( - allowTrustedEnvProjection && - input.trustedEnvProjection && - Object.keys(input.trustedEnvProjection).length > 0 - ) { - resolvedConfig.env = { - ...parseObject(resolvedConfig.env), - ...input.trustedEnvProjection, - }; - for (const key of input.trustedEnvSecretKeys ?? []) secretKeys.add(key); - } - // Pre-dispatch credential gate for codex_local: a managed Codex home with no - // usable auth.json and an empty OPENAI_API_KEY would dispatch a run that - // immediately fails with "no Codex credentials provisioned" (adapter_failed), - // making a configuration problem look like a runtime failure. Surface it as a - // configuration-incomplete blocker instead, naming the missing credential - // action and owner without leaking any secret value. This runs after secret - // resolution so a per-agent OPENAI_API_KEY (plain or resolved secret) counts - // as satisfying the credential. It shares the exact readiness predicate the - // adapter uses at execute time, so the two cannot drift. - // - // Sandbox-destined runs are exempt: the sandbox image may carry its own - // Codex login (`~/.codex/auth.json` baked in at image setup), which only the - // adapter can probe once the sandbox is up — and on managed cloud hosts a - // host-side login never exists at all. The adapter's execute-time gate - // remains the authority there; it probes the sandbox before failing. - if ( - !input.managedAiCredentials && (input.adapterType ?? null) === "codex_local" && - (input.environmentDriver ?? null) !== "sandbox" - ) { - const resolvedEnv = parseObject(resolvedConfig.env); - const readiness = await evaluateCodexCredentialReadiness({ - env: process.env, - companyId: input.companyId, - configuredCodexHome: readNonEmptyString(resolvedEnv.CODEX_HOME), - configuredApiKey: readNonEmptyString(resolvedEnv.OPENAI_API_KEY), - }); - if (readiness.managed && !readiness.ready) { - throw new ConfigurationIncompleteFailure( - `configuration incomplete: no Codex credentials available for managed home "${readiness.effectiveHome}". ` + - `Sign in to Codex on the host with a ChatGPT subscription, or bind a per-agent OPENAI_API_KEY secret for this agent.`, - { - configurationIncomplete: { - reason: "codex_credentials_missing", - companyId: input.companyId, - agentId: input.agentId ?? null, - issueId: input.issueId ?? null, - projectId: input.projectId ?? null, - routineId: input.routineId ?? null, - responsibleUserId: input.responsibleUserId ?? null, - adapterType: "codex_local", - requiredEnvKeys: ["OPENAI_API_KEY"], - effectiveCodexHome: readiness.effectiveHome, - missingBindings: [], - }, - }, - ); - } - } - return { - resolvedConfig, - // Capture resolved task values before provider credential injection or host - // inheritance. Native-only launch limits are checked at native dispatch. - configuredTaskEnvironment: Object.fromEntries( - Object.entries(parseObject(resolvedConfig.env)).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ), - secretKeys, - secretManifest: [ - ...(environmentEnvResolution.manifest ?? []), - ...(manifest ?? []), - ...(projectEnvResolution.manifest ?? []), - ...(routineEnvResolution.manifest ?? []), - ], - }; -} - -export function extractMentionedSkillIdsFromSources( - sources: Array, -): string[] { - const mentionedIds = new Set(); - for (const source of sources) { - if (typeof source !== "string" || source.length === 0) continue; - for (const skillId of extractSkillMentionIds(source)) { - if (!isUuidLike(skillId)) continue; - mentionedIds.add(skillId); - } - } - return [...mentionedIds]; -} - -export function applyRunScopedMentionedSkillKeys( - config: Record, - skillKeys: string[], -): Record { - const normalizedSkillKeys = Array.from( - new Set(skillKeys.map((value) => value.trim()).filter(Boolean)), - ); - if (normalizedSkillKeys.length === 0) return config; - - const existingPreference = readPaperclipSkillSyncPreference(config); - return writePaperclipSkillSyncPreference(config, [ - ...existingPreference.desiredSkillEntries, - ...normalizedSkillKeys, - ]); -} - -export function computeBoundedTransientHeartbeatRetrySchedule( - attempt: number, - now = new Date(), - random: () => number = Math.random, -) { - if (!Number.isInteger(attempt) || attempt <= 0) return null; - const baseDelayMs = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS[attempt - 1]; - if (typeof baseDelayMs !== "number") return null; - const sample = Math.min(1, Math.max(0, random())); - const jitterMultiplier = - 1 + (sample * 2 - 1) * BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO; - const delayMs = Math.max(1_000, Math.round(baseDelayMs * jitterMultiplier)); - return { - attempt, - baseDelayMs, - delayMs, - dueAt: new Date(now.getTime() + delayMs), - maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, - }; -} - -async function resolveRunScopedMentionedSkillKeys(input: { - db: Db; - companyId: string; - issueId: string | null; -}): Promise { - if (!input.issueId) return []; - - const issue = await input.db - .select({ - title: issues.title, - description: issues.description, - }) - .from(issues) - .where( - and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)), - ) - .then((rows) => rows[0] ?? null); - if (!issue) return []; - - const comments = await input.db - .select({ body: issueComments.body }) - .from(issueComments) - .where( - and( - eq(issueComments.issueId, input.issueId), - eq(issueComments.companyId, input.companyId), - isNull(issueComments.deletedAt), - ), - ); - const mentionedSkillIds = extractMentionedSkillIdsFromSources([ - issue.title, - issue.description ?? "", - ...comments.map((comment) => comment.body), - ]); - if (mentionedSkillIds.length === 0) return []; - - const skillRows = await input.db - .select({ - id: companySkillsTable.id, - key: companySkillsTable.key, - }) - .from(companySkillsTable) - .where( - and( - eq(companySkillsTable.companyId, input.companyId), - inArray(companySkillsTable.id, mentionedSkillIds), - ), - ); - const skillKeyById = new Map(skillRows.map((row) => [row.id, row.key])); - return mentionedSkillIds - .map((skillId) => skillKeyById.get(skillId) ?? null) - .filter((skillKey): skillKey is string => Boolean(skillKey)); -} - export function leaseReleaseStatusForRunStatus( status: string | null | undefined, ): Extract { @@ -2158,386 +1204,6 @@ export function isConfigurationIncompleteFailedRun( ); } -const heartbeatRunProcessGroupIdColumn = - heartbeatRuns.processGroupId ?? sql`NULL`.as("processGroupId"); - -const heartbeatRunListColumns = { - id: heartbeatRuns.id, - responsibleUserId: heartbeatRuns.responsibleUserId, - companyId: heartbeatRuns.companyId, - agentId: heartbeatRuns.agentId, - scopeKind: heartbeatRuns.scopeKind, - issueId: heartbeatRuns.issueId, - invocationSource: heartbeatRuns.invocationSource, - triggerDetail: heartbeatRuns.triggerDetail, - status: heartbeatRuns.status, - inputTokens: sql`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"), - cachedInputTokens: sql`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"), - outputTokens: sql`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"), - totalTokens: sql`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"), - costUsd: sql`coalesce( - (${heartbeatRuns.resultJson} ->> 'costUsd')::numeric, - (${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric, - (${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric - )`.as("costUsd"), - startedAt: heartbeatRuns.startedAt, - finishedAt: heartbeatRuns.finishedAt, - error: heartbeatRuns.error, - wakeupRequestId: heartbeatRuns.wakeupRequestId, - exitCode: heartbeatRuns.exitCode, - signal: heartbeatRuns.signal, - usageJson: heartbeatRuns.usageJson, - sessionIdBefore: heartbeatRuns.sessionIdBefore, - sessionIdAfter: heartbeatRuns.sessionIdAfter, - logStore: heartbeatRuns.logStore, - logRef: heartbeatRuns.logRef, - logBytes: heartbeatRuns.logBytes, - logSha256: heartbeatRuns.logSha256, - logCompressed: heartbeatRuns.logCompressed, - stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), - stderrExcerpt: sql`NULL`.as("stderrExcerpt"), - errorCode: heartbeatRuns.errorCode, - externalRunId: heartbeatRuns.externalRunId, - processPid: heartbeatRuns.processPid, - processGroupId: heartbeatRunProcessGroupIdColumn, - processStartedAt: heartbeatRuns.processStartedAt, - lastOutputAt: heartbeatRuns.lastOutputAt, - lastOutputSeq: heartbeatRuns.lastOutputSeq, - lastOutputStream: heartbeatRuns.lastOutputStream, - lastOutputBytes: heartbeatRuns.lastOutputBytes, - retryOfRunId: heartbeatRuns.retryOfRunId, - processLossRetryCount: heartbeatRuns.processLossRetryCount, - scheduledRetryAt: heartbeatRuns.scheduledRetryAt, - scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt, - scheduledRetryReason: heartbeatRuns.scheduledRetryReason, - livenessState: heartbeatRuns.livenessState, - livenessReason: heartbeatRuns.livenessReason, - continuationAttempt: heartbeatRuns.continuationAttempt, - lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, - nextAction: heartbeatRuns.nextAction, - createdAt: heartbeatRuns.createdAt, - updatedAt: heartbeatRuns.updatedAt, -} as const; - -const heartbeatRunSummaryListColumns = { - ...heartbeatRunListColumns, - usageJson: sql | null>`NULL`.as("usageJson"), - sessionIdBefore: sql`NULL`.as("sessionIdBefore"), - sessionIdAfter: sql`NULL`.as("sessionIdAfter"), - logStore: sql`NULL`.as("logStore"), - logRef: sql`NULL`.as("logRef"), - logSha256: sql`NULL`.as("logSha256"), - externalRunId: sql`NULL`.as("externalRunId"), - processPid: sql`NULL`.as("processPid"), - processGroupId: sql`NULL`.as("processGroupId"), - resultJson: sql | null>`NULL`.as("resultJson"), -} as const; - -const heartbeatRunListContextColumns = { - contextIssueId: sql`coalesce( - ${heartbeatRuns.issueId}::text, - ${heartbeatRuns.contextSnapshot} ->> 'issueId' - )`.as("contextIssueId"), - contextTaskId: sql`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"), - contextTaskKey: sql`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"), - contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), - contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), - contextWakeReason: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"), - contextWakeSource: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"), - contextWakeTriggerDetail: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"), -} as const; - -const heartbeatRunListResultColumns = { - resultSummary: sql< - string | null - >`left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as( - "resultSummary", - ), - resultResult: sql< - string | null - >`left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as( - "resultResult", - ), - resultMessage: sql< - string | null - >`left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as( - "resultMessage", - ), - resultError: sql< - string | null - >`left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as( - "resultError", - ), - resultTotalCostUsd: sql< - string | null - >`${heartbeatRuns.resultJson} ->> 'total_cost_usd'`.as("resultTotalCostUsd"), - resultCostUsd: sql< - string | null - >`${heartbeatRuns.resultJson} ->> 'cost_usd'`.as("resultCostUsd"), - resultCostUsdCamel: sql< - string | null - >`${heartbeatRuns.resultJson} ->> 'costUsd'`.as("resultCostUsdCamel"), -} as const; - -// Reserve at most 9 KiB for diagnostics in the reduced result. An oversized -// multibyte field uses a conservative four-byte-per-character prefix, with a -// visible pointer to the full (adapter-bounded) run error and transcript. -const diagnosticRetrievalTitleBytes = 1024; -const diagnosticRetrievalDetailsBytes = 8192; -function boundedRunDiagnosticText(field: "title" | "details", maxBytes: number) { - const value = sql`${heartbeatRuns.resultJson} #>> ARRAY['terminalSessionFailure', ${field}]`; - return sql`case when octet_length(${value}) <= ${maxBytes} then ${value} - else left(${value}, ${Math.floor((maxBytes - 100) / 4)}) - || E'\\n[truncated for run retrieval; full text in run error/transcript]' end`; -} - -const heartbeatRunSafeResultJsonColumn = sql | null>` - case - when ${heartbeatRuns.resultJson} is null then null - when pg_column_size(${heartbeatRuns.resultJson}) <= ${HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES} - then ${heartbeatRuns.resultJson} - else jsonb_strip_nulls( - jsonb_build_object( - 'summary', left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), - 'result', left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), - 'message', left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), - 'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), - 'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), - 'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), - 'terminalSessionFailure', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'terminalSessionFailure') = 'object' - then jsonb_strip_nulls(jsonb_build_object( - 'category', left(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,category}', 32), - 'title', ${boundedRunDiagnosticText("title", diagnosticRetrievalTitleBytes)}, - 'details', ${boundedRunDiagnosticText("details", diagnosticRetrievalDetailsBytes)}, - 'retrievalTruncated', case when - octet_length(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,title}') > ${diagnosticRetrievalTitleBytes} - or octet_length(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,details}') > ${diagnosticRetrievalDetailsBytes} - then to_jsonb(true) end, - 'truncatedFields', case when ${heartbeatRuns.resultJson} #> '{terminalSessionFailure,truncatedFields}' - in ('["title"]'::jsonb, '["details"]'::jsonb, '["title","details"]'::jsonb) - then ${heartbeatRuns.resultJson} #> '{terminalSessionFailure,truncatedFields}' end - )) end, - 'instructionSave', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'instructionSave') = 'object' - then jsonb_strip_nulls(jsonb_build_object( - 'state', left(${heartbeatRuns.resultJson} #>> '{instructionSave,state}', 32), - 'contract', left(${heartbeatRuns.resultJson} #>> '{instructionSave,contract}', 32), - 'entryFile', left(${heartbeatRuns.resultJson} #>> '{instructionSave,entryFile}', 512), - 'errorCode', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorCode}', 128), - 'errorMessage', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorMessage}', 1024), - 'storageWarning', left(${heartbeatRuns.resultJson} #>> '{instructionSave,storageWarning}', 1024) - )) end, - 'workspaceRestoreRecovery', case when ${heartbeatRuns.resultJson} #>> '{workspaceRestoreRecovery,schema}' = 'paperclip.workspace-restore-recovery.v1' - then jsonb_build_object('schema', 'paperclip.workspace-restore-recovery.v1') end, - 'workspaceRestoreFailure', case when ${heartbeatRuns.resultJson} ->> 'workspaceRestoreFailure' - in ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed') - then ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure' end, - 'cancellation', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'cancellation') = 'object' - then jsonb_strip_nulls(jsonb_build_object( - 'source', case when ${heartbeatRuns.resultJson} #>> '{cancellation,source}' - in ('operator', 'queued_message', 'shutdown', 'provider', 'transport', 'control_plane', 'unknown') - then ${heartbeatRuns.resultJson} #> '{cancellation,source}' end, - 'expected', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{cancellation,expected}') = 'boolean' - then ${heartbeatRuns.resultJson} #> '{cancellation,expected}' end, - 'initiator', jsonb_strip_nulls(jsonb_build_object( - 'type', case when ${heartbeatRuns.resultJson} #>> '{cancellation,initiator,type}' in ('user', 'agent', 'system', 'provider') - then ${heartbeatRuns.resultJson} #> '{cancellation,initiator,type}' end, - 'id', left(${heartbeatRuns.resultJson} #>> '{cancellation,initiator,id}', 128) - )), - 'reason', left(${heartbeatRuns.resultJson} #>> '{cancellation,reason}', 512), - 'recordedAt', left(${heartbeatRuns.resultJson} #>> '{cancellation,recordedAt}', 64) - )) end, - 'acpToolInventoryComplete', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete') = 'boolean' - then ${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete' end, - 'acpPendingToolCount', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'acpPendingToolCount') = 'number' - and length(${heartbeatRuns.resultJson} ->> 'acpPendingToolCount') < 16 - then ${heartbeatRuns.resultJson} -> 'acpPendingToolCount' end, - 'errorFamily', left(${heartbeatRuns.resultJson} ->> 'errorFamily', 32), - 'finalResponseRecorded', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'finalResponseRecorded') = 'boolean' - then ${heartbeatRuns.resultJson} -> 'finalResponseRecorded' end, - 'executionBeforeRestore', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'executionBeforeRestore') = 'object' - then jsonb_strip_nulls(jsonb_build_object( - 'errorCode', left(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,errorCode}', 128), - 'exitCode', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{executionBeforeRestore,exitCode}') = 'number' - and length(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,exitCode}') < 16 - then ${heartbeatRuns.resultJson} #> '{executionBeforeRestore,exitCode}' end, - 'signal', left(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,signal}', 50), - 'timedOut', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{executionBeforeRestore,timedOut}') = 'boolean' - then ${heartbeatRuns.resultJson} #> '{executionBeforeRestore,timedOut}' end - )) end, - 'stdoutTruncated', case - when length(${heartbeatRuns.resultJson} ->> 'stdout') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} - then to_jsonb(true) - else null - end, - 'stderrTruncated', case - when length(${heartbeatRuns.resultJson} ->> 'stderr') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} - then to_jsonb(true) - else null - end, - 'costUsd', coalesce( - ${heartbeatRuns.resultJson} -> 'costUsd', - ${heartbeatRuns.resultJson} -> 'cost_usd', - ${heartbeatRuns.resultJson} -> 'total_cost_usd' - ), - 'cost_usd', coalesce( - ${heartbeatRuns.resultJson} -> 'cost_usd', - ${heartbeatRuns.resultJson} -> 'costUsd', - ${heartbeatRuns.resultJson} -> 'total_cost_usd' - ), - 'total_cost_usd', coalesce( - ${heartbeatRuns.resultJson} -> 'total_cost_usd', - ${heartbeatRuns.resultJson} -> 'cost_usd', - ${heartbeatRuns.resultJson} -> 'costUsd' - ), - 'truncated', true, - 'truncationReason', 'oversized_result_json', - 'originalSizeBytes', pg_column_size(${heartbeatRuns.resultJson}) - ) - ) - end -`.as("resultJson"); - -// Execution admission needs retained server receipts even when presentation -// projection omits resultJson (SQL_ASCII or oversized provider output). Select -// only the evidence used by eligibility; never retrieve provider diagnostics. -const heartbeatRunExecutionEvidenceColumn = sql | null>` - case when ${heartbeatRuns.resultJson} is null then null else jsonb_build_object( - 'startupCancellation', ${heartbeatRuns.resultJson} -> 'startupCancellation', - 'startupPreparationSettledAt', ${heartbeatRuns.resultJson} -> 'startupPreparationSettledAt', - 'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason', - 'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource', - 'workspaceRestoreFailure', ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure', - 'workspaceRestoreRecovery', ${heartbeatRuns.resultJson} -> 'workspaceRestoreRecovery', - 'executionCancellation', ${heartbeatRuns.resultJson} -> 'executionCancellation', - 'nativeCancellation', ${heartbeatRuns.resultJson} -> 'nativeCancellation', - 'cancelledByActorType', ${heartbeatRuns.resultJson} -> 'cancelledByActorType', - 'cancelledByUserId', ${heartbeatRuns.resultJson} -> 'cancelledByUserId', - 'conversationContinuation', ${heartbeatRuns.resultJson} -> 'conversationContinuation', - 'cancellation', ${heartbeatRuns.resultJson} -> 'cancellation', - 'acpToolInventoryComplete', ${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete', - 'acpPendingToolCount', ${heartbeatRuns.resultJson} -> 'acpPendingToolCount' - ) end -`.as("resultJson"); - -const heartbeatRunSafeColumns = { - ...getTableColumns(heartbeatRuns), - processGroupId: heartbeatRunProcessGroupIdColumn, - resultJson: heartbeatRunSafeResultJsonColumn, -} as const; - -const heartbeatRunSqlAsciiSafeColumns = { - ...getTableColumns(heartbeatRuns), - processGroupId: heartbeatRunProcessGroupIdColumn, - error: sql`NULL`.as("error"), - resultJson: sql | null>`NULL`.as("resultJson"), - stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), - stderrExcerpt: sql`NULL`.as("stderrExcerpt"), -} as const; - -const heartbeatRunLogAccessColumns = { - id: heartbeatRuns.id, - companyId: heartbeatRuns.companyId, - scopeKind: heartbeatRuns.scopeKind, - issueId: heartbeatRuns.issueId, - logStore: heartbeatRuns.logStore, - logRef: heartbeatRuns.logRef, -} as const; - -const heartbeatRunIssueSummaryColumns = { - id: heartbeatRuns.id, - runtimeMode: heartbeatRuns.runtimeMode, - status: heartbeatRuns.status, - invocationSource: heartbeatRuns.invocationSource, - triggerDetail: heartbeatRuns.triggerDetail, - contextCommentId: sql< - string | null - >`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), - contextWakeCommentId: sql< - string | null - >`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as( - "contextWakeCommentId", - ), - startedAt: heartbeatRuns.startedAt, - finishedAt: heartbeatRuns.finishedAt, - createdAt: heartbeatRuns.createdAt, - agentId: heartbeatRuns.agentId, - logBytes: heartbeatRuns.logBytes, - processStartedAt: heartbeatRuns.processStartedAt, - livenessState: heartbeatRuns.livenessState, - livenessReason: heartbeatRuns.livenessReason, - continuationAttempt: heartbeatRuns.continuationAttempt, - lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, - nextAction: heartbeatRuns.nextAction, - lastOutputAt: heartbeatRuns.lastOutputAt, - lastOutputSeq: heartbeatRuns.lastOutputSeq, - lastOutputStream: heartbeatRuns.lastOutputStream, - lastOutputBytes: heartbeatRuns.lastOutputBytes, - issueId: heartbeatRuns.issueId, -} as const; - -function normalizeMaxConcurrentRuns(value: unknown) { - const parsed = Math.floor( - asNumber(value, HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT), - ); - if (!Number.isFinite(parsed)) return HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT; - return Math.max( - HEARTBEAT_MAX_CONCURRENT_RUNS_MIN, - Math.min(HEARTBEAT_MAX_CONCURRENT_RUNS_MAX, parsed), - ); -} - -interface WakeupOptions { - /** Set only by authenticated board wake routes; never copied from caller payloads. */ - manualUserWake?: boolean; - /** Internal resume of a queue with persisted board interruption intent. */ - queuedCommentInterruptId?: string; - /** Internal delivery of an existing undelivered user comment. */ - queuedCommentRequestId?: string; - /** Exact failed run selected by an authenticated board Retry request. */ - failedRunId?: string | null; - durableChatRequest?: DurableChatWakeupRequest; - /** Server-owned Dot admission receipt; never accepted from API payloads. */ - durableDotRequest?: { - id: string; - companyId: string; - agentId: string; - issueId: string; - requestId: string; - idempotencyKey: string; - requestedAt: Date; - }; - source?: "timer" | "assignment" | "on_demand" | "automation"; - triggerDetail?: "manual" | "ping" | "callback" | "system"; - reason?: string | null; - payload?: Record | null; - idempotencyKey?: string | null; - requestedByActorType?: "user" | "agent" | "system"; - requestedByActorId?: string | null; - contextSnapshot?: Record; - issueStateGuard?: { - statuses: string[]; - assigneeAgentId: string; - statusVersion?: number; - monitorNextCheckAt?: string; - monitorWakeRequestedAt?: string; - }; - /** Keep causally distinct external chat continuations out of an existing run. */ - allowRunCoalescing?: boolean; -} - -type UsageTotals = { - inputTokens: number; - cachedInputTokens: number; - outputTokens: number; -}; - -type SessionCompactionDecision = { - rotate: boolean; - reason: string | null; - handoffMarkdown: string | null; - previousRunId: string | null; -}; - interface ParsedIssueAssigneeAdapterOverrides { adapterConfig: Record | null; useProjectWorkspace: boolean | null; @@ -2576,1050 +1242,6 @@ function parseNativeSessionGoalControl( }; } -function sanitizeAgentSessionMessageText(value: unknown): string | null { - const text = readNonEmptyString(value); - if (!text) return null; - const redacted = redactSensitiveText(text).slice( - 0, - MAX_AGENT_SESSION_MESSAGE_CHARS, - ); - return redacted.trim().length > 0 ? redacted : null; -} - -type ManagedMcpGatewayRunConfig = { - version: 1; - managedMcpOnly: boolean; - gateways: Array<{ - id: string; - name: string; - endpointPath: string; - bearerToken: string; - tokenPrefix: string; - }>; -}; - -function configuredPaperclipApiBaseUrl(): string | null { - const configured = readNonEmptyString(process.env.PAPERCLIP_API_URL); - return configured - ? configured.replace(/\/+$/, "").replace(/\/api$/, "") - : null; -} - -function paperclipApiBaseUrl(): string { - const configured = configuredPaperclipApiBaseUrl(); - if (!configured) { - throw new Error( - "PAPERCLIP_API_URL is required to deliver managed runtime MCP servers", - ); - } - return configured; -} - -export async function revokeHeartbeatRunGatewayTokens(input: { - db: Db; - companyId: string; - runId: string; -}): Promise { - const now = new Date(); - await input.db - .update(toolMcpGatewayTokens) - .set({ revokedAt: now, updatedAt: now }) - .where( - and( - eq(toolMcpGatewayTokens.companyId, input.companyId), - eq(toolMcpGatewayTokens.subjectType, "heartbeat_run"), - eq(toolMcpGatewayTokens.subjectId, input.runId), - isNull(toolMcpGatewayTokens.revokedAt), - ), - ); -} - -export async function buildPaperclipRuntimeMcpServers(input: { - db: Db; - agent: Pick; - runId: string; - expectedAssignmentDigest?: string | null; -}): Promise { - const access = toolAccessService(input.db); - const effective = await access.getEffectiveProfilesForAgent( - input.agent.companyId, - input.agent.id, - ); - const [runIdentity] = await input.db - .select({ - responsibleUserId: heartbeatRuns.responsibleUserId, - activeIdentityContextId: heartbeatRuns.activeIdentityContextId, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, input.runId), - eq(heartbeatRuns.companyId, input.agent.companyId), - eq(heartbeatRuns.agentId, input.agent.id), - ), - ) - .limit(1); - const resolvedInstalledConnections = runIdentity?.activeIdentityContextId - ? effective.installedConnections - : await filterResolvedGitHubConnectionsForRun({ - db: input.db, - companyId: input.agent.companyId, - agentId: input.agent.id, - responsibleUserId: runIdentity?.responsibleUserId ?? null, - connections: effective.installedConnections, - }); - const permittedConnectionIds = new Set([ - ...effective.entries - .filter((entry) => entry.effect === "include" && entry.connectionId) - .map((entry) => entry.connectionId!), - ...effective.allowedTools.map((tool) => tool.connectionId), - ]); - const allInstalledConnectionIds = new Set( - effective.installedConnections.map((connection) => connection.id), - ); - const permittedConnections = - permittedConnectionIds.size > 0 - ? await input.db - .select({ - id: toolConnections.id, - name: toolConnections.name, - transport: toolConnections.transport, - config: toolConnections.config, - }) - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, input.agent.companyId), - inArray(toolConnections.id, [...permittedConnectionIds]), - ), - ) - : []; - const permittedNotInstalledConnections = permittedConnections - .filter( - (connection) => - (connection.transport === "mcp_remote" || - connection.transport === "local_stdio" || isBrowserUseConnection(connection)) && - !allInstalledConnectionIds.has(connection.id), - ) - .map(({ id, name }) => ({ id, name })) - .sort((a, b) => a.name.localeCompare(b.name)); - const githubBotConnectionIds = await githubBotConnectionIdsForRun(input.db, input.agent.companyId, input.agent.id, input.runId); - const assignedConnections = resolvedInstalledConnections.filter( - (connection) => - permittedConnectionIds.has(connection.id) && - connection.status === "active" && - connection.enabled && - ((Boolean(runIdentity?.activeIdentityContextId) && - (connection.config?.sourceTemplateKey === "github" || - connection.transportConfig?.sourceTemplateKey === "github")) || - connection.credentialPolicy === "per_user" || - !isToolConnectionAttentionHealth(connection.healthStatus)) && - (connection.transport === "mcp_remote" || - connection.transport === "local_stdio" || isBrowserUseConnection(connection) || githubBotConnectionIds.has(connection.id)), - ); - const assignedConnectionIds = new Set( - assignedConnections.map((connection) => connection.id), - ); - const assignedTools = effective.allowedTools.filter((tool) => - assignedConnectionIds.has(tool.connectionId), - ); - const service = createToolGatewayService(input.db); - if (assignedConnections.length === 0) { - await service.recordRuntimeMcpDeliveryDiagnostic({ - companyId: input.agent.companyId, - agentId: input.agent.id, - runId: input.runId, - permittedNotInstalledConnections, - }); - return []; - } - const assignment = { - version: 1, - agentId: input.agent.id, - connections: assignedConnections.map((connection) => connection.id).sort(), - tools: assignedTools.map((tool) => tool.id).sort(), - }; - const assignmentDigest = createHash("sha256") - .update(JSON.stringify(assignment)) - .digest("hex"); - // Native runs may lose access after their immutable context is captured, but - // they must never gain a new or changed assignment during dispatch. - if ( - input.expectedAssignmentDigest !== undefined && - input.expectedAssignmentDigest !== assignmentDigest - ) { - return []; - } - const profileKey = `native:${input.agent.id}:${assignmentDigest}`; - let [profile] = await input.db - .select() - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, input.agent.companyId), - eq(toolProfiles.profileKey, profileKey), - ), - ) - .limit(1); - - if (!profile) { - const fullConnectionIds = new Set( - effective.entries - .filter( - (entry) => - entry.effect === "include" && - entry.selectorType === "connection" && - entry.connectionId, - ) - .map((entry) => entry.connectionId!), - ); - const entries = [ - ...assignedConnections - .filter((connection) => fullConnectionIds.has(connection.id)) - .map((connection) => ({ - selectorType: "connection" as const, - effect: "include" as const, - applicationId: connection.applicationId, - connectionId: connection.id, - })), - ...assignedTools - .filter((tool) => !fullConnectionIds.has(tool.connectionId)) - .map((tool) => ({ - selectorType: "catalog_entry" as const, - effect: "include" as const, - applicationId: tool.applicationId, - connectionId: tool.connectionId, - catalogEntryId: tool.id, - })), - ]; - // The 250-entry limit bounds a public profile-edit request, not the - // effective assignment assembled from existing profiles. Keep every exact - // selector here: truncating or replacing them with connection-wide grants - // would either lose assigned tools or authorize tools outside this snapshot. - try { - const created = await access.createProfile(input.agent.companyId, { - profileKey, - name: `Native ${input.agent.id.slice(0, 8)} ${assignmentDigest.slice(0, 12)}`, - description: "Immutable Paperclip Runner MCP assignment profile.", - status: "active", - defaultAction: "deny", - metadata: { - source: "paperclip_runner", - agentId: input.agent.id, - assignmentDigest, - }, - entries, - }); - [profile] = await input.db - .select() - .from(toolProfiles) - .where(eq(toolProfiles.id, created.id)) - .limit(1); - } catch (error) { - [profile] = await input.db - .select() - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, input.agent.companyId), - eq(toolProfiles.profileKey, profileKey), - ), - ) - .limit(1); - if (!profile) throw error; - } - } - - if (profile!.metadata?.source !== "paperclip_runner" || profile!.metadata?.agentId !== input.agent.id || - profile!.metadata?.assignmentDigest !== assignmentDigest) { - throw new Error("Invalid native runtime profile provenance"); - } - - let [gateway] = ( - await input.db - .select() - .from(toolMcpGateways) - .where( - and( - eq(toolMcpGateways.companyId, input.agent.companyId), - eq(toolMcpGateways.status, "active"), - isNull(toolMcpGateways.archivedAt), - ), - ) - ).filter( - (candidate) => - candidate.metadata?.nativeRuntimeAssignmentDigest === assignmentDigest && - candidate.metadata?.agentId === input.agent.id && - candidate.profileId === profile!.id && - (!candidate.agentId || candidate.agentId === input.agent.id), - ); - // Gateways created before the agent binding existed have a null agentId, and - // named-gateway auth only rejects another agent's run token when it is set. - // The assignment digest includes the agent id, so a reused gateway is this - // agent's own: bind it here instead of waiting for the assignment to change. - if (gateway && !gateway.agentId) { - await input.db - .update(toolMcpGateways) - .set({ agentId: input.agent.id, contextScopeType: "agent", contextScopeId: input.agent.id, updatedAt: new Date() }) - .where( - and( - eq(toolMcpGateways.id, gateway.id), - eq(toolMcpGateways.companyId, input.agent.companyId), - isNull(toolMcpGateways.agentId), - ), - ); - [gateway] = await input.db - .select() - .from(toolMcpGateways) - .where(eq(toolMcpGateways.id, gateway.id)) - .limit(1); - } - if (!gateway) { - const slug = `native-${input.agent.id.replaceAll("-", "").slice(0, 12)}-${assignmentDigest.slice(0, 16)}`; - try { - const created = await service.createNamedGateway({ - companyId: input.agent.companyId, - body: { - name: `Native ${input.agent.name} ${assignmentDigest.slice(0, 8)}`, - slug, - description: "Run-scoped Paperclip Runner MCP gateway.", - profileId: profile!.id, - defaultProfileMode: "gateway_only", - agentId: input.agent.id, - contextScopeType: "agent", - contextScopeId: input.agent.id, - metadata: { - nativeRuntimeAssignmentDigest: assignmentDigest, - agentId: input.agent.id, - }, - }, - actor: { agentId: input.agent.id }, - }); - [gateway] = await input.db - .select() - .from(toolMcpGateways) - .where(eq(toolMcpGateways.id, created.id)) - .limit(1); - } catch (error) { - [gateway] = await input.db - .select() - .from(toolMcpGateways) - .where( - and( - eq(toolMcpGateways.companyId, input.agent.companyId), - eq(toolMcpGateways.slug, slug), - ), - ) - .limit(1); - if (!gateway) throw error; - } - } - - if (gateway!.agentId !== input.agent.id || gateway!.profileId !== profile!.id || - gateway!.metadata?.agentId !== input.agent.id || - gateway!.metadata?.nativeRuntimeAssignmentDigest !== assignmentDigest) { - throw new Error("Invalid native runtime gateway provenance"); - } - - const token = await service.createNamedGatewayToken({ - companyId: input.agent.companyId, - gatewayId: gateway!.id, - body: { - name: `Run ${input.runId.slice(0, 8)}`, - subjectType: "heartbeat_run", - subjectId: input.runId, - clientLabel: `${input.agent.name} heartbeat run`, - ownerNote: `Short-lived runtime MCP token for heartbeat run ${input.runId}.`, - allowedActions: ["tools/list", "tools/call"], - expiresAt: new Date(Date.now() + 60 * 60 * 1_000), - }, - actor: { agentId: input.agent.id }, - }); - - return [ - { - name: ASSIGNED_MCP_SERVER_NAME, - url: `${paperclipApiBaseUrl()}/mcp/gateways/${gateway!.gatewayPublicId}`, - token: token.token, - connectionId: `assignment:${assignmentDigest}`, - }, - ]; -} -function createAdapterRuntimeMcpAccess( - servers: AdapterRuntimeMcpServer[], -): AdapterRuntimeMcpAccess | undefined { - if (servers.length === 0) return undefined; - const snapshot = servers.map((server) => Object.freeze({ ...server })); - return Object.freeze({ - getServers: () => snapshot.map((server) => ({ ...server })), - }); -} - -function createAdapterRuntimeToolAccess(input: { - agentId: string; - companyId: string; - runId: string; - responsibleUserId: string | null; -}): AdapterRuntimeToolAccess | undefined { - if (!input.responsibleUserId) return undefined; - const minted = createRuntimeToolsToken({ - agentId: input.agentId, - companyId: input.companyId, - runId: input.runId, - responsibleUserId: input.responsibleUserId, - }); - if (!minted) return undefined; - // The normal server bootstrap always exports PAPERCLIP_API_URL. Some service - // tests invoke heartbeat execution without booting an HTTP server, however; - // in that context there is no reachable endpoint to advertise and runtime - // tools should simply remain unavailable instead of failing the run. - const baseUrl = configuredPaperclipApiBaseUrl(); - if (!baseUrl) return undefined; - return Object.freeze({ - version: 1, - guidance: CONNECTION_INTENT_AGENT_GUIDANCE, - mcpEndpoint: `${baseUrl}/mcp/runtime-tools`, - rest: { - connectionsSearch: `${baseUrl}/runtime-tools/connections/search`, - connectionRequest: `${baseUrl}/runtime-tools/connections/request`, - }, - bearerToken: minted.token, - expiresAt: minted.expiresAt, - tools: CONNECTION_RUNTIME_TOOL_NAMES, - }); -} - -const MANAGED_MCP_LOCAL_ADAPTERS = new Set(["codex_local"]); - -function adapterSupportsManagedMcpConfig(adapterType: string): boolean { - return MANAGED_MCP_LOCAL_ADAPTERS.has(adapterType); -} - -function gatewayAppliesToRun(input: { - gateway: typeof toolMcpGateways.$inferSelect; - agentId: string; - projectId: string | null; - issueId: string | null; -}): boolean { - const { gateway, agentId, projectId, issueId } = input; - if (gateway.agentId && gateway.agentId !== agentId) return false; - if (gateway.projectId && gateway.projectId !== projectId) return false; - if (gateway.issueId && gateway.issueId !== issueId) return false; - if ( - gateway.contextScopeType === "agent" && - gateway.contextScopeId && - gateway.contextScopeId !== agentId - ) - return false; - if ( - gateway.contextScopeType === "project" && - gateway.contextScopeId && - gateway.contextScopeId !== projectId - ) - return false; - if ( - gateway.contextScopeType === "issue" && - gateway.contextScopeId && - gateway.contextScopeId !== issueId - ) - return false; - return true; -} - -async function gatewayConnectionIds(input: { - db: Db; - companyId: string; - gateway: typeof toolMcpGateways.$inferSelect; -}): Promise> { - const managedRuntimeConnectionId = readNonEmptyString( - input.gateway.metadata?.managedRuntimeConnectionId, - ); - if (managedRuntimeConnectionId) return new Set([managedRuntimeConnectionId]); - - const [profile, entries, catalog, connections] = await Promise.all([ - input.db - .select({ defaultAction: toolProfiles.defaultAction }) - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, input.companyId), - eq(toolProfiles.id, input.gateway.profileId), - ), - ) - .then((rows) => rows[0] ?? null), - input.db - .select() - .from(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, input.companyId), - eq(toolProfileEntries.profileId, input.gateway.profileId), - ), - ), - input.db - .select({ - id: toolCatalogEntries.id, - connectionId: toolCatalogEntries.connectionId, - applicationId: toolCatalogEntries.applicationId, - toolName: toolCatalogEntries.toolName, - riskLevel: toolCatalogEntries.riskLevel, - }) - .from(toolCatalogEntries) - .where( - and( - eq(toolCatalogEntries.companyId, input.companyId), - eq(toolCatalogEntries.status, "active"), - ), - ), - input.db - .select({ - id: toolConnections.id, - applicationId: toolConnections.applicationId, - }) - .from(toolConnections) - .where(eq(toolConnections.companyId, input.companyId)), - ]); - if (!profile) return new Set(); - if (profile.defaultAction === "allow") - return new Set(catalog.map((entry) => entry.connectionId)); - - const connectionIds = new Set(); - for (const entry of entries) { - if (entry.effect !== "include") continue; - if (entry.connectionId) connectionIds.add(entry.connectionId); - if (entry.applicationId) { - for (const connection of connections) { - if (connection.applicationId === entry.applicationId) - connectionIds.add(connection.id); - } - } - for (const catalogEntry of catalog) { - if ( - (entry.catalogEntryId && entry.catalogEntryId === catalogEntry.id) || - (entry.toolName && entry.toolName === catalogEntry.toolName) || - (entry.riskLevel && entry.riskLevel === catalogEntry.riskLevel) - ) { - connectionIds.add(catalogEntry.connectionId); - } - } - } - return connectionIds; -} - -export async function createManagedMcpRunConfig(input: { - db: Db; - agent: Pick< - typeof agents.$inferSelect, - "id" | "companyId" | "name" | "adapterType" - >; - runId: string; - config: Record; - projectId: string | null; - issueId: string | null; -}): Promise { - if (!adapterSupportsManagedMcpConfig(input.agent.adapterType)) return null; - if (input.config.managedMcpOnly === false) return null; - - const rows = await input.db - .select() - .from(toolMcpGateways) - .where( - and( - eq(toolMcpGateways.companyId, input.agent.companyId), - eq(toolMcpGateways.status, "active"), - isNull(toolMcpGateways.archivedAt), - // A native profile remains an immutable assignment even if gateway - // metadata is cleared. Explicit shared gateways use ordinary profiles. - sql`not exists ( - select 1 from ${toolProfiles} - where ${toolProfiles.id} = ${toolMcpGateways.profileId} - and ${toolProfiles.companyId} = ${toolMcpGateways.companyId} - and (${toolProfiles.profileKey} like 'native:%' - or ${toolProfiles.metadata}->>'source' = 'paperclip_runner') - )`, - ), - ) - .orderBy(asc(toolMcpGateways.name)); - - const installRows = await input.db - .select({ - connectionId: toolConnectionInstalls.connectionId, - enabled: toolConnections.enabled, - status: toolConnections.status, - healthStatus: toolConnections.healthStatus, - config: toolConnections.config, - transportConfig: toolConnections.transportConfig, - }) - .from(toolConnectionInstalls) - .innerJoin( - toolConnections, - and( - eq(toolConnections.id, toolConnectionInstalls.connectionId), - eq(toolConnections.companyId, toolConnectionInstalls.companyId), - ), - ) - .where( - and( - eq(toolConnectionInstalls.companyId, input.agent.companyId), - sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${input.agent.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agent.id}))`, - ), - ); - const [runIdentity] = await input.db - .select({ - responsibleUserId: heartbeatRuns.responsibleUserId, - activeIdentityContextId: heartbeatRuns.activeIdentityContextId, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, input.runId), - eq(heartbeatRuns.companyId, input.agent.companyId), - eq(heartbeatRuns.agentId, input.agent.id), - ), - ) - .limit(1); - const resolvedAvailableInstalls = runIdentity?.activeIdentityContextId - ? installRows - .filter((install) => install.enabled && install.status === "active") - .map((install) => ({ id: install.connectionId })) - : await filterResolvedGitHubConnectionsForRun({ - db: input.db, - companyId: input.agent.companyId, - agentId: input.agent.id, - responsibleUserId: runIdentity?.responsibleUserId ?? null, - connections: installRows - .filter( - (install) => - install.enabled && - install.status === "active" && - !["degraded", "failed", "error", "missing_secret"].includes( - install.healthStatus, - ), - ) - .map((install) => ({ - id: install.connectionId, - config: install.config, - transportConfig: install.transportConfig, - })), - }); - const availableInstalledConnectionIds = new Set( - resolvedAvailableInstalls.map((install) => install.id), - ); - - const applicableGateways = rows.filter((gateway) => - // The immutable native assignment is delivered separately. Including any - // historical native gateway here duplicates and broadens that assignment. - !Object.hasOwn(gateway.metadata ?? {}, "nativeRuntimeAssignmentDigest") && gatewayAppliesToRun({ - gateway, - agentId: input.agent.id, - projectId: input.projectId, - issueId: input.issueId, - }), - ); - const gateways = ( - await Promise.all( - applicableGateways.map(async (gateway) => ({ - gateway, - connectionIds: await gatewayConnectionIds({ - db: input.db, - companyId: input.agent.companyId, - gateway, - }), - })), - ) - ) - .filter( - ({ connectionIds }) => - connectionIds.size > 0 && - [...connectionIds].every((connectionId) => - availableInstalledConnectionIds.has(connectionId), - ), - ) - .map(({ gateway }) => gateway); - if (gateways.length === 0) return null; - - const service = createToolGatewayService(input.db); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); - const managedGateways: ManagedMcpGatewayRunConfig["gateways"] = []; - for (const gateway of gateways) { - const token = await service.createNamedGatewayToken({ - companyId: input.agent.companyId, - gatewayId: gateway.id, - body: { - name: `Managed ${input.agent.name} ${input.runId.slice(0, 8)}`, - subjectType: "heartbeat_run", - subjectId: input.runId, - clientLabel: `${input.agent.name} managed local adapter`, - ownerNote: `Short-lived Paperclip-managed MCP token for heartbeat run ${input.runId}.`, - allowedActions: ["tools/list", "tools/call"], - expiresAt, - }, - actor: { agentId: input.agent.id }, - }); - managedGateways.push({ - id: gateway.id, - name: gateway.name, - // This path must bypass the normal /api agent-JWT middleware. The MCP - // gateway performs its own bearer validation for the run-scoped token. - endpointPath: `/mcp/gateways/${gateway.gatewayPublicId}`, - bearerToken: token.token, - tokenPrefix: token.tokenPrefix, - }); - } - - return { - version: 1, - managedMcpOnly: true, - gateways: managedGateways, - }; -} - -export function summarizeHeartbeatRunContextSnapshot( - contextSnapshot: Record | null | undefined, -): Record | null { - const summary: Record = {}; - const allowedKeys = [ - "issueId", - "taskId", - "taskKey", - "commentId", - "wakeCommentId", - "wakeReason", - "wakeSource", - "wakeTriggerDetail", - ] as const; - - for (const key of allowedKeys) { - const value = readNonEmptyString(contextSnapshot?.[key]); - if (value) summary[key] = value; - } - - return Object.keys(summary).length > 0 ? summary : null; -} - -export function summarizeHeartbeatRunListResultJson(input: { - summary?: string | null; - result?: string | null; - message?: string | null; - error?: string | null; - totalCostUsd?: string | null; - costUsd?: string | null; - costUsdCamel?: string | null; -}): Record | null { - const summary: Record = {}; - for (const [key, value] of [ - ["summary", input.summary], - ["result", input.result], - ["message", input.message], - ["error", input.error], - ] as const) { - const normalized = readNonEmptyString(value); - if (normalized) summary[key] = normalized; - } - - for (const [key, value] of [ - ["total_cost_usd", input.totalCostUsd], - ["cost_usd", input.costUsd], - ["costUsd", input.costUsdCamel], - ] as const) { - const normalized = readNonEmptyString(value); - if (!normalized) continue; - const parsed = Number(normalized); - if (Number.isFinite(parsed)) summary[key] = parsed; - } - - return Object.keys(summary).length > 0 ? summary : null; -} - -function normalizeLedgerBillingType(value: unknown): BillingType { - const raw = readNonEmptyString(value); - switch (raw) { - case "api": - case "metered_api": - return "metered_api"; - case "subscription": - case "subscription_included": - return "subscription_included"; - case "subscription_overage": - return "subscription_overage"; - case "credits": - return "credits"; - case "fixed": - return "fixed"; - default: - return "unknown"; - } -} - -function resolveLedgerBiller(result: AdapterExecutionResult): string { - return ( - readNonEmptyString(result.biller) ?? - readNonEmptyString(result.provider) ?? - "unknown" - ); -} - -export function normalizeBilledCostCents( - costUsd: number | null | undefined, - billingType: BillingType, -): number { - if (billingType === "subscription_included") return 0; - if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return 0; - return Math.max(0, Number((costUsd * 100).toFixed(7))); -} - -export function resolveLedgerCostStatus(input: { - costUsd: number | null | undefined; - billingType?: BillingType; - inputTokens: number; - cachedInputTokens: number; - outputTokens: number; -}): CostStatus { - if (input.billingType === "subscription_included") return "reported"; - // A paused turn can have neither a token receipt nor a cost receipt. Zero - // normalized counters do not establish that its billed cost was zero. - return typeof input.costUsd === "number" && - Number.isFinite(input.costUsd) && - input.costUsd >= 0 - ? "reported" - : "unpriced"; -} - -export function resolveCacheAdjustedCostUsd(input: { - costUsd?: number | null; - cacheAdjustedCostUsd?: number | null; -}) { - const explicit = input.cacheAdjustedCostUsd; - if ( - typeof explicit === "number" && - Number.isFinite(explicit) && - explicit >= 0 - ) { - return explicit; - } - const reported = input.costUsd; - if ( - typeof reported === "number" && - Number.isFinite(reported) && - reported >= 0 - ) { - return reported; - } - return null; -} - -export async function resolveLedgerScopeForRun( - db: Db, - companyId: string, - run: typeof heartbeatRuns.$inferSelect, -) { - const context = parseObject(run.contextSnapshot); - const contextIssueId = readNonEmptyString(context.issueId); - const contextProjectId = readNonEmptyString(context.projectId); - - if (!contextIssueId) { - return { - issueId: null, - projectId: contextProjectId, - billingCode: null, - }; - } - - const issue = await db - .select({ - id: issues.id, - projectId: issues.projectId, - billingCode: issues.billingCode, - }) - .from(issues) - .where(and(eq(issues.id, contextIssueId), eq(issues.companyId, companyId))) - .then((rows) => rows[0] ?? null); - - return { - issueId: issue?.id ?? null, - projectId: issue?.projectId ?? contextProjectId, - billingCode: issue?.billingCode ?? null, - }; -} - -type ResumeSessionRow = { - sessionParamsJson: Record | null; - sessionDisplayId: string | null; - lastRunId: string | null; -}; - -export function buildExplicitResumeSessionOverride(input: { - adapterType?: string | null; - resumeFromRunId: string; - resumeRunSessionIdBefore: string | null; - resumeRunSessionIdAfter: string | null; - resumeRunSessionParams?: Record | null; - taskSession: ResumeSessionRow | null; - sessionCodec: AdapterSessionCodec; -}) { - const resumeRunSessionIdAfter = truncateDisplayId( - input.resumeRunSessionIdAfter, - ); - const resumeRunSessionIdBefore = truncateDisplayId( - input.resumeRunSessionIdBefore, - ); - const desiredDisplayId = requiresCanonicalSessionIds(input.adapterType) - ? isCanonicalSessionIdForAdapter(input.adapterType, resumeRunSessionIdAfter) - ? resumeRunSessionIdAfter - : isCanonicalSessionIdForAdapter( - input.adapterType, - resumeRunSessionIdBefore, - ) - ? resumeRunSessionIdBefore - : null - : (resumeRunSessionIdAfter ?? resumeRunSessionIdBefore); - const runSessionParams = requiresCanonicalSessionIds(input.adapterType) - ? normalizeResumeParamsForAdapter( - input.adapterType, - input.sessionCodec.deserialize(input.resumeRunSessionParams ?? null), - ) - : null; - const runSessionDisplayId = truncateDisplayId( - readNonEmptyString(runSessionParams?.sessionId), - ); - const taskSessionParams = normalizeResumeParamsForAdapter( - input.adapterType, - input.sessionCodec.deserialize( - input.taskSession?.sessionParamsJson ?? null, - ), - ); - const taskSessionRawDisplayId = input.taskSession?.sessionDisplayId ?? null; - const taskSessionDisplayId = truncateDisplayId( - requiresCanonicalSessionIds(input.adapterType) - ? (readNonEmptyString(taskSessionParams?.sessionId) ?? - (isCanonicalSessionIdForAdapter( - input.adapterType, - taskSessionRawDisplayId, - ) - ? taskSessionRawDisplayId - : null)) - : (taskSessionRawDisplayId ?? - (input.sessionCodec.getDisplayId - ? input.sessionCodec.getDisplayId(taskSessionParams) - : null) ?? - readNonEmptyString(taskSessionParams?.sessionId)), - ); - const canReuseTaskSessionParams = - input.taskSession != null && - (!requiresCanonicalSessionIds(input.adapterType) || - taskSessionParams != null) && - (input.taskSession.lastRunId === input.resumeFromRunId || - (!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)); - const sessionParams = canReuseTaskSessionParams - ? taskSessionParams - : runSessionParams - ? runSessionParams - : desiredDisplayId - ? { sessionId: desiredDisplayId } - : null; - const sessionDisplayId = canReuseTaskSessionParams - ? taskSessionDisplayId - : runSessionParams - ? runSessionDisplayId - : desiredDisplayId; - - if (!sessionDisplayId && !sessionParams) return null; - return { - sessionDisplayId, - sessionParams, - }; -} - -function normalizeUsageTotals( - usage: UsageSummary | null | undefined, -): UsageTotals | null { - if (!usage) return null; - return { - inputTokens: Math.max(0, Math.floor(asNumber(usage.inputTokens, 0))), - cachedInputTokens: Math.max( - 0, - Math.floor(asNumber(usage.cachedInputTokens, 0)), - ), - outputTokens: Math.max(0, Math.floor(asNumber(usage.outputTokens, 0))), - }; -} - -function readRawUsageTotals(usageJson: unknown): UsageTotals | null { - const parsed = parseObject(usageJson); - if (Object.keys(parsed).length === 0) return null; - - const inputTokens = Math.max( - 0, - Math.floor( - asNumber(parsed.rawInputTokens, asNumber(parsed.inputTokens, 0)), - ), - ); - const cachedInputTokens = Math.max( - 0, - Math.floor( - asNumber( - parsed.rawCachedInputTokens, - asNumber(parsed.cachedInputTokens, 0), - ), - ), - ); - const outputTokens = Math.max( - 0, - Math.floor( - asNumber(parsed.rawOutputTokens, asNumber(parsed.outputTokens, 0)), - ), - ); - - if (inputTokens <= 0 && cachedInputTokens <= 0 && outputTokens <= 0) { - return null; - } - - return { - inputTokens, - cachedInputTokens, - outputTokens, - }; -} - -export function normalizeAdapterRunUsage( - current: UsageTotals | null, - previous: UsageTotals | null, - usageBasis?: "per_run" | "session_cumulative" | null, -): UsageTotals | null { - if (!current) return null; - if (!previous || usageBasis !== "session_cumulative") return { ...current }; - - const inputTokens = - current.inputTokens >= previous.inputTokens - ? current.inputTokens - previous.inputTokens - : current.inputTokens; - const cachedInputTokens = - current.cachedInputTokens >= previous.cachedInputTokens - ? current.cachedInputTokens - previous.cachedInputTokens - : current.cachedInputTokens; - const outputTokens = - current.outputTokens >= previous.outputTokens - ? current.outputTokens - previous.outputTokens - : current.outputTokens; - - return { - inputTokens: Math.max(0, inputTokens), - cachedInputTokens: Math.max(0, cachedInputTokens), - outputTokens: Math.max(0, outputTokens), - }; -} - -function formatCount(value: number | null | undefined) { - if (typeof value !== "number" || !Number.isFinite(value)) return "0"; - return value.toLocaleString("en-US"); -} - -export function parseSessionCompactionPolicy( - agent: typeof agents.$inferSelect, -): SessionCompactionPolicy { - return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig) - .policy; -} function parseIssueAssigneeAdapterOverrides( raw: unknown, @@ -3639,120 +1261,6 @@ function parseIssueAssigneeAdapterOverrides( }; } -/** - * Synthetic task key for timer/heartbeat wakes that have no issue context. - * This allows timer wakes to participate in the `agentTaskSessions` system - * and benefit from robust session resume, instead of relying solely on the - * simpler `agentRuntimeState.sessionId` fallback. - */ -const HEARTBEAT_TASK_KEY = "__heartbeat__"; - -function deriveTaskKey( - contextSnapshot: Record | null | undefined, - payload: Record | null | undefined, -) { - return ( - readNonEmptyString(contextSnapshot?.taskKey) ?? - readNonEmptyString(contextSnapshot?.taskId) ?? - readNonEmptyString(contextSnapshot?.issueId) ?? - readNonEmptyString(payload?.taskKey) ?? - readNonEmptyString(payload?.taskId) ?? - readNonEmptyString(payload?.issueId) ?? - null - ); -} - -/** - * Extended task key derivation that falls back to a stable synthetic key - * for timer/heartbeat wakes. The synthetic key keeps the - * `agentTaskSessions` row addressable across heartbeats so the row can be - * cleared and re-keyed deterministically. Unscoped exploratory timer wakes - * still start fresh to avoid accumulating low-value inbox scans, while timer - * wakes scoped to a real issue reuse that issue's task session. - * - * The synthetic key is only used when: - * - No explicit task/issue key exists in the context - * - The wake source is "timer" (scheduled heartbeat) - */ -export function deriveTaskKeyWithHeartbeatFallback( - contextSnapshot: Record | null | undefined, - payload: Record | null | undefined, -) { - const explicit = deriveTaskKey(contextSnapshot, payload); - if (explicit) return explicit; - - const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource); - if (wakeSource === "timer") return HEARTBEAT_TASK_KEY; - - return null; -} - -export function shouldResetTaskSessionForWake( - contextSnapshot: Record | null | undefined, -) { - if (contextSnapshot?.forceFreshSession === true) return true; - - const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); - if ( - wakeReason === "issue_assigned" || - wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON || - wakeReason === "execution_approval_requested" || - // PF-4: unscoped timer wakes are exploratory ("any new work?") and should - // not accumulate low-value inbox scans. Issue-scoped timer wakes are - // continuation work, so reuse their task session to avoid paying the full - // session-start and re-orientation cost on every heartbeat. - (wakeReason === "heartbeat_timer" && !deriveTaskKey(contextSnapshot, null)) - ) { - return true; - } - return false; -} - -function shouldRequireIssueCommentForWake( - contextSnapshot: Record | null | undefined, -) { - if (contextSnapshot?.skipIssueComment === true) return false; - - const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); - return ( - wakeReason === "issue_assigned" || - wakeReason === "execution_review_requested" || - wakeReason === "execution_approval_requested" || - wakeReason === "execution_changes_requested" - ); -} - -async function listUnresolvedBlockerSummaries( - dbOrTx: Pick, - companyId: string, - issueId: string, - unresolvedBlockerIssueIds: string[], -) { - const ids = [...new Set(unresolvedBlockerIssueIds.filter(Boolean))]; - if (ids.length === 0) return []; - return dbOrTx - .select({ - id: issues.id, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - priority: issues.priority, - assigneeAgentId: issues.assigneeAgentId, - assigneeUserId: issues.assigneeUserId, - }) - .from(issueRelations) - .innerJoin(issues, eq(issueRelations.issueId, issues.id)) - .where( - and( - eq(issueRelations.companyId, companyId), - eq(issueRelations.type, "blocks"), - eq(issueRelations.relatedIssueId, issueId), - inArray(issues.id, ids), - ), - ) - .orderBy(asc(issues.title)); -} - export function formatRuntimeWorkspaceWarningLog(warning: string) { return { stream: "stdout" as const, @@ -3788,30 +1296,6 @@ export function filterZombieCoalesceTarget< return target && isZombieRun(target, tracked) ? null : target; } -export function describeSessionResetReason( - contextSnapshot: Record | null | undefined, -) { - if (contextSnapshot?.forceFreshSession === true) - return "forceFreshSession was requested"; - - const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); - if (wakeReason === "issue_assigned") return "wake reason is issue_assigned"; - if (wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON) { - return `wake reason is ${EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON}`; - } - if (wakeReason === "execution_approval_requested") - return "wake reason is execution_approval_requested"; - // PF-4: paired with shouldResetTaskSessionForWake — keep the reason wording - // explicit so run logs make session reuse/reset behavior legible. - if ( - wakeReason === "heartbeat_timer" && - !deriveTaskKey(contextSnapshot, null) - ) { - return "wake reason is heartbeat_timer (unscoped timer wake starts fresh)"; - } - return null; -} - /** * Failure signatures from sandbox→host git workspace reconciliation. These * describe the state of the SHARED workspace (divergent histories written by @@ -3836,20 +1320,6 @@ export function isWorkspaceSyncConflictFailure( ); } -export function shouldDeferFollowupWakeForSameIssue(input: { - activeRunStatus: string | null | undefined; - isSameExecutionAgent: boolean; - wakeCommentId: string | null | undefined; - forceFreshSession: boolean; -}) { - // A comment follow-up or explicit fresh-session wake needs a new run boundary. - if (!input.isSameExecutionAgent) return false; - if (input.activeRunStatus !== "running") return false; - if (input.wakeCommentId) return true; - if (input.forceFreshSession) return true; - return false; -} - export function shouldAutoCheckoutIssueForWake(input: { contextSnapshot: Record | null | undefined; issueStatus: string | null; @@ -3889,31 +1359,6 @@ export function resolvedInteractionCheckoutExpectedStatuses() { return ["in_progress", "in_review"] as const; } -export function shouldQueueFollowupForRunningIssueWake(input: { - contextSnapshot: Record | null | undefined; - wakeCommentId: string | null; -}) { - if (input.wakeCommentId) return true; - // A structured interaction response is new input just like a comment. It - // must run after the turn that created the interaction instead of being - // merged into that still-running turn. - if ( - readNonEmptyString(input.contextSnapshot?.interactionId) && - readNonEmptyString(input.contextSnapshot?.interactionStatus) - ) { - return true; - } - const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason); - if (wakeReason === "issue_children_completed" && ( - input.contextSnapshot?.onboardingCompletion === true || - (input.contextSnapshot?.statusDecisionSource === "native_status_decision" && - readNonEmptyString(input.contextSnapshot?.nativeChildCompletionDecisionId)) - )) return true; - return Boolean( - wakeReason && RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP.has(wakeReason), - ); -} - function isCheckoutConflictError(error: unknown): boolean { return ( error instanceof HttpError && @@ -3924,1246 +1369,6 @@ function isCheckoutConflictError(error: unknown): boolean { export { extractWakeCommentIds }; -function mergeWakeCommentIds(...values: Array): string[] { - const merged: string[] = []; - const append = (value: unknown) => { - const normalized = readNonEmptyString(value); - if (!normalized || merged.includes(normalized)) return; - merged.push(normalized); - }; - - for (const value of values) { - if (Array.isArray(value)) { - for (const entry of value) append(entry); - continue; - } - if (typeof value === "object" && value !== null) { - const candidate = value as Record; - const batched = extractWakeCommentIds(candidate); - if (batched.length > 0) { - for (const entry of batched) append(entry); - continue; - } - append(candidate.wakeCommentId); - append(candidate.commentId); - continue; - } - append(value); - } - - return merged; -} - -const EXTERNAL_ATTACHMENT_OMISSION_REASONS = new Set([ - "attachment_limit", - "storage_unavailable", - "declared_too_large", - "download_unavailable", - "unsupported_type", - "empty_download", - "downloaded_too_large", - "processing_failed", -]); - -type ExternalAttachmentOmission = { - commentId: string; - reasons: Record; -}; - -function readExternalAttachmentOmissions( - value: unknown, -): ExternalAttachmentOmission[] { - if (!Array.isArray(value)) return []; - const byCommentId = new Map(); - for (const candidate of value) { - const record = parseObject(candidate); - const commentId = readNonEmptyString(record.commentId); - if (!commentId) continue; - const reasons = Object.fromEntries( - Object.entries(parseObject(record.reasons)).flatMap(([reason, count]) => - EXTERNAL_ATTACHMENT_OMISSION_REASONS.has(reason) && - typeof count === "number" && - Number.isSafeInteger(count) && - count > 0 - ? [[reason, count]] - : [], - ), - ); - if (Object.keys(reasons).length === 0) continue; - byCommentId.set(commentId, { commentId, reasons }); - } - return [...byCommentId.values()].slice(-50); -} - -function mergeExternalAttachmentOmissions( - existing: Record, - incoming: Record, -) { - return readExternalAttachmentOmissions([ - ...readExternalAttachmentOmissions( - existing[EXTERNAL_ATTACHMENT_OMISSIONS_KEY], - ), - ...readExternalAttachmentOmissions( - incoming[EXTERNAL_ATTACHMENT_OMISSIONS_KEY], - ), - ]); -} - -function externalAttachmentOmissionNotice( - omission: ExternalAttachmentOmission, -) { - const entries = Object.entries(omission.reasons); - const omitted = entries.reduce((total, [, count]) => total + count, 0); - const reasons = entries - .map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`) - .join(", "); - return `Paperclip could not import every attachment from this exact external message: ${omitted} attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons}). Treat omitted attachments as unavailable; do not infer their contents or substitute an older workspace file.`; -} - -function enrichWakeContextSnapshot(input: { - contextSnapshot: Record; - reason: string | null; - source: WakeupOptions["source"]; - triggerDetail: WakeupOptions["triggerDetail"] | null; - payload: Record | null; -}) { - const { contextSnapshot, reason, source, triggerDetail, payload } = input; - const issueIdFromPayload = - readNonEmptyString(payload?.["issueId"]) ?? - readNonEmptyString(payload?.["taskId"]); - const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]); - const taskKey = deriveTaskKey(contextSnapshot, payload); - const wakeCommentId = deriveCommentId(contextSnapshot, payload); - const wakeCommentIds = mergeWakeCommentIds( - contextSnapshot, - commentIdFromPayload, - ); - - if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) { - contextSnapshot.wakeReason = reason; - } - if (!readNonEmptyString(contextSnapshot["issueId"]) && issueIdFromPayload) { - contextSnapshot.issueId = issueIdFromPayload; - } - if (!readNonEmptyString(contextSnapshot["taskId"]) && issueIdFromPayload) { - contextSnapshot.taskId = issueIdFromPayload; - } - if (!readNonEmptyString(contextSnapshot["taskKey"]) && taskKey) { - contextSnapshot.taskKey = taskKey; - } - if ( - !readNonEmptyString(contextSnapshot["commentId"]) && - commentIdFromPayload - ) { - contextSnapshot.commentId = commentIdFromPayload; - } - if (wakeCommentIds.length > 0) { - const latestCommentId = wakeCommentIds[wakeCommentIds.length - 1]; - contextSnapshot[WAKE_COMMENT_IDS_KEY] = wakeCommentIds; - contextSnapshot.commentId = latestCommentId; - contextSnapshot.wakeCommentId = latestCommentId; - // Once comment ids are normalized into the snapshot, rebuild the structured - // wake payload from those ids later instead of carrying forward stale data. - delete contextSnapshot[PAPERCLIP_WAKE_PAYLOAD_KEY]; - } else if ( - !readNonEmptyString(contextSnapshot["wakeCommentId"]) && - wakeCommentId - ) { - contextSnapshot.wakeCommentId = wakeCommentId; - } - if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) { - contextSnapshot.wakeSource = source; - } - if ( - !readNonEmptyString(contextSnapshot["wakeTriggerDetail"]) && - triggerDetail - ) { - contextSnapshot.wakeTriggerDetail = triggerDetail; - } - normalizeInteractionContinuationWakeContext(contextSnapshot, payload); - - return { - contextSnapshot, - issueIdFromPayload, - commentIdFromPayload, - taskKey, - wakeCommentId, - }; -} - -const INTERACTION_CONTINUATION_CONTEXT_KEYS = [ - "interactionId", - "interactionKind", - "interactionStatus", - "continuationPolicy", - "checkboxSelection", - "itemVerdicts", - "newlyResolvedItemIds", -] as const; - -function isInteractionResolutionWakePayload( - payload: Record | null | undefined, -) { - return readNonEmptyString(payload?.mutation) === "interaction"; -} - -function clearInteractionContinuationWakeContext( - contextSnapshot: Record, -) { - for (const key of INTERACTION_CONTINUATION_CONTEXT_KEYS) { - delete contextSnapshot[key]; - } -} - -function hasInteractionContinuationWakeContext( - contextSnapshot: Record, -) { - return INTERACTION_CONTINUATION_CONTEXT_KEYS.some((key) => - readNonEmptyString(contextSnapshot[key]), - ); -} - -function normalizeInteractionContinuationWakeContext( - contextSnapshot: Record, - payload: Record | null | undefined, -) { - if (isInteractionResolutionWakePayload(payload)) return; - clearInteractionContinuationWakeContext(contextSnapshot); -} - -type AcceptedPlanWakeRoutingDecision = { - otherActiveClaimIssueId: string; - otherActiveClaimIdentifier: string | null; - otherActiveClaimTitle: string; - forceFreshSession: boolean; - suppressAcceptedContinuation: boolean; -}; - -async function resolveAcceptedPlanWakeRoutingDecision(args: { - db: Db; - companyId: string; - agentId: string; - issueId: string | null; - acceptedPlanContinuationWake: boolean; - contextSnapshot: Record; -}): Promise { - if (args.issueId === null) return null; - if (!args.acceptedPlanContinuationWake) return null; - - const activeClaims = await args.db - .select({ - sourceIssueId: issuePlanDecompositions.sourceIssueId, - identifier: issues.identifier, - title: issues.title, - }) - .from(issuePlanDecompositions) - .innerJoin(issues, eq(issues.id, issuePlanDecompositions.sourceIssueId)) - .where( - and( - eq(issuePlanDecompositions.companyId, args.companyId), - eq(issuePlanDecompositions.ownerAgentId, args.agentId), - eq(issuePlanDecompositions.status, "in_flight"), - ), - ) - .orderBy( - desc(issuePlanDecompositions.updatedAt), - asc(issuePlanDecompositions.createdAt), - ); - - if (activeClaims.length === 0) return null; - if (activeClaims.some((claim) => claim.sourceIssueId === args.issueId)) - return null; - - const otherActiveClaim = activeClaims[0]; - if (!otherActiveClaim) return null; - - const hasAcceptedContinuationWake = - readNonEmptyString(args.contextSnapshot.interactionKind) === - "request_confirmation" && - readNonEmptyString(args.contextSnapshot.interactionStatus) === "accepted"; - - return { - otherActiveClaimIssueId: otherActiveClaim.sourceIssueId, - otherActiveClaimIdentifier: otherActiveClaim.identifier ?? null, - otherActiveClaimTitle: otherActiveClaim.title, - forceFreshSession: true, - suppressAcceptedContinuation: hasAcceptedContinuationWake, - }; -} - -export function mergeCoalescedContextSnapshot( - existingRaw: unknown, - incoming: Record, - options: { preserveExistingInteractionContinuation?: boolean } = {}, -) { - const existing = parseObject(existingRaw); - const existingSource = readNonEmptyString(existing.source); - const incomingSource = readNonEmptyString(incoming.source); - const preservesExternalChatOrigin = - existingSource?.startsWith("chat:") === true && - readNonEmptyString(existing.issueId) !== null && - existing.issueId === incoming.issueId && - incomingSource === "native_status_decision" && - readNonEmptyString(incoming.statusDecisionSource) === - "native_status_decision"; - const merged: Record = { - ...existing, - ...incoming, - }; - // Only executeRun can mint this proof. Coalescence may retain an unchanged - // admitted proof, but must never accept a new marker from an incoming wake. - delete merged[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY]; - delete merged[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY]; - const mergedAttachmentOmissions = mergeExternalAttachmentOmissions( - existing, - incoming, - ); - if (mergedAttachmentOmissions.length > 0) { - merged[EXTERNAL_ATTACHMENT_OMISSIONS_KEY] = mergedAttachmentOmissions; - } else { - delete merged[EXTERNAL_ATTACHMENT_OMISSIONS_KEY]; - } - // A native status wake is control-flow metadata, not a new user-input - // provenance. When it coalesces into the live run, retain the verified chat - // source so the eventual terminal presentation can still prove its route. - // Fresh status-decision runs keep their native_status_decision source. - if (preservesExternalChatOrigin) { - merged.source = existingSource; - } - if ( - existing.forceFreshSession === true || - incoming.forceFreshSession === true - ) { - merged.forceFreshSession = true; - } - if (existing.refreshTools === true || incoming.refreshTools === true) { - merged.refreshTools = true; - } - const mergedCommentIds = mergeWakeCommentIds(existing, incoming); - if (mergedCommentIds.length > 0) { - const latestCommentId = mergedCommentIds[mergedCommentIds.length - 1]; - merged[WAKE_COMMENT_IDS_KEY] = mergedCommentIds; - merged.commentId = latestCommentId; - merged.wakeCommentId = latestCommentId; - // The merged context should carry canonical comment ids; the next wake will - // regenerate any structured payload from those ids. - delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY]; - } - const existingWake = parseObject(existing[PAPERCLIP_WAKE_PAYLOAD_KEY]); - const existingCommentIds = extractWakeCommentIds(existing); - const payloadCommentIds = Array.isArray(existingWake.commentIds) - ? existingWake.commentIds - : []; - const preservesAdmittedWake = - preservesExternalChatOrigin && - parseObject(existingWake.issue).id === existing.issueId && - CHAT_PROVIDERS.some( - (provider) => - existingWake.externalChatProvider === provider && - (existingSource === `chat:${provider}` || - existingSource === `chat:${provider}:recovery`), - ) && - existingCommentIds.length > 0 && - mergedCommentIds.length === existingCommentIds.length && - mergedCommentIds.every((id, index) => id === existingCommentIds[index]) && - payloadCommentIds.length === existingCommentIds.length && - payloadCommentIds.every((id, index) => id === existingCommentIds[index]) && - ((existing[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true && - existingWake.checkedOutByHarness === true) || - (existing[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] === true && - existingWake.externalChatExecutionBound === true)); - if (preservesAdmittedWake) { - merged[PAPERCLIP_WAKE_PAYLOAD_KEY] = existingWake; - merged.wakeReason = existing.wakeReason; - if (existing[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] === true) { - merged[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] = true; - } - } - if ( - !hasInteractionContinuationWakeContext(incoming) && - !( - options.preserveExistingInteractionContinuation === true && - hasInteractionContinuationWakeContext(existing) - ) - ) { - clearInteractionContinuationWakeContext(merged); - } - return merged; -} - -// This is a prompt optimization, not an authorization grant. Verify the durable -// chat binding rather than trusting an arbitrary caller's source/context marker. -export async function resolveExternalChatWakeProvider(input: { - db: Db; - companyId: string; - agentId?: string | null; - runId?: string | null; - issueId: string | null; - contextSnapshot: Record; -}): Promise { - if (input.contextSnapshot.source === "issue.interaction.respond") { - if (!input.runId || !input.agentId || !input.issueId) return null; - const answer = await resolveExternalChatQuestionResponse( - input.db, - { - companyId: input.companyId, - agentId: input.agentId, - issueId: input.issueId, - runId: input.runId, - }, - input.contextSnapshot, - "read", - ); - return answer?.provider ?? null; - } - const source = readNonEmptyString(input.contextSnapshot.source); - const provider = CHAT_PROVIDERS.find( - (candidate) => - source === `chat:${candidate}` || source === `chat:${candidate}:recovery`, - ); - const commentIds = extractWakeCommentIds(input.contextSnapshot); - if ( - !provider || - !input.agentId || - !input.issueId || - commentIds.length === 0 || - (input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] !== true && - input.contextSnapshot[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] !== - true) - ) { - return null; - } - - const links = await input.db - .select({ - commentId: chatMessageLinks.commentId, - conversationId: chatConversations.id, - }) - .from(chatMessageLinks) - .innerJoin( - chatConversations, - and( - eq(chatConversations.companyId, chatMessageLinks.companyId), - eq(chatConversations.id, chatMessageLinks.conversationId), - eq(chatConversations.endpointId, chatMessageLinks.endpointId), - ), - ) - .innerJoin( - chatEndpoints, - and( - eq(chatEndpoints.companyId, chatConversations.companyId), - eq(chatEndpoints.id, chatConversations.endpointId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, input.companyId), - eq(chatMessageLinks.direction, "inbound"), - inArray(chatMessageLinks.commentId, commentIds), - eq(chatConversations.issueId, input.issueId), - inArray(chatConversations.state, ["active", "waiting"]), - eq(chatEndpoints.provider, provider), - eq(chatEndpoints.assignedAgentId, input.agentId), - inArray(chatEndpoints.status, ["active", "verifying"]), - ), - ); - const linkedCommentIds = new Set(links.map((link) => link.commentId)); - return new Set(links.map((link) => link.conversationId)).size === 1 && - commentIds.every((id) => linkedCommentIds.has(id)) - ? provider - : null; -} - -/** Bind an already-claimed chat/reply turn without checking out or approving it. */ -export async function attestReviewedExternalChatRun(input: { - db: Db; - companyId: string; - agentId: string; - issueId: string; - runId: string; - contextSnapshot: Record; - /** Diagnostic-only value from the committed, authorized answer; never a wake field. */ - onQuestionResponseAttested?: (answeredAtMs: number) => void; -}): Promise { - const attempt = () => - input.db.transaction( - async ( - transaction, - ): Promise => { - const tx = transaction as unknown as Db; - const [issue] = await tx - .select() - .from(issues) - .where( - and( - eq(issues.id, input.issueId), - eq(issues.companyId, input.companyId), - ), - ) - .for("update", { noWait: true }) - .limit(1); - const [run] = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, input.runId), - eq(heartbeatRuns.companyId, input.companyId), - eq(heartbeatRuns.agentId, input.agentId), - ), - ) - .for("update", { noWait: true }) - .limit(1); - const [actor] = await tx - .select({ status: agents.status }) - .from(agents) - .where( - and( - eq(agents.id, input.agentId), - eq(agents.companyId, input.companyId), - ), - ) - .for("update", { noWait: true }) - .limit(1); - if ( - !issue || - !run || - !actor || - !( - issue.status === "in_review" || - (input.contextSnapshot.source === "issue.interaction.respond" && - issue.status === "in_progress") - ) || - issue.assigneeAgentId !== input.agentId || - issue.executionRunId !== input.runId || - run.status !== "running" || - (run.nativeIssueId !== null && run.nativeIssueId !== input.issueId) || - // This attestation precedes execution-start's transition to running. - // A prior run's error projection is invokable, unlike an operator - // pause, termination, or pending approval; all binding checks remain. - DIRECT_NON_INVOKABLE_STATUSES.has(actor.status) - ) - return false; - const admittedContext = parseObject(run.contextSnapshot); - const admittedIds = extractWakeCommentIds(admittedContext); - const suppliedIds = extractWakeCommentIds(input.contextSnapshot); - if ( - admittedContext.issueId !== input.issueId || - input.contextSnapshot.issueId !== input.issueId || - admittedContext.source !== input.contextSnapshot.source || - admittedIds.length === 0 || - admittedIds.length !== suppliedIds.length || - admittedIds.some((id, index) => id !== suppliedIds[index]) - ) - return false; - try { - const answer = - admittedContext.source === "issue.interaction.respond" - ? await resolveExternalChatQuestionResponse( - tx, - input, - admittedContext, - "nonblocking", - true, - ) - : null; - if (admittedContext.source === "issue.interaction.respond" && !answer) - return false; - // The marker is built here only after proving the real execution owner. - // The shared boundary then verifies current provider/resource/principal - // access for every admitted message; no lifecycle state is mutated. - await authorizeChatConversationForBoundRun( - tx, - input, - { - ...(answer?.authorizationContext ?? admittedContext), - [PAPERCLIP_HARNESS_CHECKOUT_KEY]: false, - [PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY]: true, - }, - "nonblocking", - ); - if (answer) - input.contextSnapshot[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY] = - answer.marker; - return answer ? { answeredAtMs: answer.answeredAtMs } : true; - } catch (error) { - if ( - error instanceof Error && - error.message === "paperclip_runner_chat_attachment_binding_denied" - ) { - // Inbound processing commits the message/link before dispatching its - // wake, but completes subscription and marks delivery processed after - // dispatch. Wait for that exact committed batch, never admit it early. - const links = await tx - .select({ - commentId: chatMessageLinks.commentId, - state: chatDeliveries.state, - }) - .from(chatMessageLinks) - .innerJoin( - chatDeliveries, - and( - eq(chatDeliveries.id, chatMessageLinks.deliveryId), - eq(chatDeliveries.companyId, chatMessageLinks.companyId), - eq(chatDeliveries.endpointId, chatMessageLinks.endpointId), - eq( - chatDeliveries.conversationId, - chatMessageLinks.conversationId, - ), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, input.companyId), - eq(chatMessageLinks.direction, "inbound"), - inArray(chatMessageLinks.commentId, admittedIds), - ), - ); - if ( - admittedIds.every((id) => - links.some((link) => link.commentId === id), - ) && - links.some((link) => link.state === "processing") && - links.every( - (link) => - link.state === "processing" || link.state === "processed", - ) - ) { - return "pending_delivery"; - } - } - if ( - error instanceof Error && - [ - "paperclip_runner_chat_attachment_binding_denied", - "paperclip_runner_chat_attachment_destination_denied", - "paperclip_runner_chat_attachment_principal_denied", - ].includes(error.message) - ) - return false; - throw error; - } - }, - ); - // Release every lock before retrying and re-prove the current execution - // owner, admitted batch, and policy. Routine control-plane contention is not - // evidence that the user lost access; neither is it permission to bypass it. - for (let attemptNumber = 0; attemptNumber < 51; attemptNumber += 1) { - try { - const result = await attempt(); - if (typeof result === "object") { - // The transaction (including COMMIT) must succeed before any attempt- - // local timing is accepted. A retry/rollback cannot publish this value. - input.onQuestionResponseAttested?.(result.answeredAtMs); - return true; - } - if (result !== "pending_delivery") return result; - } catch (error) { - if (!isExternalChatWaitAuthorizationContention(error)) throw error; - } - if (attemptNumber < 50) - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error("reviewed_chat_execution_binding_not_ready"); -} - -export async function buildPaperclipWakePayload(input: { - db: Db; - companyId: string; - agentId?: string | null; - runId?: string | null; - contextSnapshot: Record; - continuationSummary?: { - key: string; - title: string | null; - body: string; - sourceTrust?: SourceTrustMetadata | null; - updatedAt: Date; - } | null; - issueSummary?: { - id: string; - identifier: string | null; - title: string; - description: string | null; - status: string; - priority: string; - workMode: string; - projectId?: string | null; - executionPolicy?: unknown; - } | null; - exposeLowTrustRaw?: boolean; - // Experimental: agents write user-interaction content in ASD-STE100 - // Simplified Technical English (rendered as a prompt directive downstream). - simplifiedEnglishInteractions?: boolean; -}) { - const executionStage = parseObject(input.contextSnapshot.executionStage); - const commentIds = extractWakeCommentIds(input.contextSnapshot); - const annotationCommentId = readNonEmptyString( - input.contextSnapshot.annotationCommentId, - ); - const issueId = readNonEmptyString(input.contextSnapshot.issueId); - const conversationMode = input.contextSnapshot.conversationMode === true; - const continuationSummary = conversationMode ? null : input.continuationSummary ?? null; - const agentMessage = parseObject( - input.contextSnapshot[PAPERCLIP_AGENT_MESSAGE_KEY], - ); - const agentMessageText = sanitizeAgentSessionMessageText(agentMessage.text); - const issueSummary = - input.issueSummary ?? - (issueId - ? await input.db - .select({ - id: issues.id, - identifier: issues.identifier, - title: issues.title, - description: issues.description, - status: issues.status, - priority: issues.priority, - workMode: issues.workMode, - }) - .from(issues) - .where( - and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)), - ) - .then((rows) => rows[0] ?? null) - : null); - if ( - commentIds.length === 0 && - Object.keys(executionStage).length === 0 && - !issueSummary && - !agentMessageText - ) - return null; - - const commentRows = - commentIds.length === 0 - ? [] - : await input.db - .select({ - id: issueComments.id, - issueId: issueComments.issueId, - body: issueComments.body, - authorType: issueComments.authorType, - authorAgentId: issueComments.authorAgentId, - authorUserId: issueComments.authorUserId, - presentation: issueComments.presentation, - metadata: issueComments.metadata, - deletedAt: issueComments.deletedAt, - deletedByType: issueComments.deletedByType, - deletedByAgentId: issueComments.deletedByAgentId, - deletedByUserId: issueComments.deletedByUserId, - deletedByRunId: issueComments.deletedByRunId, - sourceTrust: issueComments.sourceTrust, - createdAt: issueComments.createdAt, - }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, input.companyId), - issueId ? eq(issueComments.issueId, issueId) : undefined, - inArray(issueComments.id, commentIds), - ), - ); - - const commentsById = new Map( - commentRows.map((comment) => [comment.id, comment]), - ); - const issueDescription = conversationMode ? null : issueSummary?.description ?? null; - const issueDescriptionTruncated = - issueDescription !== null && - issueDescription.length > MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS; - const inlineIssueDescription = issueDescriptionTruncated - ? issueDescription.slice(0, MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS) - : issueDescription; - const comments: Array> = []; - let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS; - let truncated = false; - let missingCommentCount = 0; - const safeContinuationSummary = - continuationSummary && !input.exposeLowTrustRaw - ? redactQuarantinedBodyForHigherTrust(continuationSummary) - : continuationSummary; - - for (const commentId of commentIds) { - const row = commentsById.get(commentId); - if (!row) { - truncated = true; - missingCommentCount += 1; - continue; - } - if (comments.length >= MAX_INLINE_WAKE_COMMENTS) { - truncated = true; - break; - } - - const deletedAt = row.deletedAt ?? null; - const safeRow = - deletedAt || input.exposeLowTrustRaw - ? row - : sanitizeQuarantinedCommentForHigherTrust(row); - const fullBody = deletedAt ? "" : safeRow.body; - const allowedBodyChars = Math.min( - MAX_INLINE_WAKE_COMMENT_BODY_CHARS, - remainingBodyChars, - ); - if (allowedBodyChars <= 0) { - truncated = true; - break; - } - - const body = - fullBody.length > allowedBodyChars - ? fullBody.slice(0, allowedBodyChars) - : fullBody; - const bodyTruncated = body.length < fullBody.length; - if (bodyTruncated) truncated = true; - remainingBodyChars -= body.length; - - comments.push({ - id: row.id, - issueId: row.issueId, - authorType: - row.authorType ?? - (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"), - body, - bodyTruncated, - presentation: deletedAt ? null : (safeRow.presentation ?? null), - metadata: deletedAt ? null : (safeRow.metadata ?? null), - deletedAt: deletedAt ? deletedAt.toISOString() : null, - deletedByType: deletedAt ? (row.deletedByType ?? null) : null, - deletedByAgentId: deletedAt ? (row.deletedByAgentId ?? null) : null, - deletedByUserId: deletedAt ? (row.deletedByUserId ?? null) : null, - deletedByRunId: deletedAt ? (row.deletedByRunId ?? null) : null, - sourceTrust: row.sourceTrust ?? null, - createdAt: row.createdAt.toISOString(), - author: row.authorAgentId - ? { type: "agent", id: row.authorAgentId } - : row.authorUserId - ? { type: "user", id: row.authorUserId } - : { type: "system", id: null }, - }); - } - - const attachmentCommentIds = comments.flatMap((comment) => - typeof comment.id === "string" && - comment.deletedAt === null && - (input.exposeLowTrustRaw || - !isLowTrustQuarantined(comment.sourceTrust as SourceTrustMetadata | null)) - ? [comment.id] - : [], - ); - const attachmentRows = - !issueId || attachmentCommentIds.length === 0 - ? [] - : await input.db - .select({ - id: issueAttachments.id, - issueCommentId: issueAttachments.issueCommentId, - filename: assets.originalFilename, - contentType: assets.contentType, - byteSize: assets.byteSize, - }) - .from(issueAttachments) - .innerJoin( - assets, - and( - eq(issueAttachments.assetId, assets.id), - eq(assets.companyId, input.companyId), - ), - ) - .where( - and( - eq(issueAttachments.companyId, input.companyId), - eq(issueAttachments.issueId, issueId), - inArray(issueAttachments.issueCommentId, attachmentCommentIds), - ), - ) - .orderBy(asc(issueAttachments.createdAt), asc(issueAttachments.id)) - .limit(MAX_INLINE_WAKE_ATTACHMENTS + 1); - if (attachmentRows.length > MAX_INLINE_WAKE_ATTACHMENTS) truncated = true; - const attachmentsByCommentId = new Map< - string, - Array<{ - id: string; - filename: string; - contentType: string; - byteSize: number; - contentPath: string; - }> - >(); - for (const attachment of attachmentRows.slice( - 0, - MAX_INLINE_WAKE_ATTACHMENTS, - )) { - if (!attachment.issueCommentId) continue; - const descriptors = - attachmentsByCommentId.get(attachment.issueCommentId) ?? []; - descriptors.push({ - id: attachment.id, - filename: attachment.filename?.trim() || "attachment", - contentType: attachment.contentType, - byteSize: attachment.byteSize, - contentPath: `/api/attachments/${attachment.id}/content`, - }); - attachmentsByCommentId.set(attachment.issueCommentId, descriptors); - } - for (const comment of comments) { - if (typeof comment.id !== "string") continue; - const attachments = attachmentsByCommentId.get(comment.id); - if (attachments?.length) comment.attachments = attachments; - } - - const annotationDeltas = - annotationCommentId && issueId - ? await input.db - .select({ - id: documentAnnotationComments.id, - issueId: documentAnnotationComments.issueId, - threadId: documentAnnotationComments.threadId, - body: documentAnnotationComments.body, - authorType: documentAnnotationComments.authorType, - authorAgentId: documentAnnotationComments.authorAgentId, - authorUserId: documentAnnotationComments.authorUserId, - createdAt: documentAnnotationComments.createdAt, - documentKey: documentAnnotationThreads.documentKey, - status: documentAnnotationThreads.status, - anchorState: documentAnnotationThreads.anchorState, - anchorConfidence: documentAnnotationThreads.anchorConfidence, - currentRevisionNumber: - documentAnnotationThreads.currentRevisionNumber, - selectedText: documentAnnotationThreads.selectedText, - prefixText: documentAnnotationThreads.prefixText, - suffixText: documentAnnotationThreads.suffixText, - }) - .from(documentAnnotationComments) - .innerJoin( - documentAnnotationThreads, - eq( - documentAnnotationComments.threadId, - documentAnnotationThreads.id, - ), - ) - .where( - and( - eq(documentAnnotationComments.companyId, input.companyId), - eq(documentAnnotationComments.issueId, issueId), - eq(documentAnnotationComments.id, annotationCommentId), - eq(documentAnnotationThreads.companyId, input.companyId), - eq(documentAnnotationThreads.issueId, issueId), - ), - ) - .then((rows) => - rows.map((row) => ({ - id: row.id, - issueId: row.issueId, - threadId: row.threadId, - documentKey: row.documentKey, - revisionNumber: row.currentRevisionNumber, - quote: row.selectedText, - prefix: row.prefixText, - suffix: row.suffixText, - threadStatus: row.status, - anchorState: row.anchorState, - anchorConfidence: row.anchorConfidence, - body: - row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS - ? row.body.slice(0, MAX_INLINE_WAKE_COMMENT_BODY_CHARS) - : row.body, - bodyTruncated: - row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS, - createdAt: row.createdAt.toISOString(), - author: row.authorAgentId - ? { type: "agent", id: row.authorAgentId } - : row.authorUserId - ? { type: "user", id: row.authorUserId } - : { type: row.authorType, id: null }, - })), - ) - : []; - const interactionId = readNonEmptyString(input.contextSnapshot.interactionId); - const interactionKind = readNonEmptyString( - input.contextSnapshot.interactionKind, - ); - const interactionStatus = readNonEmptyString( - input.contextSnapshot.interactionStatus, - ); - const interactionContinuationSource = readNonEmptyString( - input.contextSnapshot.source, - ); - const externalInteractionContinuation = - (interactionStatus === "answered" || interactionStatus === "accepted") && - (input.contextSnapshot.externalChatContinuation === true || - interactionContinuationSource === "external_chat.interaction.resolve"); - const checkboxSelection = parseObject( - input.contextSnapshot.checkboxSelection, - ); - // A resolved plan review is new user input, including in chat. Ordinary chat - // wakes must still exclude historical plan context across /new boundaries. - const resolvedPlanInteraction = interactionId && interactionKind === "request_confirmation" && - (interactionStatus === "accepted" || interactionStatus === "rejected"); - const planReviewContext = issueId && (!conversationMode || resolvedPlanInteraction) - ? await buildPlanReviewContext({ - db: input.db, - companyId: input.companyId, - issueId, - issueWorkMode: conversationMode ? null : issueSummary?.workMode ?? null, - includeForIssueComment: !conversationMode && commentIds.length > 0, - includeForAnnotationDelta: !conversationMode && annotationDeltas.length > 0, - interactionId, - }) - : null; - const documentReviewContext = issueId && !conversationMode - ? await buildDocumentReviewContext({ - db: input.db, - companyId: input.companyId, - issueId, - includeForIssueComment: commentIds.length > 0, - includeForAnnotationDelta: annotationDeltas.length > 0, - }) - : null; - const payloadTruncated = - truncated || - issueDescriptionTruncated || - planReviewContext?.truncated === true || - documentReviewContext?.truncated === true; - const recoveryActionId = readNonEmptyString( - input.contextSnapshot.recoveryActionId, - ); - const recoveryCause = readNonEmptyString(input.contextSnapshot.recoveryCause); - const recoveryAction = recoveryActionId - ? await input.db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.id, recoveryActionId), - eq(issueRecoveryActions.companyId, input.companyId), - ), - ) - .then((rows) => rows[0] ?? null) - : null; - const recoveryEvidence = parseObject(recoveryAction?.evidence); - // A restored task resumes its deliverable, not the completed repair. A - // referenced settled/missing action must not fall back to a stale cause. - const recoveryStillActive = recoveryAction - ? ["active", "escalated"].includes(recoveryAction.status) - : !recoveryActionId && Boolean(recoveryCause); - const originalAssigneeId = - recoveryAction?.returnOwnerAgentId ?? - recoveryAction?.previousOwnerAgentId ?? - null; - const originalAssignee = originalAssigneeId - ? await input.db - .select({ id: agents.id, name: agents.name }) - .from(agents) - .where( - and( - eq(agents.id, originalAssigneeId), - eq(agents.companyId, input.companyId), - ), - ) - .then((rows) => rows[0] ?? null) - : null; - - const externalChatProvider = await resolveExternalChatWakeProvider({ - db: input.db, - companyId: input.companyId, - agentId: input.agentId, - runId: input.runId, - issueId: issueSummary?.id === issueId ? issueId : null, - contextSnapshot: input.contextSnapshot, - }); - const attachmentOmissions = externalChatProvider - ? readExternalAttachmentOmissions( - input.contextSnapshot[EXTERNAL_ATTACHMENT_OMISSIONS_KEY], - ) - .filter((omission) => commentIds.includes(omission.commentId)) - .map((omission) => ({ - commentId: omission.commentId, - reasons: omission.reasons, - notice: externalAttachmentOmissionNotice(omission), - })) - : []; - const payload = { - reason: readNonEmptyString(input.contextSnapshot.wakeReason), - executionContinuation: input.contextSnapshot.executionContinuation ?? null, - chatCompletionUpdates: input.contextSnapshot.chatCompletionUpdates ?? null, - attachmentOmissions, - externalChatProvider, - recovery: - recoveryStillActive - ? { - cause: recoveryAction?.cause ?? recoveryCause, - failureSummary: readNonEmptyString(recoveryEvidence.failureSummary), - originalAssignee: originalAssignee - ? { id: originalAssignee.id, name: originalAssignee.name } - : originalAssigneeId - ? { id: originalAssigneeId, name: null } - : null, - attemptCount: recoveryAction?.attemptCount ?? null, - maxAttempts: recoveryAction?.maxAttempts ?? null, - nextAction: recoveryAction?.nextAction ?? null, - routingFallbackReason: readNonEmptyString( - recoveryEvidence.routingFallbackReason, - ), - } - : null, - issue: issueSummary - ? { - id: issueSummary.id, - identifier: issueSummary.identifier, - title: issueSummary.title, - description: inlineIssueDescription, - descriptionTruncated: issueDescriptionTruncated, - status: issueSummary.status, - priority: issueSummary.priority, - workMode: issueSummary.workMode, - } - : null, - agentMessage: agentMessageText - ? { - text: agentMessageText, - source: readNonEmptyString(agentMessage.source), - pluginKey: readNonEmptyString(agentMessage.pluginKey), - sessionId: readNonEmptyString(agentMessage.sessionId), - ...(Array.isArray(agentMessage.untrustedToolResults) - ? { - untrustedToolResults: agentMessage.untrustedToolResults - .slice(0, 8) - .map((value) => { - const result = parseObject(value); - return { - actionRequestId: - sanitizeAgentSessionMessageText( - result.actionRequestId, - ) ?? "", - toolName: - sanitizeAgentSessionMessageText(result.toolName) ?? "", - resultSummary: - sanitizeAgentSessionMessageText(result.resultSummary) ?? - "", - error: sanitizeAgentSessionMessageText(result.error), - declineReason: sanitizeAgentSessionMessageText( - result.declineReason, - ), - }; - }), - } - : {}), - } - : null, - childIssueSummaries: Array.isArray( - input.contextSnapshot.childIssueSummaries, - ) - ? input.contextSnapshot.childIssueSummaries - : [], - childIssueSummaryTruncated: - input.contextSnapshot.childIssueSummaryTruncated === true, - dispositionRepair: input.contextSnapshot.legacyDispositionEpisode ? { - attempt: input.contextSnapshot.dispositionRepairAttempt, - maxAttempts: input.contextSnapshot.dispositionRepairMaxAttempts, - sourceRunId: input.contextSnapshot.retryOfRunId, - instruction: input.contextSnapshot.dispositionRepairInstruction, - } : null, - livenessContinuation: - readNonEmptyString(input.contextSnapshot.livenessContinuationState) || - readNonEmptyString( - input.contextSnapshot.livenessContinuationInstruction, - ) || - readNonEmptyString( - input.contextSnapshot.livenessContinuationSourceRunId, - ) || - typeof input.contextSnapshot.livenessContinuationAttempt === "number" - ? { - attempt: input.contextSnapshot.livenessContinuationAttempt, - maxAttempts: input.contextSnapshot.livenessContinuationMaxAttempts, - sourceRunId: readNonEmptyString( - input.contextSnapshot.livenessContinuationSourceRunId, - ), - state: readNonEmptyString( - input.contextSnapshot.livenessContinuationState, - ), - reason: readNonEmptyString( - input.contextSnapshot.livenessContinuationReason, - ), - instruction: readNonEmptyString( - input.contextSnapshot.livenessContinuationInstruction, - ), - } - : null, - interactionKind, - interactionStatus, - interactionId, - sourceRunId: readNonEmptyString(input.contextSnapshot.sourceRunId), - externalChatQuestionResponse: externalChatProvider - ? (input.contextSnapshot[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY] ?? null) - : null, - externalInteractionContinuation, - checkboxSelection: - Object.keys(checkboxSelection).length > 0 ? checkboxSelection : null, - checkedOutByHarness: - input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, - externalChatExecutionBound: - input.contextSnapshot[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] === - true, - simplifiedEnglishInteractions: input.simplifiedEnglishInteractions === true, - dependencyBlockedInteraction: - input.contextSnapshot.dependencyBlockedInteraction === true, - treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true, - activeTreeHold: parseObject(input.contextSnapshot.activeTreeHold), - unresolvedBlockerIssueIds: Array.isArray( - input.contextSnapshot.unresolvedBlockerIssueIds, - ) - ? input.contextSnapshot.unresolvedBlockerIssueIds.filter( - (value): value is string => - typeof value === "string" && value.length > 0, - ) - : [], - unresolvedBlockerSummaries: Array.isArray( - input.contextSnapshot.unresolvedBlockerSummaries, - ) - ? input.contextSnapshot.unresolvedBlockerSummaries - : [], - executionStage: - Object.keys(executionStage).length > 0 ? executionStage : null, - taskWatchdog: (input.contextSnapshot.taskWatchdog ?? null) as unknown, - skillTest: (input.contextSnapshot.paperclipSkillTest ?? null) as unknown, - continuationSummary: safeContinuationSummary - ? { - key: safeContinuationSummary.key, - title: safeContinuationSummary.title, - body: - safeContinuationSummary.body.length > 4_000 - ? safeContinuationSummary.body.slice(0, 4_000) - : safeContinuationSummary.body, - bodyTruncated: safeContinuationSummary.body.length > 4_000, - sourceTrust: safeContinuationSummary.sourceTrust ?? null, - updatedAt: safeContinuationSummary.updatedAt.toISOString(), - } - : null, - commentIds, - latestCommentId: commentIds[commentIds.length - 1] ?? null, - comments, - annotationDeltas, - planReviewContext, - documentReviewContext, - commentWindow: { - requestedCount: commentIds.length, - includedCount: comments.length, - missingCount: missingCommentCount, - }, - truncated: payloadTruncated, - fallbackFetchNeeded: payloadTruncated || missingCommentCount > 0, - }; - return issueId - ? createRunSecretRedactionRegistry(input.db).redactForIssue( - input.companyId, - issueId, - payload, - ) - : payload; -} - function runTaskKey(run: typeof heartbeatRuns.$inferSelect) { return deriveTaskKey( run.contextSnapshot as Record | null, @@ -5175,10 +1380,6 @@ function isSameTaskScope(left: string | null, right: string | null) { return (left ?? null) === (right ?? null); } -function isTrackedLocalChildProcessAdapter(adapterType: string) { - return SESSIONED_LOCAL_ADAPTERS.has(adapterType); -} - function isHeartbeatRunTerminalStatus( status: string | null | undefined, ): status is (typeof HEARTBEAT_RUN_TERMINAL_STATUSES)[number] { @@ -5456,23 +1657,6 @@ function buildRunEventRuntimeProgress(input: { }; } -// A positive liveness check means some process currently owns the PID. -// On Linux, PIDs can be recycled, so this is a best-effort signal rather -// than proof that the original child is still alive. -function isProcessAlive(pid: number | null | undefined) { - if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) - return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code === "EPERM") return true; - if (code === "ESRCH") return false; - return false; - } -} - export async function persistHeartbeatRunProcessMetadata( db: Db, runId: string, @@ -5532,110 +1716,6 @@ async function terminateHeartbeatRunProcess(input: { ); } -function buildProcessLossMessage( - run: { - processPid: number | null; - processGroupId: number | null; - }, - options?: { descendantOnly?: boolean }, -) { - if (options?.descendantOnly && run.processGroupId) { - return `Process lost -- parent pid ${run.processPid ?? "unknown"} exited, but descendant process group ${run.processGroupId} was still alive and was terminated`; - } - if (run.processPid) { - return `Process lost -- child pid ${run.processPid} is no longer running`; - } - if (run.processGroupId) { - return `Process lost -- process group ${run.processGroupId} is no longer running`; - } - return "Process lost -- server may have restarted"; -} - -function readHotRestartAdoptionMetadata( - resultJson: Record | null | undefined, -) { - const result = parseObject(resultJson); - const hotRestart = parseObject(result.hotRestart); - if (hotRestart.adopted !== true || typeof hotRestart.adoptedAt !== "string") - return null; - return hotRestart; -} - -function mergeHotRestartAdoptionResultJson( - resultJson: Record | null | undefined, - input: { - adoptedAt: Date; - previousServerPid: number; - newServerPid: number; - previousServerVersion: string | null; - newServerVersion: string; - processPid: number | null; - processGroupId: number | null; - }, -) { - const result = parseObject(resultJson); - const existing = parseObject(result.hotRestart); - return { - ...result, - hotRestart: { - ...existing, - adopted: true, - adoptedAt: input.adoptedAt.toISOString(), - previousServerPid: input.previousServerPid, - newServerPid: input.newServerPid, - previousServerVersion: input.previousServerVersion, - newServerVersion: input.newServerVersion, - processPid: input.processPid, - processGroupId: input.processGroupId, - }, - }; -} - -function truncateDisplayId(value: string | null | undefined, max = 128) { - if (!value) return null; - return value.length > max ? value.slice(0, max) : value; -} - -function normalizeAgentNameKey(value: string | null | undefined) { - if (typeof value !== "string") return null; - const normalized = value.trim().toLowerCase(); - return normalized.length > 0 ? normalized : null; -} - -const defaultSessionCodec: AdapterSessionCodec = { - deserialize(raw: unknown) { - const asObj = parseObject(raw); - if (Object.keys(asObj).length > 0) return asObj; - const sessionId = readNonEmptyString( - (raw as Record | null)?.sessionId, - ); - if (sessionId) return { sessionId }; - return null; - }, - serialize(params: Record | null) { - if (!params || Object.keys(params).length === 0) return null; - return params; - }, - getDisplayId(params: Record | null) { - return readNonEmptyString(params?.sessionId); - }, -}; - -function getAdapterSessionCodec(adapterType: string) { - const adapter = getServerAdapter(adapterType); - return adapter.sessionCodec ?? defaultSessionCodec; -} - -export function normalizeSessionParams( - params: Record | null | undefined, -) { - if (!params) return null; - return Object.keys(params).length > 0 ? params : null; -} - -type RunSessionOutcome = - "succeeded" | "interrupted" | "failed" | "cancelled" | "timed_out"; - type SkillTestHeartbeatCompletion = { outcome: "failed" | "cancelled"; error: string | null; @@ -5670,196 +1750,6 @@ export function resolveSkillTestRunCompletionForHeartbeatOutcome( return null; } -const HERMES_ADAPTER_TYPE = "hermes_local"; -const HERMES_SESSION_ID_REGEX = - /^(?:\d{8}_\d{6}_[A-Za-z0-9_-]{4,}|[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})$/; - -function requiresCanonicalSessionIds(adapterType: string | null | undefined) { - return adapterType === HERMES_ADAPTER_TYPE; -} - -function isCanonicalSessionIdForAdapter( - adapterType: string | null | undefined, - sessionId: string | null | undefined, -) { - if (!sessionId) return false; - if (!requiresCanonicalSessionIds(adapterType)) return true; - return HERMES_SESSION_ID_REGEX.test(sessionId); -} - -function normalizeResumeParamsForAdapter( - adapterType: string | null | undefined, - params: Record | null | undefined, -) { - const normalized = normalizeSessionParams(params); - if (!normalized) return null; - if (!requiresCanonicalSessionIds(adapterType)) return normalized; - const sessionId = readNonEmptyString(normalized.sessionId); - return isCanonicalSessionIdForAdapter(adapterType, sessionId) - ? normalized - : null; -} - -export function resolveNextSessionState(input: { - adapterType?: string | null; - codec: AdapterSessionCodec; - adapterResult: AdapterExecutionResult; - outcome: RunSessionOutcome; - previousParams: Record | null; - previousDisplayId: string | null; - previousLegacySessionId: string | null; -}) { - const { - adapterType, - codec, - adapterResult, - previousParams, - previousDisplayId, - previousLegacySessionId, - } = input; - - if (adapterResult.clearSession) { - return { - params: null as Record | null, - displayId: null as string | null, - legacySessionId: null as string | null, - }; - } - - if (!requiresCanonicalSessionIds(adapterType)) { - const explicitParams = adapterResult.sessionParams; - const hasExplicitParams = adapterResult.sessionParams !== undefined; - const hasExplicitSessionId = adapterResult.sessionId !== undefined; - const explicitSessionId = readNonEmptyString(adapterResult.sessionId); - const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined; - const explicitDisplayId = readNonEmptyString( - adapterResult.sessionDisplayId, - ); - const shouldUsePrevious = - !hasExplicitParams && !hasExplicitSessionId && !hasExplicitDisplay; - - const candidateParams = hasExplicitParams - ? explicitParams - : hasExplicitSessionId - ? explicitSessionId - ? { sessionId: explicitSessionId } - : null - : previousParams; - - const serialized = normalizeSessionParams( - codec.serialize(normalizeSessionParams(candidateParams) ?? null), - ); - const deserialized = normalizeSessionParams(codec.deserialize(serialized)); - - const displayId = truncateDisplayId( - explicitDisplayId ?? - (codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ?? - readNonEmptyString(deserialized?.sessionId) ?? - (shouldUsePrevious ? previousDisplayId : null) ?? - explicitSessionId ?? - (shouldUsePrevious ? previousLegacySessionId : null), - ); - - const legacySessionId = - explicitSessionId ?? - readNonEmptyString(deserialized?.sessionId) ?? - displayId ?? - (shouldUsePrevious ? previousLegacySessionId : null); - - return { - params: serialized, - displayId, - legacySessionId, - }; - } - - const previousSerializedParams = normalizeResumeParamsForAdapter( - adapterType, - codec.serialize( - normalizeResumeParamsForAdapter(adapterType, previousParams), - ), - ); - const validPreviousDisplayId = isCanonicalSessionIdForAdapter( - adapterType, - previousDisplayId, - ) - ? previousDisplayId - : null; - const validPreviousLegacySessionId = isCanonicalSessionIdForAdapter( - adapterType, - previousLegacySessionId, - ) - ? previousLegacySessionId - : null; - const previousState = () => { - const displayId = truncateDisplayId( - readNonEmptyString(previousSerializedParams?.sessionId) ?? - validPreviousDisplayId ?? - validPreviousLegacySessionId, - ); - return { - params: previousSerializedParams, - displayId, - legacySessionId: - readNonEmptyString(previousSerializedParams?.sessionId) ?? - displayId ?? - validPreviousLegacySessionId, - }; - }; - - if (input.outcome !== "succeeded") { - return previousState(); - } - - const explicitParams = adapterResult.sessionParams; - const hasExplicitParams = adapterResult.sessionParams !== undefined; - const explicitSessionId = readNonEmptyString(adapterResult.sessionId); - const validExplicitSessionId = isCanonicalSessionIdForAdapter( - adapterType, - explicitSessionId, - ) - ? explicitSessionId - : null; - const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId); - const validExplicitDisplayId = isCanonicalSessionIdForAdapter( - adapterType, - explicitDisplayId, - ) - ? explicitDisplayId - : null; - const explicitSerializedParams = hasExplicitParams - ? normalizeResumeParamsForAdapter( - adapterType, - codec.serialize(normalizeSessionParams(explicitParams) ?? null), - ) - : null; - const explicitCanonicalSessionId = - readNonEmptyString(explicitSerializedParams?.sessionId) ?? - validExplicitSessionId ?? - validExplicitDisplayId; - - if (!explicitCanonicalSessionId) { - return previousState(); - } - - const serialized = normalizeResumeParamsForAdapter( - adapterType, - codec.serialize({ sessionId: explicitCanonicalSessionId }), - ); - const displayId = truncateDisplayId( - readNonEmptyString(serialized?.sessionId) ?? - (codec.getDisplayId ? codec.getDisplayId(serialized) : null) ?? - explicitCanonicalSessionId, - ); - const legacySessionId = - readNonEmptyString(serialized?.sessionId) ?? explicitCanonicalSessionId; - - return { - params: serialized, - displayId, - legacySessionId, - }; -} export type HeartbeatEnvironmentRuntime = ReturnType< typeof environmentRuntimeService @@ -6055,6 +1945,41 @@ export function heartbeatService( db: Db, options: HeartbeatServiceOptions = {}, ) { + const { + getAgent, + resolveSessionBeforeForWakeup, + getRun, + ensureRuntimeState, + getTaskSession, + getLatestAgentConfigRevision, + evaluateSessionCompaction, + upsertTaskSession, + resolveNormalizedUsageForSession, + clearTaskSessions, + resolveExplicitResumeSessionOverride, + hasResolvablePriorSessionWorkspaceForWake, + getRunLogAccess, + listRuns, + getRuntimeStateWithSessions, + listTaskSessions, + resetRuntimeSession, + listEvents, + getRetryExhaustedReason, + getRunIssueSummary, + getActiveRunForAgent, + getActiveRunIssueSummaryForAgent, + } = createHeartbeatRunState(db); + const { + toAgentOrgRow, + listCompanyAgentOrgRows, + groupAgentOrgRowsByCompany, + getIssueExecutionContext, + getPinnedSkillTestContext, + getRoutineEnvForExecutionIssue, + resolveResponsibleUserIdForRunSeed, + resolveResponsibleUserIdForRun, + resolveResponsibleUserIdForRunContext, + } = createHeartbeatRunPreparation(db); let shutdownInProgress = false; const instanceSettings = instanceSettingsService(db); const getCurrentUserRedactionOptions = async () => ({ @@ -6170,6 +2095,76 @@ export function heartbeatService( BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, }); const runDispatch = createRunDispatch(db); + const { + scheduleBoundedRetryForRun, + timerClaimWasFirstHeartbeat, + scheduleInteractionContinuationInfrastructureRetryIfEligible, + findSharedWorkspaceHolder, + finalizeAiConnectionBusyDeferral, + finalizeWorkspaceBusyDeferral, + promoteDueScheduledRetries, + retryScheduledRetryNow, + scheduleBoundedRetry, + } = createHeartbeatRetries(db, { + appendRunEvent, + escalatePlanApprovalResumeFailureNeedsAttention, + getAgentInvokability, + runDispatch, + resolveSessionBeforeForWakeup, + resolveResponsibleUserIdForRunContext, + recordPlanApprovalResumeFailureRetry, + setRunStatusIfRunning, + setWakeupStatus, + getRun, + getAgent, + releaseIssueExecutionAndPromote, + finalizeAgentStatus, + getWorktreeExecutionCutoff, + applyRunDispatchPostCommitEffects, + }); + + const { + sweepPendingCleanupLeases, + markNativeOwnershipUnverified, + prepareHotRestartShutdown, + reconcileHotRestartAdoption, + recoverNativeRunsAfterRestart, + reapOrphanedRuns, + sweepOrphanedActiveLeases, + drainRunningRunsForShutdown, + } = createHeartbeatRecovery(db, { + enterShutdown: () => { shutdownInProgress = true; }, + scheduleBoundedRetryForRun, + appendRunEvent, + environmentRuntime, + scheduleNativeSessionResumeDispatch, + getRun, + executeRun, + activeRunExecutionPromises, + cancelHeartbeatNativeRun, + terminateHeartbeatRunProcess, + setRunStatusIfRunning, + mergeRunStopMetadataForAgent, + setWakeupStatus, + classifyAndPersistRunLiveness, + releaseEnvironmentLeasesForRun, + releaseIssueExecutionAndPromote, + finalizeAgentStatus, + timerClaimWasFirstHeartbeat, + environmentsSvc, + acknowledgeRemoteStop, + resumeRemoteStopComments, + setRunStatusFromLive, + instructionCopies, + dispatchPendingNativeStatusWakeups, + runtimeEnv, + cancelRunInternal, + activeRunExecutions, + setRunStatus, + getAgent, + scheduleInteractionContinuationInfrastructureRetryIfEligible, + startNextQueuedRunForAgent, + }); // Applies the post-commit effects a run-dispatch operation returns, on a // best-effort basis, exactly as this service publishes them for every @@ -6356,6 +2351,77 @@ export function heartbeatService( }, }); + const heartbeatQueue = createHeartbeatQueue(db, { + setRunStatus, + setWakeupStatus, + appendRunEvent, + releaseIssueExecutionAndPromote, + issuesSvc, + options, + publishRunLifecyclePluginEvent, + finalizeAgentStatus, + getAgent, + cancelRunInternal, + toAgentOrgRow, + getAgentInvokability, + budgets, + activeRunExecutions, + treeControlSvc, + runDispatch, + applyRunDispatchPostCommitEffects, + resolveResponsibleUserIdForRun, + getIssueExecutionContext, + isHeartbeatRunTerminalStatus, + liveRunExecutions, + getSchedulingSuppression, + resumeExecutionWaitComments, + getWorktreeExecutionCutoff, + resumeQueuedCommentInterrupt, + resumeSavedLegacyComments, + cancelActiveForAgentInternal, + listCompanyAgentOrgRows, + executeRun, + activeRunExecutionPromises, + activeWakeupPromises, + instanceSettings, + getRun, + sweepPendingCleanupLeases, + resolveExplicitResumeSessionOverride, + resolveSessionBeforeForWakeup, + hasResolvablePriorSessionWorkspaceForWake, + getRoutineEnvForExecutionIssue, + resolveResponsibleUserIdForRunSeed, + formatIssueIdentifierLink, + wakeQueue, + isSameTaskScope, + runTaskKey, + filterZombieCoalesceTarget, + }); + + const { + countRunningRunsForAgent, + releaseRunClaimedJustBeforeSuppression, + claimQueuedRun, + withChatControlRecoveryGate, + trackWakeup, + resumeQueuedRuns, + parseHeartbeatPolicy, + claimDueTimerHeartbeat, + } = heartbeatQueue; + + // Earlier factories capture these callbacks before queue construction. + function enqueueWakeup(...args: Parameters) { + return heartbeatQueue.enqueueWakeup(...args); + } + + function startNextQueuedRunForAgent(...args: Parameters) { + return heartbeatQueue.startNextQueuedRunForAgent(...args); + } + + function dispatchPendingNativeStatusWakeups(...args: Parameters) { + return heartbeatQueue.dispatchPendingNativeStatusWakeups(...args); + } + // Applies the post-commit effects a wake-queue release returns, exactly as // the original release function did before its writes moved into that // module: publish + dispatch a promoted or recovery run, log a reopened @@ -6675,8 +2741,6 @@ export function heartbeatService( } const taskWatchdogs = taskWatchdogService(db, { enqueueWakeup }); - let unsafeTextProjectionPromise: Promise | null = null; - async function completeSkillTestRunForHeartbeatOutcome(input: { run: typeof heartbeatRuns.$inferSelect; issueId: string | null; @@ -7176,109 +3240,12 @@ export function heartbeatService( } } - async function hasUnsafeTextProjectionDatabase() { - if (!unsafeTextProjectionPromise) { - unsafeTextProjectionPromise = db - .execute( - sql`select current_setting('server_encoding') as server_encoding`, - ) - .then((rows) => { - const first = Array.isArray(rows) ? rows[0] : null; - const serverEncoding = - typeof first === "object" && first !== null - ? (first as Record).server_encoding - : null; - return ( - typeof serverEncoding === "string" && - serverEncoding.toUpperCase() === "SQL_ASCII" - ); - }) - .catch((err) => { - logger.warn( - { err }, - "failed to inspect database server encoding; using conservative heartbeat result projection", - ); - return true; - }); - } - return unsafeTextProjectionPromise; - } - - async function getAgent(agentId: string) { - return db - .select() - .from(agents) - .where(eq(agents.id, agentId)) - .then((rows) => rows[0] ?? null); - } - async function getAgentInvokability( agent: typeof agents.$inferSelect | null | undefined, ) { return evaluateAgentInvokabilityFromDb(db, agent); } - function toAgentOrgRow( - agent: Pick< - typeof agents.$inferSelect, - "id" | "companyId" | "name" | "reportsTo" | "status" - >, - ): AgentOrgRow { - return { - id: agent.id, - companyId: agent.companyId, - name: agent.name, - reportsTo: agent.reportsTo, - status: agent.status, - }; - } - - async function listCompanyAgentOrgRows( - companyId: string, - ): Promise { - return db - .select({ - id: agents.id, - companyId: agents.companyId, - name: agents.name, - reportsTo: agents.reportsTo, - status: agents.status, - }) - .from(agents) - .where(eq(agents.companyId, companyId)); - } - - function groupAgentOrgRowsByCompany(agentRows: AgentOrgRow[]) { - const byCompany = new Map(); - for (const agent of agentRows) { - const companyAgents = byCompany.get(agent.companyId); - if (companyAgents) { - companyAgents.push(agent); - } else { - byCompany.set(agent.companyId, [agent]); - } - } - return byCompany; - } - - async function getRun( - runId: string, - opts?: { unsafeFullResultJson?: boolean; includeExecutionEvidence?: boolean }, - ) { - const safeForLegacyEncoding = - !opts?.unsafeFullResultJson && (await hasUnsafeTextProjectionDatabase()); - const columns = opts?.unsafeFullResultJson - ? getTableColumns(heartbeatRuns) - : safeForLegacyEncoding ? heartbeatRunSqlAsciiSafeColumns : heartbeatRunSafeColumns; - return db - .select(opts?.includeExecutionEvidence - ? { ...columns, resultJson: heartbeatRunExecutionEvidenceColumn } - : columns) - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, runId)) - .then((rows) => rows[0] ?? null); - } - async function recordCurrentHeartbeatRunRuntimeProgress( run: Pick< typeof heartbeatRuns.$inferSelect, @@ -7301,498 +3268,6 @@ export function heartbeatService( return recordHeartbeatRunRuntimeProgress(currentRun, update, issueId); } - async function getRunLogAccess(runId: string) { - return db - .select(heartbeatRunLogAccessColumns) - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, runId)) - .then((rows) => rows[0] ?? null); - } - - async function getIssueExecutionContext(companyId: string, issueId: string) { - return db - .select({ - chatCommunicationGuidance: chatConversations.communicationGuidance, - chatAssignedAgentId: chatEndpoints.assignedAgentId, - // Select only the public command, never the rest of setup state. - chatSlackCommand: sql`case when ${chatEndpoints.status} in ('active', 'verifying') then ${chatEndpoints.setup}->>'command' end`, - externalConversationState: externalConversationStateSql(), - conversationAgentId: issues.conversationAgentId, - conversationUserId: issues.conversationUserId, - conversationState: issues.conversationState, - conversationSessionGeneration: issues.conversationSessionGeneration, - conversationBoundaryCommentId: issues.conversationBoundaryCommentId, - id: issues.id, - identifier: issues.identifier, - title: issues.title, - titleNeedsGeneration: issues.titleNeedsGeneration, - description: issues.description, - status: issues.status, - workMode: issues.workMode, - reviewPolicy: issues.reviewPolicy, - priority: issues.priority, - projectId: issues.projectId, - projectWorkspaceId: issues.projectWorkspaceId, - executionWorkspaceId: issues.executionWorkspaceId, - executionWorkspacePreference: issues.executionWorkspacePreference, - assigneeAgentId: issues.assigneeAgentId, - assigneeAdapterOverrides: issues.assigneeAdapterOverrides, - executionPolicy: issues.executionPolicy, - executionState: issues.executionState, - executionWorkspaceSettings: issues.executionWorkspaceSettings, - parentId: issues.parentId, - createdByUserId: issues.createdByUserId, - responsibleUserId: issues.responsibleUserId, - originKind: issues.originKind, - originId: issues.originId, - originRunId: issues.originRunId, - originIdentityContextId: issues.originIdentityContextId, - continuationIdentityContextId: issues.continuationIdentityContextId, - updatedAt: issues.updatedAt, - }) - .from(issues) - .leftJoin(chatConversations, and( - eq(chatConversations.companyId, issues.companyId), - eq(chatConversations.issueId, issues.id), - )) - .leftJoin(chatEndpoints, and( - eq(chatEndpoints.companyId, chatConversations.companyId), - eq(chatEndpoints.id, chatConversations.endpointId), - eq(chatEndpoints.provider, "slack"), - )) - .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) - .then((rows) => rows[0] ?? null); - } - - async function getPinnedSkillTestContext(companyId: string, issueId: string) { - const row = await db - .select({ - testRunId: companySkillTestRuns.id, - skillId: companySkillTestRuns.skillId, - inputId: companySkillTestRuns.inputId, - skillVersionId: companySkillTestRuns.skillVersionId, - outputDocumentKey: companySkillTestRuns.outputDocumentKey, - fileInventory: companySkillVersions.fileInventory, - revisionNumber: companySkillVersions.revisionNumber, - label: companySkillVersions.label, - }) - .from(companySkillTestRuns) - .innerJoin( - companySkillVersions, - and( - eq(companySkillVersions.id, companySkillTestRuns.skillVersionId), - eq(companySkillVersions.companyId, companySkillTestRuns.companyId), - ), - ) - .where( - and( - eq(companySkillTestRuns.companyId, companyId), - eq(companySkillTestRuns.issueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null); - if (!row) return null; - const fileInventory = Array.isArray(row.fileInventory) - ? row.fileInventory.flatMap((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) - return []; - const record = entry as unknown as Record; - const path = typeof record.path === "string" ? record.path : ""; - if (!path) return []; - return [ - { - path, - kind: typeof record.kind === "string" ? record.kind : "other", - content: typeof record.content === "string" ? record.content : "", - }, - ]; - }) - : []; - return { - testRunId: row.testRunId, - skillId: row.skillId, - inputId: row.inputId ?? null, - skillVersionId: row.skillVersionId, - revisionNumber: row.revisionNumber, - label: row.label ?? null, - outputDocumentKey: row.outputDocumentKey, - fileInventory, - }; - } - - async function getRoutineEnvForExecutionIssue( - companyId: string, - issueContext: { originKind: string | null; originId: string | null; originRunId: string | null } | null, - ) { - if ( - !issueContext || - issueContext.originKind !== "routine_execution" || - !issueContext.originId - ) { - return { routineId: null, env: null, responsibleUserId: null }; - } - - const routineRun = issueContext.originRunId - ? await db - .select({ - routineRevisionId: routineRuns.routineRevisionId, - responsibleUserId: routineRuns.responsibleUserId, - }) - .from(routineRuns) - .where( - and( - eq(routineRuns.id, issueContext.originRunId), - eq(routineRuns.companyId, companyId), - eq(routineRuns.routineId, issueContext.originId), - ), - ) - .then((rows) => rows[0] ?? null) - : null; - - if (routineRun?.routineRevisionId) { - const revision = await db - .select({ - snapshot: routineRevisions.snapshot, - responsibleUserId: routineRevisions.responsibleUserId, - }) - .from(routineRevisions) - .where( - and( - eq(routineRevisions.id, routineRun.routineRevisionId), - eq(routineRevisions.companyId, companyId), - eq(routineRevisions.routineId, issueContext.originId), - ), - ) - .then((rows) => rows[0] ?? null); - const snapshot = revision?.snapshot as - RoutineRevisionSnapshotV1 | undefined; - if (snapshot?.version === 1) { - return { - routineId: issueContext.originId, - env: snapshot.routine.env ?? null, - responsibleUserId: - routineRun?.responsibleUserId ?? - revision?.responsibleUserId ?? - snapshot.routine.responsibleUserId ?? - null, - }; - } - } - - const routine = await db - .select({ - env: routines.env, - responsibleUserId: routines.responsibleUserId, - }) - .from(routines) - .where( - and( - eq(routines.id, issueContext.originId), - eq(routines.companyId, companyId), - ), - ) - .then((rows) => rows[0] ?? null); - return { - routineId: issueContext.originId, - env: routine?.env ?? null, - responsibleUserId: - routineRun?.responsibleUserId ?? routine?.responsibleUserId ?? null, - }; - } - - async function resolveCompanyDefaultResponsibleUserId(companyId: string) { - const company = await db - .select({ defaultResponsibleUserId: companies.defaultResponsibleUserId }) - .from(companies) - .where(eq(companies.id, companyId)) - .then((rows) => rows[0] ?? null); - const explicitDefault = readNonEmptyString( - company?.defaultResponsibleUserId, - ); - if (explicitDefault) return explicitDefault; - - const owner = await db - .select({ userId: companyMemberships.principalId }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.status, "active"), - eq(companyMemberships.membershipRole, "owner"), - ), - ) - .orderBy(asc(companyMemberships.createdAt), asc(companyMemberships.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - if (owner?.userId) return owner.userId; - - const firstUser = await db - .select({ userId: companyMemberships.principalId }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.status, "active"), - ), - ) - .orderBy(asc(companyMemberships.createdAt), asc(companyMemberships.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - return firstUser?.userId ?? null; - } - - async function resolveParentIssueResponsibleUserId( - companyId: string, - parentId: string | null | undefined, - ) { - if (!parentId) return null; - const parent = await db - .select({ - responsibleUserId: issues.responsibleUserId, - createdByUserId: issues.createdByUserId, - }) - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.id, parentId))) - .then((rows) => rows[0] ?? null); - return parent?.responsibleUserId ?? null; - } - - function isManualUserRun(input: { - contextSnapshot: Record; - requestedByActorType?: "user" | "agent" | "system" | null; - source?: WakeupOptions["source"] | null; - triggerDetail?: WakeupOptions["triggerDetail"] | null; - }) { - if (input.requestedByActorType !== "user") return false; - const wakeReason = readNonEmptyString(input.contextSnapshot.wakeReason); - if (wakeReason && ISSUE_RESPONSIBLE_USER_WAKE_REASONS.has(wakeReason)) - return false; - return input.source === "on_demand" || input.triggerDetail === "manual"; - } - - async function resolveResponsibleUserIdForRunSeed(input: { - companyId: string; - contextSnapshot: Record; - issueContext: { id: string; responsibleUserId: string | null; parentId: string | null } | null; - routineEnvContext: Awaited< - ReturnType - >; - requestedByActorType?: "user" | "agent" | "system" | null; - requestedByActorId?: string | null; - source?: WakeupOptions["source"] | null; - triggerDetail?: WakeupOptions["triggerDetail"] | null; - existingRunResponsibleUserId?: string | null; - }) { - const contextResponsibleUserId = readNonEmptyString( - input.contextSnapshot.responsibleUserId, - ); - const requestedUserId = - input.requestedByActorType === "user" - ? readNonEmptyString(input.requestedByActorId) - : null; - const messageIds = Array.isArray(input.contextSnapshot.wakeCommentIds) - ? input.contextSnapshot.wakeCommentIds.filter( - (id): id is string => typeof id === "string", - ) - : readNonEmptyString(input.contextSnapshot.wakeCommentId) - ? [String(input.contextSnapshot.wakeCommentId)] - : []; - if ( - input.issueContext && - messageIds.length && - !input.contextSnapshot.retryOfRunId - ) { - const messages = await db - .select({ - id: issueComments.id, - authorUserId: issueComments.authorUserId, - }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, input.companyId), - eq(issueComments.issueId, input.issueContext.id), - inArray(issueComments.id, messageIds), - ), - ); - for (const id of [...messageIds].reverse()) { - const author = messages.find( - (message) => message.id === id, - )?.authorUserId; - if (author) { - delete input.contextSnapshot.executionIdentityCause; - return author; - } - } - } - const retryOfRunId = readNonEmptyString(input.contextSnapshot.retryOfRunId); - if (retryOfRunId) { - const [origin] = await db - .select({ responsibleUserId: heartbeatRuns.responsibleUserId }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, input.companyId), - eq(heartbeatRuns.id, retryOfRunId), - ), - ); - if (origin?.responsibleUserId) return origin.responsibleUserId; - } - if (contextResponsibleUserId) return contextResponsibleUserId; - if (input.existingRunResponsibleUserId) - return input.existingRunResponsibleUserId; - if (input.routineEnvContext.responsibleUserId) - return input.routineEnvContext.responsibleUserId; - if (isManualUserRun(input) && requestedUserId) return requestedUserId; - if (input.issueContext?.responsibleUserId) - return input.issueContext.responsibleUserId; - const parentResponsibleUserId = await resolveParentIssueResponsibleUserId( - input.companyId, - input.issueContext?.parentId, - ); - if (parentResponsibleUserId) return parentResponsibleUserId; - if (!input.issueContext && requestedUserId) return requestedUserId; - input.contextSnapshot.executionIdentityCause = "company_default"; - return resolveCompanyDefaultResponsibleUserId(input.companyId); - } - - async function resolveResponsibleUserIdForRun(input: { - run: typeof heartbeatRuns.$inferSelect; - contextSnapshot: Record; - issueContext: Awaited> | null; - routineEnvContext: Awaited< - ReturnType - >; - }) { - const operatorIdentity = await explicitOperatorRunIdentity(db, input.run); - const responsibleUserId = operatorIdentity?.actorId ?? await resolveResponsibleUserIdForRunSeed({ - companyId: input.run.companyId, - contextSnapshot: input.contextSnapshot, - issueContext: input.issueContext, - routineEnvContext: input.routineEnvContext, - existingRunResponsibleUserId: input.run.responsibleUserId, - source: input.run.invocationSource as WakeupOptions["source"], - triggerDetail: input.run.triggerDetail as WakeupOptions["triggerDetail"], - }); - if (!responsibleUserId) { - throw new HttpError( - 422, - "Unable to resolve responsible user for heartbeat run dispatch", - { - code: "responsible_user_unresolved", - runId: input.run.id, - agentId: input.run.agentId, - companyId: input.run.companyId, - issueId: input.issueContext?.id ?? null, - invocationSource: input.run.invocationSource, - triggerDetail: input.run.triggerDetail, - wakeReason: readNonEmptyString(input.contextSnapshot.wakeReason), - }, - ); - } - return responsibleUserId; - } - - async function resolveResponsibleUserIdForRunContext( - run: typeof heartbeatRuns.$inferSelect, - contextSnapshot: Record, - ) { - const issueId = - readNonEmptyString(contextSnapshot.issueId) ?? - readNonEmptyString(contextSnapshot.taskId); - const issueContext = issueId - ? await getIssueExecutionContext(run.companyId, issueId) - : null; - return resolveResponsibleUserIdForRun({ - run, - contextSnapshot, - issueContext, - routineEnvContext: await getRoutineEnvForExecutionIssue( - run.companyId, - issueContext, - ), - }); - } - - async function getRuntimeState(agentId: string) { - return db - .select() - .from(agentRuntimeState) - .where(eq(agentRuntimeState.agentId, agentId)) - .then((rows) => rows[0] ?? null); - } - - async function getLatestAgentConfigRevision( - companyId: string, - agentId: string, - ) { - return db - .select({ - id: agentConfigRevisions.id, - changedKeys: agentConfigRevisions.changedKeys, - createdAt: agentConfigRevisions.createdAt, - }) - .from(agentConfigRevisions) - .where( - and( - eq(agentConfigRevisions.companyId, companyId), - eq(agentConfigRevisions.agentId, agentId), - ), - ) - .orderBy( - desc(agentConfigRevisions.createdAt), - desc(agentConfigRevisions.id), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - } - - async function getTaskSession( - companyId: string, - agentId: string, - adapterType: string, - taskKey: string, - ) { - return db - .select() - .from(agentTaskSessions) - .where( - and( - eq(agentTaskSessions.companyId, companyId), - eq(agentTaskSessions.agentId, agentId), - eq(agentTaskSessions.adapterType, adapterType), - eq(agentTaskSessions.taskKey, taskKey), - ), - ) - .then((rows) => rows[0] ?? null); - } - - async function getLatestRunForSession( - agentId: string, - sessionId: string, - opts?: { excludeRunId?: string | null }, - ) { - const conditions = [ - eq(heartbeatRuns.agentId, agentId), - eq(heartbeatRuns.sessionIdAfter, sessionId), - ]; - if (opts?.excludeRunId) { - conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`); - } - return db - .select({ - id: heartbeatRuns.id, - usageJson: heartbeatRuns.usageJson, - }) - .from(heartbeatRuns) - .where(and(...conditions)) - .orderBy(desc(heartbeatRuns.createdAt)) - .limit(1) - .then((rows) => rows[0] ?? null); - } - const issueMonitorDispatchColumns = { id: issues.id, companyId: issues.companyId, @@ -8659,470 +4134,11 @@ export function heartbeatService( }; } - async function getOldestRunForSession(agentId: string, sessionId: string) { - return db - .select({ - id: heartbeatRuns.id, - createdAt: heartbeatRuns.createdAt, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.agentId, agentId), - eq(heartbeatRuns.sessionIdAfter, sessionId), - ), - ) - .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - } - - async function resolveNormalizedUsageForSession(input: { - agentId: string; - runId: string; - sessionId: string | null; - rawUsage: UsageTotals | null; - usageBasis?: "per_run" | "session_cumulative" | null; - }) { - const { agentId, runId, sessionId, rawUsage, usageBasis } = input; - // Adapters that declare per-run usage (e.g. the ACPX lane reports each - // turn's tokens, not session totals) must not be session-delta'd, or - // consecutive runs would be undercounted. - if (!sessionId || !rawUsage || usageBasis !== "session_cumulative") { - return { - normalizedUsage: rawUsage, - previousRawUsage: null as UsageTotals | null, - derivedFromSessionTotals: false, - }; - } - - const previousRun = await getLatestRunForSession(agentId, sessionId, { - excludeRunId: runId, - }); - const previousRawUsage = readRawUsageTotals(previousRun?.usageJson); - return { - normalizedUsage: normalizeAdapterRunUsage(rawUsage, previousRawUsage, usageBasis), - previousRawUsage, - derivedFromSessionTotals: previousRawUsage !== null, - }; - } - - async function evaluateSessionCompaction(input: { - agent: typeof agents.$inferSelect; - sessionId: string | null; - issueId: string | null; - continuationSummaryBody?: string | null; - }): Promise { - const { agent, sessionId, issueId } = input; - if (!sessionId) { - return { - rotate: false, - reason: null, - handoffMarkdown: null, - previousRunId: null, - }; - } - - const policy = parseSessionCompactionPolicy(agent); - if (!policy.enabled || !hasSessionCompactionThresholds(policy)) { - return { - rotate: false, - reason: null, - handoffMarkdown: null, - previousRunId: null, - }; - } - - const fetchLimit = Math.max( - policy.maxSessionRuns > 0 ? policy.maxSessionRuns + 1 : 0, - 4, - ); - const runs = await db - .select({ - id: heartbeatRuns.id, - createdAt: heartbeatRuns.createdAt, - usageJson: heartbeatRuns.usageJson, - error: heartbeatRuns.error, - terminalFailureCategory: sql`case - when jsonb_typeof(${heartbeatRuns.resultJson} -> 'terminalSessionFailure') = 'object' - then coalesce(left(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,category}', 32), 'unknown') end`, - ...heartbeatRunListResultColumns, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.agentId, agent.id), - eq(heartbeatRuns.sessionIdAfter, sessionId), - ), - ) - .orderBy(desc(heartbeatRuns.createdAt)) - .limit(fetchLimit); - - if (runs.length === 0) { - return { - rotate: false, - reason: null, - handoffMarkdown: null, - previousRunId: null, - }; - } - - const latestRun = runs[0] ?? null; - const oldestRun = - policy.maxSessionAgeHours > 0 - ? await getOldestRunForSession(agent.id, sessionId) - : (runs[runs.length - 1] ?? latestRun); - const latestRawUsage = readRawUsageTotals(latestRun?.usageJson); - // Historical Codex/Gemini raw input includes cache reads. Only add the - // separate cache counter when the writer explicitly saved exclusive input. - const latestRawInputTokens = (latestRawUsage?.inputTokens ?? 0) + - (latestRun?.usageJson?.rawInputIncludesCached === false ? latestRawUsage?.cachedInputTokens ?? 0 : 0); - const sessionAgeHours = - latestRun && oldestRun - ? Math.max( - 0, - (new Date(latestRun.createdAt).getTime() - - new Date(oldestRun.createdAt).getTime()) / - (1000 * 60 * 60), - ) - : 0; - - let reason: string | null = null; - if (policy.maxSessionRuns > 0 && runs.length > policy.maxSessionRuns) { - reason = `session exceeded ${policy.maxSessionRuns} runs`; - } else if ( - policy.maxRawInputTokens > 0 && - latestRawUsage && - latestRawInputTokens >= policy.maxRawInputTokens - ) { - reason = - `session raw input reached ${formatCount(latestRawInputTokens)} tokens ` + - `(threshold ${formatCount(policy.maxRawInputTokens)})`; - } else if ( - policy.maxSessionAgeHours > 0 && - sessionAgeHours >= policy.maxSessionAgeHours - ) { - reason = `session age reached ${Math.floor(sessionAgeHours)} hours`; - } - - if (!reason || !latestRun) { - return { - rotate: false, - reason: null, - handoffMarkdown: null, - previousRunId: latestRun?.id ?? null, - }; - } - - const latestSummary = summarizeHeartbeatRunListResultJson({ - summary: latestRun?.resultSummary, - result: latestRun?.resultResult, - message: latestRun?.resultMessage, - error: latestRun?.resultError, - totalCostUsd: latestRun?.resultTotalCostUsd, - costUsd: latestRun?.resultCostUsd, - costUsdCamel: latestRun?.resultCostUsdCamel, - }); - const latestTextSummary = - readNonEmptyString(latestSummary?.summary) ?? - readNonEmptyString(latestSummary?.result) ?? - readNonEmptyString(latestSummary?.message) ?? - readNonEmptyString(summarizeRunErrorForModel(latestRun.error, latestRun.terminalFailureCategory)); - - const handoffMarkdown = [ - "Paperclip session handoff:", - `- Previous session: ${sessionId}`, - issueId ? `- Issue: ${issueId}` : "", - `- Rotation reason: ${reason}`, - latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "", - input.continuationSummaryBody - ? `- Issue continuation summary: ${input.continuationSummaryBody.slice(0, 1_500)}` - : "", - "Continue from the current task state. Rebuild only the minimum context you need.", - ] - .filter(Boolean) - .join("\n"); - - return { - rotate: true, - reason, - handoffMarkdown, - previousRunId: latestRun.id, - }; - } - - async function resolveSessionBeforeForWakeup( - agent: typeof agents.$inferSelect, - taskKey: string | null, - ) { - if (taskKey) { - const codec = getAdapterSessionCodec(agent.adapterType); - const existingTaskSession = await getTaskSession( - agent.companyId, - agent.id, - agent.adapterType, - taskKey, - ); - const parsedParams = normalizeSessionParams( - codec.deserialize(existingTaskSession?.sessionParamsJson ?? null), - ); - return truncateDisplayId( - existingTaskSession?.sessionDisplayId ?? - (codec.getDisplayId ? codec.getDisplayId(parsedParams) : null) ?? - readNonEmptyString(parsedParams?.sessionId), - ); - } - - const runtimeForRun = await getRuntimeState(agent.id); - return runtimeForRun?.sessionId ?? null; - } - - async function hasResolvableSessionWorkspaceCwd( - sessionParams: Record | null | undefined, - ) { - const cwd = readNonEmptyString(sessionParams?.cwd); - if (!cwd || isUnsafeSessionWorkspaceCwd(cwd)) return false; - return fs - .stat(cwd) - .then((stats) => stats.isDirectory()) - .catch(() => false); - } - - async function hasResolvablePriorSessionWorkspaceForWake(input: { - agent: typeof agents.$inferSelect; - contextSnapshot: Record; - taskKey: string | null; - explicitResumeSession: Awaited< - ReturnType - > | null; - }) { - if ( - await hasResolvableSessionWorkspaceCwd( - input.explicitResumeSession?.sessionParams, - ) - ) - return true; - if (shouldResetTaskSessionForWake(input.contextSnapshot)) return false; - if (!input.taskKey) return false; - - const codec = getAdapterSessionCodec(input.agent.adapterType); - const taskSession = await getTaskSession( - input.agent.companyId, - input.agent.id, - input.agent.adapterType, - input.taskKey, - ); - const taskSessionParams = normalizeResumeParamsForAdapter( - input.agent.adapterType, - codec.deserialize(taskSession?.sessionParamsJson ?? null), - ); - return hasResolvableSessionWorkspaceCwd(taskSessionParams); - } - - async function resolveExplicitResumeSessionOverride( - agent: typeof agents.$inferSelect, - payload: Record | null, - taskKey: string | null, - ) { - const resumeFromRunId = readNonEmptyString(payload?.resumeFromRunId); - if (!resumeFromRunId) return null; - - const resumeRun = await db - .select({ - id: heartbeatRuns.id, - contextSnapshot: heartbeatRuns.contextSnapshot, - resultJson: heartbeatRuns.resultJson, - sessionIdBefore: heartbeatRuns.sessionIdBefore, - sessionIdAfter: heartbeatRuns.sessionIdAfter, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, resumeFromRunId), - eq(heartbeatRuns.companyId, agent.companyId), - eq(heartbeatRuns.agentId, agent.id), - ), - ) - .then((rows) => rows[0] ?? null); - if (!resumeRun) return null; - - const resumeContext = parseObject(resumeRun.contextSnapshot); - const resumeTaskKey = deriveTaskKey(resumeContext, null) ?? taskKey; - const resumeTaskSession = resumeTaskKey - ? await getTaskSession( - agent.companyId, - agent.id, - agent.adapterType, - resumeTaskKey, - ) - : null; - const sessionCodec = getAdapterSessionCodec(agent.adapterType); - const resumeRunResult = parseObject(resumeRun.resultJson); - const resumeRunSessionId = requiresCanonicalSessionIds(agent.adapterType) - ? (readNonEmptyString(resumeRunResult.sessionId) ?? - readNonEmptyString(resumeRunResult.session_id)) - : null; - const sessionOverride = buildExplicitResumeSessionOverride({ - adapterType: agent.adapterType, - resumeFromRunId, - resumeRunSessionIdBefore: resumeRun.sessionIdBefore, - resumeRunSessionIdAfter: resumeRun.sessionIdAfter, - resumeRunSessionParams: resumeRunSessionId - ? { sessionId: resumeRunSessionId } - : null, - taskSession: resumeTaskSession, - sessionCodec, - }); - if (!sessionOverride) return null; - - return { - resumeFromRunId, - taskKey: resumeTaskKey, - issueId: readNonEmptyString(resumeContext.issueId), - taskId: - readNonEmptyString(resumeContext.taskId) ?? - readNonEmptyString(resumeContext.issueId), - sessionDisplayId: sessionOverride.sessionDisplayId, - sessionParams: sessionOverride.sessionParams, - }; - } - const { resolveReusedGitWorkspaceAnchor, resolveWorkspaceForRun, } = createHeartbeatWorkspaceResolver(db); - async function upsertTaskSession(input: { - companyId: string; - agentId: string; - adapterType: string; - taskKey: string; - sessionParamsJson: Record | null; - sessionDisplayId: string | null; - lastRunId: string | null; - lastError: string | null; - }) { - return db.transaction(async (tx) => { - const [issue] = await tx.select().from(issues).where(and(sql`${issues.id}::text = ${input.taskKey}`, eq(issues.companyId, input.companyId))).for("update"); - if (isConversation(issue)) { - const [run] = input.lastRunId ? await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.lastRunId)) : []; - if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return null; - } - const existing = await tx.select().from(agentTaskSessions).where(and(eq(agentTaskSessions.companyId, input.companyId), eq(agentTaskSessions.agentId, input.agentId), eq(agentTaskSessions.adapterType, input.adapterType), eq(agentTaskSessions.taskKey, input.taskKey))).then((rows) => rows[0] ?? null); - if (existing) { - return tx - .update(agentTaskSessions) - .set({ - sessionParamsJson: input.sessionParamsJson, - sessionDisplayId: input.sessionDisplayId, - lastRunId: input.lastRunId, - lastError: input.lastError, - updatedAt: new Date(), - }) - .where(eq(agentTaskSessions.id, existing.id)) - .returning() - .then((rows) => rows[0] ?? null); - } - - return tx - .insert(agentTaskSessions) - .values({ - companyId: input.companyId, - agentId: input.agentId, - adapterType: input.adapterType, - taskKey: input.taskKey, - sessionParamsJson: input.sessionParamsJson, - sessionDisplayId: input.sessionDisplayId, - lastRunId: input.lastRunId, - lastError: input.lastError, - }) - .returning() - .then((rows) => rows[0] ?? null); - }); - } - - async function clearTaskSessions( - companyId: string, - agentId: string, - opts?: { - taskKey?: string | null; - adapterType?: string | null; - expectedRunId?: string; - includeIssueAliases?: boolean; - }, - ) { - const conditions = [ - eq(agentTaskSessions.companyId, companyId), - eq(agentTaskSessions.agentId, agentId), - ]; - if (opts?.taskKey) { - const exactTaskKey = eq(agentTaskSessions.taskKey, opts.taskKey); - if (opts.includeIssueAliases) { - const selectedIssue = isUuidLike(opts.taskKey) - ? eq(issues.id, opts.taskKey) - : eq(issues.identifier, opts.taskKey.toUpperCase()); - // Operator task resets accept the UUID sent by run detail and the - // identifier used by some saved sessions. Resolve only from the current - // same-company issue row, in this DELETE's snapshot; arbitrary custom - // keys retain exact-match behavior and run/model context grants no alias. - conditions.push( - or( - exactTaskKey, - sql`exists ( - select 1 from ${issues} - where ${issues.companyId} = ${companyId} - and ${selectedIssue} - and (${agentTaskSessions.taskKey} = ${issues.id}::text - or ${agentTaskSessions.taskKey} = ${issues.identifier}) - )`, - )!, - ); - } else { - conditions.push(exactTaskKey); - } - } - if (opts?.adapterType) { - conditions.push(eq(agentTaskSessions.adapterType, opts.adapterType)); - } - - return db.transaction(async (tx) => { - if (opts?.taskKey && opts.expectedRunId) { - const [issue] = await tx.select().from(issues).where(sql`${issues.id}::text = ${opts.taskKey}`).for("update"); - if (isConversation(issue)) { - const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, opts.expectedRunId)); - if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return 0; - } - } - return tx.delete(agentTaskSessions).where(and(...conditions)).returning().then((rows) => rows.length); - }); - } - - async function ensureRuntimeState(agent: typeof agents.$inferSelect) { - const existing = await getRuntimeState(agent.id); - if (existing) return existing; - - const inserted = await db - .insert(agentRuntimeState) - .values({ - agentId: agent.id, - companyId: agent.companyId, - adapterType: agent.adapterType, - stateJson: {}, - }) - .onConflictDoNothing({ - target: agentRuntimeState.agentId, - }) - .returning() - .then((rows) => rows[0] ?? null); - if (inserted) return inserted; - - const ensured = await getRuntimeState(agent.id); - if (!ensured) { - throw new Error(`Failed to ensure runtime state for agent ${agent.id}`); - } - return ensured; - } - // Emits agent.task_run for a run write that just reached a terminal // status, unless the write only re-set a status the run already had (a // status-preserving patch, such as a livenessReason update on a run that @@ -10790,2554 +5806,6 @@ export function heartbeatService( return { outcome: "retry_exhausted" as const, queuedRun: null }; } - async function enqueueProcessLossRetry( - run: typeof heartbeatRuns.$inferSelect, - agent: typeof agents.$inferSelect, - now: Date, - ) { - // Completion deliveries own their durable bounded retry and reply identity. - // A second process-loss retry would compete for the same outbox input. - if (run.contextSnapshot?.wakeReason === CHAT_COMPLETION_WAKE_REASON && - Array.isArray(run.contextSnapshot?.chatCompletionDeliveryIds)) return null; - // Native sessions have their own fenced same-run controller. Legacy - // bootstrap recovery shares the durable delay and incident counter with - // transient retries; process loss must not open a second retry budget. - if (run.runtimeMode === "native" || await legacyExecutionNeedsReconciliationWithEvidence(db, run)) - return null; - const scheduled = await scheduleBoundedRetryForRun(run, agent, { now }); - return scheduled.outcome === "scheduled" ? scheduled.run : null; - } - - function toHotRestartIntentRun(input: { - run: typeof heartbeatRuns.$inferSelect; - adapterType: string; - }): HotRestartIntentRun { - const context = parseObject(input.run.contextSnapshot); - return { - runId: input.run.id, - companyId: input.run.companyId, - agentId: input.run.agentId, - adapterType: input.adapterType, - status: input.run.status, - processPid: input.run.processPid ?? null, - processGroupId: input.run.processGroupId ?? null, - issueId: readNonEmptyString(context.issueId), - runtimeMode: input.run.runtimeMode, - nativeSessionId: input.run.nativeSessionId, - runnerInstanceId: input.run.runnerInstanceId, - processStartedAt: input.run.processStartedAt?.toISOString() ?? null, - }; - } - - function isServerStdioBoundHotRestartRun(input: { - run: typeof heartbeatRuns.$inferSelect; - adapterType: string; - adapterConfig: unknown; - }) { - const context = parseObject(input.run.contextSnapshot); - if ( - context.processTopology === "server_stdio" || - context.executionEngine === "acp" - ) { - return true; - } - if ( - context.processTopology === "detached" || - context.executionEngine === "cli" - ) { - return false; - } - if ( - !["claude_local", "codex_local", "gemini_local"].includes( - input.adapterType, - ) - ) { - return false; - } - return ( - readNonEmptyString(parseObject(input.adapterConfig).engine) !== "cli" - ); - } - - async function prepareHotRestartShutdown( - signal: "SIGINT" | "SIGTERM", - now = new Date(), - ) { - shutdownInProgress = true; - const idleSessions = await closeIdleWarmNativeSessionsForRestart(); - if (idleSessions.failed > 0) { - logger.warn({ idleSessions }, "idle native sessions could not checkpoint before controller shutdown"); - } - let intent: Awaited>; - try { - intent = await readHotRestartIntent(); - } catch (err) { - logger.warn( - { err }, - "failed to read hot-restart intent; falling back to normal shutdown drain", - ); - return { - mode: "read_error" as const, - skipDrain: false as const, - activeRunIds: [] as string[], - }; - } - - if (!intent) - return { - mode: "not_requested" as const, - skipDrain: false as const, - activeRunIds: [] as string[], - }; - if (intent.drainRequired) - return { - mode: "drain_required" as const, - skipDrain: false as const, - activeRunIds: [] as string[], - }; - if (!shouldHonorHotRestartIntentForProcess(intent)) { - logger.warn( - { expectedPid: intent.previousServerPid, currentPid: process.pid }, - "hot-restart intent targets a different server pid; falling back to normal shutdown drain", - ); - return { - mode: "pid_mismatch" as const, - skipDrain: false as const, - activeRunIds: [] as string[], - }; - } - - const activeRuns = await db - .select({ - run: heartbeatRuns, - adapterType: agents.adapterType, - adapterConfig: agents.adapterConfig, - }) - .from(heartbeatRuns) - .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) - .where(eq(heartbeatRuns.status, "running")); - const snapshotRuns = activeRuns.map(toHotRestartIntentRun); - const intentWithVersion = { - ...intent, - previousServerVersion: intent.previousServerVersion ?? serverVersion, - }; - - const serverStdioRuns = activeRuns.filter(isServerStdioBoundHotRestartRun); - if (serverStdioRuns.length > 0) { - const activeServerStdioRunIds = serverStdioRuns.map(({ run }) => run.id); - await writeHotRestartShutdownSnapshot({ - intent: intentWithVersion, - signal, - activeRuns: snapshotRuns, - drainReason: "active_acp_run", - drainRunIds: activeServerStdioRunIds, - capturedAt: now, - }); - - logger.warn( - { - signal, - previousServerPid: intent.previousServerPid, - activeRunIds: snapshotRuns.map((run) => run.runId), - activeServerStdioRunIds, - drainReason: "active_acp_run", - }, - "server-stdio agent run prevents hot-restart adoption; using graceful drain and retry", - ); - - return { - mode: "acp_drain_required" as const, - skipDrain: false as const, - activeRunIds: snapshotRuns.map((run) => run.runId), - activeAcpRunIds: activeServerStdioRunIds, - drainRunIds: activeServerStdioRunIds, - drainReason: "active_acp_run" as const, - }; - } - - await writeHotRestartShutdownSnapshot({ - intent: intentWithVersion, - signal, - activeRuns: snapshotRuns, - capturedAt: now, - }); - - for (const { run } of activeRuns) { - await appendRunEvent(run, { - eventType: "lifecycle", - stream: "system", - level: "info", - message: - "Hot restart requested; leaving child process alive for startup adoption", - payload: { - signal, - previousServerPid: intent.previousServerPid, - previousServerVersion: intentWithVersion.previousServerVersion, - processPid: run.processPid ?? null, - processGroupId: run.processGroupId ?? null, - }, - }); - } - - const nativeRunIds = activeRuns - .filter( - ({ run, adapterType }) => - adapterType === "paperclip_runner" && - isNativeSessionId(run.nativeSessionId), - ) - .map(({ run }) => run.id); - const detachedNativeSessions = - await detachNativeSessionsForRestart(nativeRunIds); - - logger.info( - { - signal, - previousServerPid: intent.previousServerPid, - activeRunIds: snapshotRuns.map((run) => run.runId), - detachedNativeSessions, - }, - "hot-restart shutdown snapshot captured; skipping graceful run drain", - ); - - return { - mode: "hot_restart" as const, - skipDrain: true as const, - activeRunIds: snapshotRuns.map((run) => run.runId), - }; - } - - async function reconcileHotRestartAdoption(now = new Date()) { - let intent: Awaited>; - try { - intent = await readHotRestartIntent(); - } catch (err) { - logger.warn( - { err }, - "failed to read hot-restart intent on startup; skipping adoption", - ); - return { - mode: "read_error" as const, - adoptedRunIds: [] as string[], - finalizedWhileDownRunIds: [] as string[], - lostRunIds: [] as string[], - skippedRunIds: [] as string[], - }; - } - if (!intent) { - return { - mode: "not_requested" as const, - adoptedRunIds: [] as string[], - finalizedWhileDownRunIds: [] as string[], - lostRunIds: [] as string[], - skippedRunIds: [] as string[], - }; - } - - if (!intent.shutdownSnapshot) { - const log = intent.drainRequired - ? logger.info.bind(logger) - : logger.warn.bind(logger); - log( - { - previousServerPid: intent.previousServerPid, - preflightActiveRunIds: intent.preflightActiveRunIds, - drainReason: intent.drainReason ?? null, - }, - intent.drainRequired - ? "drain-required restart intent has no adoption snapshot" - : "hot-restart intent present but shutdown snapshot is missing; no runs can be adopted", - ); - } - const candidates = intent.shutdownSnapshot?.activeRuns ?? []; - const missingSnapshotRunIds = findMissingHotRestartSnapshotRunIds(intent); - const reconciliationRunIds = [ - ...new Set([ - ...candidates.map((run) => run.runId), - ...missingSnapshotRunIds, - ]), - ]; - const currentRows = - reconciliationRunIds.length > 0 - ? await db - .select({ - run: heartbeatRuns, - adapterType: agents.adapterType, - }) - .from(heartbeatRuns) - .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) - .where(inArray(heartbeatRuns.id, reconciliationRunIds)) - : []; - const currentByRunId = new Map(currentRows.map((row) => [row.run.id, row])); - - const reportRuns: HotRestartReportRun[] = []; - const adoptedRunIds: string[] = []; - const finalizedWhileDownRunIds: string[] = []; - const lostRunIds: string[] = []; - const skippedRunIds: string[] = []; - - const classify = ( - candidate: HotRestartIntentRun, - classification: HotRestartReportRun["classification"], - reason: string, - patch?: Partial, - ) => { - const run = { - ...candidate, - ...patch, - classification, - reason, - } satisfies HotRestartReportRun; - reportRuns.push(run); - if (classification === "adopted") adoptedRunIds.push(candidate.runId); - else if (classification === "finalized_while_down") - finalizedWhileDownRunIds.push(candidate.runId); - else if (classification === "lost") lostRunIds.push(candidate.runId); - else skippedRunIds.push(candidate.runId); - }; - - for (const runId of missingSnapshotRunIds) { - const current = currentByRunId.get(runId); - if (!current) { - finalizedWhileDownRunIds.push(runId); - continue; - } - - const candidate = toHotRestartIntentRun(current); - if (current.run.status !== "running") { - classify( - candidate, - "finalized_while_down", - `run_status_${current.run.status}`, - ); - } else { - classify(candidate, "lost", "missing_shutdown_snapshot"); - } - } - - if (lostRunIds.length > 0) { - logger.error( - { previousServerPid: intent.previousServerPid, lostRunIds }, - "hot-restart shutdown snapshot omitted live preflight runs; reporting them as lost", - ); - } - - for (const candidate of candidates) { - const current = currentByRunId.get(candidate.runId); - if (!current) { - classify(candidate, "finalized_while_down", "run_row_missing"); - continue; - } - - const { run, adapterType } = current; - const patch = { - adapterType, - status: run.status, - processPid: run.processPid ?? candidate.processPid, - processGroupId: run.processGroupId ?? candidate.processGroupId, - }; - - if (run.status !== "running") { - classify( - candidate, - "finalized_while_down", - `run_status_${run.status}`, - patch, - ); - continue; - } - - const hasSelectiveAcpDrain = - intent.drainReason === "active_acp_run" && - (intent.drainRunIds?.length ?? 0) > 0; - if ( - hasSelectiveAcpDrain && - intent.drainRunIds?.includes(candidate.runId) - ) { - // A selective ACP drain is expected to persist a terminal row before - // the new server starts. If the process was terminated but that write - // failed, surface the run as lost instead of hiding it as an expected - // drain skip. - classify(candidate, "lost", "selective_drain_not_finalized", patch); - continue; - } - if (intent.drainRequired && !hasSelectiveAcpDrain) { - classify(candidate, "skipped", "drain_required", patch); - continue; - } - - if (run.runtimeMode === "native" && adapterType === "paperclip_runner") { - classify(candidate, "skipped", "native_restart_recovery_owned", patch); - continue; - } - - if (!isTrackedLocalChildProcessAdapter(adapterType)) { - classify( - candidate, - "skipped", - "adapter_not_local_child_process", - patch, - ); - continue; - } - - const processPid = run.processPid ?? candidate.processPid; - const processGroupId = run.processGroupId ?? candidate.processGroupId; - const processPidAlive = isProcessAlive(processPid); - const processGroupAlive = isProcessGroupAlive(processGroupId); - if (!processPid && !processGroupId) { - classify(candidate, "lost", "missing_process_metadata", patch); - continue; - } - if (!processPidAlive && !processGroupAlive) { - classify(candidate, "lost", "process_not_alive", patch); - continue; - } - - const resultJson = mergeHotRestartAdoptionResultJson( - parseObject(run.resultJson), - { - adoptedAt: now, - previousServerPid: intent.previousServerPid, - newServerPid: process.pid, - previousServerVersion: intent.previousServerVersion, - newServerVersion: serverVersion, - processPid, - processGroupId, - }, - ); - const updated = await db - .update(heartbeatRuns) - .set({ - resultJson: preserveWorkspaceRestoreRecoveryMetadataSql(resultJson, true), - error: - run.errorCode === DETACHED_PROCESS_ERROR_CODE ? null : run.error, - errorCode: - run.errorCode === DETACHED_PROCESS_ERROR_CODE - ? null - : run.errorCode, - updatedAt: now, - }) - .where( - and( - eq(heartbeatRuns.id, run.id), - eq(heartbeatRuns.status, "running"), - ), - ) - .returning() - .then((rows) => rows[0] ?? null); - - if (!updated) { - const latest = await db - .select({ status: heartbeatRuns.status }) - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, run.id)) - .then((rows) => rows[0] ?? null); - if (latest && latest.status !== "running") { - classify( - candidate, - "finalized_while_down", - `run_status_${latest.status}`, - patch, - ); - } else { - classify(candidate, "lost", "adoption_update_not_applied", patch); - } - continue; - } - - await appendRunEvent(updated, { - eventType: "lifecycle", - stream: "system", - level: "info", - message: "Adopted live child process after hot restart", - payload: { - previousServerPid: intent.previousServerPid, - newServerPid: process.pid, - previousServerVersion: intent.previousServerVersion, - newServerVersion: serverVersion, - processPid, - processGroupId, - }, - }); - classify( - candidate, - "adopted", - processPidAlive ? "process_pid_alive" : "process_group_alive", - patch, - ); - } - - const report = await writeHotRestartReport({ - version: 1, - requestedAt: intent.requestedAt, - completedAt: now.toISOString(), - drainRequired: intent.drainRequired, - drainReason: - intent.drainReason ?? (intent.drainRequired ? "requested" : null), - previousServerPid: intent.previousServerPid, - newServerPid: process.pid, - previousServerVersion: intent.previousServerVersion, - newServerVersion: serverVersion, - adoptedRunIds, - finalizedWhileDownRunIds, - lostRunIds, - skippedRunIds, - runs: reportRuns, - }); - await removeHotRestartIntent(undefined, intent); - - logger.info( - { - previousServerPid: report.previousServerPid, - newServerPid: report.newServerPid, - adoptedRunIds, - finalizedWhileDownRunIds, - lostRunIds, - missingSnapshotRunIds, - skippedRunIds, - }, - "hot-restart adoption report written", - ); - - return { - mode: "reported" as const, - adoptedRunIds, - finalizedWhileDownRunIds, - lostRunIds, - skippedRunIds, - }; - } - - async function recoverNativeRunsAfterRestart(now = new Date()) { - // A result committed before the old controller stopped outranks process - // recovery. Finish its durable workspace/status suffix before deciding - // whether any provider authority needs to be reopened. - await reconcileNativeFinalizations(db, undefined, { - environmentRuntime, - onWorkspaceSettled: settleRecoveredNativeWorkspace, - }); - scheduleRetainedNativeSessionCleanup(); - const intent = await readHotRestartIntent().catch((error) => { - logger.warn( - { err: error }, - "failed to read hot-restart intent before native startup recovery", - ); - return null; - }); - const restartKind = intent ? ("hot" as const) : ("hard" as const); - const previousStartedAt = intent?.previousServerStartedAt - ? new Date(intent.previousServerStartedAt) - : null; - const scheduledNativeRetries = await db - .select({ - runId: nativeRunFinalizations.runId, - nextAttemptAt: nativeRunFinalizations.nextAttemptAt, - }) - .from(nativeRunFinalizations) - .innerJoin( - heartbeatRuns, - eq(heartbeatRuns.id, nativeRunFinalizations.runId), - ) - .where( - and( - eq(heartbeatRuns.runtimeMode, "native"), - inArray(heartbeatRuns.status, ["running", "failed"]), - isNull(nativeRunFinalizations.resultId), - eq(nativeRunFinalizations.phase, "retryable_failure"), - gt(nativeRunFinalizations.nextAttemptAt, now), - ), - ); - for (const scheduled of scheduledNativeRetries) { - if (scheduled.nextAttemptAt) { - scheduleNativeSessionResumeDispatch( - scheduled.runId, - scheduled.nextAttemptAt, - ); - } - } - const dispositions = await claimNativeRestartRecoveries({ - db, - restartKind, - recoveryRequestId: intent?.recoveryRequestId ?? null, - coordinatedPreviousController: intent - ? { - pid: intent.previousServerPid, - processStartedAt: - previousStartedAt && !Number.isNaN(previousStartedAt.getTime()) - ? previousStartedAt - : null, - } - : null, - now, - }); - - const claims = dispositions.filter( - (disposition): disposition is NativeRestartRecoveryClaim => - disposition.kind === "reattach_existing_runner" || - disposition.kind === "reattach_remote_runner" || - disposition.kind === "resume_dead_runner" || - disposition.kind === "bootstrap_incomplete", - ); - for (const disposition of dispositions) { - const run = await getRun(disposition.runId); - if (run) { - const isClaim = - disposition.kind === "reattach_existing_runner" || - disposition.kind === "reattach_remote_runner" || - disposition.kind === "resume_dead_runner" || - disposition.kind === "bootstrap_incomplete"; - await appendRunEvent(run, { - eventType: "native.recovery.transition", - stream: "system", - level: disposition.kind === "blocked" ? "warn" : "info", - message: - disposition.kind === "reattach_existing_runner" || - disposition.kind === "reattach_remote_runner" - ? "Recovering the existing native runner process after server restart" - : disposition.kind === "resume_dead_runner" - ? "Resuming the durable native provider session after runner process loss" - : disposition.kind === "bootstrap_incomplete" - ? "Restarting an incomplete native runner bootstrap on the same heartbeat run" - : disposition.kind === "awaiting_evidence" - ? "Native restart recovery is waiting for safe ownership evidence" - : disposition.kind === "already_finalized" - ? "Native restart recovery found an already-finalized result" - : "Native restart recovery blocked ambiguous or conflicting ownership", - payload: { - restartKind: isClaim ? disposition.restartKind : restartKind, - recoveryRequestId: isClaim - ? disposition.recoveryRequestId - : (intent?.recoveryRequestId ?? null), - runnerDisposition: disposition.kind, - ...(isClaim - ? { - controllerGeneration: disposition.controllerGeneration, - providerAttempt: disposition.providerAttempt, - } - : { reason: disposition.reason }), - ...(disposition.kind === "reattach_existing_runner" - ? { - processPid: disposition.process.pid, - processGroupId: disposition.process.processGroupId, - processStartedAt: disposition.process.startedAt, - } - : {}), - }, - }); - } - } - for (const claim of claims) { - const execution = executeRun(claim.runId, { - nativeLeaseOwner: claim.leaseOwner, - nativeRestartRecovery: claim, - }).catch((error) => { - logger.error( - { err: error, runId: claim.runId, disposition: claim.kind }, - "native restart recovery execution failed", - ); - }); - activeRunExecutionPromises.add(execution); - void execution.finally(() => - activeRunExecutionPromises.delete(execution), - ); - } - - return { - restartKind, - claims, - dispositions, - scheduledRetryRunIds: scheduledNativeRetries.map((entry) => entry.runId), - awaitingEvidenceRunIds: dispositions - .filter((entry) => entry.kind === "awaiting_evidence") - .map((entry) => entry.runId), - blockedRunIds: dispositions - .filter((entry) => entry.kind === "blocked") - .map((entry) => entry.runId), - }; - } - - async function drainRunningRunsForShutdown( - signal: "SIGINT" | "SIGTERM", - now = new Date(), - runIds: readonly string[] | null = null, - ) { - const selectedRunIds = runIds ? [...new Set(runIds)] : null; - if (selectedRunIds?.length === 0) { - return { - interrupted: 0, - interruptedRunIds: [], - retryRunIds: [], - restartSuspendedRunIds: [], - }; - } - const activeRuns = await db - .select({ - run: heartbeatRuns, - agent: agents, - }) - .from(heartbeatRuns) - .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) - .where( - selectedRunIds - ? and( - eq(heartbeatRuns.status, "running"), - inArray(heartbeatRuns.id, selectedRunIds), - ) - : eq(heartbeatRuns.status, "running"), - ); - - const interruptedRunIds: string[] = []; - const retryRunIds: string[] = []; - const restartSuspendedRunIds: string[] = []; - - for (const { run, agent } of activeRuns) { - // Shutdown owns only this boot's legacy executions. Expired foreign - // owners belong to the reaper, not another container's drain. - if (run.runtimeMode === "legacy" && run.controllerBootId && - run.controllerBootId !== legacyControllerBootId) continue; - if (isNativeRunnerOwnershipHeld(run)) continue; - if ( - run.runtimeMode === "native" && - agent.adapterType === "paperclip_runner" - ) { - // A graceful shutdown relinquishes controller authority just like a - // hot restart. Leaving the old event consumer attached lets its - // finalizer interrupt/suspend Claude while the next server is adopting - // the same turn. - await detachNativeSessionsForRestart([run.id]); - const recoveryHistoryEntry = JSON.stringify({ - at: now.toISOString(), - restartKind: "graceful", - disposition: "restart_suspended", - reason: signal, - processPid: run.processPid, - processStartedAt: run.processStartedAt?.toISOString() ?? null, - }); - await db - .update(nativeRunFinalizations) - .set({ - recoveryState: "awaiting_runner_reattach", - recoveryHistory: sql`( - select coalesce(jsonb_agg(item order by ordinal), '[]'::jsonb) - from jsonb_array_elements( - coalesce(${nativeRunFinalizations.recoveryHistory}, '[]'::jsonb) - || jsonb_build_array(${recoveryHistoryEntry}::jsonb) - ) with ordinality as history(item, ordinal) - where ordinal > greatest( - jsonb_array_length( - coalesce(${nativeRunFinalizations.recoveryHistory}, '[]'::jsonb) - || jsonb_build_array(${recoveryHistoryEntry}::jsonb) - ) - 20, - 0 - ) - )`, - updatedAt: now, - }) - .where( - and( - eq(nativeRunFinalizations.runId, run.id), - isNull(nativeRunFinalizations.resultId), - ), - ); - await appendRunEvent(run, { - eventType: "native.recovery.transition", - stream: "system", - level: "info", - message: - "Server shutdown suspended native controller ownership without cancelling provider work", - payload: { - restartKind: "graceful", - signal, - runnerDisposition: "awaiting_runner_reattach", - processPid: run.processPid, - processGroupId: run.processGroupId, - processStartedAt: run.processStartedAt?.toISOString() ?? null, - retryRunCreated: false, - }, - }); - restartSuspendedRunIds.push(run.id); - continue; - } - const message = `Interrupted by graceful server shutdown (${signal})`; - const running = runningProcesses.get(run.id); - try { - if (run.runtimeMode === "native") { - await cancelHeartbeatNativeRun({ - db, - runId: run.id, - reason: message, - runtimeMode: run.runtimeMode, - }); - } - if (running) { - await terminateHeartbeatRunProcess({ - pid: running.child.pid, - processGroupId: running.processGroupId, - graceMs: Math.max(1, running.graceSec) * 1000, - }); - } - } finally { - runningProcesses.delete(run.id); - } - - const persistedCancellationResult = - run.runtimeMode === "native" - ? await getRun(run.id).then((current) => - parseObject(current?.resultJson), - ) - : parseObject(run.resultJson); - - const interruptedStatus = await setRunStatusIfRunning( - run.id, - "interrupted", - { - finishedAt: now, - error: message, - errorCode: "server_shutdown_interrupted", - signal, - resultJson: mergeRunStopMetadataForAgent(agent, "interrupted", { - conversationContinuationEligible: await runUsedConversationAdapter(db, run), - resultJson: persistedCancellationResult, - errorCode: "server_shutdown_interrupted", - errorMessage: message, - }), - }, - ); - if (!interruptedStatus.updated || !interruptedStatus.run) continue; - let interrupted = interruptedStatus.run; - await setWakeupStatus(run.wakeupRequestId, "cancelled", { - finishedAt: now, - error: null, - }); - interrupted = - (await classifyAndPersistRunLiveness( - interrupted, - parseObject(interrupted.resultJson), - )) ?? interrupted; - - await releaseEnvironmentLeasesForRun({ - runId: interrupted.id, - companyId: interrupted.companyId, - agentId: interrupted.agentId, - status: interrupted.status, - failureReason: interrupted.error ?? undefined, - }); - - const retry = await enqueueProcessLossRetry(interrupted, agent, now); - if (!retry) { - await releaseIssueExecutionAndPromote(interrupted); - } else { - retryRunIds.push(retry.id); - } - - await appendRunEvent(interrupted, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message, - payload: { - signal, - ...(run.processPid ? { processPid: run.processPid } : {}), - ...(run.processGroupId ? { processGroupId: run.processGroupId } : {}), - ...(retry ? { retryRunId: retry.id } : {}), - }, - }); - - await finalizeAgentStatus(run.agentId, "interrupted", message, { - wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run), - }); - interruptedRunIds.push(interrupted.id); - } - - if (interruptedRunIds.length > 0) { - logger.warn( - { - signal, - interrupted: interruptedRunIds.length, - interruptedRunIds, - retryRunIds, - }, - "interrupted running heartbeat runs for graceful shutdown", - ); - } - - return { - interrupted: interruptedRunIds.length, - interruptedRunIds, - retryRunIds, - restartSuspendedRunIds, - }; - } - - async function scheduleBoundedRetryForRun( - run: typeof heartbeatRuns.$inferSelect, - agent: typeof agents.$inferSelect, - opts?: { - now?: Date; - random?: () => number; - retryReason?: string; - wakeReason?: string; - maxAttempts?: number; - delayMs?: number; - }, - ) { - if (parseObject(agent.adapterConfig).provider === "openai_dot" - || parseObject(parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput).provider).kind === "openai_dot") { - return { outcome: "not_scheduled" as const, reason: "Dot external execution must be reconciled before a new assignment; Paperclip cannot confirm its external stop.", issueId: readNonEmptyString(run.contextSnapshot?.issueId) }; - } - if (run.errorCode === "provider_tool_definition_invalid") { - return { outcome: "not_scheduled" as const, - reason: "Repair the invalid tool definitions before starting a new attempt.", - issueId: readNonEmptyString(run.contextSnapshot?.issueId) }; - } - if (Array.isArray(run.contextSnapshot?.chatCompletionDeliveryIds) && - run.contextSnapshot.chatCompletionDeliveryIds.some(id => typeof id === "string")) { - return { outcome: "not_scheduled" as const, reason: "The completion outbox owns this reply's retry budget and publication identity.", - errorCode: "chat_completion_outbox_owns_retry" as const, issueId: readNonEmptyString(run.contextSnapshot.issueId) }; - } - const now = opts?.now ?? new Date(); - const retryReason = - opts?.retryReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON; - const wakeReason = - opts?.wakeReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON; - const maxAttempts = Math.max( - 0, - Math.floor( - opts?.maxAttempts ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, - ), - ); - const consumedAttempts = executionRetryAttemptCount(run, retryReason); - const nextAttempt = consumedAttempts + 1; - const computedBaseSchedule = - opts?.delayMs != null - ? nextAttempt <= maxAttempts - ? { - attempt: nextAttempt, - baseDelayMs: Math.max(0, Math.floor(opts.delayMs)), - delayMs: Math.max(0, Math.floor(opts.delayMs)), - dueAt: new Date( - now.getTime() + Math.max(0, Math.floor(opts.delayMs)), - ), - maxAttempts, - } - : null - : nextAttempt <= maxAttempts - ? computeBoundedTransientHeartbeatRetrySchedule( - nextAttempt, - now, - opts?.random, - ) - : null; - const baseSchedule = computedBaseSchedule - ? { ...computedBaseSchedule, maxAttempts } - : null; - const transientRecovery = - retryReason === BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON - ? readTransientRecoveryContractFromRun(run) - : null; - const codexTransientFallbackMode = - agent.adapterType === "codex_local" && - transientRecovery?.errorFamily === "transient_upstream" - ? resolveCodexTransientFallbackMode(nextAttempt) - : null; - const transientRetryNotBefore = transientRecovery?.retryNotBefore ?? null; - const contextSnapshot = parseObject(run.contextSnapshot); - // A retry inherits the durable authorization scope of its source run. Do - // not promote an untrusted or legacy contextSnapshot.issueId into a new - // issue binding: that could either violate the FK or misclassify history. - const issueId = run.scopeKind === "issue" ? run.issueId : null; - - if (!baseSchedule) { - const exhaustion = { - retryReason, - scheduledRetryAttempt: consumedAttempts, - maxAttempts, - }; - await appendRunEvent(run, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: `Bounded retry exhausted after ${consumedAttempts} scheduled attempts; no further automatic retry will be queued`, - payload: exhaustion, - retryExhaustion: exhaustion, - }); - if (retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON) { - await escalatePlanApprovalResumeFailureNeedsAttention({ - run, - issueId, - attempt: Math.min( - consumedAttempts, - maxAttempts, - ), - maxAttempts, - }).catch((error) => { - logger.warn( - { err: error, runId: run.id, issueId }, - "failed to escalate exhausted plan-approval resume failure", - ); - }); - } - return { - outcome: "retry_exhausted" as const, - attempt: nextAttempt, - maxAttempts, - }; - } - - if (await legacyExecutionNeedsReconciliationWithEvidence(db, run)) { - return { - outcome: "not_scheduled" as const, - reason: - "Reconcile the previous execution before retrying; safe provider recovery is unavailable.", - errorCode: "legacy_execution_requires_reconciliation" as const, - issueId: readNonEmptyString(run.contextSnapshot?.issueId), - }; - } - if (retryReason !== MAX_TURN_CONTINUATION_RETRY_REASON) { - const invokability = await getAgentInvokability(agent); - if (!invokability.invokable) { - await appendRunEvent(run, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: - "Scheduled retry suppressed because the agent is not invokable", - payload: { - retryReason, - scheduledRetryAttempt: nextAttempt, - maxAttempts, - reason: invokability.reason, - invalidOrgChain: invokability.invalidOrgChain, - ...invokability.details, - }, - }); - return { - outcome: "not_scheduled" as const, - reason: - "Scheduled retry suppressed because the agent is not invokable", - errorCode: "agent_not_invokable" as const, - issueId, - }; - } - } - - const schedule = - transientRetryNotBefore && - transientRetryNotBefore.getTime() > baseSchedule.dueAt.getTime() - ? { - ...baseSchedule, - dueAt: transientRetryNotBefore, - delayMs: Math.max( - 0, - transientRetryNotBefore.getTime() - now.getTime(), - ), - } - : baseSchedule; - - const requiresIssueGate = - isTransientWorkspaceGitScanCode(run.errorCode) || - hasConversationContinuationPolicy(run.resultJson) || - (retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) || - retryReason === MAX_TURN_CONTINUATION_RETRY_REASON || - retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON; - if (requiresIssueGate) { - const gate = await runDispatch.evaluateScheduledRetryGate({ - runId: run.id, - companyId: run.companyId, - retryReasonOverride: retryReason, - now, - }); - if (!gate.allowed) { - await appendRunEvent(run, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: gate.reason, - payload: { - retryReason, - scheduledRetryAttempt: nextAttempt, - maxAttempts, - ...gate.details, - }, - }); - return { - outcome: "not_scheduled" as const, - reason: gate.reason, - errorCode: gate.errorCode, - issueId: gate.issueId, - }; - } - } - const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null); - const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); - const interactionContinuationPayload = - retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON - ? { - mutation: "interaction", - interactionId: readNonEmptyString(contextSnapshot.interactionId), - interactionKind: readNonEmptyString( - contextSnapshot.interactionKind, - ), - interactionStatus: readNonEmptyString( - contextSnapshot.interactionStatus, - ), - continuationPolicy: readNonEmptyString( - contextSnapshot.continuationPolicy, - ), - } - : {}; - const workspaceValidationRetryPayload = - retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON && - isWorkspaceValidationFailedRun(run) - ? readWorkspaceValidationPayloadFromRun(run) - : null; - const shouldQuarantineWorkspaceForRetry = - workspaceValidationRetryPayload !== null && - Object.keys(workspaceValidationRetryPayload).length > 0; - const retryContextSnapshot: Record = withRecoveryContext( - { - ...contextSnapshot, - executionRetryAccounting: accountingForScheduledRetry(run, retryReason, schedule.attempt), - retryOfRunId: run.id, - wakeReason, - retryReason, - ...(retryReason === WORKSPACE_BUSY_RETRY_REASON - ? { - failureRetriesBeforeWorkspaceWait: - executionFailureRetryCount(run), - } - : {}), - ...((retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) - ? { failureRetriesBeforeAiConnectionWait: executionFailureRetryCount(run) } - : {}), - ...(shouldQuarantineWorkspaceForRetry - ? { - workspaceValidationRecovery: { - strategy: "quarantine_failed_workspace_and_retry_clean", - sourceRunId: run.id, - reason: - readNonEmptyString(workspaceValidationRetryPayload?.reason) ?? - WORKSPACE_VALIDATION_FAILURE_CODE, - fingerprint: readNonEmptyString( - workspaceValidationRetryPayload?.fingerprint, - ), - failedExecutionWorkspaceId: readNonEmptyString( - workspaceValidationRetryPayload?.executionWorkspaceId, - ), - }, - } - : {}), - ...(transientRecovery - ? { errorFamily: transientRecovery.errorFamily } - : {}), - scheduledRetryAttempt: schedule.attempt, - scheduledRetryAt: schedule.dueAt.toISOString(), - ...(transientRetryNotBefore - ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } - : {}), - ...(transientRecovery?.errorFamily === "provider_quota" && - transientRetryNotBefore - ? { - providerQuotaRetryNotBefore: - transientRetryNotBefore.toISOString(), - } - : {}), - ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), - }, - "normal_model", - ); - const responsibleUserId = await resolveResponsibleUserIdForRunContext( - run, - retryContextSnapshot, - ); - const continuationRetryIdempotencyKey = - retryReason === MAX_TURN_CONTINUATION_RETRY_REASON - ? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}` - : retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON - ? `interaction-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}` - : null; - - type ScheduledRetryTransactionResult = - | { - outcome: "scheduled"; - run: typeof heartbeatRuns.$inferSelect; - reusedExisting: boolean; - } - | { - outcome: "not_scheduled"; - reason: string; - errorCode: - | "issue_not_found" - | "issue_reassigned" - | "issue_cancelled" - | "issue_terminal_status" - | "issue_not_in_progress" - | "continuation_user_authorization_missing" - | "issue_execution_lock_changed"; - issueId: string | null; - details: Record; - }; - - const scheduleResult = await db.transaction( - async (tx): Promise => { - // All automatic failure paths share the same predecessor claim. A - // duplicate monitor, restart sweep or wake must reuse its successor. - if ( - retryReason !== MAX_TURN_CONTINUATION_RETRY_REASON && - retryReason !== INTERACTION_CONTINUATION_INFRA_RETRY_REASON - ) { - if (issueId) - await tx.execute( - sql`select id from issues where company_id = ${run.companyId} and id = ${issueId} for update`, - ); - await tx.execute( - sql`select id from heartbeat_runs where company_id = ${run.companyId} and id = ${run.id} for update`, - ); - const [existing] = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, run.companyId), - eq(heartbeatRuns.retryOfRunId, run.id), - ), - ) - .limit(1); - if (existing) - return { - outcome: "scheduled", - run: existing, - reusedExisting: true, - }; - } - if (retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON) { - if (issueId) { - await tx.execute( - sql`select id from issues where company_id = ${run.companyId} and id = ${issueId} for update`, - ); - } else { - await tx.execute( - sql`select id from heartbeat_runs where company_id = ${run.companyId} and id = ${run.id} for update`, - ); - } - - const existingContinuation = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, run.companyId), - eq(heartbeatRuns.retryOfRunId, run.id), - eq(heartbeatRuns.scheduledRetryReason, retryReason), - eq(heartbeatRuns.scheduledRetryAttempt, schedule.attempt), - inArray(heartbeatRuns.status, [ - ...MAX_TURN_CONTINUATION_LIVE_RUN_STATUSES, - ]), - issueId - ? sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}` - : sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is null`, - ), - ) - .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - - if (existingContinuation) { - if (existingContinuation.wakeupRequestId) { - const existingWakeup = await tx - .select({ coalescedCount: agentWakeupRequests.coalescedCount }) - .from(agentWakeupRequests) - .where( - eq( - agentWakeupRequests.id, - existingContinuation.wakeupRequestId, - ), - ) - .then((rows) => rows[0] ?? null); - - await tx - .update(agentWakeupRequests) - .set({ - coalescedCount: (existingWakeup?.coalescedCount ?? 0) + 1, - updatedAt: now, - }) - .where( - eq( - agentWakeupRequests.id, - existingContinuation.wakeupRequestId, - ), - ); - } - - return { - outcome: "scheduled", - run: existingContinuation, - reusedExisting: true, - }; - } - } - - if (retryReason === MAX_TURN_CONTINUATION_RETRY_REASON) { - if (issueId) { - await tx.execute( - sql`select id from issues where company_id = ${run.companyId} and id = ${issueId} for update`, - ); - } else { - await tx.execute( - sql`select id from heartbeat_runs where company_id = ${run.companyId} and id = ${run.id} for update`, - ); - } - - const existingContinuation = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, run.companyId), - eq(heartbeatRuns.retryOfRunId, run.id), - eq(heartbeatRuns.scheduledRetryReason, retryReason), - eq(heartbeatRuns.scheduledRetryAttempt, schedule.attempt), - inArray(heartbeatRuns.status, [ - ...MAX_TURN_CONTINUATION_LIVE_RUN_STATUSES, - ]), - issueId - ? sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}` - : sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is null`, - ), - ) - .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - - if (existingContinuation) { - if (existingContinuation.wakeupRequestId) { - const existingWakeup = await tx - .select({ coalescedCount: agentWakeupRequests.coalescedCount }) - .from(agentWakeupRequests) - .where( - eq( - agentWakeupRequests.id, - existingContinuation.wakeupRequestId, - ), - ) - .then((rows) => rows[0] ?? null); - - await tx - .update(agentWakeupRequests) - .set({ - coalescedCount: (existingWakeup?.coalescedCount ?? 0) + 1, - updatedAt: now, - }) - .where( - eq( - agentWakeupRequests.id, - existingContinuation.wakeupRequestId, - ), - ); - } - - return { - outcome: "scheduled", - run: existingContinuation, - reusedExisting: true, - }; - } - - if (issueId) { - const lockedIssue = await tx - .select({ - id: issues.id, - status: issues.status, - assigneeAgentId: issues.assigneeAgentId, - executionRunId: issues.executionRunId, - }) - .from(issues) - .where( - and( - eq(issues.id, issueId), - eq(issues.companyId, run.companyId), - ), - ) - .then((rows) => rows[0] ?? null); - - if (!lockedIssue) { - return { - outcome: "not_scheduled", - reason: - "Scheduled max-turn continuation suppressed because the target issue no longer exists", - errorCode: "issue_not_found", - issueId, - details: { issueId }, - }; - } - - if (lockedIssue.assigneeAgentId !== run.agentId) { - return { - outcome: "not_scheduled", - reason: - "Scheduled max-turn continuation suppressed because issue ownership changed", - errorCode: "issue_reassigned", - issueId, - details: { - issueId, - previousAssigneeAgentId: run.agentId, - currentAssigneeAgentId: lockedIssue.assigneeAgentId, - }, - }; - } - - if ( - lockedIssue.status === "cancelled" || - lockedIssue.status === "done" - ) { - return { - outcome: "not_scheduled", - reason: `Scheduled max-turn continuation suppressed because issue reached terminal status (${lockedIssue.status})`, - errorCode: - lockedIssue.status === "cancelled" - ? "issue_cancelled" - : "issue_terminal_status", - issueId, - details: { issueId, currentStatus: lockedIssue.status }, - }; - } - - if (lockedIssue.status !== "in_progress") { - return { - outcome: "not_scheduled", - reason: `Scheduled max-turn continuation suppressed because issue is no longer in_progress (current status: ${lockedIssue.status})`, - errorCode: "issue_not_in_progress", - issueId, - details: { - issueId, - currentStatus: lockedIssue.status, - requiredStatus: "in_progress", - }, - }; - } - - if (lockedIssue.executionRunId !== run.id) { - return { - outcome: "not_scheduled", - reason: - "Scheduled max-turn continuation suppressed because the issue execution lock belongs to a different run", - errorCode: "issue_execution_lock_changed", - issueId, - details: { - issueId, - expectedExecutionRunId: run.id, - currentExecutionRunId: lockedIssue.executionRunId, - }, - }; - } - } - } - - if ( - (retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) && issueId && - !isNonAssigneeWorkspaceBusyRetry(retryReason, contextSnapshot) - ) { - // The issue row is locked above. Recheck after the preflight gate so - // cancellation or recovery cannot leave a successor without its lock. - const [lockedIssue] = await tx.select({ executionRunId: issues.executionRunId }) - .from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))); - if (lockedIssue?.executionRunId !== run.id) { - return { - outcome: "not_scheduled", issueId, errorCode: "issue_execution_lock_changed", - reason: "Subscription retry suppressed because the task execution lock changed", - details: { issueId, expectedExecutionRunId: run.id, currentExecutionRunId: lockedIssue?.executionRunId ?? null }, - }; - } - } - - const scheduledRunId = randomUUID(); - if (contextSnapshot.explicitUserContinuation) { - const continuation = issueId && retryReason === "transient_failure" ? await admitExplicitContinuationRetry({ - db: tx as unknown as Db, companyId: run.companyId, issueId, agentId: run.agentId, - parentRunId: run.id, successorRunId: scheduledRunId, now, - }) : null; - if (!continuation) return { - outcome: "not_scheduled", issueId, - errorCode: "continuation_user_authorization_missing", - reason: "The automatic retry could not revalidate the original user continuation.", - details: {}, - }; - retryContextSnapshot.explicitUserContinuation = continuation; - retryContextSnapshot.previousRunId = continuation.previousRunId; - } - - const wakeupRequest = await tx - .insert(agentWakeupRequests) - .values({ - companyId: run.companyId, - agentId: run.agentId, - source: "automation", - triggerDetail: "system", - reason: wakeReason, - payload: withRecoveryContext( - { - ...(issueId ? { issueId } : {}), - retryOfRunId: run.id, - ...interactionContinuationPayload, - retryReason, - ...(transientRecovery - ? { errorFamily: transientRecovery.errorFamily } - : {}), - scheduledRetryAttempt: schedule.attempt, - scheduledRetryAt: schedule.dueAt.toISOString(), - ...(transientRetryNotBefore - ? { - transientRetryNotBefore: - transientRetryNotBefore.toISOString(), - } - : {}), - ...(transientRecovery?.errorFamily === "provider_quota" && - transientRetryNotBefore - ? { - providerQuotaRetryNotBefore: - transientRetryNotBefore.toISOString(), - } - : {}), - ...(codexTransientFallbackMode - ? { codexTransientFallbackMode } - : {}), - }, - "normal_model", - ), - status: "queued", - requestedByActorType: "system", - requestedByActorId: null, - idempotencyKey: continuationRetryIdempotencyKey, - updatedAt: now, - }) - .returning() - .then((rows) => rows[0]); - - const scheduledRun = await tx - .insert(heartbeatRuns) - .values({ - id: scheduledRunId, - companyId: run.companyId, - agentId: run.agentId, - scopeKind: run.scopeKind, - issueId, - invocationSource: "automation", - triggerDetail: "system", - status: "scheduled_retry", - wakeupRequestId: wakeupRequest.id, - contextSnapshot: retryContextSnapshot, - ...(hasConversationContinuationPolicy(run.resultJson) - ? { resultJson: { conversationContinuation: CONVERSATION_CONTINUATION_POLICY } } : {}), - responsibleUserId, - sessionIdBefore: sessionBefore, - retryOfRunId: run.id, - scheduledRetryAt: schedule.dueAt, - scheduledRetryAttempt: schedule.attempt, - scheduledRetryReason: retryReason, - continuationAttempt: readContinuationAttempt( - retryContextSnapshot.livenessContinuationAttempt, - ), - updatedAt: now, - }) - .returning() - .then((rows) => rows[0]); - - await tx - .update(agentWakeupRequests) - .set({ - runId: scheduledRun.id, - updatedAt: now, - }) - .where(eq(agentWakeupRequests.id, wakeupRequest.id)); - - let detachWorkspaceFromIssue = false; - if (issueId && shouldQuarantineWorkspaceForRetry) { - const issueWorkspace = await tx - .select({ - id: issues.id, - companyId: issues.companyId, - executionWorkspaceId: issues.executionWorkspaceId, - }) - .from(issues) - .where( - and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const failedExecutionWorkspaceId = - readNonEmptyString( - workspaceValidationRetryPayload?.executionWorkspaceId, - ) ?? readNonEmptyString(issueWorkspace?.executionWorkspaceId); - - if (issueWorkspace && failedExecutionWorkspaceId) { - const failedWorkspace = await tx - .select({ - id: executionWorkspaces.id, - companyId: executionWorkspaces.companyId, - sourceIssueId: executionWorkspaces.sourceIssueId, - status: executionWorkspaces.status, - metadata: executionWorkspaces.metadata, - }) - .from(executionWorkspaces) - .where( - and( - eq(executionWorkspaces.id, failedExecutionWorkspaceId), - eq(executionWorkspaces.companyId, run.companyId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - - const workspaceBelongsToIssue = failedWorkspace - ? failedWorkspace.sourceIssueId === issueId - : false; - - if ( - failedWorkspace && - workspaceBelongsToIssue && - issueWorkspace.executionWorkspaceId === failedExecutionWorkspaceId - ) { - const existingMetadata = parseObject(failedWorkspace.metadata); - const quarantine = { - reason: WORKSPACE_VALIDATION_FAILURE_CODE, - retryReason, - sourceRunId: run.id, - retryRunId: scheduledRun.id, - issueId, - sourceIssueId: failedWorkspace.sourceIssueId ?? null, - quarantinedAt: now.toISOString(), - workspaceValidation: workspaceValidationRetryPayload ?? {}, - }; - await tx - .update(executionWorkspaces) - .set({ - status: "archived", - closedAt: now, - cleanupEligibleAt: null, - cleanupReason: WORKSPACE_VALIDATION_FAILURE_CODE, - metadata: { - ...existingMetadata, - workspaceValidationQuarantine: quarantine, - }, - updatedAt: now, - }) - .where( - and( - eq(executionWorkspaces.id, failedWorkspace.id), - eq(executionWorkspaces.companyId, run.companyId), - ), - ); - - await logActivity(tx as unknown as Db, { - companyId: run.companyId, - actorType: "system", - actorId: "heartbeat", - agentId: run.agentId, - runId: run.id, - action: "execution_workspace.workspace_validation_quarantined", - entityType: "execution_workspace", - entityId: failedWorkspace.id, - details: quarantine, - }); - detachWorkspaceFromIssue = - issueWorkspace.executionWorkspaceId === - failedExecutionWorkspaceId; - } - } - } - - if (issueId) { - await tx - .update(issues) - .set({ - executionRunId: scheduledRun.id, - checkoutRunId: sql`case when ${issues.checkoutRunId} = ${run.id} then null else ${issues.checkoutRunId} end`, - executionAgentNameKey: normalizeAgentNameKey(agent.name), - executionLockedAt: now, - ...(detachWorkspaceFromIssue - ? { - executionWorkspaceId: null, - executionWorkspacePreference: null, - } - : {}), - updatedAt: now, - }) - .where( - and( - eq(issues.id, issueId), - eq(issues.companyId, run.companyId), - eq(issues.executionRunId, run.id), - ), - ); - } - - return { - outcome: "scheduled", - run: scheduledRun, - reusedExisting: false, - }; - }, - ); - - if (scheduleResult.outcome === "not_scheduled") { - await appendRunEvent(run, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: scheduleResult.reason, - payload: { - retryReason, - scheduledRetryAttempt: nextAttempt, - maxAttempts, - ...scheduleResult.details, - }, - }); - return { - outcome: "not_scheduled" as const, - reason: scheduleResult.reason, - errorCode: scheduleResult.errorCode, - issueId: scheduleResult.issueId, - }; - } - - const retryRun = scheduleResult.run; - const dueAt = retryRun.scheduledRetryAt - ? new Date(retryRun.scheduledRetryAt) - : schedule.dueAt; - - if (scheduleResult.reusedExisting) { - await appendRunEvent(run, { - eventType: "lifecycle", - stream: "system", - level: "info", - message: `Reused existing continuation retry ${retryRun.scheduledRetryAttempt}/${schedule.maxAttempts}`, - payload: { - retryRunId: retryRun.id, - retryReason, - idempotencyKey: continuationRetryIdempotencyKey, - scheduledRetryAttempt: retryRun.scheduledRetryAttempt, - scheduledRetryAt: dueAt.toISOString(), - }, - }); - - return { - outcome: "scheduled" as const, - run: retryRun, - dueAt, - attempt: retryRun.scheduledRetryAttempt, - maxAttempts: schedule.maxAttempts, - reusedExisting: true, - }; - } - - await appendRunEvent(run, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: `Scheduled bounded retry ${schedule.attempt}/${schedule.maxAttempts} for ${schedule.dueAt.toISOString()}`, - payload: { - retryRunId: retryRun.id, - retryReason, - ...(transientRecovery - ? { errorFamily: transientRecovery.errorFamily } - : {}), - scheduledRetryAttempt: schedule.attempt, - scheduledRetryAt: schedule.dueAt.toISOString(), - baseDelayMs: schedule.baseDelayMs, - delayMs: schedule.delayMs, - ...(transientRetryNotBefore - ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } - : {}), - ...(transientRecovery?.errorFamily === "provider_quota" && - transientRetryNotBefore - ? { - providerQuotaRetryNotBefore: - transientRetryNotBefore.toISOString(), - } - : {}), - ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), - }, - }); - - if (retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON) { - await recordPlanApprovalResumeFailureRetry({ - run, - issueId, - retryRunId: retryRun.id, - attempt: schedule.attempt, - maxAttempts: schedule.maxAttempts, - }).catch((error) => { - logger.warn( - { err: error, runId: run.id, issueId, retryRunId: retryRun.id }, - "failed to record plan-approval resume retry failure", - ); - }); - } - - return { - outcome: "scheduled" as const, - run: retryRun, - dueAt, - attempt: schedule.attempt, - maxAttempts: schedule.maxAttempts, - }; - } - - // Finds a running heartbeat run (other than the caller's) whose context - // issue shares the same project workspace, i.e. the run that currently - // "holds" the shared working tree. Runs that have been silent past - // WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS do not count — a zombie holder must - // not park other work forever. This cutoff is independent of informational - // silence warnings. When isolated workspaces are enabled, holders whose - // issue explicitly opted into an isolated workspace never touch the shared - // tree, so they are excluded; a NULL/agent_default mode may resolve to the - // shared tree and counts as a holder (over-serializing is the safe - // direction). When the isolated-workspaces experiment is off, every run - // resolves to the shared tree, so no holder is excluded. - async function findSharedWorkspaceHolder(input: { - companyId: string; - projectWorkspaceId: string; - excludeIssueId: string; - excludeRunId: string; - honorIsolatedWorkspaceModes: boolean; - now?: Date; - }): Promise { - const staleCutoff = new Date( - (input.now ?? new Date()).getTime() - - WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS, - ); - return await db - .select({ - runId: heartbeatRuns.id, - agentId: heartbeatRuns.agentId, - issueId: sql`${issues.id}::text`, - issueIdentifier: issues.identifier, - }) - .from(heartbeatRuns) - .innerJoin( - issues, - and( - eq(issues.companyId, heartbeatRuns.companyId), - sql`${issues.id}::text = ${heartbeatRuns.contextSnapshot} ->> 'issueId'`, - ), - ) - .where( - and( - eq(heartbeatRuns.companyId, input.companyId), - eq(heartbeatRuns.status, "running"), - ne(heartbeatRuns.id, input.excludeRunId), - // Last observed activity: output beats start beats creation. A run - // that started recently but has not written output yet is live. - sql`coalesce(${heartbeatRuns.lastOutputAt}, ${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${staleCutoff.toISOString()}::timestamptz`, - eq(issues.projectWorkspaceId, input.projectWorkspaceId), - ne(sql`${issues.id}::text`, input.excludeIssueId), - ...(input.honorIsolatedWorkspaceModes - ? [ - or( - // Covers both a NULL settings blob and a blob without a mode - // key; either may still resolve to the shared workspace. - sql`${issues.executionWorkspaceSettings} ->> 'mode' is null`, - notInArray( - sql`${issues.executionWorkspaceSettings} ->> 'mode'`, - [...ISOLATED_EXECUTION_WORKSPACE_MODES], - ), - ), - ] - : []), - ), - ) - .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - } - - // Credential rotation can briefly contend with a fresh runtime read. Keep - // the task on its automatic pre-provider retry path while the lock clears. - async function finalizeAiConnectionBusyDeferral( - run: typeof heartbeatRuns.$inferSelect, - error: HttpError, - wasIssueAssignee: boolean, - ) { - const now = new Date(); - const cancelled = await setRunStatusIfRunning(run.id, "cancelled", { - error: error.message, errorCode: AI_CONNECTION_BUSY_RETRY_REASON, finishedAt: now, - resultJson: { executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false } }, - contextSnapshot: { - ...parseObject(run.contextSnapshot), - aiConnectionBusyDeferredWhileAssignee: wasIssueAssignee, - }, - }); - if (!cancelled.updated) return; - await setWakeupStatus(run.wakeupRequestId, "cancelled", { finishedAt: now, error: error.message }).catch(() => undefined); - const cancelledRun = cancelled.run ?? await getRun(run.id); - const agent = await getAgent(run.agentId); - let scheduled = false; - try { - if (cancelledRun && agent) { - const retry = await scheduleBoundedRetryForRun(cancelledRun, agent, { - now, retryReason: AI_CONNECTION_BUSY_RETRY_REASON, wakeReason: "ai_connection_busy_retry", - maxAttempts: (cancelledRun.scheduledRetryAttempt ?? 0) + 1, - delayMs: computeWorkspaceBusyRetryDelayMs(), - }); - scheduled = retry.outcome === "scheduled"; - await appendRunEvent(cancelledRun, { - eventType: "lifecycle", stream: "system", level: "info", - message: scheduled ? "Waiting for the shared AI subscription. This task will retry automatically." : "The AI subscription is busy; this task can no longer retry automatically.", - payload: { retryScheduled: scheduled }, - }); - } - } finally { - try { - if (cancelledRun && !scheduled) await releaseIssueExecutionAndPromote(cancelledRun); - } finally { - await finalizeAgentStatus(run.agentId, "cancelled", null, { wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run) }); - } - } - } - - // Terminal handling for a WorkspaceBusyDeferral thrown by the pre-dispatch - // gate: cancel the run (contention is not a failure), schedule a - // workspace_busy retry, and leave the agent idle. The issue execution lock - // transfers to the scheduled retry run inside scheduleBoundedRetryForRun, so - // the issue keeps an active execution path and recovery leaves it alone. - // Deferral has no attempt ceiling — the retry keeps rescheduling while a - // live holder exists, and holder staleness (not a counter) is what prevents - // waiting on a zombie. If no retry could be scheduled (agent no longer - // invokable), the lock is released so the issue does not strand on a - // cancelled run. - async function finalizeWorkspaceBusyDeferral( - run: typeof heartbeatRuns.$inferSelect, - deferral: WorkspaceBusyDeferral, - ) { - const now = new Date(); - const cancelWrite = await setRunStatusIfRunning(run.id, "cancelled", { - error: deferral.message, - errorCode: WORKSPACE_BUSY_ERROR_CODE, - finishedAt: now, - resultJson: { - executionRecovery: { - kind: "workspace_wait", - providerWorkStarted: false, - }, - workspaceBusy: { - projectWorkspaceId: deferral.projectWorkspaceId, - holderRunId: deferral.holder.runId, - holderIssueId: deferral.holder.issueId, - deferralAttempt: deferral.deferralAttempt, - }, - }, - // Recorded on the run (and inherited by the scheduled retry's context) - // so the retry promotion gate can tell a non-assignee wake — where an - // assignee mismatch is the expected state — from a reassignment race. - contextSnapshot: { - ...parseObject(run.contextSnapshot), - workspaceBusyDeferredWhileAssignee: deferral.wasIssueAssignee, - }, - }); - if (!cancelWrite.updated) { - logger.info( - { runId: run.id, currentStatus: cancelWrite.run?.status ?? null }, - "skipping workspace-busy deferral finalization because the run already left running state", - ); - return; - } - await setWakeupStatus(run.wakeupRequestId, "cancelled", { - finishedAt: now, - error: deferral.message, - }).catch(() => undefined); - - const cancelledRun = - cancelWrite.run ?? (await getRun(run.id).catch(() => null)); - const agentRow = await getAgent(run.agentId).catch(() => null); - let scheduleOutcome: string | null = null; - if (cancelledRun && agentRow) { - const scheduleResult = await scheduleBoundedRetryForRun( - cancelledRun, - agentRow, - { - now, - retryReason: WORKSPACE_BUSY_RETRY_REASON, - wakeReason: WORKSPACE_BUSY_RETRY_WAKE_REASON, - // Always admit the next attempt: workspace-busy deferral is bounded by - // holder liveness, not by an attempt counter. - maxAttempts: (cancelledRun.scheduledRetryAttempt ?? 0) + 1, - delayMs: computeWorkspaceBusyRetryDelayMs(), - }, - ).catch((scheduleErr) => { - logger.error( - { err: scheduleErr, runId: run.id }, - "failed to schedule workspace-busy retry after deferral", - ); - return null; - }); - scheduleOutcome = scheduleResult?.outcome ?? null; - } - - if (cancelledRun) { - await appendRunEvent(cancelledRun, { - eventType: "lifecycle", - stream: "system", - level: "info", - message: - scheduleOutcome === "scheduled" - ? `Deferred: ${deferral.message}. Retry ${deferral.deferralAttempt + 1} scheduled; the run waits for the workspace to free.` - : `Deferred: ${deferral.message}. No retry could be scheduled; releasing the issue for other runs.`, - payload: { - projectWorkspaceId: deferral.projectWorkspaceId, - holderRunId: deferral.holder.runId, - holderIssueId: deferral.holder.issueId, - deferralAttempt: deferral.deferralAttempt, - retryScheduled: scheduleOutcome === "scheduled", - }, - }).catch(() => undefined); - } - - if (cancelledRun && scheduleOutcome !== "scheduled") { - await releaseIssueExecutionAndPromote(cancelledRun).catch( - (releaseErr) => { - logger.error( - { err: releaseErr, runId: run.id }, - "failed to release issue execution after workspace-busy deferral", - ); - }, - ); - } - - await finalizeAgentStatus(run.agentId, "cancelled", null, { - wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run), - }).catch(() => undefined); - } - - async function scheduleInteractionContinuationInfrastructureRetryIfEligible( - run: typeof heartbeatRuns.$inferSelect, - agent: typeof agents.$inferSelect, - ) { - if (!run.wakeupRequestId) return null; - if (!isResolvedInteractionContinuationWakeContext(run.contextSnapshot)) - return null; - if (!isRetryableInteractionContinuationInfrastructureFailure(run)) { - const context = parseObject(run.contextSnapshot); - const issueId = readNonEmptyString(context.issueId); - await escalatePlanApprovalResumeFailureNeedsAttention({ - run, - issueId, - attempt: Math.min( - run.scheduledRetryAttempt ?? - INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS, - INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS, - ), - maxAttempts: INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS, - }).catch((error) => { - logger.warn( - { err: error, runId: run.id, issueId }, - "failed to escalate non-retryable plan-approval resume failure", - ); - }); - return null; - } - - return scheduleBoundedRetryForRun(run, agent, { - retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON, - wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON, - maxAttempts: INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS, - }); - } - - async function promoteDueScheduledRetries(now = new Date()) { - const cutoff = await getWorktreeExecutionCutoff(); - const result = await runDispatch.promoteDueScheduledRetries({ - now, - cutoff, - }); - applyRunDispatchPostCommitEffects(result.postCommitEffects); - return { promoted: result.promoted, runIds: result.runIds }; - } - - async function getIssueRetryRun( - companyId: string, - issueId: string, - statuses: Array<"scheduled_retry" | "queued" | "running" | "cancelled">, - ) { - if (statuses.length === 0) return null; - return db - .select({ - run: heartbeatRuns, - agentName: agents.name, - }) - .from(heartbeatRuns) - .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) - .where( - and( - eq(heartbeatRuns.companyId, companyId), - inArray(heartbeatRuns.status, statuses), - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, - sql`${heartbeatRuns.retryOfRunId} is not null`, - ), - ) - .orderBy( - desc(heartbeatRuns.updatedAt), - desc(heartbeatRuns.createdAt), - desc(heartbeatRuns.id), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - } - - function summarizeIssueScheduledRetryRun(row: { - run: typeof heartbeatRuns.$inferSelect; - agentName: string | null; - }) { - return { - runId: row.run.id, - status: row.run.status as - "scheduled_retry" | "queued" | "running" | "cancelled", - agentId: row.run.agentId, - agentName: row.agentName, - retryOfRunId: row.run.retryOfRunId, - scheduledRetryAt: row.run.scheduledRetryAt, - scheduledRetryAttempt: row.run.scheduledRetryAttempt, - scheduledRetryReason: row.run.scheduledRetryReason, - error: row.run.error, - errorCode: row.run.errorCode, - }; - } - - async function retryScheduledRetryNow(input: { - issueId: string; - actor?: { - actorType?: "user" | "agent" | "system"; - actorId?: string | null; - }; - now?: Date; - }) { - const now = input.now ?? new Date(); - const issue = await db - .select({ id: issues.id, companyId: issues.companyId }) - .from(issues) - .where(eq(issues.id, input.issueId)) - .then((rows) => rows[0] ?? null); - if (!issue) throw notFound("Issue not found"); - - const scheduled = await getIssueRetryRun(issue.companyId, issue.id, [ - "scheduled_retry", - ]); - if (!scheduled) { - const alreadyPromoted = await getIssueRetryRun( - issue.companyId, - issue.id, - ["queued", "running"], - ); - if (alreadyPromoted) { - return { - outcome: "already_promoted" as const, - message: "Scheduled retry was already promoted", - scheduledRetry: summarizeIssueScheduledRetryRun(alreadyPromoted), - }; - } - return { - outcome: "no_scheduled_retry" as const, - message: "No live scheduled retry exists for this issue", - scheduledRetry: null, - }; - } - - const contextSnapshot = { - ...parseObject(scheduled.run.contextSnapshot), - scheduledRetryAt: now.toISOString(), - retryNowRequestedAt: now.toISOString(), - retryNowRequestedByActorType: input.actor?.actorType ?? null, - retryNowRequestedByActorId: input.actor?.actorId ?? null, - }; - - const updated = await db.transaction(async (tx) => { - const row = await tx - .update(heartbeatRuns) - .set({ - scheduledRetryAt: now, - contextSnapshot, - updatedAt: now, - }) - .where( - and( - eq(heartbeatRuns.id, scheduled.run.id), - eq(heartbeatRuns.status, "scheduled_retry"), - ), - ) - .returning() - .then((rows) => rows[0] ?? null); - if (!row) return null; - - if (row.wakeupRequestId) { - const wakeupPayload = { - ...parseObject( - await tx - .select({ payload: agentWakeupRequests.payload }) - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.id, row.wakeupRequestId)) - .then((rows) => rows[0]?.payload ?? null), - ), - scheduledRetryAt: now.toISOString(), - retryNowRequestedAt: now.toISOString(), - }; - await tx - .update(agentWakeupRequests) - .set({ - payload: wakeupPayload, - updatedAt: now, - }) - .where(eq(agentWakeupRequests.id, row.wakeupRequestId)); - } - - return row; - }); - - if (!updated) { - const alreadyPromoted = await getIssueRetryRun( - issue.companyId, - issue.id, - ["queued", "running"], - ); - if (alreadyPromoted) { - return { - outcome: "already_promoted" as const, - message: "Scheduled retry was already promoted", - scheduledRetry: summarizeIssueScheduledRetryRun(alreadyPromoted), - }; - } - return { - outcome: "no_scheduled_retry" as const, - message: "No live scheduled retry exists for this issue", - scheduledRetry: null, - }; - } - - await appendRunEvent(updated, { - eventType: "lifecycle", - stream: "system", - level: "info", - message: "Scheduled retry was requested to run now", - payload: { - issueId: issue.id, - scheduledRetryAttempt: updated.scheduledRetryAttempt, - scheduledRetryAt: updated.scheduledRetryAt - ? new Date(updated.scheduledRetryAt).toISOString() - : null, - scheduledRetryReason: updated.scheduledRetryReason, - requestedByActorType: input.actor?.actorType ?? null, - requestedByActorId: input.actor?.actorId ?? null, - }, - }); - - const promotion = await runDispatch.promoteScheduledRetry({ - runId: updated.id, - companyId: updated.companyId, - now, - }); - if (promotion.outcome === "promoted") { - applyRunDispatchPostCommitEffects(promotion.postCommitEffects); - } - // Promotion can preserve this row as a cleanup wait. Read that exact run, - // not an older cancelled retry or the pre-promotion schedule. - const currentRun = await getRun(updated.id); - const scheduledRetry = currentRun - ? summarizeIssueScheduledRetryRun({ - run: currentRun, - agentName: scheduled.agentName, - }) - : null; - - if (currentRun?.status === "scheduled_retry") { - return { - outcome: "waiting" as const, - message: parseObject(currentRun.resultJson?.executionWait).cause === "execution_owner_active" - ? "Waiting for execution cleanup. Paperclip will retry automatically once cleanup finishes." - : "The retry remains scheduled. Paperclip will check again at the scheduled time.", - scheduledRetry, - }; - } - - if (promotion.outcome === "promoted") { - return { - outcome: "promoted" as const, - message: "Scheduled retry was promoted to the queued run pool", - scheduledRetry, - }; - } - if (promotion.outcome === "gate_suppressed") { - return { - outcome: "gate_suppressed" as const, - message: promotion.reason, - scheduledRetry, - }; - } - if (currentRun && ["queued", "running"].includes(currentRun.status)) { - return { - outcome: "already_promoted" as const, - message: "Scheduled retry was already promoted", - scheduledRetry, - }; - } - return { - outcome: "no_scheduled_retry" as const, - message: "No live scheduled retry exists for this issue", - scheduledRetry: null, - }; - } - - function parseHeartbeatPolicy(agent: typeof agents.$inferSelect) { - const runtimeConfig = parseObject(agent.runtimeConfig); - const heartbeat = parseObject(runtimeConfig.heartbeat); - - return { - enabled: asBoolean(heartbeat.enabled, false), - intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)), - wakeOnDemand: isHeartbeatWakeOnDemandEnabled(agent), - // A Dot binding has one external turn. Competing assignments must retain - // their queue position instead of claiming a second run that cannot bind. - maxConcurrentRuns: agent.adapterType === "paperclip_runner" && - parseObject(agent.adapterConfig).provider === "openai_dot" - ? 1 : normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns), - skipTimerWhenNoActionableWork: asBoolean( - heartbeat.skipTimerWhenNoActionableWork ?? - heartbeat.requireActionableTimerWork ?? - heartbeat.issueOnlyTimer, - false, - ), - maxDailyRuns: normalizeOptionalNonNegativeInteger( - heartbeat.maxDailyRuns ?? - heartbeat.dailyRunLimit ?? - heartbeat.dailyRunCap ?? - heartbeat.maxRunsPerDay, - ), - maxDailyCostCents: normalizeOptionalNonNegativeInteger( - heartbeat.maxDailyCostCents ?? - heartbeat.dailyCostCentsLimit ?? - heartbeat.dailySpendCentsLimit ?? - heartbeat.dailyBudgetCents, - ), - }; - } - - function normalizeOptionalNonNegativeInteger(value: unknown) { - if (value === null || value === undefined || value === "") return null; - const normalized = Math.floor(asNumber(value, 0)); - return normalized >= 0 ? normalized : null; - } - - function currentUtcDayWindow(now = new Date()) { - const start = new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate(), - 0, - 0, - 0, - 0, - ), - ); - const end = new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate() + 1, - 0, - 0, - 0, - 0, - ), - ); - return { start, end }; - } - - async function getHeartbeatDailyCapBlock( - agent: typeof agents.$inferSelect, - policy: ReturnType, - options: { - checkRunCap?: boolean; - checkCostCap?: boolean; - excludeRunId?: string | null; - } = {}, - client: Pick = db, - ) { - const checkRunCap = options.checkRunCap ?? true; - const checkCostCap = options.checkCostCap ?? true; - const { start, end } = currentUtcDayWindow(); - if (checkRunCap && policy.maxDailyRuns !== null) { - const conditions = [ - eq(heartbeatRuns.companyId, agent.companyId), - eq(heartbeatRuns.agentId, agent.id), - gte(heartbeatRuns.startedAt, start), - lt(heartbeatRuns.startedAt, end), - notInArray(heartbeatRuns.status, ["queued", "scheduled_retry"]), - ]; - if (options.excludeRunId) { - conditions.push(sql`${heartbeatRuns.id} <> ${options.excludeRunId}`); - } - const [row] = await client - .select({ total: sql`count(*)::integer` }) - .from(heartbeatRuns) - .where(and(...conditions)); - const observed = Number(row?.total ?? 0); - if (observed >= policy.maxDailyRuns) { - return { - reason: "heartbeat.daily_run_limit", - observed, - limit: policy.maxDailyRuns, - }; - } - } - - if (checkCostCap && policy.maxDailyCostCents !== null) { - const [row] = await client - .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::text`, - }) - .from(costEvents) - .where( - and( - eq(costEvents.companyId, agent.companyId), - eq(costEvents.agentId, agent.id), - gte(costEvents.occurredAt, start), - lt(costEvents.occurredAt, end), - ), - ); - const observed = Number(row?.total ?? 0); - if (compareCents(String(row?.total ?? 0), policy.maxDailyCostCents) >= 0) { - return { - reason: "heartbeat.daily_cost_limit", - observed, - limit: policy.maxDailyCostCents, - }; - } - } - - return null; - } - - async function cancelQueuedRunForHeartbeatDailyCap( - run: typeof heartbeatRuns.$inferSelect, - dailyCapBlock: NonNullable< - Awaited> - >, - ) { - const now = new Date(); - const reason = - "Cancelled because the agent reached a per-day heartbeat budget cap before adapter invocation"; - const cancelled = await setRunStatus(run.id, "cancelled", { - finishedAt: now, - error: reason, - errorCode: dailyCapBlock.reason, - resultJson: { - ...parseObject(run.resultJson), - stopReason: dailyCapBlock.reason, - observed: dailyCapBlock.observed, - limit: dailyCapBlock.limit, - effectiveTimeoutSec: 0, - timeoutConfigured: false, - timeoutSource: "heartbeat_daily_cap_gate", - timeoutFired: false, - }, - }); - if (!cancelled) return null; - - await setWakeupStatus(run.wakeupRequestId, "skipped", { - finishedAt: now, - error: reason, - }); - - await appendRunEvent(cancelled, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: reason, - payload: { - reason: dailyCapBlock.reason, - observed: dailyCapBlock.observed, - limit: dailyCapBlock.limit, - }, - }); - - await releaseIssueExecutionAndPromote(cancelled, { - suppressImmediateRecovery: true, - }); - - return cancelled; - } - - async function hasActionableTimerWork(agent: typeof agents.$inferSelect) { - const row = await db - .select({ id: issues.id }) - .from(issues) - .where( - and( - eq(issues.companyId, agent.companyId), - eq(issues.assigneeAgentId, agent.id), - isNull(issues.assigneeUserId), - isNull(issues.hiddenAt), - inArray(issues.status, [...TIMER_ACTIONABLE_ISSUE_STATUSES]), - isNull(issues.conversationAgentId), - nonIdleSlackIssueCondition(), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - return Boolean(row); - } - - async function markTimerHeartbeatChecked( - agentId: string, - source: WakeupOptions["source"], - ) { - if (source !== "timer") return; - await db - .update(agents) - .set({ - lastHeartbeatAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(agents.id, agentId)); - } - - async function claimDueTimerHeartbeat( - agent: typeof agents.$inferSelect, - now: Date, - intervalSec: number, - ) { - const dueBefore = new Date(now.getTime() - intervalSec * 1000); - const claimed = await db - .update(agents) - .set({ - lastHeartbeatAt: now, - updatedAt: now, - }) - .where( - and( - eq(agents.id, agent.id), - eq(agents.companyId, agent.companyId), - or( - lte(agents.lastHeartbeatAt, dueBefore), - and( - isNull(agents.lastHeartbeatAt), - lte(agents.createdAt, dueBefore), - ), - ), - ), - ) - .returning({ id: agents.id }) - .then((rows) => rows[0] ?? null); - if (!claimed) return null; - return { wasFirstHeartbeat: !agent.lastHeartbeatAt }; - } - - function timerClaimWasFirstHeartbeat( - run: Pick, - ): true | undefined { - return parseObject(run.contextSnapshot).timerClaimWasFirstHeartbeat === true - ? true - : undefined; - } - function parseMaxTurnContinuationPolicy( agent: typeof agents.$inferSelect, ): MaxTurnContinuationPolicy { @@ -13367,1090 +5835,6 @@ export function heartbeatService( }; } - function issueRunPriorityRank(priority: string | null | undefined) { - switch (priority) { - case "critical": - return 0; - case "high": - return 1; - case "medium": - return 2; - case "low": - return 3; - default: - return 4; - } - } - - async function listQueuedRunDependencyReadiness( - companyId: string, - queuedRuns: Array, - ) { - const issueIds = [ - ...new Set( - queuedRuns - .map((run) => - readNonEmptyString(parseObject(run.contextSnapshot).issueId), - ) - .filter((issueId): issueId is string => Boolean(issueId)), - ), - ]; - if (issueIds.length === 0) { - return new Map< - string, - Awaited> - >(); - } - return issuesSvc.listDependencyReadiness(companyId, issueIds); - } - - async function countRunningRunsForAgent(agentId: string) { - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.agentId, agentId), - eq(heartbeatRuns.status, "running"), - ), - ); - return Number(count ?? 0); - } - - async function withChatControlRecoveryGate( - run: typeof heartbeatRuns.$inferSelect, - stage: "claim" | "dispatch", - onClear: (tx: Db) => Promise, - nativeRecovery = false, - ) { - const issueId = readNonEmptyString( - parseObject(run.contextSnapshot).issueId, - ); - if (!issueId || run.invocationSource !== "automation") return onClear(db); - await options.beforeChatControlRecoveryCheck?.({ - runId: run.id, - issueId, - stage, - }); - if (stage === "dispatch" && nativeRecovery) { - // Protected historical/already-admitted owners did not previously take - // this fence's locks. A fresh exact read keeps unrelated Board contention - // from turning their recovery into a new admission failure. - const [current] = await db - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, run.id), - eq(heartbeatRuns.companyId, run.companyId), - eq(heartbeatRuns.agentId, run.agentId), - eq(heartbeatRuns.status, "running"), - ), - ) - .limit(1); - if ( - current && - current.wakeupRequestId === run.wakeupRequestId && - parseObject(current.contextSnapshot).issueId === issueId - ) { - const admission = readChatControlRecoveryAdmission(current); - const expected = readChatControlRecoveryAdmission(run); - if ( - expected !== "invalid" && - (admission === "admitted" || - (admission === "historical" && expected === "historical")) - ) - return onClear(db); - } - } - let terminal: typeof heartbeatRuns.$inferSelect | null = null; - try { - const attempt = () => db.transaction(async (tx) => { - terminal = null; - // Same queue-edit lock order, then the close committer's conversation - // row. NOWAIT releases partial locks on contention. Claim defers to the - // queue; dispatch retries this transaction before considering failure. - const [issue] = await tx - .select({ id: issues.id }) - .from(issues) - .where( - and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)), - ) - .for("update", { noWait: true }) - .limit(1); - if (!issue) return null; - if (run.wakeupRequestId) - await tx - .select({ id: agentWakeupRequests.id }) - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.id, run.wakeupRequestId), - eq(agentWakeupRequests.companyId, run.companyId), - eq(agentWakeupRequests.agentId, run.agentId), - ), - ) - .for("update", { noWait: true }) - .limit(1); - const [current] = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, run.id), - eq(heartbeatRuns.companyId, run.companyId), - eq(heartbeatRuns.agentId, run.agentId), - ), - ) - .for("update", { noWait: true }) - .limit(1); - if ( - !current || - current.status !== (stage === "claim" ? "queued" : "running") - ) - return null; - const admission = readChatControlRecoveryAdmission(current); - const expectedAdmission = readChatControlRecoveryAdmission(run); - const admissionLost = - expectedAdmission !== "historical" && admission === "historical"; - if ( - stage === "dispatch" && - nativeRecovery && - !admissionLost && - (admission === "historical" || admission === "admitted") - ) - return onClear(tx as unknown as Db); - // A new warm child can inherit a prior runner's PID during preparation. - // Those fields do not prove this run's turn was dispatched. - // Exact admitted/historical recovery retains its existing ownership; - // a marked, unadmitted bootstrap must earn admission even after crash. - const proof = - admission === "invalid" || admissionLost - ? { kind: "unresolved" as const } - : await readChatControlRecoveryStop( - tx as unknown as Db, - { - companyId: run.companyId, - issueId, - agentId: run.agentId, - sourceRunId: run.id, - }, - true, - ); - if (proof.kind === "clear") { - if (stage === "claim" || admission === "required") - await tx - .update(heartbeatRuns) - .set({ - runnerProfileJson: { - ...parseObject(current.runnerProfileJson), - [CHAT_CONTROL_RECOVERY_ADMISSION_KEY]: - chatControlRecoveryAdmission( - current, - stage === "dispatch" - ? "admitted" - : admission === "admitted" - ? "admitted" - : "required", - ), - }, - updatedAt: new Date(), - }) - .where(eq(heartbeatRuns.id, current.id)); - return onClear(tx as unknown as Db); - } - if (proof.kind === "unresolved" && stage === "claim") return null; - const now = new Date(); - const stopped = proof.kind === "stopped"; - const code = stopped - ? CHAT_CONTROL_RECOVERY_STOP_CODE - : CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE; - const error = stopped - ? "Automatic continuation stopped by the committed chat conversation close. Send a new request in chat or on the Board to start fresh work." - : "Automatic continuation source could not be verified before provider admission. Review the task and send a fresh request; this attempt will not automatically retry."; - [terminal] = await tx - .update(heartbeatRuns) - .set({ - status: stopped ? "cancelled" : "failed", - errorCode: code, - error, - finishedAt: now, - resultJson: { - ...parseObject(current.resultJson), - automaticRecovery: { - code, - providerDispatched: false, - ...(stopped - ? { - sourceRunId: proof.sourceRunId, - conversationId: proof.conversationId, - publicationId: proof.publicationId, - } - : {}), - }, - }, - updatedAt: now, - }) - .where( - and( - eq(heartbeatRuns.id, current.id), - eq(heartbeatRuns.status, current.status), - ), - ) - .returning(); - if (!terminal) return null; - if (current.wakeupRequestId) - await tx - .update(agentWakeupRequests) - .set({ - status: stopped ? "skipped" : "failed", - error, - finishedAt: now, - updatedAt: now, - }) - .where( - and( - eq(agentWakeupRequests.id, current.wakeupRequestId), - eq(agentWakeupRequests.companyId, current.companyId), - eq(agentWakeupRequests.agentId, current.agentId), - eq(agentWakeupRequests.runId, current.id), - ne(agentWakeupRequests.status, "cancelled"), - ), - ); - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: now, - }) - .where( - and( - eq(issues.companyId, current.companyId), - eq(issues.id, issueId), - eq(issues.executionRunId, current.id), - ), - ); - return null; - }); - const result = stage === "dispatch" - ? await retryChatControlAdmission(attempt) - : await attempt(); - if (terminal) { - const settled = terminal as typeof heartbeatRuns.$inferSelect; - publishLiveEvent({ - companyId: settled.companyId, - type: "heartbeat.run.status", - payload: { - runId: settled.id, - agentId: settled.agentId, - status: settled.status, - errorCode: settled.errorCode, - error: settled.error, - }, - }); - publishRunLifecyclePluginEvent(settled); - if (stage === "dispatch") - await finalizeAgentStatus(settled.agentId, "cancelled"); - } - return result; - } catch (error) { - if (!isExternalChatWaitAuthorizationContention(error)) throw error; - if (stage === "dispatch") { - // No effect was admitted. Let the existing setup-failure path settle - // this attempt distinctly; it must not become a successful close. - throw new ChatControlRecoveryUnresolvedError(); - } - return null; - } - } - - async function claimQueuedRun( - run: typeof heartbeatRuns.$inferSelect, - companyAgents?: AgentOrgRow[], - ) { - if (run.status !== "queued") return run; - const agent = await getAgent(run.agentId); - if (!agent) { - await cancelRunInternal( - run.id, - "Cancelled because the agent no longer exists", - ); - return null; - } - const invokability = companyAgents - ? evaluateAgentInvokability(toAgentOrgRow(agent), companyAgents) - : await getAgentInvokability(agent); - if (!invokability.invokable) { - await cancelRunInternal( - run.id, - `Cancelled because the agent is not invokable: ${invokability.reason}`, - ); - return null; - } - - const context = parseObject(run.contextSnapshot); - const budgetBlock = await budgets.getInvocationBlock( - run.companyId, - run.agentId, - { - issueId: readNonEmptyString(context.issueId), - projectId: readNonEmptyString(context.projectId), - }, - ); - if (budgetBlock) { - await cancelRunInternal(run.id, budgetBlock.reason); - return null; - } - - const dailyCapBlock = await getHeartbeatDailyCapBlock( - agent, - parseHeartbeatPolicy(agent), - { - excludeRunId: run.id, - checkRunCap: true, - checkCostCap: true, - }, - ); - if (dailyCapBlock) { - await cancelQueuedRunForHeartbeatDailyCap(run, dailyCapBlock); - return null; - } - - const issueId = readNonEmptyString(context.issueId); - if (issueId && activeRunExecutions.size > 0) { - // Native finalization publishes success before workspace synchronization, - // provider suspension, and lease release finish. A queued comment must - // not acquire a fresh sandbox while its predecessor still owns that work. - // The executor's finally block retries this agent after removing its owner. - const [settlingOwner] = await db.select({ id: heartbeatRuns.id }) - .from(heartbeatRuns) - .where(and( - eq(heartbeatRuns.companyId, run.companyId), - eq(heartbeatRuns.agentId, run.agentId), - eq(heartbeatRuns.runtimeMode, "native"), - inArray(heartbeatRuns.id, [...activeRunExecutions]), - sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}`, - )) - .limit(1); - if (settlingOwner) return null; - } - if (issueId) { - const activePauseHold = await treeControlSvc.getActivePauseHoldGate( - run.companyId, - issueId, - ); - const treeHoldInteractionWake = - activePauseHold && - (await isVerifiedIssueTreeControlInteractionWake(db, { - companyId: run.companyId, - issueId, - agentId: run.agentId, - runId: run.id, - wakeupRequestId: run.wakeupRequestId, - contextSnapshot: context, - })); - if (activePauseHold && !treeHoldInteractionWake) { - await cancelRunInternal( - run.id, - "Cancelled because issue is held by an active subtree pause hold", - ); - await logActivity(db, { - companyId: run.companyId, - actorType: "system", - actorId: "system", - agentId: run.agentId, - runId: run.id, - action: "issue.tree_hold_run_interrupted", - entityType: "heartbeat_run", - entityId: run.id, - issueId: issueId, - details: { - issueId, - holdId: activePauseHold.holdId, - rootIssueId: activePauseHold.rootIssueId, - source: "heartbeat.claim_queued_run", - securityPrinciples: [ - "Complete Mediation", - "Fail Securely", - "Secure Defaults", - ], - }, - }); - return null; - } - - const dependencyReadiness = await issuesSvc.listDependencyReadiness( - run.companyId, - [issueId], - ); - const readiness = dependencyReadiness.get(issueId); - const unresolvedBlockerCount = readiness?.unresolvedBlockerCount ?? 0; - if ( - unresolvedBlockerCount > 0 && - !allowsIssueInteractionWake( - context, - ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS, - ) - ) { - await cancelQueuedRunForBlockedDependencies( - run, - issueId, - readiness?.unresolvedBlockerIssueIds ?? [], - ); - logger.info( - { runId: run.id, issueId, unresolvedBlockerCount }, - "claimQueuedRun: cancelled blocked queued run", - ); - return null; - } - - const staleness = await runDispatch.cancelStaleQueuedRun({ - runId: run.id, - companyId: run.companyId, - expectedStatus: "queued", - }); - if (staleness.outcome === "cancelled" || staleness.outcome === "deferred") { - applyRunDispatchPostCommitEffects(staleness.postCommitEffects); - logger.info( - { - runId: run.id, - issueId, - outcome: staleness.outcome, - errorCode: staleness.outcome === "cancelled" ? staleness.errorCode : undefined, - }, - "claimQueuedRun: withheld queued run at the execution gate", - ); - return null; - } - } - - const claimedAt = new Date(); - const responsibleUserId = await resolveResponsibleUserIdForRun({ - run, - contextSnapshot: context, - issueContext: issueId - ? await getIssueExecutionContext(run.companyId, issueId) - : null, - routineEnvContext: { - routineId: null, - env: null, - responsibleUserId: null, - }, - }); - // All ordinary and comment claims use the same company-scoped issue - // lock. A batch may claim several runs before executeRun tracks any owner. - async function lockIssueExecutionClaim(tx: Db) { - const [owner] = issueId ? await tx.select({ - assigneeAgentId: issues.assigneeAgentId, - executionRunId: issues.executionRunId, - checkoutRunId: issues.checkoutRunId, - }).from(issues).where(and( - eq(issues.id, issueId), eq(issues.companyId, run.companyId), - )).for("update") : []; - const ownsIssue = owner?.assigneeAgentId === run.agentId && - context.wakeReason !== "source_scoped_recovery_action"; - if (ownsIssue && ["native_safe_replacement", "native_provider_overloaded"].includes(run.scheduledRetryReason ?? "") && - owner.checkoutRunId && owner.checkoutRunId !== run.id) { - return { ownsIssue, blocked: true }; - } - if (run.scheduledRetryReason === "native_provider_overloaded" && owner?.executionRunId && - owner.executionRunId !== run.id && owner.executionRunId !== run.retryOfRunId) { - return { ownsIssue, blocked: true }; - } - const previousRunId = run.scheduledRetryReason === "native_provider_overloaded" - ? run.retryOfRunId : ownsIssue ? owner?.executionRunId : null; - if (previousRunId && previousRunId !== run.id) { - const [previous] = await tx.select({ status: heartbeatRuns.status }) - .from(heartbeatRuns).where(and( - eq(heartbeatRuns.id, previousRunId), - eq(heartbeatRuns.companyId, run.companyId), - )); - // A terminal result can precede workspace/lease cleanup on this or - // another controller. Local absence alone is not a release receipt. - if (!isHeartbeatRunTerminalStatus(previous?.status) || - liveRunExecutions.has(previousRunId)) { - return { ownsIssue, blocked: true }; - } - const [pendingLease] = await tx.select({ id: environmentLeases.id }) - .from(environmentLeases).where(and( - eq(environmentLeases.companyId, run.companyId), - eq(environmentLeases.heartbeatRunId, previousRunId), - or(and(isNull(environmentLeases.releasedAt), - // Warm release deliberately retains the sandbox. Its successful - // receipt settles the old run without destroying the resource. - sql`not coalesce(${environmentLeases.status} = 'retained' - and ${environmentLeases.leasePolicy} = 'reuse_by_environment' - and ${environmentLeases.cleanupStatus} = 'success', false)`), - eq(environmentLeases.status, "pending_cleanup"), - eq(environmentLeases.cleanupStatus, "failed")), - )).limit(1); - const [finalization] = await tx.select({ - phase: nativeRunFinalizations.phase, leaseOwner: nativeRunFinalizations.leaseOwner, - }).from(nativeRunFinalizations).where(and( - eq(nativeRunFinalizations.companyId, run.companyId), - eq(nativeRunFinalizations.runId, previousRunId), - )); - if (pendingLease || (finalization && (finalization.leaseOwner || - !["committed", "applied", "terminal_failure"].includes(finalization.phase)))) { - return { ownsIssue, blocked: true }; - } - } - return { ownsIssue, blocked: false }; - } - async function bindClaimedIssueExecution(tx: Db, ownsIssue: boolean, claimedRun: typeof heartbeatRuns.$inferSelect | null | undefined) { - if (!claimedRun || !issueId || !ownsIssue) return; - await tx.update(issues).set({ - executionRunId: claimedRun.id, - executionAgentNameKey: normalizeAgentNameKey(agent.name), - executionLockedAt: claimedAt, - updatedAt: claimedAt, - }).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))); - } - const nativeReviewContext = readNativeReviewAssignmentContext(context); - const queuedCommentIds = queuedCommentIdsFromRunContext(context); - if ( - issueId && - run.invocationSource === "automation" && - queuedCommentIds.length > 0 - ) - await options.beforeChatControlRecoveryCheck?.({ - runId: run.id, - issueId, - stage: "claim", - }); - const queuedCommentClaim = - !nativeReviewContext && issueId && run.wakeupRequestId && queuedCommentIds.length > 0 - ? await db - .transaction(async (tx) => { - // Match the queue-edit lock order: issue, wake, then run. Once the - // run becomes running, a concurrent discard must observe the - // claimed wake and return an explicit conflict; if discard wins, - // this claim observes the cancelled queue and does no work. - const issueClaim = await lockIssueExecutionClaim(tx as unknown as Db); - if (issueClaim.blocked) return { kind: "stale" as const, run: null }; - const wake = await tx - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.id, run.wakeupRequestId!), - eq(agentWakeupRequests.companyId, run.companyId), - eq(agentWakeupRequests.agentId, run.agentId), - ), - ) - .for("update") - .limit(1) - .then((rows) => rows[0] ?? null); - const lockedRun = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, run.id), - eq(heartbeatRuns.companyId, run.companyId), - eq(heartbeatRuns.agentId, run.agentId), - ), - ) - .for("update") - .limit(1) - .then((rows) => rows[0] ?? null); - if ( - !wake || - wake.status !== "queued" || - wake.runId !== run.id || - !lockedRun || - lockedRun.status !== "queued" || - lockedRun.wakeupRequestId !== wake.id - ) { - return { kind: "stale" as const, run: null }; - } - - if (lockedRun.invocationSource === "automation") { - const admission = readChatControlRecoveryAdmission(lockedRun); - if (admission === "invalid") - return { kind: "stale" as const, run: null }; - const proof = await readChatControlRecoveryStop( - tx as unknown as Db, - { - companyId: lockedRun.companyId, - issueId, - agentId: lockedRun.agentId, - sourceRunId: lockedRun.id, - }, - true, - ); - if (proof.kind === "unresolved") - return { kind: "stale" as const, run: null }; - if (proof.kind === "stopped") { - const error = - "Automatic continuation stopped by the committed chat conversation close. Send a fresh request in chat or on the Board."; - const [cancelled] = await tx - .update(heartbeatRuns) - .set({ - status: "cancelled", - errorCode: CHAT_CONTROL_RECOVERY_STOP_CODE, - error, - finishedAt: claimedAt, - resultJson: { - ...parseObject(lockedRun.resultJson), - automaticRecovery: { - code: CHAT_CONTROL_RECOVERY_STOP_CODE, - providerDispatched: false, - sourceRunId: proof.sourceRunId, - conversationId: proof.conversationId, - publicationId: proof.publicationId, - }, - }, - updatedAt: claimedAt, - }) - .where( - and( - eq(heartbeatRuns.id, lockedRun.id), - eq(heartbeatRuns.status, "queued"), - ), - ) - .returning(); - if (!cancelled) return { kind: "stale" as const, run: null }; - await tx - .update(agentWakeupRequests) - .set({ - status: "skipped", - error, - finishedAt: claimedAt, - updatedAt: claimedAt, - }) - .where(eq(agentWakeupRequests.id, wake.id)); - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: claimedAt, - }) - .where( - and( - eq(issues.id, issueId), - eq(issues.companyId, run.companyId), - eq(issues.executionRunId, run.id), - ), - ); - return { kind: "cancelled" as const, run: cancelled }; - } - await tx - .update(heartbeatRuns) - .set({ - runnerProfileJson: { - ...parseObject(lockedRun.runnerProfileJson), - [CHAT_CONTROL_RECOVERY_ADMISSION_KEY]: - chatControlRecoveryAdmission( - lockedRun, - admission === "admitted" ? "admitted" : "required", - ), - }, - updatedAt: claimedAt, - }) - .where(eq(heartbeatRuns.id, lockedRun.id)); - } - const authoritativeIds = queuedCommentIdsFromWakePayload( - wake.payload, - ); - if (authoritativeIds.length === 0) { - // Legacy/direct comment wakes carry comment ids in their ordinary - // payload and context, not in the authoritative queued-message - // envelope. Preserve their established claim path; only an - // explicitly bound queued-message envelope is subject to the - // live-comment discard gate below. - const [claimedRun] = await tx - .update(heartbeatRuns) - .set({ - status: "running", - runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`, - ...legacyControllerClaim(run.runtimeMode), - responsibleUserId, - startedAt: lockedRun.startedAt ?? claimedAt, - updatedAt: claimedAt, - }) - .where( - and( - eq(heartbeatRuns.id, lockedRun.id), - eq(heartbeatRuns.status, "queued"), - ), - ) - .returning(); - await bindClaimedIssueExecution(tx as unknown as Db, issueClaim.ownsIssue, claimedRun); - return claimedRun - ? { kind: "claimed" as const, run: claimedRun } - : { kind: "stale" as const, run: null }; - } - const commentRows = await tx - .select({ - id: issueComments.id, - deletedAt: issueComments.deletedAt, - }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, run.companyId), - eq(issueComments.issueId, issueId), - inArray(issueComments.id, authoritativeIds), - ), - ); - const liveIds = authoritativeIds.filter((commentId) => { - const comment = commentRows.find((row) => row.id === commentId); - return Boolean(comment && !comment.deletedAt); - }); - if (liveIds.length === 0) { - const reason = "Queued messages were discarded before dispatch"; - const [cancelled] = await tx - .update(heartbeatRuns) - .set({ - status: "cancelled", - finishedAt: claimedAt, - error: reason, - errorCode: "queued_comment_discarded", - updatedAt: claimedAt, - }) - .where( - and( - eq(heartbeatRuns.id, lockedRun.id), - eq(heartbeatRuns.status, "queued"), - ), - ) - .returning(); - await tx - .update(agentWakeupRequests) - .set({ - status: "cancelled", - finishedAt: claimedAt, - error: reason, - updatedAt: claimedAt, - }) - .where(eq(agentWakeupRequests.id, wake.id)); - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: claimedAt, - }) - .where( - and( - eq(issues.id, issueId), - eq(issues.companyId, run.companyId), - eq(issues.executionRunId, run.id), - ), - ); - return { - kind: "cancelled" as const, - run: cancelled ?? lockedRun, - }; - } - - await tx - .update(agentWakeupRequests) - .set({ - status: "claimed", - claimedAt, - payload: withQueuedCommentIdsInWakePayload( - wake.payload, - liveIds, - ), - updatedAt: claimedAt, - }) - .where(eq(agentWakeupRequests.id, wake.id)); - const [claimedRun] = await tx - .update(heartbeatRuns) - .set({ - status: "running", - runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`, - ...legacyControllerClaim(run.runtimeMode), - responsibleUserId, - startedAt: lockedRun.startedAt ?? claimedAt, - contextSnapshot: withQueuedCommentIdsInRunContext( - lockedRun.contextSnapshot, - liveIds, - ), - updatedAt: claimedAt, - }) - .where( - and( - eq(heartbeatRuns.id, lockedRun.id), - eq(heartbeatRuns.status, "queued"), - ), - ) - .returning(); - await bindClaimedIssueExecution(tx as unknown as Db, issueClaim.ownsIssue, claimedRun); - return claimedRun - ? { kind: "claimed" as const, run: claimedRun } - : { kind: "stale" as const, run: null }; - }) - .catch((error) => { - if (isExternalChatWaitAuthorizationContention(error)) - return { kind: "stale" as const, run: null }; - throw error; - }) - : null; - if (queuedCommentClaim?.kind === "cancelled") { - await appendRunEvent(queuedCommentClaim.run, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: - queuedCommentClaim.run.error ?? - "Queued messages were discarded before dispatch", - }); - publishLiveEvent({ - companyId: queuedCommentClaim.run.companyId, - type: "heartbeat.run.status", - payload: { - runId: queuedCommentClaim.run.id, - agentId: queuedCommentClaim.run.agentId, - status: queuedCommentClaim.run.status, - invocationSource: queuedCommentClaim.run.invocationSource, - triggerDetail: queuedCommentClaim.run.triggerDetail, - error: queuedCommentClaim.run.error ?? null, - errorCode: queuedCommentClaim.run.errorCode ?? null, - startedAt: queuedCommentClaim.run.startedAt - ? new Date(queuedCommentClaim.run.startedAt).toISOString() - : null, - finishedAt: queuedCommentClaim.run.finishedAt - ? new Date(queuedCommentClaim.run.finishedAt).toISOString() - : null, - }, - }); - publishRunLifecyclePluginEvent(queuedCommentClaim.run); - // Fire-and-forget: nothing else in this path depends on the emission, - // so it must not delay the return. - void emitAgentTaskRun(db, queuedCommentClaim.run); - return null; - } - const claimed = queuedCommentClaim - ? queuedCommentClaim.run - : await withChatControlRecoveryGate(run, "claim", async (tx) => { - const claimValues = { - status: "running", - runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`, - ...legacyControllerClaim(run.runtimeMode), - responsibleUserId, - startedAt: run.startedAt ?? claimedAt, - updatedAt: claimedAt, - }; - if (nativeReviewContext) { - if (run.scheduledRetryReason === "native_provider_overloaded") { - const predecessor = await lockIssueExecutionClaim(tx); - if (predecessor.blocked) return null; - } - return claimQueuedNativeReviewRun(tx, { - run, claimedAt, claimValues, - agentNameKey: normalizeAgentNameKey(agent.name), - }); - } - return tx.transaction(async (claimTx) => { - const issueClaim = await lockIssueExecutionClaim(claimTx as unknown as Db); - if (issueClaim.blocked) return null; - const claimedRun = await claimTx.update(heartbeatRuns).set(claimValues).where(and( - eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "queued"), - )).returning().then((rows) => rows[0] ?? null); - await bindClaimedIssueExecution(claimTx as unknown as Db, issueClaim.ownsIssue, claimedRun); - return claimedRun; - }); - }); - if (!claimed) return null; - - publishLiveEvent({ - companyId: claimed.companyId, - type: "heartbeat.run.status", - payload: { - runId: claimed.id, - agentId: claimed.agentId, - status: claimed.status, - invocationSource: claimed.invocationSource, - triggerDetail: claimed.triggerDetail, - error: claimed.error ?? null, - errorCode: claimed.errorCode ?? null, - startedAt: claimed.startedAt - ? new Date(claimed.startedAt).toISOString() - : null, - finishedAt: claimed.finishedAt - ? new Date(claimed.finishedAt).toISOString() - : null, - }, - }); - publishRunLifecyclePluginEvent(claimed); - - if (!nativeReviewContext) { - await setWakeupStatus(claimed.wakeupRequestId, "claimed", { claimedAt }); - } - - // Fix A (lazy locking): stamp executionRunId now that the run is actually running, - // not at queue time. Guard is idempotent — safe if called more than once. - const claimedContext = parseObject(claimed.contextSnapshot); - const claimedIssueId = readNonEmptyString(claimedContext.issueId); - const claimedWakeReason = readNonEmptyString(claimedContext.wakeReason); - if ( - !nativeReviewContext && claimedIssueId && - claimedWakeReason !== "source_scoped_recovery_action" - ) { - const claimedAgent = await getAgent(claimed.agentId); - await db - .update(issues) - .set({ - executionRunId: claimed.id, - executionAgentNameKey: normalizeAgentNameKey(claimedAgent?.name), - executionLockedAt: claimedAt, - updatedAt: claimedAt, - }) - .where( - and( - eq(issues.id, claimedIssueId), - eq(issues.companyId, claimed.companyId), - // Mention/context runs can touch an issue, but only the current assignee - // owns the issue execution lock shown as the active run. - eq(issues.assigneeAgentId, claimed.agentId), - ["native_safe_replacement", "native_provider_overloaded"].includes(claimed.scheduledRetryReason ?? "") - ? or( - isNull(issues.checkoutRunId), - eq(issues.checkoutRunId, claimed.id), - ) - : undefined, - or( - isNull(issues.executionRunId), - eq(issues.executionRunId, claimed.id), - ), - ), - ); - } - - return claimed; - } - - // startNextQueuedRunForAgent checks admission suppression once, then claims - // runs (sets status "running"), then dispatches each to executeRun, which - // checks suppression again before it does any work. Suppression (task - // drain, worktree mode, a database restore) can start in the gap between - // those two checks. When executeRun's check catches that, the run is - // already claimed — release it back to "queued" so it does not keep a - // running execution lock that nothing will ever process. This runs inside - // the same promise startNextQueuedRunForAgent already tracks in - // activeRunExecutionPromises, so getTaskDrainStatus() keeps reporting the - // run as active until the release finishes. - // - // The run row, the wakeup request, and the issue execution lock all guard - // the same claim, so one transaction commits all three writes together. A - // partial write (for example the run flips to "queued" but the wakeup or - // issue update then fails) would let the execution promise clear from - // activeRunExecutionPromises while the wakeup stayed "claimed" or the - // issue stayed locked to a queued run — task-drain status would then read - // quiescent while the database still held part of the old claim. - async function releaseRunClaimedJustBeforeSuppression(runId: string) { - const now = new Date(); - await db.transaction(async (tx) => { - const released = await tx - .update(heartbeatRuns) - .set({ - status: "queued", - startedAt: null, - responsibleUserId: null, - updatedAt: now, - }) - .where( - and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.status, "running")), - ) - .returning() - .then((rows) => rows[0] ?? null); - if (!released) return; - - if (released.wakeupRequestId) { - await tx - .update(agentWakeupRequests) - .set({ status: "queued", claimedAt: null, updatedAt: now }) - .where(and(eq(agentWakeupRequests.id, released.wakeupRequestId), ne(agentWakeupRequests.status, "cancelled"))); - } - - const context = parseObject(released.contextSnapshot); - const issueId = readNonEmptyString(context.issueId); - if (issueId) { - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: now, - }) - .where( - and( - eq(issues.id, issueId), - eq(issues.companyId, released.companyId), - eq(issues.executionRunId, released.id), - ), - ); - } - }); - } - - async function cancelQueuedRunForBlockedDependencies( - run: typeof heartbeatRuns.$inferSelect, - issueId: string, - unresolvedBlockerIssueIds: string[], - ) { - const now = new Date(); - const reason = - "Cancelled because issue dependencies are still blocked; Paperclip will wake the assignee when blockers resolve"; - const cancelled = await setRunStatus(run.id, "cancelled", { - finishedAt: now, - error: reason, - errorCode: "issue_dependencies_blocked", - resultJson: { - ...parseObject(run.resultJson), - stopReason: "issue_dependencies_blocked", - effectiveTimeoutSec: 0, - timeoutConfigured: false, - timeoutSource: "dependency_gate", - timeoutFired: false, - }, - }); - if (!cancelled) return null; - - await setWakeupStatus(run.wakeupRequestId, "skipped", { - finishedAt: now, - error: reason, - }); - - await db - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: now, - }) - .where( - and( - eq(issues.companyId, run.companyId), - eq(issues.id, issueId), - eq(issues.executionRunId, run.id), - ), - ); - - await appendRunEvent(cancelled, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: reason, - payload: { - issueId, - unresolvedBlockerIssueIds, - }, - }); - - return cancelled; - } - function truncateAgentErrorReason( reason: string | null | undefined, ): string | null { @@ -14790,1256 +6174,6 @@ export function heartbeatService( .then((rows) => rows[0] ?? null); } - // Clamp the stored attempt count to the range [0, cap]. The SQL reader - // `pendingCleanupAttemptsSql` clamps to the same range, so both readers yield - // the same value for every input. The claim predicate compares the two values, - // so this alignment lets the claim match for a malformed lease. - function readPendingCleanupRetryAttempts( - metadata: Record, - ): number { - const value = metadata[PENDING_CLEANUP_ATTEMPTS_METADATA_KEY]; - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) - return 0; - return Math.min(Math.floor(value), PENDING_CLEANUP_SWEEP_ATTEMPT_CAP); - } - - // Atomically claim one retry attempt on a pending_cleanup lease. The update - // only matches when the lease is still pending_cleanup and its stored attempt - // count still equals `expectedAttempts`. Two concurrent sweeps read the same - // count, but Postgres serializes the two updates on the row and only the first - // matches the guard. The loser gets zero rows and skips the lease. The persisted in-flight deadline also prevents a later tick - // from starting another attempt while this one is still running. - // Returns the attempt identity only for the sweep that won the claim. - // - // The update patches cleanup ownership fields without writing a copied - // metadata object, so a concurrent write to an unrelated metadata key - // survives. The guard reads the stored count through the safe SQL reader, so a - // malformed value never throws. - async function claimPendingCleanupRetryAttempt( - leaseId: string, - expectedAttempts: number, - manualAttempt?: { previousId: unknown }, - ): Promise { - const now = new Date(); - const attemptId = randomUUID(); - const claimed = await db - .update(environmentLeases) - .set({ - metadata: sql`jsonb_set(${pendingCleanupMetadataObjectSql()}, array[${PENDING_CLEANUP_ATTEMPTS_METADATA_KEY}], to_jsonb(${expectedAttempts + 1}::int), true) - || ${JSON.stringify({ ...(manualAttempt ? { pendingCleanupManualAttemptId: randomUUID() } : {}), - pendingCleanupAttemptId: attemptId, pendingCleanupInFlight: true, - pendingCleanupLeaseExpiresAtMs: Date.now() + 15 * 60_000, - })}::jsonb`, - lastUsedAt: now, - updatedAt: now, - }) - .where( - and( - eq(environmentLeases.id, leaseId), - eq(environmentLeases.status, "pending_cleanup"), - sql`${pendingCleanupAttemptsSql()} = ${expectedAttempts}`, - manualAttempt ? sql`coalesce(${environmentLeases.metadata}->'pendingCleanupManualAttemptId', 'null'::jsonb) is not distinct from ${JSON.stringify(manualAttempt.previousId ?? null)}::jsonb` : undefined, - pendingCleanupRetryDueSql(Boolean(manualAttempt)), - ), - ) - .returning({ id: environmentLeases.id }); - return claimed.length > 0 ? attemptId : null; - } - - // Atomically claim the one-time cap warning for a lease. The update only - // matches when the lease is still pending_cleanup, its stored attempt count is - // at or above the cap, and it has not yet carried the warned flag. Two - // concurrent sweeps that both reach the cap race here, but only one update - // sets the flag and returns a row. The loser skips the warning. This keeps the - // warning to one log line per lease. - // - // The status and cap predicates are the last line of defense. They stop a warn - // flag write to a lease that left pending_cleanup or dropped below the cap - // between the read and this claim. The update writes only the warned key with - // `jsonb_set`, so a concurrent write to an unrelated metadata key survives. - async function claimPendingCleanupCapWarning( - leaseId: string, - ): Promise { - const now = new Date(); - const claimed = await db - .update(environmentLeases) - .set({ - metadata: sql`jsonb_set(${pendingCleanupMetadataObjectSql()}, array[${PENDING_CLEANUP_CAP_WARNED_METADATA_KEY}], to_jsonb(true), true)`, - updatedAt: now, - }) - .where( - and( - eq(environmentLeases.id, leaseId), - eq(environmentLeases.status, "pending_cleanup"), - sql`${pendingCleanupAttemptsSql()} >= ${PENDING_CLEANUP_SWEEP_ATTEMPT_CAP}`, - sql`${pendingCleanupCapWarnedSql()} = false`, - ), - ) - .returning({ id: environmentLeases.id }); - return claimed.length > 0; - } - - // Defer a pending_cleanup lease whose provider plugin is not ready this tick. - // The sweep reads one page of the oldest rows, ordered by `updatedAt`. A lease - // that the sweep only skips keeps its old `updatedAt`, so it stays the oldest - // and refills the page on every tick. That starves a newer lease whose - // provider is ready. The defer bumps `updatedAt` to now, so the unavailable - // lease moves to the back of the queue and a ready lease takes its page slot. - // The defer never writes the attempt count, so a long provider outage never - // consumes a finite retry. The status guard keeps the write on a lease that is - // still pending_cleanup. - async function deferPendingCleanupLease(leaseId: string): Promise { - const now = new Date(); - await db - .update(environmentLeases) - .set({ updatedAt: now }) - .where( - and( - eq(environmentLeases.id, leaseId), - eq(environmentLeases.status, "pending_cleanup"), - ), - ); - } - - // Move a guarded orphan candidate to the back of the sweep order. The - // shared-resource guard below can skip a row on every tick as long as the - // other owner stays live, retained, or pending cleanup. The select orders - // by `updatedAt` ascending and takes only the oldest page, so an untouched - // skipped row keeps refilling that same page and blocks every row behind - // it. The bump pushes the row past the fixed-size page, so the next tick - // reaches the rows behind it. It costs the row one extra backoff wait, - // which is safe because the guard means a physical sandbox still exists. - async function deferOrphanedActiveLease(leaseId: string): Promise { - const now = new Date(); - await db - .update(environmentLeases) - .set({ updatedAt: now }) - .where( - and( - eq(environmentLeases.id, leaseId), - eq(environmentLeases.status, "active"), - ), - ); - } - - // An active lease is reachable only while its heartbeat run keeps the - // running status. The reaper writes the run status and the lease release as - // two separate statements, so a restart between them can leave a terminal - // run and an active lease. `releaseRunLeases` also skips a lease whose - // environment row is gone, so that lease stays active too. No later query - // finds either lease, because every production query selects an active - // lease by its environment or by its heartbeat run id, never by age. This - // sweep finds both stranded classes and moves each lease to pending_cleanup, - // so the existing pending_cleanup sweep tears the sandbox down from the - // data already on the lease row. - async function sweepOrphanedActiveLeases(opts: { - backoffMs: number; - }): Promise<{ recovered: number }> { - const cutoff = new Date(Date.now() - opts.backoffMs); - - const rows = await db - .select({ lease: environmentLeases }) - .from(environmentLeases) - .leftJoin( - heartbeatRuns, - eq(environmentLeases.heartbeatRunId, heartbeatRuns.id), - ) - .where( - and( - eq(environmentLeases.status, "active"), - or( - isNull(environmentLeases.heartbeatRunId), - inArray(heartbeatRuns.status, [ - ...HEARTBEAT_RUN_TERMINAL_STATUSES, - ]), - ), - lte(environmentLeases.updatedAt, cutoff), - ), - ) - .orderBy(asc(environmentLeases.updatedAt)) - .limit(ORPHANED_ACTIVE_LEASE_SWEEP_PAGE_SIZE); - - let recovered = 0; - for (const { lease } of rows) { - // A provider resource id names one physical sandbox. A different lease - // row can still hold that same resource in a live status, so this sweep - // must not tear down a sandbox that a different lease still owns. - if (lease.provider && lease.providerLeaseId) { - const [otherOwner] = await db - .select({ id: environmentLeases.id }) - .from(environmentLeases) - .where( - and( - ne(environmentLeases.id, lease.id), - eq(environmentLeases.provider, lease.provider), - eq(environmentLeases.providerLeaseId, lease.providerLeaseId), - inArray(environmentLeases.status, [ - "active", - "retained", - "pending_cleanup", - ]), - ), - ) - .limit(1); - if (otherOwner) { - // Defer this row so the fixed-size page reaches the rows behind - // it next tick, instead of re-selecting the same guarded rows - // forever. - await deferOrphanedActiveLease(lease.id); - continue; - } - } - - // Keep the row's existing updatedAt value. The select above already - // proved the row is older than the backoff cutoff, so the - // pending_cleanup sweep can accept the same row in this same tick. A - // fresh timestamp here would push the row inside that sweep's own - // backoff window and delay the teardown by one full tick. - const flipped = await db - .update(environmentLeases) - .set({ - status: "pending_cleanup", - failureReason: "orphaned_active_lease_recovered", - }) - .where( - and( - eq(environmentLeases.id, lease.id), - eq(environmentLeases.status, "active"), - ), - ) - .returning({ id: environmentLeases.id }); - if (flipped.length > 0) recovered += 1; - } - - return { recovered }; - } - - // Retry the leases stranded in "pending_cleanup". A failed destroy leaves a - // lease in that state forever without this sweep. The reaper tick runs the - // sweep. The backoff equals the reaper staleness threshold, so a lease waits - // for that period between attempts. The sweep reads and writes the attempt - // count in the lease metadata. It warns once when a lease reaches the attempt - // threshold, then continues with slower retries. Live attempts renew their - // cleanup claim; after controller loss, exact-resource destruction may repeat. - async function sweepPendingCleanupLeases(opts?: { - backoffMs?: number; - /** One cleanup attempt per explicit user Retry, for this failed run only. - * A later user Retry may bypass the cooldown after a provider failure, - * but cannot take over an in-flight cleanup attempt. - */ - explicitRetry?: { companyId: string; runId: string; actorId: string; reason?: "retry_failed_run" | "queued_comment_interrupt" }; - }): Promise<{ - swept: number; - destroyed: number; - capped: number; - }> { - const backoffMs = opts?.backoffMs ?? 0; - const now = new Date(); - const cutoff = new Date(now.getTime() - backoffMs); - - // Flush the in-process orphan-cleanup buffer first. A failed acquire buffers - // an orphan there when every synchronous pending-cleanup write failed after a - // failed teardown. The flush re-inserts each buffered record, so a durable - // `pending_cleanup` row lands once the database recovers. The flush runs - // before the read below, so this same tick tears down a freshly-landed row. - try { - const flushed = opts?.explicitRetry ? null : await environmentRuntime.flushDeferredOrphanCleanups?.(); - if (flushed && (flushed.recovered > 0 || flushed.pending > 0)) { - logger.info( - { recovered: flushed.recovered, pending: flushed.pending }, - "flushed the in-process orphan sandbox cleanup buffer to the database", - ); - } - } catch { - // A flush failure never stops the sweep. The buffer keeps the orphan for a - // later tick, and the database rows below still need this sweep. The caught - // exception never enters the log, because a write error can carry a - // credential in its message, code, cause, or stack. - logger.warn( - "orphan sandbox cleanup buffer flush failed; the sweep continues", - ); - } - - const rows = await db - .select() - .from(environmentLeases) - .where( - and( - eq(environmentLeases.status, "pending_cleanup"), - opts?.explicitRetry ? eq(environmentLeases.companyId, opts.explicitRetry.companyId) : undefined, - opts?.explicitRetry ? eq(environmentLeases.heartbeatRunId, opts.explicitRetry.runId) : undefined, - pendingCleanupRetryDueSql(Boolean(opts?.explicitRetry)), - backoffMs > 0 ? lte(environmentLeases.updatedAt, cutoff) : undefined, - ), - ) - .orderBy(asc(environmentLeases.updatedAt)) - .limit(PENDING_CLEANUP_SWEEP_PAGE_SIZE); - - let destroyed = 0; - let capped = 0; - for (const row of rows) { - if (pendingCleanupAttemptsInFlight.has(row.id)) continue; - const metadata = { ...(row.metadata ?? {}) } as Record; - const attempts = readPendingCleanupRetryAttempts(metadata); - - const environment = row.environmentId - ? await environmentsSvc.getById(row.environmentId) - : null; - const lease = await environmentsSvc.getLeaseById(row.id); - if (!lease) continue; - - // An orphan ephemeral lease keeps its provider, its provider lease id, and - // its sandbox config in the lease row. A failed acquire records it, and its - // environment row may be gone or foreign-bound. A reuse_by_environment lease - // whose environment a delete removed keeps the same recorded data, because - // the schema sets the environment reference to null on delete and preserves - // the row. Both leases tear down from the recorded lease data through - // `retryPendingSandboxTeardown`, which never reads the environment row. So - // the sweep uses that path whenever the lease is an orphan ephemeral lease - // or its environment row is gone. A reuse_by_environment lease whose - // environment still exists tears down through `destroyRunLease`. That - // path uses the provider and configuration recorded on the lease first; - // the environment is lifecycle context and only a legacy fallback. - const isOrphanEphemeralLease = lease.leasePolicy === "ephemeral"; - const useRecordedTeardown = isOrphanEphemeralLease || !environment || hasStopOnlyCleanup(lease); - - // Do not consume a finite cleanup attempt while the provider plugin is - // briefly unavailable. A plugin worker restart, a plugin reload, or a - // plugin reinstall makes the provider unavailable for a short window. The - // plugin can be missing or not ready in that window. A teardown then throws, - // and the atomic claim below would count that throw against the cap, so a - // long restart or reload could exhaust the retries and strand a live - // sandbox. So probe the provider first, and defer the lease this tick when - // the provider is not ready. The sweep preserves the pending_cleanup row, - // and a later sweep retries after the provider recovers. The probe reports - // ready only for a permanent condition (a missing provider string, a - // built-in provider, or no worker manager), so a genuine teardown failure - // still runs, throws, and counts toward the cap. A runtime with no probe - // method treats the lease as ready, so the sweep keeps its earlier - // behavior. - const workerReady = environmentRuntime.isPendingCleanupWorkerReady - ? await environmentRuntime.isPendingCleanupWorkerReady({ - environment, - lease, - }) - : true; - if (!workerReady) { - // Move the unavailable lease to the back of the sweep queue. Otherwise - // the oldest unavailable rows refill the page on every tick and starve a - // newer lease that has a ready provider. The defer bumps `updatedAt` - // only, so it consumes no finite retry attempt. - await deferPendingCleanupLease(row.id); - continue; - } - - // Atomically claim the attempt before the retry. Only the winning sweep - // increments the count and tears the sandbox down, so an overlapping sweep - // cannot start another attempt while this one holds the cleanup lease. - // The deadline survives a server restart; the counter saturates at the - // escalation threshold while attempt identities remain unique. - const claimed = await claimPendingCleanupRetryAttempt(row.id, attempts, - opts?.explicitRetry ? { previousId: metadata.pendingCleanupManualAttemptId } : undefined); - if (!claimed) continue; - pendingCleanupAttemptsInFlight.add(row.id); - lease.metadata = { ...lease.metadata, pendingCleanupAttemptId: claimed }; - let activeRenewal: Promise | null = null; - const renewal = setInterval(() => { - if (activeRenewal) return; - activeRenewal = db.update(environmentLeases).set({ - metadata: sql`jsonb_set(${pendingCleanupMetadataObjectSql()}, '{pendingCleanupLeaseExpiresAtMs}', to_jsonb(${Date.now() + 15 * 60_000}::bigint))`, - }).where(and(eq(environmentLeases.id, row.id), eq(environmentLeases.status, "pending_cleanup"), - sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${claimed}`, - sql`${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'`)) - .then(() => {}) - .catch(() => logger.warn({ leaseId: row.id }, "cleanup ownership renewal failed")) - .finally(() => { activeRenewal = null; }); - }, 30_000); - renewal.unref(); - - try { - if (opts?.explicitRetry) await logActivity(db, { - companyId: row.companyId, actorType: "user", actorId: opts.explicitRetry.actorId, - action: "environment_lease.cleanup_retried", entityType: "environment_lease", entityId: row.id, - runId: opts.explicitRetry.runId, details: { attempt: attempts + 1, reason: opts.explicitRetry.reason ?? "retry_failed_run" }, - }); - if (useRecordedTeardown) { - // Tear the sandbox down from the recorded provider config and the - // cleanup-authorized secret versions. Preserve any provider receipt; - // a completed retry must grant the same evidence as initial cleanup. - const receipt = await environmentRuntime.retryPendingSandboxTeardown({ - environment, - lease, - }); - const released = hasStopOnlyCleanup(lease) - ? await settleStopOnlyCleanup(db, lease, { attemptId: claimed, receipt }) - : await environmentsSvc.releaseLease(lease.id, "expired", { - expectedPendingCleanupAttemptId: claimed, - cleanupStatus: "success", - failureReason: "pending_cleanup_retry", - remoteExecutionTermination: remoteTerminationReceipt(lease, receipt), - }); - if (released) destroyed += 1; - } else if (environment) { - const result = await environmentRuntime.destroyRunLease({ - environment, - lease, - failureReason: "pending_cleanup_retry", - }); - if (result && result.status !== "pending_cleanup") { - destroyed += 1; - } - } - } catch { - // The recorded-data teardown throws on failure, so revert the lease to - // pending_cleanup for a later sweep. The claimed attempt still counts - // for backoff, so requests stay bounded. The `destroyRunLease` - // path reverts the lease itself, so this revert only runs for the - // recorded-data teardown path. - if (useRecordedTeardown) { - await environmentsSvc.releaseLease(lease.id, "pending_cleanup", { - expectedPendingCleanupAttemptId: claimed, - cleanupStatus: "failed", - failureReason: "pending_cleanup_retry", - }); - } - // Log a constant errorKind only. The exception can carry a credential in - // its name, code, message, cause, or stack, so the sweep never reads it. - logger.warn( - { - errorKind: PENDING_CLEANUP_RETRY_ERROR_KIND, - leaseId: row.id, - environmentId: row.environmentId, - attempts: attempts + 1, - }, - "pending_cleanup lease retry failed", - ); - } finally { - clearInterval(renewal); - // A hung renewal must not retain process-local cleanup ownership. - // Late renewals are attempt-fenced and never write the retry cooldown. - pendingCleanupAttemptsInFlight.delete(row.id); - } - if (attempts + 1 >= PENDING_CLEANUP_SWEEP_ATTEMPT_CAP) { - capped += 1; - // Warn once, then continue automatic cleanup with backoff. The atomic claim - // keeps the warning to one log line even when two sweeps overlap. - if (metadata[PENDING_CLEANUP_CAP_WARNED_METADATA_KEY] !== true) { - const warned = await claimPendingCleanupCapWarning(row.id); - if (warned) { - logger.warn( - { leaseId: row.id, environmentId: row.environmentId, attempts }, - "environment lease needs operator attention; automatic cleanup continues with backoff", - ); - } - } - } - // Persist the cooldown independently of process memory. A crash before - // this write leaves the bounded in-flight lease for a later sweep. - await db.update(environmentLeases).set({ - metadata: sql`${pendingCleanupMetadataObjectSql()} || ${JSON.stringify({ - pendingCleanupInFlight: false, - pendingCleanupRetryAfterMs: Date.now() + (attempts + 1 >= PENDING_CLEANUP_SWEEP_ATTEMPT_CAP - ? 30 * 60_000 : Math.min(30 * 60_000, Math.max(30_000, backoffMs))), - })}::jsonb`, - }).where(and(eq(environmentLeases.id, lease.id), - sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${claimed}`)); - if (lease.heartbeatRunId) { - // Delivery failure must not revert successful provider cleanup. A new - // message can still use the persisted receipt on its next admission. - await (async () => { - await acknowledgeRemoteStop(lease.heartbeatRunId!, lease.companyId); - const run = await getRun(lease.heartbeatRunId!); - if (run) await resumeRemoteStopComments(run); - })().catch(() => logger.warn({ leaseId: lease.id }, "could not reconsider messages after cleanup retry")); - } - } - - return { swept: rows.length, destroyed, capped }; - } - - async function markNativeOwnershipUnverified( - run: typeof heartbeatRuns.$inferSelect, - evidence: { - reason: - | "live_process_identifier" - | "observed_owner_unverified" - | "adopted_runner_authentication_timeout" - | "native_chat_workspace_scope_mismatch"; - processPidAlive?: boolean; - processGroupAlive?: boolean; - }, - ) { - const durableOwnershipHold = - evidence.reason === "adopted_runner_authentication_timeout" || - evidence.reason === "native_chat_workspace_scope_mismatch"; - if ( - run.errorCode === NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE && - run.error === NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE && - (!durableOwnershipHold || isNativeRunnerOwnershipHeld(run)) - ) - return run; - const blockedStatus = run.status === "failed" ? "failed" : "running"; - const blockedWrite = await setRunStatusFromLive( - run.id, - blockedStatus, - [blockedStatus], - { - error: NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE, - errorCode: NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE, - ...(durableOwnershipHold - ? { - nativePhase: "terminal_failure", - nativePhaseUpdatedAt: new Date(), - } - : {}), - }, - ); - if (!blockedWrite.updated || !blockedWrite.run) { - return blockedWrite.run ?? run; - } - const blocked = blockedWrite.run; - await appendRunEvent(blocked, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE, - payload: { - reason: evidence.reason, - ...(evidence.processPidAlive === true ? { processPidAlive: true } : {}), - ...(evidence.processGroupAlive === true - ? { processGroupAlive: true } - : {}), - }, - }); - return blocked; - } - - async function settleRecoveredNativeWorkspace(input: { - runId: string; - companyId: string; - agentId: string; - succeeded: boolean; - }) { - const settledRun = await getRun(input.runId); - const workspaceSyncReference = readNativeWorkspaceSyncReference( - parseObject(settledRun?.runnerProfileJson).nativeWorkspaceSync, - ); - await releaseEnvironmentLeasesForRun({ - runId: input.runId, - companyId: input.companyId, - agentId: input.agentId, - status: settledRun?.status, - failureReason: settledRun?.error ?? undefined, - providerResourceDisposition: input.succeeded && (!parseObject(settledRun?.resultJson).workspaceExportRetry || workspaceSyncReference?.resourceDisposition === "destroy") - ? (workspaceSyncReference?.resourceDisposition ?? "stop_and_retain") - : "stop_and_retain", - }); - await releaseRuntimeServicesForRun(input.runId).catch(() => undefined); - await finalizeAgentStatus( - input.agentId, - input.succeeded ? "succeeded" : "failed", - input.succeeded - ? null - : (settledRun?.error ?? "native_workspace_sync_out_failed"), - { - wasFirstHeartbeat: settledRun - ? timerClaimWasFirstHeartbeat(settledRun) - : undefined, - }, - ).catch(() => undefined); - } - - function scheduleRetainedNativeSessionCleanup() { - // The per-database sweep joins startup and periodic callers. One bounded - // control-only repair must not hold up unrelated provider ingress or the - // entire orphan reaper, but shutdown must still await its physical owner. - const cleanup = reconcileRetainedNativeSessionCleanups(db, { - cleanup: (input) => reconcileRetainedNativeSessionCleanup(db, input), - onError: (error, runId) => { - logger.warn( - { err: error, runId }, - "retained native session cleanup failed", - ); - }, - }) - .then(() => undefined) - .catch((error) => { - logger.warn({ err: error }, "retained native cleanup discovery failed"); - }) - // The bounded maintenance attempt may fail before an already-started - // database callback settles. Keep shutdown ownership until the original - // operations finish; their timeout cannot authorize closing the database. - .finally(() => drainRetainedRunnerdMaintenanceOperations()); - activeRunExecutionPromises.add(cleanup); - void cleanup.finally(() => activeRunExecutionPromises.delete(cleanup)); - } - - async function reapOrphanedRuns(opts?: { staleThresholdMs?: number }) { - const staleThresholdMs = opts?.staleThresholdMs ?? 0; - const now = new Date(); - // Recovery never launches a provider or infers stopped ownership from - // terminal status. Uncaptured local copies require durable stop evidence. - await instructionCopies.recoverStopped().catch(error => { - logger.warn({ err: error }, "failed to recover stopped instruction copies"); - }); - await instructionCopies.recoverCaptured().catch(error => { - logger.warn({ err: error }, "failed to retry captured instruction revisions"); - }); - - // Complete persisted native results before generic orphan recovery. The - // reconciler reads the durable workspace barrier and persisted runtime - // mode, never the current feature flag. - await reconcileNativeFinalizations(db, undefined, { - environmentRuntime, - onWorkspaceSettled: settleRecoveredNativeWorkspace, - }).catch((error) => { - logger.warn( - { err: error }, - "failed to reconcile persisted native finalizations before orphan reaping", - ); - }); - scheduleRetainedNativeSessionCleanup(); - await dispatchPendingNativeStatusWakeups().catch((error) => { - logger.warn( - { err: error }, - "failed to dispatch persisted native status wake intents before orphan reaping", - ); - }); - - // A retryable native run can retain process identifiers from the failed - // attempt. Inspect them before the recovery claim: a live identifier is - // unowned and blocks recovery, while identifiers that are all dead can be - // cleared with a compare-and-set so the explicit retryable failure becomes - // claimable in this same sweep. - const retryableNativeProcesses = await db - .select({ run: heartbeatRuns }) - .from(heartbeatRuns) - .innerJoin( - nativeRunFinalizations, - eq(nativeRunFinalizations.runId, heartbeatRuns.id), - ) - .where( - and( - inArray(heartbeatRuns.status, ["running", "failed"]), - eq(heartbeatRuns.runtimeMode, "native"), - eq(nativeRunFinalizations.phase, "retryable_failure"), - isNull(nativeRunFinalizations.resultId), - ), - ); - const claimableNativeRunIds = new Set(); - for (const { run } of retryableNativeProcesses) { - if (isNativeRunnerOwnershipHeld(run)) continue; - if (!run.processPid && !run.processGroupId) { - claimableNativeRunIds.add(run.id); - continue; - } - const processPidAlive = - !!run.processPid && isProcessAlive(run.processPid); - const processGroupAlive = - !!run.processGroupId && isProcessGroupAlive(run.processGroupId); - if (processPidAlive || processGroupAlive) { - await markNativeOwnershipUnverified(run, { - reason: "live_process_identifier", - processPidAlive, - processGroupAlive, - }); - continue; - } - const cleared = await db.transaction(async tx => { - const cleared = await tx - .update(heartbeatRuns) - .set({ - processPid: null, - processGroupId: null, - processStartedAt: null, - updatedAt: now, - }) - .where( - and( - eq(heartbeatRuns.id, run.id), - eq(heartbeatRuns.runtimeMode, "native"), - run.processPid === null - ? isNull(heartbeatRuns.processPid) - : eq(heartbeatRuns.processPid, run.processPid), - run.processGroupId === null - ? isNull(heartbeatRuns.processGroupId) - : eq(heartbeatRuns.processGroupId, run.processGroupId), - run.processStartedAt === null - ? isNull(heartbeatRuns.processStartedAt) - : eq(heartbeatRuns.processStartedAt, run.processStartedAt), - ), - ) - .returning({ id: heartbeatRuns.id }) - .then((rows) => rows[0] ?? null); - if (cleared) await recordNativeLocalProcessStop(tx as unknown as Db, run); - return cleared; - }); - if (cleared) claimableNativeRunIds.add(cleared.id); - } - - // An explicit result-less retryable failure resumes on the original run. - // The database lease is claimed before dispatch so concurrent service - // instances cannot open competing recoveries; executeRun receives the exact - // claimed owner. Expired `observed` ownership never enters this set. - const nativeResumeClaims = - claimableNativeRunIds.size === 0 - ? [] - : await dispatchNativeSessionResumptions({ - db, - runnerInstanceId: - runtimeEnv.PAPERCLIP_INSTANCE_ID?.trim() || "paperclip-heartbeat", - now, - runIds: [...claimableNativeRunIds], - dispatch: (claim) => { - const execution = executeRun(claim.runId, { - nativeLeaseOwner: claim.leaseOwner, - }).catch((error) => { - logger.error( - { err: error, runId: claim.runId }, - "persisted native session resume failed", - ); - }); - activeRunExecutionPromises.add(execution); - void execution.finally(() => - activeRunExecutionPromises.delete(execution), - ); - }, - }).catch((error) => { - logger.warn( - { err: error }, - "failed to claim persisted native session resumptions", - ); - return []; - }); - const resumedRunIds = new Set( - nativeResumeClaims.map((claim) => claim.runId), - ); - - // A terminal issue transition writes this intent in the same transaction - // that closes the question's task, even when its card is retained. Consume - // it before generic orphan recovery so a restart preserves cancellation. - const cancellationRequests = await db - .select({ - id: heartbeatRuns.id, - contextSnapshot: heartbeatRuns.contextSnapshot, - }) - .from(heartbeatRuns) - .where( - and( - inArray(heartbeatRuns.status, [ - ...CANCELLABLE_HEARTBEAT_RUN_STATUSES, - ]), - sql`${heartbeatRuns.contextSnapshot} -> ${NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY} is not null`, - ), - ); - for (const request of cancellationRequests) { - const marker = parseObject( - parseObject(request.contextSnapshot)[ - NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY - ], - ); - const issueId = readNonEmptyString(marker.issueId); - const issueStatus = readNonEmptyString(marker.issueStatus); - const interactionId = readNonEmptyString(marker.interactionId); - const kind = readNonEmptyString(marker.kind); - const reason = - kind === "interaction_withdrawn" - ? "Question withdrawn while waiting for operator input" - : kind === "interaction_cancelled" - ? "Cancelled while waiting for operator input" - : "Task closed while waiting for operator input"; - try { - await cancelRunInternal(request.id, reason, { - resultJson: { - ...(kind === "interaction_withdrawn" && interactionId - ? { withdrawnInteractionId: interactionId } - : {}), - ...(kind === "interaction_cancelled" && interactionId - ? { cancelledInteractionId: interactionId } - : {}), - ...((!kind || kind === "issue_terminal") && issueStatus - ? { cancelledByIssueStatus: issueStatus } - : {}), - ...(issueId ? { cancelledIssueId: issueId } : {}), - }, - }); - } catch (err) { - // Keep the marker intact for the next startup/periodic sweep. - logger.warn( - { err, runId: request.id }, - "native question cancellation recovery attempt failed", - ); - } - } - - // Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them) - const activeRuns = await db - .select({ - run: heartbeatRuns, - adapterType: agents.adapterType, - adapterConfig: agents.adapterConfig, - nativeCoordinatorPhase: nativeRunFinalizations.phase, - nativeRecoveryState: nativeRunFinalizations.recoveryState, - nativeControllerBootId: nativeRunFinalizations.controllerBootId, - nativeControllerPid: nativeRunFinalizations.controllerPid, - nativeControllerProcessStartedAt: - nativeRunFinalizations.controllerProcessStartedAt, - nativeControllerLeaseExpiresAt: nativeRunFinalizations.leaseExpiresAt, - }) - .from(heartbeatRuns) - .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) - .leftJoin( - nativeRunFinalizations, - eq(nativeRunFinalizations.runId, heartbeatRuns.id), - ) - .where(eq(heartbeatRuns.status, "running")); - - const monitorIssueIds = [ - ...new Set( - activeRuns.flatMap(({ run }) => { - const runContext = parseObject(run.contextSnapshot); - if (readNonEmptyString(runContext.wakeReason) !== "issue_monitor_due") - return []; - const issueId = readNonEmptyString(runContext.issueId); - return issueId ? [issueId] : []; - }), - ), - ]; - const monitorIssues = - monitorIssueIds.length > 0 - ? await db - .select({ - id: issues.id, - companyId: issues.companyId, - monitorNextCheckAt: issues.monitorNextCheckAt, - }) - .from(issues) - .where(inArray(issues.id, monitorIssueIds)) - : []; - const monitorNextCheckAtByIssue = new Map( - monitorIssues.map((issue) => [ - `${issue.companyId}:${issue.id}`, - issue.monitorNextCheckAt, - ]), - ); - - const reaped: string[] = []; - const currentNativeController = await currentNativeControllerIdentity(); - - for (const { - run, - adapterType, - adapterConfig, - nativeCoordinatorPhase, - nativeRecoveryState, - nativeControllerBootId, - nativeControllerPid, - nativeControllerProcessStartedAt, - nativeControllerLeaseExpiresAt, - } of activeRuns) { - // Authentication timeout requires an explicit ownership resolution, not - // repeated reattachment or a process-gone guess on subsequent sweeps. - if (isNativeRunnerOwnershipHeld(run)) continue; - const nativeRun = run.runtimeMode === "native"; - const nativeProcessPidAlive = - nativeRun && !!run.processPid && isProcessAlive(run.processPid); - const nativeProcessGroupAlive = - nativeRun && - !!run.processGroupId && - isProcessGroupAlive(run.processGroupId); - const coordinatorOwnedByCurrentController = - nativeRun && - nativeControllerBootId === currentNativeController.bootId && - nativeControllerPid === currentNativeController.pid && - nativeControllerProcessStartedAt?.getTime() === - currentNativeController.processStartedAt.getTime() && - !!nativeControllerLeaseExpiresAt && - nativeControllerLeaseExpiresAt.getTime() > now.getTime(); - const locallyTracked = - runningProcesses.has(run.id) || - activeRunExecutions.has(run.id) || - coordinatorOwnedByCurrentController; - if ( - nativeRun && - ([ - "awaiting_evidence", - "awaiting_runner_reattach", - "resuming_session", - "bootstrap_incomplete", - ].includes(nativeRecoveryState ?? "") || - nativeCoordinatorPhase === "retryable_failure") - ) { - continue; - } - const observedOwnerUnverified = - nativeRun && - (nativeCoordinatorPhase === "observed" || - (nativeCoordinatorPhase === null && - run.nativePhase === "observed")) && - !resumedRunIds.has(run.id) && - !locallyTracked; - // Persisted numeric process identifiers prove only that some process is - // alive, not that Paperclip still owns it. Likewise an observed native - // coordinator without a live in-process execution has no durable proof - // that its prior provider owner stopped. Keep both cases running but - // blocked: never signal, finalize, or retry them automatically. This gate - // intentionally precedes resumedRunIds so a claim cannot bypass the - // ownership check. - if ( - !locallyTracked && - (nativeProcessPidAlive || - nativeProcessGroupAlive || - observedOwnerUnverified) - ) { - await markNativeOwnershipUnverified(run, { - reason: - nativeProcessPidAlive || nativeProcessGroupAlive - ? "live_process_identifier" - : "observed_owner_unverified", - processPidAlive: nativeProcessPidAlive, - processGroupAlive: nativeProcessGroupAlive, - }); - continue; - } - if (resumedRunIds.has(run.id)) continue; - if (locallyTracked) continue; - if (await hasLiveLegacyController(db, run)) continue; - - // Apply staleness threshold to avoid false positives - if (staleThresholdMs > 0) { - const refTime = run.updatedAt ? new Date(run.updatedAt).getTime() : 0; - if (now.getTime() - refTime < staleThresholdMs) continue; - } - - const currentAdapterTracksLocalChild = - isTrackedLocalChildProcessAdapter(adapterType); - const tracksLegacyLocalChild = - run.runtimeMode !== "native" && currentAdapterTracksLocalChild; - // Native runner processes also persist child metadata, but they must not - // inherit legacy retry or termination authority. Use their PID/group only - // for a read-only liveness check so a lost in-memory handle cannot cause - // overlapping provider/tool execution while that child is still alive. - const checksPersistedChildLiveness = - currentAdapterTracksLocalChild || run.runtimeMode === "native"; - const processPidAlive = - checksPersistedChildLiveness && - run.processPid && - isProcessAlive(run.processPid); - const processGroupAlive = - checksPersistedChildLiveness && - run.processGroupId && - isProcessGroupAlive(run.processGroupId); - if ( - (processPidAlive || processGroupAlive) && - readHotRestartAdoptionMetadata(parseObject(run.resultJson)) - ) { - continue; - } - if (processPidAlive || processGroupAlive) { - if (run.errorCode !== DETACHED_PROCESS_ERROR_CODE) { - const detachedMessage = processPidAlive - ? `Lost in-memory process handle, but child pid ${run.processPid} is still alive` - : `Lost in-memory process handle, but persisted process group ${run.processGroupId} is still alive`; - const detachedRun = await setRunStatus(run.id, "running", { - error: detachedMessage, - errorCode: DETACHED_PROCESS_ERROR_CODE, - }); - if (detachedRun) { - await appendRunEvent(detachedRun, { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: detachedMessage, - payload: { - processPid: run.processPid ?? null, - processGroupId: run.processGroupId ?? null, - ownedProcessHandle: false, - }, - }); - } - } - continue; - } - - const runContext = parseObject(run.contextSnapshot); - const monitorIssueId = readNonEmptyString(runContext.issueId); - const monitorNextCheckAt = monitorIssueId - ? monitorNextCheckAtByIssue.get(`${run.companyId}:${monitorIssueId}`) - : undefined; - const monitorDispatchLostWithoutFutureWake = - readNonEmptyString(runContext.wakeReason) === "issue_monitor_due" && - monitorNextCheckAt !== undefined && - (!monitorNextCheckAt || monitorNextCheckAt.getTime() <= now.getTime()); - const shouldRetry = - (run.processLossRetryCount ?? 0) < 1 && - ((tracksLegacyLocalChild && - (!!run.processPid || !!run.processGroupId)) || - monitorDispatchLostWithoutFutureWake); - if (!(await revokeExpiredLegacyController(db, run))) continue; - const baseMessage = buildProcessLossMessage(run); - const processLossDiagnostic = buildProcessLossDiagnostic({ - run, - nowMs: Date.now(), - observerStartedAtMs: performance.timeOrigin, - checksPersistedChildLiveness, - retryEligible: shouldRetry, - }); - const conversationContinuationEligible = await runUsedConversationAdapter(db, run); - - const failureWrite = await setRunStatusFromLive( - run.id, - "failed", - ["running"], - { - error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, - errorCode: "process_lost", - finishedAt: now, - resultJson: (() => { - const result = mergeRunStopMetadataForAgent( - { adapterType, adapterConfig }, - "failed", - { - conversationContinuationEligible, - resultJson: { ...parseObject(run.resultJson), processLossDiagnostic }, - errorCode: "process_lost", - errorMessage: shouldRetry - ? `${baseMessage}; retrying once` - : baseMessage, - }, - ); - return result; - })(), - }, - ); - if (!failureWrite.updated || !failureWrite.run) continue; - let finalizedRun: typeof heartbeatRuns.$inferSelect | null = - failureWrite.run; - await setWakeupStatus(run.wakeupRequestId, "failed", { - finishedAt: now, - error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, - }); - if (!finalizedRun) finalizedRun = await getRun(run.id); - if (!finalizedRun) continue; - finalizedRun = - (await classifyAndPersistRunLiveness( - finalizedRun, - parseObject(finalizedRun.resultJson), - )) ?? finalizedRun; - await releaseEnvironmentLeasesForRun({ - runId: finalizedRun.id, - companyId: finalizedRun.companyId, - agentId: finalizedRun.agentId, - status: finalizedRun.status, - failureReason: finalizedRun.error ?? undefined, - }); - - let retriedRun: typeof heartbeatRuns.$inferSelect | null = null; - const retryAgent = await getAgent(run.agentId); - if (shouldRetry) { - if (retryAgent) { - retriedRun = await enqueueProcessLossRetry( - finalizedRun, - retryAgent, - now, - ); - } - } else if (retryAgent) { - const scheduled = - await scheduleInteractionContinuationInfrastructureRetryIfEligible( - finalizedRun, - retryAgent, - ); - retriedRun = scheduled?.outcome === "scheduled" ? scheduled.run : null; - } - - if (!retriedRun) { - await releaseIssueExecutionAndPromote(finalizedRun); - } - - await appendRunEvent(finalizedRun, { - eventType: "lifecycle", - stream: "system", - level: "error", - message: shouldRetry - ? `${baseMessage}; queued retry ${retriedRun?.id ?? ""}`.trim() - : baseMessage, - payload: { - ...(run.processPid ? { processPid: run.processPid } : {}), - ...(run.processGroupId ? { processGroupId: run.processGroupId } : {}), - ...(retriedRun ? { retryRunId: retriedRun.id } : {}), - }, - }); - - await finalizeAgentStatus(run.agentId, "failed", baseMessage, { - wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run), - }); - await startNextQueuedRunForAgent(run.agentId); - runningProcesses.delete(run.id); - reaped.push(run.id); - } - - if (reaped.length > 0) { - logger.warn( - { reapedCount: reaped.length, runIds: reaped }, - "reaped orphaned heartbeat runs", - ); - } - - // Recover an active lease whose run already ended before the same-tick - // pending_cleanup sweep, so this tick can stop the recovered sandbox. - // Isolate the sweep so its failure never hides the reaper result. - try { - const orphanedActiveLeaseSweep = await sweepOrphanedActiveLeases({ - backoffMs: staleThresholdMs, - }); - if (orphanedActiveLeaseSweep.recovered > 0) { - logger.warn( - { recovered: orphanedActiveLeaseSweep.recovered }, - "recovered orphaned active environment leases", - ); - } - } catch { - // Log a constant errorKind only. The exception can carry a credential in - // its name, code, message, cause, or stack, so the sweep never reads it. - logger.error( - { errorKind: ORPHANED_ACTIVE_LEASE_SWEEP_ERROR_KIND }, - "orphaned active environment lease sweep failed", - ); - } - - // Retry stranded pending_cleanup leases on the same tick. Isolate the sweep - // so its failure never hides the reaper result. The backoff equals the - // reaper staleness threshold. - try { - const sweep = await sweepPendingCleanupLeases({ - backoffMs: staleThresholdMs, - }); - if (sweep.destroyed > 0 || sweep.capped > 0) { - logger.warn( - { - destroyed: sweep.destroyed, - capped: sweep.capped, - swept: sweep.swept, - }, - "swept pending_cleanup environment leases", - ); - } - } catch { - // Log a constant errorKind only. The exception can carry a credential in - // its name, code, message, cause, or stack, so the sweep never reads it. - logger.error( - { errorKind: PENDING_CLEANUP_SWEEP_ERROR_KIND }, - "pending_cleanup lease sweep failed", - ); - } - - return { reaped: reaped.length, runIds: reaped }; - } - - async function resumeQueuedRuns() { - if ((await getSchedulingSuppression()).suppressed) return; - await resumeExecutionWaitComments(); - const cutoff = await getWorktreeExecutionCutoff(); - const pendingInterrupts = await db.select({ id: agentWakeupRequests.id, companyId: agentWakeupRequests.companyId }) - .from(agentWakeupRequests).innerJoin(companies, eq(companies.id, agentWakeupRequests.companyId)) - .where(and(eq(agentWakeupRequests.status, "deferred_issue_execution"), - eq(companies.status, "active"), - sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is not null`, - lte(agentWakeupRequests.updatedAt, new Date(Date.now() - 30_000)), - cutoff ? gte(agentWakeupRequests.requestedAt, cutoff) : undefined)) - .orderBy(asc(agentWakeupRequests.updatedAt)).limit(50); - for (const wake of pendingInterrupts) { - await db.update(agentWakeupRequests).set({ updatedAt: new Date() }).where(and( - eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.status, "deferred_issue_execution"), - )); - await resumeQueuedCommentInterrupt(wake.companyId, wake.id).catch(err => { - logger.warn({ err, queueId: wake.id }, "failed to resume interrupted comment queue"); - }); - } - // A server restart or a message/cleanup race can leave a deferred wake - // after its owner has released the issue lock. Revisit it through the same - // release admission, so recovery holds and operator Stops still apply. - const strandedQueues = await db.select({ wake: agentWakeupRequests }) - .from(agentWakeupRequests) - .innerJoin(issues, and(eq(issues.companyId, agentWakeupRequests.companyId), - sql`${issues.id}::text = ${agentWakeupRequests.payload}->>'issueId'`, - eq(issues.assigneeAgentId, agentWakeupRequests.agentId))) - .innerJoin(companies, and(eq(companies.id, issues.companyId), eq(companies.status, "active"))) - .where(and(eq(agentWakeupRequests.status, "deferred_issue_execution"), - isNull(issues.executionRunId), - or(and( - sql`jsonb_typeof(${agentWakeupRequests.payload} #> '{_paperclipWakeContext,wakeCommentIds}') = 'array'`, - sql`${agentWakeupRequests.payload} #> '{_paperclipWakeContext,wakeCommentIds}' <> '[]'::jsonb`, - ), sql`${agentWakeupRequests.payload}->>'mutation' = 'interaction'`), - sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is null`, - cutoff ? gte(agentWakeupRequests.requestedAt, cutoff) : undefined)) - .orderBy(asc(agentWakeupRequests.updatedAt)).limit(50); - for (const { wake } of strandedQueues) { - if (!queuedCommentIdsFromWakePayload(wake.payload).length && - !await readQueuedInteractionResponse(db, wake.companyId, String(wake.payload?.issueId), wake.payload)) continue; - const [latest] = await db.select().from(heartbeatRuns).where(and( - eq(heartbeatRuns.companyId, wake.companyId), eq(heartbeatRuns.agentId, wake.agentId), - sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${String(wake.payload?.issueId)}`, - )).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1); - await db.update(agentWakeupRequests).set({ updatedAt: new Date() }).where(and( - eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.status, "deferred_issue_execution"), - )); - if (!latest || latest.runtimeMode !== "legacy" || !isHeartbeatRunTerminalStatus(latest.status)) continue; - const cancelledAdmission = latest.status === "cancelled" && !latest.startedAt && - latest.errorCode === "execution_reconciliation_required"; - if ((latest.status !== "cancelled" || cancelledAdmission) && await getExecutionBlocker(db, wake.companyId, String(wake.payload?.issueId))) { - await resumeSavedLegacyComments(wake.companyId, wake.id).catch(err => { - logger.warn({ err, queueId: wake.id }, "failed to deliver saved legacy comment after recovery stopped"); - }); - } - await releaseIssueExecutionAndPromote(latest, { suppressImmediateRecovery: true }).catch(err => { - logger.warn({ err, queueId: wake.id }, "failed to promote stranded legacy comments"); - }); - } - - // The cancellation marker is durable intent. Retry while its exact queue - // is still deferred, including after a failed cleanup promotion or restart. - // Normal admission still checks process ownership, leases, pauses, and scope. - const interruptedQueues = await db - .select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId }) - .from(agentWakeupRequests) - .innerJoin(heartbeatRuns, and( - sql`${heartbeatRuns.resultJson}->>'queuedCommentInterruptQueueId' = ${agentWakeupRequests.id}::text`, - eq(heartbeatRuns.companyId, agentWakeupRequests.companyId), - eq(heartbeatRuns.agentId, agentWakeupRequests.agentId), - )) - .innerJoin(companies, eq(companies.id, heartbeatRuns.companyId)) - .where(and( - eq(agentWakeupRequests.status, "deferred_issue_execution"), - eq(heartbeatRuns.status, "cancelled"), - eq(heartbeatRuns.runtimeMode, "legacy"), - eq(companies.status, "active"), - cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, - )); - for (const run of interruptedQueues) { - await releaseIssueExecutionAndPromote(run, { suppressImmediateRecovery: true }).catch((err) => { - logger.error({ err, runId: run.id }, "failed to retry interrupted comment queue"); - }); - } - - const queuedRuns = await db - .select({ agentId: heartbeatRuns.agentId }) - .from(heartbeatRuns) - .innerJoin(companies, eq(companies.id, heartbeatRuns.companyId)) - .where( - and( - eq(heartbeatRuns.status, "queued"), - eq(companies.status, "active"), - cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, - ), - ); - - const agentIds = [...new Set(queuedRuns.map((r) => r.agentId))]; - for (const agentId of agentIds) { - await startNextQueuedRunForAgent(agentId); - } - } - async function recoverActiveSessionGoals() { if ((await getSchedulingSuppression()).suppressed) { return { scanned: 0, enqueued: 0 }; @@ -16296,203 +6430,6 @@ export function heartbeatService( } - // A 403 from claimQueuedRun comes from the run's own persisted identity - // (an unverifiable interrupt receipt, a manual wake with no user). Those rows - // do not change, so the claim fails the same way on every pass and restart. - function isPermanentClaimRejection(err: unknown): err is HttpError { - return err instanceof HttpError && err.status === 403; - } - - // Other 4xx rejections can clear later (a responsible user gets assigned, a - // conflicting claim finishes). Keep the run queued, but do not let it stop - // the rest of the queue or startup recovery. - function isDeferrableClaimRejection(err: unknown): err is HttpError { - return err instanceof HttpError && err.status >= 400 && err.status < 500; - } - - // Settle runs that can never be claimed. Letting the error escape stalls the - // agent's queue and, during startup recovery, stops the server from booting. - async function cancelRejectedQueuedRuns( - rejected: Array<{ run: typeof heartbeatRuns.$inferSelect; err: HttpError }>, - ) { - for (const { run, err } of rejected) { - logger.warn( - { err, runId: run.id, agentId: run.agentId, companyId: run.companyId }, - "cancelling queued heartbeat run whose claim was rejected", - ); - try { - await cancelRunInternal( - run.id, - `Cancelled because the queued run cannot be claimed: ${err.message}`, - { errorCode: "queued_run_claim_rejected" }, - ); - } catch (cancelErr) { - logger.error( - { err: cancelErr, runId: run.id }, - "failed to cancel queued heartbeat run whose claim was rejected; it stays queued for the next recovery pass", - ); - } - } - } - - async function startNextQueuedRunForAgent(agentId: string) { - if ((await getSchedulingSuppression()).suppressed) return []; - const cutoff = await getWorktreeExecutionCutoff(); - // Cancelled after the start lock is released: cancelRunInternal promotes the - // agent's next queued run, which takes this same lock. - const rejectedClaims: Array<{ run: typeof heartbeatRuns.$inferSelect; err: HttpError }> = []; - - return withAgentStartLock(agentId, async () => { - const agent = await getAgent(agentId); - if (!agent) return []; - const invokability = await getAgentInvokability(agent); - if (!invokability.invokable) { - if (shouldCancelRunsForNonInvokableAgent(invokability)) { - await cancelActiveForAgentInternal( - agentId, - `Cancelled because the agent is not invokable: ${invokability.reason}`, - ); - } - return []; - } - const policy = parseHeartbeatPolicy(agent); - const runningCount = await countRunningRunsForAgent(agentId); - const availableSlots = Math.max( - 0, - policy.maxConcurrentRuns - runningCount, - ); - if (availableSlots <= 0) return []; - - const queuedRuns = await db - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.agentId, agentId), - eq(heartbeatRuns.status, "queued"), - cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, - ), - ) - .orderBy(asc(heartbeatRuns.createdAt)); - if (queuedRuns.length === 0) return []; - - const dependencyReadiness = await listQueuedRunDependencyReadiness( - agent.companyId, - queuedRuns, - ); - const queuedIssueIds = [ - ...new Set( - queuedRuns - .map((run) => - readNonEmptyString(parseObject(run.contextSnapshot).issueId), - ) - .filter((issueId): issueId is string => Boolean(issueId)), - ), - ]; - const issueRows = await db - .select({ - id: issues.id, - status: issues.status, - priority: issues.priority, - }) - .from(issues) - .where( - queuedIssueIds.length > 0 - ? and( - eq(issues.companyId, agent.companyId), - inArray(issues.id, queuedIssueIds), - ) - : sql`false`, - ); - const issueById = new Map(issueRows.map((row) => [row.id, row])); - const companyAgents = await listCompanyAgentOrgRows(agent.companyId); - const prioritizedRuns = [...queuedRuns].sort((left, right) => { - const leftIssueId = readNonEmptyString( - parseObject(left.contextSnapshot).issueId, - ); - const rightIssueId = readNonEmptyString( - parseObject(right.contextSnapshot).issueId, - ); - const leftReadiness = leftIssueId - ? dependencyReadiness.get(leftIssueId) - : null; - const rightReadiness = rightIssueId - ? dependencyReadiness.get(rightIssueId) - : null; - const leftReady = leftIssueId - ? (leftReadiness?.isDependencyReady ?? true) - : true; - const rightReady = rightIssueId - ? (rightReadiness?.isDependencyReady ?? true) - : true; - const leftIssue = leftIssueId ? issueById.get(leftIssueId) : null; - const rightIssue = rightIssueId ? issueById.get(rightIssueId) : null; - const leftRank = leftIssueId - ? leftReady - ? leftIssue?.status === "in_progress" - ? 0 - : 1 - : 3 - : 2; - const rightRank = rightIssueId - ? rightReady - ? rightIssue?.status === "in_progress" - ? 0 - : 1 - : 3 - : 2; - if (leftRank !== rightRank) return leftRank - rightRank; - const leftPriorityRank = issueRunPriorityRank(leftIssue?.priority); - const rightPriorityRank = issueRunPriorityRank(rightIssue?.priority); - if (leftPriorityRank !== rightPriorityRank) - return leftPriorityRank - rightPriorityRank; - return left.createdAt.getTime() - right.createdAt.getTime(); - }); - - const claimedRuns: Array = []; - for (const queuedRun of prioritizedRuns) { - if (claimedRuns.length >= availableSlots) break; - let claimed: typeof heartbeatRuns.$inferSelect | null; - try { - claimed = await claimQueuedRun(queuedRun, companyAgents); - } catch (err) { - if (isPermanentClaimRejection(err)) { - rejectedClaims.push({ run: queuedRun, err }); - continue; - } - if (!isDeferrableClaimRejection(err)) throw err; - logger.warn( - { err, runId: queuedRun.id, agentId: queuedRun.agentId, companyId: queuedRun.companyId }, - "queued heartbeat run claim was rejected; leaving it queued for the next recovery pass", - ); - continue; - } - if (claimed) claimedRuns.push(claimed); - } - if (claimedRuns.length === 0) return []; - - for (const claimedRun of claimedRuns) { - const execution = executeRun(claimedRun.id).catch((err) => { - logger.error( - { err, runId: claimedRun.id }, - "queued heartbeat execution failed", - ); - }); - // Register the in-flight execution so drainActiveRunExecutions() can await - // it. executeRun resolves only after its finally block finishes flushing - // run rows/events, so awaiting this promise guarantees the run's writes - // have landed before a caller (e.g. a test's afterEach) mutates the DB. - activeRunExecutionPromises.add(execution); - void execution.finally(() => { - // drainActiveRunExecutions loops on activeRunExecutionPromises.size, - // so an entry that never clears here would hang it forever. - activeRunExecutionPromises.delete(execution); - }); - } - return claimedRuns; - }).finally(() => cancelRejectedQueuedRuns(rejectedClaims)); - } - // Await every background heartbeat execution that is currently in flight. A // draining run can, in its finally block, promote and dispatch the next queued // run for the same agent — that follow-up execution is registered in the set @@ -16563,25 +6500,6 @@ export function heartbeatService( nativeSessionResumeDispatchTimers.set(runId, timer); } - // Public wakeup entry point. Callers dispatch it fire-and-forget, so register - // the promise in activeWakeupPromises before it starts its asynchronous - // prologue. drainActiveRunExecutions can then await a wake that is still before - // run registration. Internal callers reference enqueueWakeup directly and - // already await it, so they do not need this registration. - function trackWakeup( - agentId: string, - opts: WakeupOptions = {}, - ): ReturnType { - const promise = enqueueWakeup(agentId, opts); - activeWakeupPromises.add(promise); - void promise - .catch(() => {}) - .finally(() => { - activeWakeupPromises.delete(promise); - }); - return promise; - } - async function executeRun( runId: string, runOptions: { @@ -16741,6 +6659,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; @@ -17269,7 +7188,21 @@ export function heartbeatService( if (error instanceof AiConnectionPoolExhausted && !routerHasPersistedInput) { const now = new Date(); context.aiConnectionBusyDeferredWhileAssignee = issueContext?.assigneeAgentId === agent.id; - const cancelled = await setRunStatusIfRunning(run.id, "cancelled", { error: error.message, errorCode: error.code, finishedAt: now, resultJson: { executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false }, retryAt: error.retryAt }, contextSnapshot: context }); + const cancelled = await setRunStatusIfRunning(run.id, "cancelled", { + error: error.message, errorCode: error.code, finishedAt: now, + resultJson: { + executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false }, + cancellation: { + source: "control_plane", + expected: true, + initiator: { type: "system" }, + reason: "Waiting for an AI connection pool account", + recordedAt: now.toISOString(), + }, + retryAt: error.retryAt, + }, + contextSnapshot: context, + }); if (cancelled.updated) { await setWakeupStatus(run.wakeupRequestId, "cancelled", { finishedAt: now, error: error.message }); const retry = await scheduleBoundedRetryForRun(cancelled.run ?? run, agent, { now, retryReason: AI_CONNECTION_POOL_WAIT_RETRY_REASON, wakeReason: "ai_connection_pool_retry", maxAttempts: (run.scheduledRetryAttempt ?? 0) + 1, delayMs: Math.max(1000, Date.parse(error.retryAt) - Date.now()) }); @@ -18045,6 +7978,8 @@ export function heartbeatService( } throw new ConfigurationIncompleteFailure(error instanceof Error ? error.message : "Configure this agent’s AI connection", { configurationIncomplete: { reason: "ai_connection_unavailable", companyId: agent.companyId, agentId: agent.id, responsibleUserId, + ...(!persistedNativeExecutionInput && readAiConnectionConfigurationFailure(error) + ? { selectionFailure: readAiConnectionConfigurationFailure(error) } : {}), provider: aiBinding.provider, method: aiBinding.method, actionUrl: `/agents/${agent.id}/runtime`, fingerprint: `ai:${agent.id}:${responsibleUserId}:${JSON.stringify(aiBinding)}` }, }); @@ -18976,6 +8911,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, @@ -18988,6 +8932,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({ @@ -19110,6 +9059,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); @@ -19126,15 +9082,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` } }); } @@ -19982,9 +9947,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); @@ -19999,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, @@ -20010,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 @@ -21225,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, @@ -22952,8 +12922,10 @@ export function heartbeatService( sandboxProviderPluginNotReadySetupFailure, ) : null); + const connectionFailure = readGitConnectionFailure(outerErr); const setupFailureResultJson = { ...setupFailureDetails, + ...(connectionFailure ? { connectionFailure } : {}), executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, }; const setupFailureWrite = await setRunStatusIfRunning(runId, "failed", { @@ -23179,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", @@ -23363,2533 +13336,6 @@ export function heartbeatService( } } - async function enqueueWakeup(agentId: string, opts: WakeupOptions = {}, executionWaitRequestId?: string) { - const source = opts.source ?? "on_demand"; - const triggerDetail = opts.triggerDetail ?? null; - const contextSnapshot: Record = { - ...(opts.contextSnapshot ?? {}), - }; - const reason = opts.reason ?? null; - let payload = opts.payload ? { ...opts.payload } : null; - // Only the board queue route can record interruption authority on an - // existing receipt. Never accept this internal marker from a wake caller. - if (payload) { - delete payload.queuedCommentInterrupt; - delete payload.manualUserWake; - } - if (opts.manualUserWake) { - if (opts.requestedByActorType !== "user" || !opts.requestedByActorId || opts.failedRunId) { - throw new HttpError(403, "Manual wake requires an authenticated user"); - } - payload = { ...payload, manualUserWake: true }; - } - const executionReconciliationWake = - contextSnapshot.source === "execution.reconciled" || - opts.idempotencyKey?.startsWith("execution-reconciliation:") === true; - const { - contextSnapshot: enrichedContextSnapshot, - issueIdFromPayload, - taskKey, - wakeCommentId, - } = enrichWakeContextSnapshot({ - contextSnapshot, - reason, - source, - triggerDetail, - payload, - }); - // Keep each request's own server-derived origin, including coalesced wakes. - // A run's merged context cannot establish which caller authored one receipt. - // Overwrite caller-supplied nested context rather than trusting it. - payload = { ...payload, [DEFERRED_WAKE_CONTEXT_KEY]: { ...enrichedContextSnapshot } }; - let issueId = - readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; - if (executionReconciliationWake && !issueId) return null; - - let agent = await getAgent(agentId); - if (!agent) throw notFound("Agent not found"); - // Mentions only annotate comments. Ignore legacy callers before creating - // a run or deferred request; assignment and review have their own wakes. - if (reason === "issue_comment_mentioned" || enrichedContextSnapshot.wakeReason === "issue_comment_mentioned") return null; - if (issueId) { - const conversation = await getIssueExecutionContext(agent.companyId, issueId); - if (reason === "issue_children_completed" && conversation?.originKind === "onboarding_first_task") enrichedContextSnapshot.onboardingCompletion = true; - if (isConversation(conversation)) { - if (opts.manualUserWake && conversation!.conversationUserId !== opts.requestedByActorId) { - throw new HttpError(403, "Only the conversation owner can start a chat run"); - } - if (isConversationExecutionWake(conversation, reason ?? readNonEmptyString(enrichedContextSnapshot.wakeReason))) return null; - if (agent.id !== conversation!.conversationAgentId) return null; - if (!(await instanceSettings.getExperimental()).enableAgentChat) return null; - if (!wakeCommentId && isWaitingConversation(conversation) && !hasInteractionContinuationWakeContext(enrichedContextSnapshot) && reason !== CHAT_COMPLETION_WAKE_REASON) return null; - } - } - if (agent.adapterType === "paperclip_runner") { - const oldConfig = parseObject(agent.adapterConfig); - const nextConfig = normalizeLegacyRunnerProvider(oldConfig); - if (nextConfig !== oldConfig) { - await agentService(db).update( - agent.id, - { adapterConfig: nextConfig }, - { - recordRevision: { - source: "normalize_runner_provider", - createdByAgentId: null, - createdByUserId: null, - }, - }, - ); - await logActivity(db, { - companyId: agent.companyId, - actorType: "system", - actorId: "heartbeat", - action: "agent.updated", - entityType: "agent", - entityId: agent.id, - details: { provider: "codex", reason: "native_codex_provider" }, - }); - agent = (await getAgent(agentId))!; - } - } - - if (opts.failedRunId) { - const failed = await getRun(opts.failedRunId, { includeExecutionEvidence: true }); - if (opts.requestedByActorType !== "user" || !opts.requestedByActorId || - reason !== "retry_failed_run" || source !== "on_demand" || triggerDetail !== "manual" || - !failed || failed.companyId !== agent.companyId || failed.agentId !== agentId || - !(await canRetryStoppedRun(db, failed)) || - (failed.nativeIssueId ?? readNonEmptyString(failed.contextSnapshot?.issueId)) !== issueId) { - throw conflict("The selected failed run cannot be retried for this task."); - } - if (!activeRunExecutions.has(failed.id) && !adapterExecutionControls.has(failed.id)) { - await sweepPendingCleanupLeases({ explicitRetry: { - companyId: failed.companyId, runId: failed.id, actorId: opts.requestedByActorId, - } }); - } - if (isConversationAdapter(agent.adapterType) || agent.adapterType === "paperclip_runner") { - enrichedContextSnapshot.previousRunId = failed.id; - enrichedContextSnapshot.forceFreshSession = true; - } - } - - const durableRequest = opts.durableChatRequest; - if (durableRequest) { - assertDurableChatWakeupRequest(durableRequest, { - agentId, - companyId: agent.companyId, - issueId, - commentId: wakeCommentId ?? null, - requestedByActorType: opts.requestedByActorType, - requestedByActorId: opts.requestedByActorId, - }); - opts = { ...opts, idempotencyKey: durableRequest.idempotencyKey }; - } - if ( - Object.hasOwn(enrichedContextSnapshot, "chatFailedRunRetry") && - !durableRequest?.failedRunRetry - ) { - throw new FailedChatRunRetryAuthorizationError(); - } - if (durableRequest?.failedRunRetry) { - opts = { ...opts, allowRunCoalescing: false }; - } - const dotRequest = opts.durableDotRequest; - if (dotRequest && (durableRequest || dotRequest.agentId !== agentId || - dotRequest.companyId !== agent.companyId || dotRequest.issueId !== issueId || - dotRequest.requestId !== payload?.dotRequestId || source !== "assignment" || - opts.requestedByActorType !== "agent" || opts.requestedByActorId !== agentId || - agent.adapterType !== "paperclip_runner" || parseObject(agent.adapterConfig).provider !== "openai_dot" || - opts.idempotencyKey !== dotRequest.idempotencyKey)) { - throw conflict("Dot work request does not match its admission authority."); - } - const receiptRequest = durableRequest ?? dotRequest; - const durableReceiptFields = receiptRequest - ? { id: receiptRequest.id, requestedAt: receiptRequest.requestedAt } - : {}; - const existingDurableReceipt = async (queryDb: Db) => { - if (!receiptRequest) return null; - const receipt = await queryDb - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.id, receiptRequest.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - if (receipt && durableRequest) assertDurableChatWakeupReceipt(durableRequest, receipt); - if (receipt && dotRequest && (receipt.companyId !== dotRequest.companyId || - receipt.agentId !== dotRequest.agentId || receipt.source !== "assignment" || - receipt.requestedByActorType !== "agent" || receipt.requestedByActorId !== dotRequest.agentId || - receipt.idempotencyKey !== dotRequest.idempotencyKey || receipt.payload?.issueId !== dotRequest.issueId || - receipt.payload?.dotRequestId !== dotRequest.requestId)) { - throw conflict("requestId was reused for another task."); - } - return receipt; - }; - const priorReceipt = await existingDurableReceipt(db); - if (priorReceipt) { - // Replaying an admission receipt is not fresh authority to dispatch it. - // The normal queue owns dispatch and its current execution-policy checks. - return priorReceipt.runId ? getRun(priorReceipt.runId) : null; - } - - const agentDebug = parseObject(parseObject(agent.runtimeConfig).debug); - const runDebug = parseObject(enrichedContextSnapshot.debug); - if ( - agentDebug.providerTrace === "raw" && - runDebug.providerTrace !== "raw" - ) { - enrichedContextSnapshot.debug = { - ...runDebug, - providerTrace: "raw", - }; - enrichedContextSnapshot.providerTraceRequestedBy = `agent:${agent.id}:debug-setting`; - enrichedContextSnapshot.providerTraceRequestSource = - "agent_debug_setting"; - } - - // Automatic signals are replaceable; user input and interaction delivery - // retain distinct durable receipts even when the same gate blocks them. - const coalesceExecutionWait = - opts.requestedByActorType === "system" && - !receiptRequest && - !wakeCommentId && - queuedCommentIdsFromRunContext(enrichedContextSnapshot).length === 0 && - !isInteractionResolutionWakePayload(payload ?? {}) && - !hasInteractionContinuationWakeContext(enrichedContextSnapshot); - const writeSkippedRequest = async ( - skipReason: string, - patch: Partial = {}, - waitCondition?: Record, - ) => { - if (executionWaitRequestId) { - await db.update(agentWakeupRequests).set({ - payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}', - coalesce(${agentWakeupRequests.payload}->'executionWait', '{}'::jsonb) || ${JSON.stringify({ - reason: skipReason, message: patch.error ?? (skipReason === "issue_tree_hold_active" - ? "This task is paused. Resume it to send your saved message." - : "Waiting for task execution to be enabled. Your message is saved."), - })}::jsonb)`, - updatedAt: new Date(), - }).where(and(eq(agentWakeupRequests.id, executionWaitRequestId), - eq(agentWakeupRequests.companyId, agent.companyId), eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"))); - return { created: false }; - } - const request = { - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: skipReason, - payload, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: new Date(), - ...patch, - }; - if (waitCondition && issueId && isUuidLike(issueId)) { - const waitIssueId = issueId; - return db.transaction(async (tx) => { - await tx.execute(sql`select id from issues where id = ${waitIssueId} and company_id = ${agent.companyId} for update`); - return recordExecutionWait(tx as unknown as Db, { - issueId: waitIssueId, request, condition: waitCondition, coalesce: coalesceExecutionWait, - }); - }); - } - await db.insert(agentWakeupRequests).values(request); - return { created: true }; - }; - const writeSkippedHeartbeatRequest = async ( - skipReason: string, - details: Record, - ) => { - await writeSkippedRequest(skipReason, { - payload: { - ...(payload ?? {}), - heartbeatSkip: details, - }, - }); - }; - - const schedulingSuppression = await getSchedulingSuppression(); - // A task drain holds ADMISSION, not the request. The drain is a - // process-local pre-restart hold, so a wake that arrives while it is - // active still names real work that must run once the process comes - // back: leave it in the durable queue and let the dispatch-side checks - // (startNextQueuedRunForAgent / executeRun) keep it from starting until - // the drain lifts or the restart clears it. Writing it as `skipped` - // here dropped the wake permanently — an accepted plan whose - // continuation wake landed mid-drain left its issue in `todo` with no - // run and no path until a person noticed. - if ( - schedulingSuppression.suppressed && - schedulingSuppression.reason !== "task_drain" - ) { - await writeSkippedHeartbeatRequest("heartbeat.scheduling_suppressed", { - reason: schedulingSuppression.reason, - }); - return null; - } - - const worktreeExecutionCutoff = - opts.requestedByActorType === "user" - ? null - : await getWorktreeExecutionCutoff(); - - const company = await db - .select({ status: companies.status }) - .from(companies) - .where(eq(companies.id, agent.companyId)) - .then((rows) => rows[0] ?? null); - - if (!company || company.status !== "active") { - const companyStatus = company?.status ?? "missing"; - if (opts.requestedByActorType === "user") { - throw conflict("Company is not active", { status: companyStatus }); - } - await writeSkippedRequest("company.inactive", { - error: `Wake suppressed because company status is ${companyStatus}`, - }, { companyStatus }); - return null; - } - - const explicitResumeSession = await resolveExplicitResumeSessionOverride( - agent, - payload, - taskKey, - ); - if (explicitResumeSession) { - enrichedContextSnapshot.resumeFromRunId = - explicitResumeSession.resumeFromRunId; - enrichedContextSnapshot.resumeSessionDisplayId = - explicitResumeSession.sessionDisplayId; - enrichedContextSnapshot.resumeSessionParams = - explicitResumeSession.sessionParams; - if ( - !readNonEmptyString(enrichedContextSnapshot.issueId) && - explicitResumeSession.issueId - ) { - enrichedContextSnapshot.issueId = explicitResumeSession.issueId; - } - if ( - !readNonEmptyString(enrichedContextSnapshot.taskId) && - explicitResumeSession.taskId - ) { - enrichedContextSnapshot.taskId = explicitResumeSession.taskId; - } - if ( - !readNonEmptyString(enrichedContextSnapshot.taskKey) && - explicitResumeSession.taskKey - ) { - enrichedContextSnapshot.taskKey = explicitResumeSession.taskKey; - } - issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueId; - } - const effectiveTaskKey = - readNonEmptyString(enrichedContextSnapshot.taskKey) ?? taskKey; - const sessionBefore = - explicitResumeSession?.sessionDisplayId ?? - (await resolveSessionBeforeForWakeup(agent, effectiveTaskKey)); - let hasResolvablePriorSessionWorkspace: boolean | null = null; - const resolveHasResolvablePriorSessionWorkspace = async () => { - if (hasResolvablePriorSessionWorkspace !== null) - return hasResolvablePriorSessionWorkspace; - hasResolvablePriorSessionWorkspace = issueId - ? await hasResolvablePriorSessionWorkspaceForWake({ - agent, - contextSnapshot: enrichedContextSnapshot, - taskKey: effectiveTaskKey, - explicitResumeSession, - }) - : false; - return hasResolvablePriorSessionWorkspace; - }; - const continuationAttempt = readContinuationAttempt( - enrichedContextSnapshot.livenessContinuationAttempt, - ); - - let projectId = readNonEmptyString(enrichedContextSnapshot.projectId); - if (!projectId && issueId) { - // Look up by either UUID or identifier (e.g. "ENV-13"), but always scope - // by companyId so a row from another tenant can never be returned even - // when identifiers collide across companies. Guard the UUID arm because - // issues.id is a Postgres uuid column — passing "ENV-13" into eq(issues.id, …) - // would fail with an invalid-input-syntax cast error before the OR is - // evaluated. - const lookupIsUuid = isUuidLike(issueId); - const idMatch = lookupIsUuid - ? or( - eq(issues.id, issueId), - eq(issues.identifier, issueId.toUpperCase()), - ) - : eq(issues.identifier, issueId.toUpperCase()); - const resolvedIssue = await db - .select({ - id: issues.id, - projectId: issues.projectId, - createdAt: issues.createdAt, - }) - .from(issues) - .where(and(eq(issues.companyId, agent.companyId), idMatch)) - .then((rows) => rows[0] ?? null); - if (resolvedIssue) { - if ( - worktreeExecutionCutoff && - resolvedIssue.createdAt < worktreeExecutionCutoff - ) { - await writeSkippedHeartbeatRequest( - "heartbeat.worktree_execution_cutoff", - { - reason: "worktree_execution_cutoff", - cutoff: worktreeExecutionCutoff.toISOString(), - issueId: resolvedIssue.id, - }, - ); - return null; - } - projectId = resolvedIssue.projectId ?? null; - // Canonicalize context to the UUID so downstream lookups always use UUID - if (resolvedIssue.id !== issueId) { - issueId = resolvedIssue.id; - enrichedContextSnapshot.issueId = issueId; - if (readNonEmptyString(enrichedContextSnapshot.taskId)) { - enrichedContextSnapshot.taskId = issueId; - } - } - } - } - // Propagate projectId into context so resolveWorkspaceForRun can bind the - // project workspace even when context.projectId wasn't set by the caller. - if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) { - enrichedContextSnapshot.projectId = projectId; - } - const isolatedWorkspacesEnabled = issueId - ? (await instanceSettings.getExperimental()).enableIsolatedWorkspaces - : false; - let operatorResponsibleUserId: string | null = opts.manualUserWake ? opts.requestedByActorId! : null; - let queuedResponsibleUserIdPromise: Promise | null = null; - const resolveQueuedResponsibleUserId = () => { - if (operatorResponsibleUserId) return Promise.resolve(operatorResponsibleUserId); - queuedResponsibleUserIdPromise ??= (async () => { - const queuedIssueContext = issueId - ? await getIssueExecutionContext(agent.companyId, issueId) - : null; - const queuedRoutineEnvContext = await getRoutineEnvForExecutionIssue( - agent.companyId, - queuedIssueContext, - ); - const queuedResponsibleUserId = - await resolveResponsibleUserIdForRunSeed({ - companyId: agent.companyId, - contextSnapshot: enrichedContextSnapshot, - issueContext: queuedIssueContext, - routineEnvContext: queuedRoutineEnvContext, - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - source, - triggerDetail, - }); - if (!queuedResponsibleUserId) { - throw new HttpError( - 422, - "Unable to resolve responsible user for heartbeat run dispatch", - { - code: "responsible_user_unresolved", - agentId, - companyId: agent.companyId, - issueId: issueId ?? null, - source, - triggerDetail, - wakeReason: readNonEmptyString( - enrichedContextSnapshot.wakeReason, - ), - }, - ); - } - return queuedResponsibleUserId; - })(); - return queuedResponsibleUserIdPromise; - }; - - const budgetBlock = await budgets.getInvocationBlock( - agent.companyId, - agentId, - { - issueId, - projectId, - }, - ); - if (budgetBlock) { - await writeSkippedRequest("budget.blocked", { error: budgetBlock.reason }, { - scopeType: budgetBlock.scopeType, scopeId: budgetBlock.scopeId, - }); - throw conflict(budgetBlock.reason, { - scopeType: budgetBlock.scopeType, - scopeId: budgetBlock.scopeId, - }); - } - - const invokability = await getAgentInvokability(agent); - if (!invokability.invokable) { - if (opts.requestedByActorType !== "user" || executionWaitRequestId) { - await writeSkippedRequest("agent.not_invokable", { - error: invokability.message, - }, { status: agent.status, reason: invokability.reason }); - } - throw conflict(invokability.message, { - status: agent.status, - reason: invokability.reason, - invalidOrgChain: invokability.invalidOrgChain, - ...invokability.details, - }); - } - - const policy = parseHeartbeatPolicy(agent); - - if (source === "timer" && !policy.enabled) { - await writeSkippedRequest("heartbeat.disabled", {}, { enabled: false }); - return null; - } - if (source !== "timer" && !policy.wakeOnDemand) { - await writeSkippedRequest("heartbeat.wakeOnDemand.disabled", {}, { wakeOnDemand: false }); - return null; - } - - const genericTimerWake = - source === "timer" && - !issueId && - !wakeCommentId && - !readNonEmptyString(enrichedContextSnapshot.taskId) && - !readNonEmptyString(enrichedContextSnapshot.taskKey); - if ( - policy.skipTimerWhenNoActionableWork && - genericTimerWake && - !(await hasActionableTimerWork(agent)) - ) { - await writeSkippedHeartbeatRequest("heartbeat.timer.no_actionable_work", { - reason: - "No assigned todo or in_progress issue requires this agent before timer adapter invocation.", - }); - await markTimerHeartbeatChecked(agentId, source); - return null; - } - - if (issueId) { - const activePauseHold = await treeControlSvc.getActivePauseHoldGate( - agent.companyId, - issueId, - ); - if (activePauseHold) { - const treeHoldInteractionWake = - await isVerifiedIssueTreeControlInteractionWake(db, { - companyId: agent.companyId, - issueId, - agentId, - contextSnapshot: enrichedContextSnapshot, - requestedByActorType: opts.requestedByActorType, - requestedByActorId: opts.requestedByActorId, - }); - - if (!treeHoldInteractionWake) { - const wait = await writeSkippedRequest("issue_tree_hold_active", {}, { - holdId: activePauseHold.holdId, - }); - if (wait.created) await logActivity(db, { - companyId: agent.companyId, - actorType: "system", - actorId: "system", - agentId, - runId: null, - action: "issue.tree_hold_wakeup_deferred", - entityType: "issue", - entityId: issueId, - details: { - holdId: activePauseHold.holdId, - rootIssueId: activePauseHold.rootIssueId, - requestedReason: reason, - source, - triggerDetail, - securityPrinciples: [ - "Complete Mediation", - "Fail Securely", - "Secure Defaults", - ], - }, - }); - return null; - } - - enrichedContextSnapshot.treeHoldInteraction = true; - enrichedContextSnapshot.activeTreeHold = { - holdId: activePauseHold.holdId, - rootIssueId: activePauseHold.rootIssueId, - mode: activePauseHold.mode, - reason: activePauseHold.reason, - releasePolicy: activePauseHold.releasePolicy, - interaction: true, - }; - } - } - - if (issueId) { - // Mention-triggered wakes can request input from another agent, but they must - // still respect the issue execution lock so a second agent cannot start on the - // same issue workspace while the assignee already has a live run. - const agentNameKey = normalizeAgentNameKey(agent.name); - - const cancelledRunsToEmit: (typeof heartbeatRuns.$inferSelect)[] = []; - - const outcome = await db - .transaction(async (tx) => { - await tx.execute( - sql`select id from issues where id = ${issueId} and company_id = ${agent.companyId} for update`, - ); - - if (executionWaitRequestId) { - const [pending] = await tx.select().from(agentWakeupRequests).where(and( - eq(agentWakeupRequests.id, executionWaitRequestId), eq(agentWakeupRequests.companyId, agent.companyId), - eq(agentWakeupRequests.agentId, agentId), eq(agentWakeupRequests.status, "deferred_issue_execution"), - // A user message can join a queue originally created by a - // system wake. Admission validates the saved user comment or board click. - (opts.queuedCommentInterruptId ?? opts.queuedCommentRequestId) === executionWaitRequestId - ? undefined : eq(agentWakeupRequests.requestedByActorType, "user"), - opts.queuedCommentInterruptId === executionWaitRequestId - ? sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt'->>'actorId' = ${opts.requestedByActorId ?? ""}` - : opts.queuedCommentRequestId === executionWaitRequestId ? undefined - : eq(agentWakeupRequests.requestedByActorId, opts.requestedByActorId ?? ""), - sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`, - )); - // The issue lock serializes cleanup callbacks and periodic workers. - // An adopted, discarded, or edited receipt is no longer authority. - if (!pending || (!(wakeCommentId && queuedCommentIdsFromWakePayload(pending.payload).includes(wakeCommentId)) && - !(opts.queuedCommentInterruptId && await readQueuedInteractionResponse(tx as unknown as Db, - agent.companyId, issueId, pending.payload)))) { - return { kind: "deferred" as const }; - } - if (opts.queuedCommentRequestId) { - const ids = await undeliveredLegacyUserCommentIds(tx as unknown as Db, - agent.companyId, issueId, agentId, queuedCommentIdsFromWakePayload(pending.payload)); - if (!wakeCommentId || !ids.includes(wakeCommentId)) return { kind: "deferred" as const }; - pending.payload = withQueuedCommentIdsInWakePayload(parseObject(pending.payload), ids); - await tx.update(agentWakeupRequests).set({ payload: pending.payload }).where(and( - eq(agentWakeupRequests.id, pending.id), eq(agentWakeupRequests.companyId, agent.companyId), - )); - } - if (!opts.queuedCommentInterruptId && !opts.queuedCommentRequestId && pending.payload?.manualUserWake === true) { - // A persisted manual wake keeps its actor when an execution wait - // resumes. The locked receipt above has revalidated that actor. - payload = { ...payload, manualUserWake: true }; - operatorResponsibleUserId = opts.requestedByActorId!; - } - if (opts.queuedCommentInterruptId) { - // The locked board receipt supplies execution authority even when - // another user authored the messages. Dispatch revalidates the receipt. - operatorResponsibleUserId = opts.requestedByActorId!; - } - if (opts.queuedCommentInterruptId || opts.queuedCommentRequestId) { - // Edits/discards between the click and dispatch remain authoritative. - Object.assign(enrichedContextSnapshot, withQueuedCommentIdsInRunContext( - enrichedContextSnapshot, queuedCommentIdsFromWakePayload(pending.payload), - )); - } - } - let automaticParentRunId: string | null = null; - if ( - source === "automation" && - opts.requestedByActorType === "system" - ) { - const nativeParent = await readChatControlNativeParent( - tx as unknown as Db, - { companyId: agent.companyId, issueId, agentId }, - opts.requestedByActorId ?? null, - ); - const genericParent = - !opts.requestedByActorId && reason === "issue_continuation_needed" - ? readNonEmptyString(enrichedContextSnapshot.retryOfRunId) - : null; - automaticParentRunId = - nativeParent.kind === "parent" - ? nativeParent.runId - : genericParent; - if (automaticParentRunId) { - const [parent] = await tx - .select({ - agentId: heartbeatRuns.agentId, - context: heartbeatRuns.contextSnapshot, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, agent.companyId), - eq(heartbeatRuns.id, automaticParentRunId), - ), - ) - .limit(1); - if ( - parent && - (parseObject(parent.context).issueId ?? - parseObject(parent.context).taskId) === issueId && - parent.agentId !== agentId - ) - automaticParentRunId = null; - } - const proof = - nativeParent.kind === "unresolved" - ? { kind: "unresolved" as const } - : automaticParentRunId - ? await readChatControlRecoveryStop( - tx as unknown as Db, - { - companyId: agent.companyId, - issueId, - agentId, - sourceRunId: automaticParentRunId, - }, - true, - ) - : { kind: "clear" as const }; - if (proof.kind !== "clear") { - await tx - .insert(agentWakeupRequests) - .values({ - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: - proof.kind === "stopped" - ? CHAT_CONTROL_RECOVERY_STOP_CODE - : CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE, - payload: { issueId }, - status: "skipped", - requestedByActorType: "system", - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: new Date(), - }); - return { kind: "skipped" as const }; - } - } - - const durableReceipt = await existingDurableReceipt( - tx as unknown as Db, - ); - if (durableReceipt) - return { kind: "durable" as const, receipt: durableReceipt }; - const failedChatRetry = await authorizeFailedChatRunRetryWake( - db, - tx as unknown as Db, - { - phase: "admission", - wakeupRequestId: durableRequest?.id ?? null, - companyId: agent.companyId, - agentId, - issueId, - contextSnapshot: enrichedContextSnapshot, - }, - ); - if ( - failedChatRetry !== Boolean(durableRequest?.failedRunRetry) || - (failedChatRetry && - durableRequest?.failedRunRetry?.failedRunId !== - readNonEmptyString(enrichedContextSnapshot.retryOfRunId)) - ) { - throw new FailedChatRunRetryAuthorizationError(); - } - if (durableRequest) { - if (issueId !== durableRequest.issueId) - throw new Error("chat_inbound_wakeup_binding_denied"); - await durableRequest.authorize(tx as unknown as Db); - } else { - if ( - source === "on_demand" && - triggerDetail === "manual" && - reason === "retry_failed_run" - ) { - // Generic Board retry does not carry server-authorized failed-run - // lineage. Caller-supplied source/comment/retry markers cannot - // restore it, including for a retired conversation generation. - const [chatBinding] = await tx - .select({ id: chatConversations.id }) - .from(chatConversations) - .innerJoin(chatEndpoints, eq(chatEndpoints.id, chatConversations.endpointId)) - .where( - and( - eq(chatEndpoints.externalExecutionPolicy, "restricted"), - eq(chatConversations.companyId, agent.companyId), - eq(chatConversations.issueId, issueId), - ), - ) - .limit(1); - if (chatBinding) { - throw conflict( - "Retry needs the exact failed chat request and current access. Send the request again in the current connected conversation.", - { code: "chat_failed_run_retry_requires_authorized_context" }, - ); - } - } - const [held] = await tx - .select({ id: issues.id }) - .from(issues) - .where( - and( - eq(issues.id, issueId), - eq(issues.companyId, agent.companyId), - unadmittedChatWakeupCondition(issues.id, issues.companyId), - ), - ) - .limit(1); - if (held) { - throw conflict( - "This task has external chat input that has not been admitted. Check the connection's Activity; let pending input finish, or restore access and send a new authorized message before starting the task.", - { code: "chat_inbound_wakeup_unadmitted", issueId }, - ); - } - } - - const issue = await tx - .select({ - id: issues.id, - companyId: issues.companyId, - identifier: issues.identifier, - conversationAgentId: issues.conversationAgentId, - conversationUserId: issues.conversationUserId, - conversationState: issues.conversationState, - status: issues.status, - statusVersion: issues.statusVersion, - projectId: issues.projectId, - projectWorkspaceId: issues.projectWorkspaceId, - executionWorkspaceId: issues.executionWorkspaceId, - executionWorkspacePreference: issues.executionWorkspacePreference, - executionWorkspaceSettings: issues.executionWorkspaceSettings, - assigneeAgentId: issues.assigneeAgentId, - assigneeUserId: issues.assigneeUserId, - monitorNextCheckAt: issues.monitorNextCheckAt, - monitorWakeRequestedAt: issues.monitorWakeRequestedAt, - executionRunId: issues.executionRunId, - executionAgentNameKey: issues.executionAgentNameKey, - createdAt: issues.createdAt, - }) - .from(issues) - .where( - and( - eq(issues.id, issueId), - eq(issues.companyId, agent.companyId), - ), - ) - .then((rows) => rows[0] ?? null); - - if (!issue) { - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: "issue_execution_issue_not_found", - payload, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: new Date(), - }); - return { kind: "skipped" as const }; - } - - const issueStateGuard = opts.issueStateGuard; - const activeMonitorRun = issueStateGuard?.monitorNextCheckAt === undefined ? null - : await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( - eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.nativeIssueId, issue.id), - eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]), - )).limit(1).then(rows => rows[0] ?? null); - if ( - issueStateGuard && - (!issueStateGuard.statuses.includes(issue.status) || - issue.assigneeAgentId !== issueStateGuard.assigneeAgentId || - (issueStateGuard.statusVersion !== undefined && issue.statusVersion !== issueStateGuard.statusVersion) || - (issueStateGuard.monitorNextCheckAt !== undefined && ( - activeMonitorRun !== null || issue.assigneeUserId !== null || - issue.monitorNextCheckAt?.toISOString() !== issueStateGuard.monitorNextCheckAt || - issue.monitorWakeRequestedAt?.toISOString() !== issueStateGuard.monitorWakeRequestedAt - ))) - ) { - // A deferred monitor retains its schedule; do not create a receipt - // that could suppress its next admission attempt. - if (issueStateGuard.monitorNextCheckAt !== undefined) return { kind: "skipped" as const }; - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: "issue_state_guard_mismatch", - payload: { - ...(payload ?? {}), - heartbeatSkip: { - reason: - "Issue status or assignee changed before the wake could be queued.", - issueId: issue.id, - expectedStatuses: issueStateGuard.statuses, - actualStatus: issue.status, - expectedAssigneeAgentId: issueStateGuard.assigneeAgentId, - actualAssigneeAgentId: issue.assigneeAgentId, - }, - }, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: new Date(), - }); - return { kind: "skipped" as const }; - } - - if (opts.failedRunId) { - // The issue lock makes double-clicks and network retries adopt the - // same successor, including after it has already finished. - const [previousRetry] = await tx.select().from(heartbeatRuns).where(and( - eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.agentId, agentId), - eq(heartbeatRuns.retryOfRunId, opts.failedRunId), - sql`${heartbeatRuns.contextSnapshot}->>'wakeReason' = 'retry_failed_run'`, - )).orderBy(desc(heartbeatRuns.createdAt)).limit(1); - if (previousRetry) return { kind: "replayed" as const, run: previousRetry }; - } - - let reconciledSourceRunId: string | null = null; - let reconciledRestoreRetryCount: number | null = null; - if (executionReconciliationWake) { - const actionId = readNonEmptyString( - enrichedContextSnapshot.recoveryActionId, - ); - if ( - !actionId || - !isUuidLike(actionId) || - source !== "automation" || - triggerDetail !== "system" || - reason !== "issue_recovery_action_restored" || - opts.requestedByActorType !== "system" || - opts.requestedByActorId !== "execution-recovery" || - opts.idempotencyKey !== `execution-reconciliation:${actionId}` || - enrichedContextSnapshot.source !== "execution.reconciled" || - enrichedContextSnapshot.forceFreshSession !== true || - payload?.issueId !== issue.id || - payload?.recoveryActionId !== actionId || - issue.assigneeAgentId !== agentId || - ["done", "cancelled"].includes(issue.status) - ) - return { kind: "skipped" as const }; - - // The issue lock serializes all admissions for this source. Validate - // the durable operator decision, then reconcile a prior queue commit - // before considering a new wake (including a now-terminal successor). - const [action] = await tx - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, issue.companyId), - eq(issueRecoveryActions.sourceIssueId, issue.id), - eq(issueRecoveryActions.id, actionId), - ), - ) - .for("update"); - const decision = parseObject( - action?.evidence.executionReconciliation, - ); - const sourceRunId = readNonEmptyString(decision.runId); - if ( - !action || - action.status !== "resolved" || - action.kind !== "active_run_watchdog" || - action.returnOwnerAgentId !== agentId || - !sourceRunId || - !isUuidLike(sourceRunId) || - decision.providerStopped !== true || - !["completed", "not_performed", "mixed"].includes( - String(decision.actionOutcome), - ) || - !readNonEmptyString(decision.outcomeEvidence) || - enrichedContextSnapshot.previousRunId !== sourceRunId || - enrichedContextSnapshot.retryOfRunId !== sourceRunId || - !["pending", "delivered"].includes( - String(action.evidence.continuationDelivery), - ) - ) - return { kind: "skipped" as const }; - - const [existingWake] = await tx - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, issue.companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.idempotencyKey, opts.idempotencyKey), - ne(agentWakeupRequests.status, "skipped"), - ), - ) - .orderBy(asc(agentWakeupRequests.requestedAt)) - .limit(1); - if (existingWake) { - if ( - existingWake.payload?.issueId !== issue.id || - existingWake.payload?.recoveryActionId !== action.id || - existingWake.requestedByActorType !== "system" || - existingWake.requestedByActorId !== "execution-recovery" || - !existingWake.runId - ) - return { kind: "deferred" as const }; - const [existingRun] = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, issue.companyId), - eq(heartbeatRuns.agentId, agentId), - eq(heartbeatRuns.id, existingWake.runId), - ), - ); - if ( - !existingRun || - existingRun.contextSnapshot?.issueId !== issue.id || - existingRun.contextSnapshot?.recoveryActionId !== action.id || - existingRun.contextSnapshot?.previousRunId !== sourceRunId - ) - return { kind: "deferred" as const }; - return { kind: "replayed" as const, run: existingRun }; - } - if (action.evidence.continuationDelivery !== "pending") - return { kind: "skipped" as const }; - const [reconciledRun] = await tx.select().from(heartbeatRuns).where(and( - eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.id, sourceRunId), - )); - if (hasWorkspaceRestoreFailure(reconciledRun?.resultJson)) { - if ((readNonEmptyString(decision.workspaceRepairEvidence)?.length ?? 0) < 20) - return { kind: "skipped" as const }; - // Repair does not reset the remaining automatic retry budget. - reconciledRestoreRetryCount = executionFailureRetryCount(reconciledRun!); - } - reconciledSourceRunId = sourceRunId; - } - - let continuationWait = { reason: "execution_recovery", message: "Waiting for execution recovery. Your message is saved." }; - const deferBlockedExecution = async ( - executionBlocker: NonNullable>>, - ) => { - const condition = { recoveryActionId: executionBlocker.recoveryActionId, ...continuationWait }; - if (executionWaitRequestId) { - await tx.update(agentWakeupRequests).set({ - payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}', ${JSON.stringify(condition)}::jsonb)`, - updatedAt: new Date(), - }).where(eq(agentWakeupRequests.id, executionWaitRequestId)); - return { kind: "deferred" as const }; - } - if (durableRequest || wakeCommentId || - hasInteractionContinuationWakeContext(enrichedContextSnapshot) || - readNonEmptyString(enrichedContextSnapshot.nativeStatusWakeIntentId)) { - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, agentId, source, triggerDetail, reason, - payload: withQueuedCommentIdsInWakePayload({ - ...payload, - issueId: issue.id, - [DEFERRED_WAKE_CONTEXT_KEY]: enrichedContextSnapshot, - executionWait: condition, - }, [...new Set([ - ...queuedCommentIdsFromRunContext(enrichedContextSnapshot), - ...(wakeCommentId ? [wakeCommentId] : []), - ])]), - status: "deferred_issue_execution", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - }); - } else { - await recordExecutionWait(tx as unknown as Db, { - issueId: issue.id, condition, coalesce: coalesceExecutionWait, - request: { - ...durableReceiptFields, - companyId: agent.companyId, agentId, source, triggerDetail, - reason: "execution_reconciliation_required", - error: executionBlocker.nextAction, - payload, - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - }, - }); - } - return { kind: "deferred" as const }; - }; - const explicitContinuationRunId = randomUUID(); - const executionBlocker = await getExecutionBlocker( - tx as unknown as Db, issue.companyId, issue.id, - { conversationResetCommentId: opts.requestedByActorType === "user" ? wakeCommentId : null }, - ); - // Prove eligibility without retiring the hold. Later gates can still - // decline this wake; hold retirement and successor creation stay atomic. - // A bound chat request has already rechecked its current principal - // above. Treat its new user message like a board comment, but keep - // failed-run retry actions on their separate exact-request path. - if (executionBlocker && !(await admitExplicitNativeContinuation({ - db: tx as unknown as Db, companyId: issue.companyId, issueId: issue.id, - agentId, actorType: opts.requestedByActorType, actorId: opts.requestedByActorId, - reason: durableRequest && !failedChatRetry ? "issue_commented" : reason, - commentId: wakeCommentId ?? null, failedRunId: opts.failedRunId, successorRunId: explicitContinuationRunId, - queuedCommentInterruptId: opts.queuedCommentInterruptId, - queuedCommentRequestId: opts.queuedCommentRequestId, - dryRun: true, - onBlocked: (reason, message) => { continuationWait = { reason, message }; }, - }))) return deferBlockedExecution(executionBlocker); - - - if ( - worktreeExecutionCutoff && - issue.createdAt < worktreeExecutionCutoff - ) { - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: "heartbeat.worktree_execution_cutoff", - payload: { - ...(payload ?? {}), - heartbeatSkip: { - reason: "worktree_execution_cutoff", - cutoff: worktreeExecutionCutoff.toISOString(), - issueId: issue.id, - }, - }, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: new Date(), - }); - return { kind: "skipped" as const }; - } - - const cancelStaleScheduledRetry = async ( - scheduledRun: typeof heartbeatRuns.$inferSelect, - ) => { - const issueCancelled = issue.status === "cancelled"; - if ( - scheduledRun.status !== "scheduled_retry" || - (scheduledRun.agentId === issue.assigneeAgentId && - !issueCancelled) - ) { - return false; - } - - const now = new Date(); - const reason = issueCancelled - ? "Cancelled because the issue was cancelled before the scheduled retry became due" - : "Cancelled because the issue was reassigned before the scheduled retry became due"; - const cancelled = await tx - .update(heartbeatRuns) - .set({ - status: "cancelled", - finishedAt: now, - error: reason, - errorCode: issueCancelled - ? "issue_cancelled" - : "issue_reassigned", - updatedAt: now, - }) - .where( - and( - eq(heartbeatRuns.id, scheduledRun.id), - eq(heartbeatRuns.status, "scheduled_retry"), - ), - ) - .returning() - .then((rows) => rows[0] ?? null); - - if (!cancelled) return false; - - if (scheduledRun.wakeupRequestId) { - await tx - .update(agentWakeupRequests) - .set({ - status: "cancelled", - finishedAt: now, - error: reason, - updatedAt: now, - }) - .where( - eq(agentWakeupRequests.id, scheduledRun.wakeupRequestId), - ); - } - - if (issue.executionRunId === scheduledRun.id) { - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: now, - }) - .where( - and( - eq(issues.id, issue.id), - eq(issues.executionRunId, scheduledRun.id), - ), - ); - } - - const eventSeq = await allocateHeartbeatRunEventSeq( - tx as unknown as Db, - cancelled.id, - ); - - await tx.insert(heartbeatRunEvents).values({ - companyId: cancelled.companyId, - runId: cancelled.id, - agentId: cancelled.agentId, - seq: eventSeq, - eventType: "lifecycle", - stream: "system", - level: "warn", - message: issueCancelled - ? "Scheduled retry cancelled because issue was cancelled before it became due" - : "Scheduled retry cancelled because issue ownership changed before it became due", - payload: { - issueId: issue.id, - issueStatus: issue.status, - scheduledRetryAttempt: cancelled.scheduledRetryAttempt, - scheduledRetryAt: cancelled.scheduledRetryAt - ? new Date(cancelled.scheduledRetryAt).toISOString() - : null, - scheduledRetryReason: cancelled.scheduledRetryReason, - previousRetryAgentId: cancelled.agentId, - currentAssigneeAgentId: issue.assigneeAgentId, - }, - }); - await tx - .update(heartbeatRuns) - .set({ nextEventSeq: eventSeq + 1, updatedAt: now }) - .where(eq(heartbeatRuns.id, cancelled.id)); - - cancelledRunsToEmit.push(cancelled); - - return true; - }; - - let activeExecutionRun = issue.executionRunId - ? await tx - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, issue.executionRunId)) - .then((rows) => rows[0] ?? null) - : null; - - if ( - activeExecutionRun && - !EXECUTION_PATH_HEARTBEAT_RUN_STATUSES.includes( - activeExecutionRun.status as (typeof EXECUTION_PATH_HEARTBEAT_RUN_STATUSES)[number], - ) - ) { - activeExecutionRun = null; - } - - if ( - activeExecutionRun && - (await cancelStaleScheduledRetry(activeExecutionRun)) - ) { - activeExecutionRun = null; - } - - // A queued/scheduled run holding the lock for an agent that is - // no longer the issue's assignee is stale by design — the issue - // has been re-routed (e.g. blocked → in_review with a different - // assignee). Cancel it and release the lock; otherwise the new - // assignee's wake gets parked in `deferred_issue_execution` - // forever, because the original queued holder will never run - // (the issue's status / target now belongs to someone else). - // - // Race guard: pin the cancel UPDATE to the exact non-running - // status we read above. A worker could transition the holder - // from `queued` → `running` between the SELECT and this UPDATE; - // the status predicate ensures we never clobber a freshly- - // claimed running run. If zero rows matched, leave - // `activeExecutionRun` populated so the defer path runs - // normally against the now-running holder. - if ( - activeExecutionRun && - activeExecutionRun.status !== "running" && - issue.assigneeAgentId && - activeExecutionRun.agentId !== issue.assigneeAgentId - ) { - const cancelled = await tx - .update(heartbeatRuns) - .set({ - status: "cancelled", - finishedAt: new Date(), - error: - "Execution lock released after issue reassigned to a different agent", - errorCode: "lock_released_on_reassignment", - updatedAt: new Date(), - }) - .where( - and( - eq(heartbeatRuns.id, activeExecutionRun.id), - eq(heartbeatRuns.status, activeExecutionRun.status), - ), - ) - .returning(); - if (cancelled.length > 0) { - cancelledRunsToEmit.push(cancelled[0]); - if (activeExecutionRun.wakeupRequestId) { - await tx - .update(agentWakeupRequests) - .set({ - status: "cancelled", - finishedAt: new Date(), - error: - "Execution lock released after issue reassigned to a different agent", - updatedAt: new Date(), - }) - .where( - eq( - agentWakeupRequests.id, - activeExecutionRun.wakeupRequestId, - ), - ); - } - activeExecutionRun = null; - } - } - - if (!activeExecutionRun && issue.executionRunId) { - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: new Date(), - }) - .where(eq(issues.id, issue.id)); - } - - if (!activeExecutionRun) { - const legacyRun = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, issue.companyId), - inArray(heartbeatRuns.status, [ - ...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES, - ]), - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, - ), - ) - .orderBy( - sql`case when ${heartbeatRuns.status} = 'running' then 0 else 1 end`, - asc(heartbeatRuns.createdAt), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - - if (legacyRun) { - if (await cancelStaleScheduledRetry(legacyRun)) { - activeExecutionRun = null; - } else { - activeExecutionRun = legacyRun; - const legacyAgent = await tx - .select({ name: agents.name }) - .from(agents) - .where(eq(agents.id, legacyRun.agentId)) - .then((rows) => rows[0] ?? null); - await tx - .update(issues) - .set({ - executionRunId: legacyRun.id, - executionAgentNameKey: normalizeAgentNameKey( - legacyAgent?.name, - ), - executionLockedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(issues.id, issue.id)); - } - } - } - - const dependencyReadiness = await issuesSvc - .listDependencyReadiness(issue.companyId, [issue.id], tx) - .then((rows) => rows.get(issue.id) ?? null); - - // Blocked descendants should stay idle until the final blocker resolves. - // Human comment/mention wakes are the exception: they may run in a - // bounded interaction mode so the assignee can answer or triage. - const blockedInteractionWake = - dependencyReadiness && - !dependencyReadiness.isDependencyReady && - allowsIssueInteractionWake( - enrichedContextSnapshot, - ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS, - ); - - if (blockedInteractionWake) { - enrichedContextSnapshot.dependencyBlockedInteraction = true; - enrichedContextSnapshot.unresolvedBlockerIssueIds = - dependencyReadiness.unresolvedBlockerIssueIds; - enrichedContextSnapshot.unresolvedBlockerCount = - dependencyReadiness.unresolvedBlockerCount; - enrichedContextSnapshot.unresolvedBlockerSummaries = - await listUnresolvedBlockerSummaries( - tx, - issue.companyId, - issue.id, - dependencyReadiness.unresolvedBlockerIssueIds, - ); - } - - if ( - !activeExecutionRun && - dependencyReadiness && - !dependencyReadiness.isDependencyReady && - !blockedInteractionWake - ) { - await recordExecutionWait(tx as unknown as Db, { - issueId: issue.id, - coalesce: coalesceExecutionWait, - condition: { unresolvedBlockerIssueIds: [...dependencyReadiness.unresolvedBlockerIssueIds].sort() }, - request: { - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: "issue_dependencies_blocked", - payload: { - ...(payload ?? {}), - issueId, - unresolvedBlockerIssueIds: - dependencyReadiness.unresolvedBlockerIssueIds, - }, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: new Date(), - }, - }); - return { kind: "skipped" as const }; - } - - if ( - isolatedWorkspacesEnabled && - !activeExecutionRun && - issue.status !== "done" && - issue.status !== "cancelled" - ) { - const issueSettings = parseIssueExecutionWorkspaceSettings( - issue.executionWorkspaceSettings, - ); - const resolvedMode = resolveExecutionWorkspaceMode({ - projectPolicy: null, - issueSettings, - legacyUseProjectWorkspace: null, - }); - const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig( - { - agentConfig: parseObject(agent.adapterConfig), - projectPolicy: null, - issueSettings, - mode: resolvedMode, - legacyUseProjectWorkspace: null, - }, - ); - const resolvedStrategy = resolveEffectiveWorkspaceStrategyType( - resolvedMode, - workspaceManagedConfig, - ); - const existingExecutionWorkspaceStatus = issue.executionWorkspaceId - ? await tx - .select({ status: executionWorkspaces.status }) - .from(executionWorkspaces) - .where( - and( - eq(executionWorkspaces.id, issue.executionWorkspaceId), - eq(executionWorkspaces.companyId, issue.companyId), - ), - ) - .then((rows) => rows[0]?.status ?? null) - : null; - const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({ - issueExecutionWorkspaceId: issue.executionWorkspaceId, - issueExecutionWorkspacePreference: - issue.executionWorkspacePreference, - existingExecutionWorkspaceStatus, - }); - const hasResolvablePriorSessionWorkspace = - await resolveHasResolvablePriorSessionWorkspace(); - - if ( - isUnrunnableWorktreeCombo({ - issue: { - projectId: issue.projectId ?? projectId ?? null, - projectWorkspaceId: issue.projectWorkspaceId, - executionWorkspaceId: issue.executionWorkspaceId, - executionWorkspacePreference: - issue.executionWorkspacePreference, - }, - resolvedMode, - resolvedStrategy, - reusableExecutionWorkspaceAvailable: - reuseRequest.existingExecutionWorkspaceAvailable, - hasResolvablePriorSessionWorkspace, - }) - ) { - const now = new Date(); - const issueLabel = formatIssueIdentifierLink( - issue.identifier, - issue.id, - ); - const blockedComment = [ - `Paperclip blocked ${issueLabel} before dispatch because its workspace settings are not runnable.`, - "", - `- Code: \`${WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE}\``, - `- Reason: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE}`, - `- Next action: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION}`, - ].join("\n"); - await tx - .update(issues) - .set({ - status: "blocked", - checkoutRunId: null, - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: now, - }) - .where(eq(issues.id, issue.id)); - await tx.insert(issueComments).values({ - companyId: issue.companyId, - issueId: issue.id, - body: blockedComment, - createdAt: now, - updatedAt: now, - }); - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, - payload: { - ...(payload ?? {}), - issueId, - heartbeatSkip: { - code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, - reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, - remediation: - WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, - }, - }, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: now, - }); - await logActivity(tx as unknown as Db, { - companyId: issue.companyId, - actorType: "system", - actorId: "system", - agentId, - runId: null, - action: "issue.workspace_preflight_blocked", - entityType: "issue", - entityId: issue.id, - details: { - code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, - reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, - remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, - requestedReason: reason, - source, - triggerDetail, - resolvedMode, - resolvedStrategy, - hasResolvablePriorSessionWorkspace, - }, - }); - return { kind: "skipped" as const }; - } - } - - if (activeExecutionRun) { - // The resolved action is already a durable retry outbox. Do not merge - // its fresh-session contract into unrelated work or create a second - // deferred wake that could later replay the same reconciliation. - if (reconciledSourceRunId) return { kind: "deferred" as const }; - - const admissionScope = wakeQueue.createAdmissionTransactionScope( - agent.companyId, - tx as unknown as Db, - ); - const admission = await wakeQueue.admitWakeBehindIssueExecution( - admissionScope, - { - companyId: agent.companyId, - issueId: issue.id, - agentId, - agentNameKey, - issueExecutionAgentNameKey: issue.executionAgentNameKey, - activeExecutionRun: { - id: activeExecutionRun.id, - agentId: activeExecutionRun.agentId, - status: activeExecutionRun.status, - contextSnapshot: activeExecutionRun.contextSnapshot, - wakeupRequestId: activeExecutionRun.wakeupRequestId, - }, - allowRunCoalescing: isConversation(issue) ? false : opts.allowRunCoalescing, - durableReceipt: receiptRequest - ? { - id: receiptRequest.id, - requestedAt: receiptRequest.requestedAt, - } - : undefined, - reason, - liveRunExecutions, - wakeCommentId, - forceFreshSession: - enrichedContextSnapshot.forceFreshSession === true, - contextSnapshot: enrichedContextSnapshot, - source, - triggerDetail, - payload, - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - }, - ); - - if (admission.kind === "coalesced") { - return { - kind: "coalesced" as const, - run: admission.run as typeof heartbeatRuns.$inferSelect, - }; - } - if (admission.kind === "deferred") { - return { kind: "deferred" as const }; - } - // admission.kind === "proceed": no active run absorbed this wake, - // so fall through to the ordinary queue path below. - } - - // PAP-13775: no live run holds the lock, so this wake would start a - // fresh adapter session. If this agent's recent runs on this issue - // keep succeeding without any issue-visible progress and the wake - // carries no new information, hold it back for an escalating cooldown - // so external pollers/reconcilers can't storm full-price sessions. - // Server-side recovery retries insert runs directly and never reach - // this gate. - if ( - isThrottleCandidateIssueRewake({ - reason, - wakeCommentId: wakeCommentId ?? null, - requestedByActorType: opts.requestedByActorType ?? null, - forceFreshSession: - enrichedContextSnapshot.forceFreshSession === true, - hasExplicitResume: Boolean(explicitResumeSession), - }) - ) { - const throttleNow = new Date(); - const recentTerminalRuns = await tx - .select({ - id: heartbeatRuns.id, - status: heartbeatRuns.status, - finishedAt: heartbeatRuns.finishedAt, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, agent.companyId), - eq(heartbeatRuns.agentId, agentId), - sql`${heartbeatRuns.finishedAt} is not null`, - gte( - heartbeatRuns.finishedAt, - new Date(throttleNow.getTime() - ISSUE_REWAKE_LOOKBACK_MS), - ), - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, - ), - ) - .orderBy(desc(heartbeatRuns.finishedAt)) - .limit(ISSUE_REWAKE_RUN_SAMPLE_LIMIT); - - if (recentTerminalRuns.length > 0) { - const sampleRunIds = recentTerminalRuns.map( - (sampleRun) => sampleRun.id, - ); - const progressRows = await tx - .select({ runId: activityLog.runId }) - .from(activityLog) - .where( - and( - eq(activityLog.companyId, agent.companyId), - eq(activityLog.entityType, "issue"), - eq(activityLog.entityId, issue.id), - inArray(activityLog.runId, sampleRunIds), - inArray( - activityLog.action, - ISSUE_PROGRESS_ACTIVITY_ACTIONS, - ), - ), - ); - const lastRunFinishedAt = - recentTerminalRuns[0]?.finishedAt ?? null; - const newInputRows = lastRunFinishedAt - ? await tx - .select({ id: activityLog.id }) - .from(activityLog) - .where( - and( - eq(activityLog.companyId, agent.companyId), - eq(activityLog.entityType, "issue"), - eq(activityLog.entityId, issue.id), - gt(activityLog.createdAt, lastRunFinishedAt), - inArray( - activityLog.action, - ISSUE_NEW_INPUT_ACTIVITY_ACTIONS, - ), - wakeCommentId && opts.requestedByActorType === "agent" - ? ne(activityLog.actorType, "agent") - : undefined, - ), - ) - .limit(1) - : []; - - const throttleDecision = evaluateIssueRewakeThrottle({ - now: throttleNow, - recentTerminalRuns, - runIdsWithIssueProgress: new Set( - progressRows - .map((row) => row.runId) - .filter((runId): runId is string => Boolean(runId)), - ), - // For an agent comment wake, the query excludes agent-authored - // activity while preserving genuinely new user/system input. - // Presentation/author metadata therefore cannot smuggle human - // wake privilege, nor can it mask an actual human response. - hasNewIssueInputSinceLastRun: newInputRows.length > 0, - }); - - if (throttleDecision.blocked) { - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: "issue_rewake_throttled", - payload: { - ...(payload ?? {}), - issueId, - heartbeatSkip: { - reason: "issue_rewake_throttled", - requestedReason: reason, - noProgressStreak: throttleDecision.noProgressStreak, - cooldownMs: throttleDecision.cooldownMs, - lastRunFinishedAt: - throttleDecision.lastRunFinishedAt.toISOString(), - nextAllowedAt: - throttleDecision.nextAllowedAt.toISOString(), - }, - }, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: throttleNow, - }); - return { kind: "skipped" as const }; - } - } - } - - const dailyCapBlock = await getHeartbeatDailyCapBlock( - agent, - policy, - {}, - tx, - ); - if (dailyCapBlock) { - if (executionWaitRequestId && executionBlocker) { - continuationWait = { reason: dailyCapBlock.reason, - message: "The agent has reached its daily limit. Your message is saved until work can resume." }; - return deferBlockedExecution(executionBlocker); - } - const now = new Date(); - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: dailyCapBlock.reason, - payload: { - ...(payload ?? {}), - heartbeatSkip: { - reason: - "Per-agent heartbeat daily cap reached before adapter invocation.", - observed: dailyCapBlock.observed, - limit: dailyCapBlock.limit, - }, - }, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: now, - }); - if (source === "timer") { - await tx - .update(agents) - .set({ - lastHeartbeatAt: now, - updatedAt: now, - }) - .where(eq(agents.id, agentId)); - } - return { kind: "skipped" as const }; - } - - const explicitContinuation = await admitExplicitNativeContinuation({ - db: tx as unknown as Db, companyId: issue.companyId, issueId: issue.id, - agentId, actorType: opts.requestedByActorType, actorId: opts.requestedByActorId, - reason: durableRequest && !failedChatRetry ? "issue_commented" : reason, - commentId: wakeCommentId ?? null, failedRunId: opts.failedRunId, successorRunId: explicitContinuationRunId, - queuedCommentInterruptId: opts.queuedCommentInterruptId, - queuedCommentRequestId: opts.queuedCommentRequestId, - }); - if (!explicitContinuation && executionBlocker) return deferBlockedExecution(executionBlocker); - if (explicitContinuation) { - enrichedContextSnapshot.forceFreshSession = true; - enrichedContextSnapshot.previousRunId = explicitContinuation.previousRunId; - enrichedContextSnapshot.explicitUserContinuation = explicitContinuation; - } - - const wakeupRequest = await tx - .insert(agentWakeupRequests) - .values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason, - payload, - status: "queued", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - }) - .returning() - .then((rows) => rows[0]); - - // A handoff changes the executor, not the owner of saved user input. - // Validate its exact stopped source while the issue row is locked; - // unrelated agents and dedicated continuations keep their own wakes. - const interruptedRunId = readNonEmptyString(enrichedContextSnapshot.interruptedRunId); - const handoffSource = source === "assignment" && reason === "issue_assigned" && - issue.assigneeAgentId === agentId && interruptedRunId && isUuidLike(interruptedRunId) - ? await tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns).where(and( - eq(heartbeatRuns.id, interruptedRunId), eq(heartbeatRuns.companyId, issue.companyId), - eq(heartbeatRuns.status, "cancelled"), eq(heartbeatRuns.errorCode, "issue_reassigned"), - ne(heartbeatRuns.agentId, agentId), - sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id}`, - or(isNull(heartbeatRuns.nativeIssueId), eq(heartbeatRuns.nativeIssueId, issue.id)), - )).then(rows => rows[0] ?? null) - : null; - const canCoalesceComments = !isConversation(issue) && opts.allowRunCoalescing !== false; - // A resumed receipt already passed admission under this issue lock. - // Consume it with its successor even when chat keeps other messages - // in separate turns, or finalization will deliver it a second time. - const pendingComments = - (executionWaitRequestId || canCoalesceComments) && - !(await getExecutionBlocker(tx as unknown as Db, issue.companyId, issue.id)) - ? await tx - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, issue.companyId), - inArray(agentWakeupRequests.agentId, handoffSource ? [agentId, handoffSource.agentId] : [agentId]), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - sql`${agentWakeupRequests.payload}->>'issueId' = ${issue.id}`, - canCoalesceComments ? undefined : eq(agentWakeupRequests.id, executionWaitRequestId!), - ), - ) - .orderBy(asc(agentWakeupRequests.requestedAt)) - : []; - const adoptedComments = pendingComments.filter((wake) => { - if (wake.id === executionWaitRequestId) return true; - if (wake.id === opts.queuedCommentInterruptId || wake.id === opts.queuedCommentRequestId) return true; - const deferredPayload = parseObject(wake.payload); - const deferredContext = parseObject( - deferredPayload[DEFERRED_WAKE_CONTEXT_KEY], - ); - // Dedicated interaction wakes carry their own source and session - // contract. ID-only adoption must not erase that continuation. - return ( - // Durable chat work must keep its receipt, actor, source, and - // session contract through normal promotion and authorization. - !wake.idempotencyKey?.startsWith("chat-inbound:") && - !isInteractionResolutionWakePayload(deferredPayload) && - !hasInteractionContinuationWakeContext(deferredContext) && - ["issue_commented", "issue_reopened_via_comment"].includes(String(deferredContext.wakeReason ?? wake.reason)) && - queuedCommentIdsFromWakePayload(wake.payload).length > 0 - ); - }); - let adoptedCommentIds = [ - ...new Set([ - ...adoptedComments.flatMap((wake) => - queuedCommentIdsFromWakePayload(wake.payload), - ), - ...queuedCommentIdsFromRunContext(enrichedContextSnapshot), - ]), - ]; - if (opts.queuedCommentRequestId || handoffSource) { - adoptedCommentIds = await undeliveredLegacyUserCommentIds(tx as unknown as Db, - agent.companyId, issueId, agentId, adoptedCommentIds); - } - const newRun = await tx - .insert(heartbeatRuns) - .values({ - ...(explicitContinuation ? { id: explicitContinuationRunId } : {}), - companyId: agent.companyId, - agentId, - scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company", - issueId: readNonEmptyString(enrichedContextSnapshot.issueId), - invocationSource: source, - triggerDetail, - status: "queued", - responsibleUserId: await resolveQueuedResponsibleUserId(), - wakeupRequestId: wakeupRequest.id, - retryOfRunId: failedChatRetry - ? durableRequest!.failedRunRetry!.failedRunId - : opts.failedRunId ?? automaticParentRunId, - contextSnapshot: adoptedComments.length - ? withQueuedCommentIdsInRunContext( - enrichedContextSnapshot, - adoptedCommentIds, - ) - : enrichedContextSnapshot, - sessionIdBefore: explicitContinuation ? null : sessionBefore, - continuationAttempt, - ...(reconciledSourceRunId - ? { retryOfRunId: reconciledSourceRunId } - : {}), - ...(reconciledRestoreRetryCount !== null ? { - scheduledRetryAttempt: reconciledRestoreRetryCount, - scheduledRetryReason: "transient_failure", - } : {}), - }) - .returning() - .then((rows) => rows[0]); - - await tx - .update(agentWakeupRequests) - .set({ - runId: newRun.id, - updatedAt: new Date(), - }) - .where(eq(agentWakeupRequests.id, wakeupRequest.id)); - - if (adoptedComments.length) { - await tx - .update(agentWakeupRequests) - .set({ - status: "coalesced", - runId: newRun.id, - finishedAt: new Date(), - updatedAt: new Date(), - }) - .where( - inArray( - agentWakeupRequests.id, - adoptedComments.map((wake) => wake.id), - ), - ); - await tx - .update(agentWakeupRequests) - .set({ - payload: withQueuedCommentIdsInWakePayload(payload, adoptedCommentIds), - }) - .where(eq(agentWakeupRequests.id, wakeupRequest.id)); - } - - // executionRunId is NOT stamped here (enqueueWakeup queues the run but - // doesn't start it). It will be stamped in claimQueuedRun() once the run - // transitions to "running" — Fix A (lazy locking). - - return { kind: "queued" as const, run: newRun }; - }) - .catch((error) => { - if (isExternalChatWaitAuthorizationContention(error)) - return { kind: "deferred" as const }; - throw error; - }); - - // Telemetry for the cancelled runs is best-effort background work. - // Fire it here and never await it: none of the lifecycle work below, - // nor this function's return, depends on it, so a slow telemetry - // lookup must not delay them. - for (const cancelledRun of cancelledRunsToEmit) { - void emitAgentTaskRun(db, cancelledRun); - } - - if (outcome.kind === "durable") { - return outcome.receipt.runId ? getRun(outcome.receipt.runId) : null; - } - if (outcome.kind === "deferred" || outcome.kind === "skipped") { - return null; - } - if (outcome.kind === "coalesced") { - await startNextQueuedRunForAgent(agent.id); - return outcome.run; - } - if (outcome.kind === "replayed") { - if (outcome.run.status === "queued") - await startNextQueuedRunForAgent(agent.id); - return outcome.run; - } - - const newRun = outcome.run; - publishLiveEvent({ - companyId: newRun.companyId, - type: "heartbeat.run.queued", - payload: { - runId: newRun.id, - agentId: newRun.agentId, - invocationSource: newRun.invocationSource, - triggerDetail: newRun.triggerDetail, - wakeupRequestId: newRun.wakeupRequestId, - }, - }); - - await startNextQueuedRunForAgent(agent.id); - return newRun; - } - - if (durableRequest) throw new Error("chat_inbound_wakeup_binding_denied"); - - const activeRuns = await db - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.agentId, agentId), - inArray(heartbeatRuns.status, [ - ...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES, - ]), - ), - ) - .orderBy(desc(heartbeatRuns.createdAt)); - - const sameScopeQueuedRun = activeRuns.find( - (candidate) => - candidate.status === "queued" && - isSameTaskScope(runTaskKey(candidate), taskKey), - ); - const sameScopeScheduledRetryRun = activeRuns.find( - (candidate) => - candidate.status === "scheduled_retry" && - isSameTaskScope(runTaskKey(candidate), taskKey), - ); - const sameScopeRunningRun = activeRuns.find( - (candidate) => - candidate.status === "running" && - isSameTaskScope(runTaskKey(candidate), taskKey), - ); - const shouldQueueFollowupForRunningWake = - Boolean(sameScopeRunningRun) && - !sameScopeQueuedRun && - shouldQueueFollowupForRunningIssueWake({ - contextSnapshot: enrichedContextSnapshot, - wakeCommentId, - }); - // Unscoped manual wakes need their own receipt and execution identity too. - const rawCoalescedTarget = - opts.allowRunCoalescing === false || opts.manualUserWake - ? null - : (sameScopeQueuedRun ?? - sameScopeScheduledRetryRun ?? - (shouldQueueFollowupForRunningWake - ? null - : (sameScopeRunningRun ?? null))); - - const coalescedTargetRun = filterZombieCoalesceTarget( - rawCoalescedTarget, - liveRunExecutions, - ); - - if (coalescedTargetRun) { - const mergedContextSnapshot = mergeCoalescedContextSnapshot( - coalescedTargetRun.contextSnapshot, - enrichedContextSnapshot, - { - preserveExistingInteractionContinuation: - coalescedTargetRun.status === "queued" || - coalescedTargetRun.status === "scheduled_retry", - }, - ); - const mergedRun = await db - .update(heartbeatRuns) - .set({ - contextSnapshot: mergedContextSnapshot, - updatedAt: new Date(), - }) - .where(eq(heartbeatRuns.id, coalescedTargetRun.id)) - .returning() - .then((rows) => rows[0] ?? coalescedTargetRun); - - await db.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason, - payload, - status: "coalesced", - coalescedCount: 1, - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - runId: mergedRun.id, - finishedAt: new Date(), - }); - return mergedRun; - } - - const queueOutcome = await db.transaction(async (tx) => { - await tx.execute( - sql`select id from agents where id = ${agentId} and company_id = ${agent.companyId} for update`, - ); - - const dailyCapBlock = await getHeartbeatDailyCapBlock( - agent, - policy, - {}, - tx, - ); - if (dailyCapBlock) { - const now = new Date(); - await tx.insert(agentWakeupRequests).values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason: dailyCapBlock.reason, - payload: { - ...(payload ?? {}), - heartbeatSkip: { - reason: - "Per-agent heartbeat daily cap reached before adapter invocation.", - observed: dailyCapBlock.observed, - limit: dailyCapBlock.limit, - }, - }, - status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - finishedAt: now, - }); - if (source === "timer") { - await tx - .update(agents) - .set({ - lastHeartbeatAt: now, - updatedAt: now, - }) - .where(eq(agents.id, agentId)); - } - return { kind: "skipped" as const }; - } - - const wakeupRequest = await tx - .insert(agentWakeupRequests) - .values({ - ...durableReceiptFields, - companyId: agent.companyId, - agentId, - source, - triggerDetail, - reason, - payload, - status: "queued", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, - idempotencyKey: opts.idempotencyKey ?? null, - }) - .returning() - .then((rows) => rows[0]); - - const newRun = await tx - .insert(heartbeatRuns) - .values({ - companyId: agent.companyId, - agentId, - scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company", - issueId: readNonEmptyString(enrichedContextSnapshot.issueId), - invocationSource: source, - triggerDetail, - status: "queued", - responsibleUserId: await resolveQueuedResponsibleUserId(), - wakeupRequestId: wakeupRequest.id, - contextSnapshot: enrichedContextSnapshot, - sessionIdBefore: sessionBefore, - continuationAttempt, - }) - .returning() - .then((rows) => rows[0]); - - await tx - .update(agentWakeupRequests) - .set({ - runId: newRun.id, - updatedAt: new Date(), - }) - .where(eq(agentWakeupRequests.id, wakeupRequest.id)); - - return { kind: "queued" as const, run: newRun }; - }); - - if (queueOutcome.kind === "skipped") return null; - const newRun = queueOutcome.run; - - publishLiveEvent({ - companyId: newRun.companyId, - type: "heartbeat.run.queued", - payload: { - runId: newRun.id, - agentId: newRun.agentId, - invocationSource: newRun.invocationSource, - triggerDetail: newRun.triggerDetail, - wakeupRequestId: newRun.wakeupRequestId, - }, - }); - - await startNextQueuedRunForAgent(agent.id); - - return newRun; - } - - /** - * Native status commitment deliberately persists dependency/parent wake - * intents in the same transaction as the authoritative status projection. - * Those rows are not runnable until the heartbeat scheduler has applied its - * normal policy, workspace, concurrency, and responsible-user checks. Bridge - * the durable intent into that scheduler here instead of treating a bare - * `agent_wakeup_requests` row as if it were already a queued heartbeat run. - * - * The intent is claimed before dispatch. A deterministic dispatcher actor id - * lets a later sweep recover the narrow process-crash window after the real - * wake was inserted but before the intent was linked to it. Dispatch failure - * only requeues the intent; it never changes the provider run outcome. - */ - async function dispatchPendingNativeStatusWakeups( - input: { - companyId?: string; - limit?: number; - staleClaimMs?: number; - } = {}, - ) { - const now = new Date(); - const staleClaimMs = Math.max(1_000, input.staleClaimMs ?? 60_000); - const candidates = await db - .select() - .from(agentWakeupRequests) - .where( - and( - input.companyId - ? eq(agentWakeupRequests.companyId, input.companyId) - : undefined, - eq(agentWakeupRequests.requestedByActorType, "system"), - eq(agentWakeupRequests.requestedByActorId, "native-status-committer"), - inArray(agentWakeupRequests.status, ["queued", "claimed"]), - isNull(agentWakeupRequests.runId), - ), - ) - .orderBy(asc(agentWakeupRequests.requestedAt)) - .limit(Math.max(1, Math.min(input.limit ?? 100, 500))); - - let dispatched = 0; - let recovered = 0; - let deferred = 0; - const deliveredByIssueScope = new Map< - string, - { runId: string | null; status: string } - >(); - - for (const candidate of candidates) { - const dispatchActorId = `native-status-wake-dispatch:${candidate.id}`; - const existingDispatch = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, candidate.companyId), - eq(agentWakeupRequests.requestedByActorType, "system"), - eq(agentWakeupRequests.requestedByActorId, dispatchActorId), - ), - ) - .orderBy(desc(agentWakeupRequests.requestedAt)) - .limit(1) - .then((rows) => rows[0] ?? null); - - if (existingDispatch) { - const recoveredStatus = existingDispatch.runId - ? "coalesced" - : existingDispatch.status === "deferred_issue_execution" - ? "coalesced" - : existingDispatch.status; - await db - .update(agentWakeupRequests) - .set({ - status: recoveredStatus, - runId: existingDispatch.runId, - finishedAt: - (existingDispatch.runId ?? existingDispatch.finishedAt) - ? (existingDispatch.finishedAt ?? now) - : null, - error: existingDispatch.error, - updatedAt: now, - }) - .where( - and( - eq(agentWakeupRequests.id, candidate.id), - isNull(agentWakeupRequests.runId), - ), - ); - recovered += 1; - continue; - } - - if ( - candidate.status === "claimed" && - candidate.claimedAt && - now.getTime() - candidate.claimedAt.getTime() < staleClaimMs - ) { - deferred += 1; - continue; - } - - const claimed = await db - .update(agentWakeupRequests) - .set({ - status: "claimed", - claimedAt: now, - error: null, - updatedAt: now, - }) - .where( - and( - eq(agentWakeupRequests.id, candidate.id), - isNull(agentWakeupRequests.runId), - candidate.status === "claimed" - ? eq(agentWakeupRequests.status, "claimed") - : eq(agentWakeupRequests.status, "queued"), - ), - ) - .returning({ id: agentWakeupRequests.id }); - if (claimed.length === 0) continue; - - const payload = parseObject(candidate.payload); - const wakeContext = parseObject(payload._paperclipWakeContext); - const issueId = - readNonEmptyString(payload.issueId) ?? - readNonEmptyString(payload.taskId) ?? - readNonEmptyString(wakeContext.issueId) ?? - null; - const scopeKey = issueId - ? `${candidate.companyId}:${candidate.agentId}:${issueId}:${readNativeReviewAssignmentContext(wakeContext)?.nativeReviewInteractionId ?? ""}` - : null; - const priorDelivery = scopeKey - ? deliveredByIssueScope.get(scopeKey) - : null; - if (priorDelivery) { - await db - .update(agentWakeupRequests) - .set({ - status: "coalesced", - runId: priorDelivery.runId, - finishedAt: new Date(), - error: null, - updatedAt: new Date(), - }) - .where(eq(agentWakeupRequests.id, candidate.id)); - recovered += 1; - continue; - } - - let completedOnboardingGuard: WakeupOptions["issueStateGuard"]; - if (issueId) { - const targetIssue = await db - .select({ - status: issues.status, - statusVersion: issues.statusVersion, - assigneeAgentId: issues.assigneeAgentId, - }) - .from(issues) - .where( - and( - eq(issues.id, issueId), - eq(issues.companyId, candidate.companyId), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - const nativeReview = candidate.reason === "native_completion_review" - ? await getNativeReviewAssignment(db, { - companyId: candidate.companyId, issueId, agentId: candidate.agentId, - contextSnapshot: wakeContext, - }) - : null; - const onboardingResultReport = targetIssue?.status === "done" && await isCompletedOnboardingHandoffWake(db, { - companyId: candidate.companyId, issueId, agentId: candidate.agentId, - reason: candidate.reason, contextSnapshot: wakeContext, - }); - if (onboardingResultReport) completedOnboardingGuard = { assigneeAgentId: candidate.agentId, statuses: ["done"], statusVersion: targetIssue!.statusVersion }; - if ( - !targetIssue || - (["done", "cancelled"].includes(targetIssue.status) && !onboardingResultReport) || - (targetIssue.assigneeAgentId !== candidate.agentId && !nativeReview) || - (candidate.reason === "native_completion_review" && !nativeReview) - ) { - await db - .update(agentWakeupRequests) - .set({ - status: "skipped", - finishedAt: new Date(), - error: !targetIssue - ? "Native status wake target no longer exists" - : ["done", "cancelled"].includes(targetIssue.status) - ? `Native status wake target is already ${targetIssue.status}` - : "Native status wake target has a different assignee", - updatedAt: new Date(), - }) - .where(eq(agentWakeupRequests.id, candidate.id)); - recovered += 1; - continue; - } - } - - try { - const wakeRun = await enqueueWakeup(candidate.agentId, { - source: candidate.source as WakeupOptions["source"], - triggerDetail: (candidate.triggerDetail ?? - "system") as WakeupOptions["triggerDetail"], - reason: candidate.reason, - payload, - idempotencyKey: candidate.idempotencyKey, - requestedByActorType: "system", - requestedByActorId: dispatchActorId, - ...(completedOnboardingGuard ? { issueStateGuard: completedOnboardingGuard } : {}), - contextSnapshot: { - ...wakeContext, - ...(issueId ? { issueId, taskId: issueId } : {}), - wakeReason: candidate.reason, - source: "native_status_decision", - statusDecisionSource: "native_status_decision", - nativeStatusWakeIntentId: candidate.id, - }, - }); - - const delivered = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, candidate.companyId), - eq(agentWakeupRequests.requestedByActorType, "system"), - eq(agentWakeupRequests.requestedByActorId, dispatchActorId), - ), - ) - .orderBy(desc(agentWakeupRequests.requestedAt)) - .limit(1) - .then((rows) => rows[0] ?? null); - - // The committer intent is the durable outbox entry. When admission is - // blocked, enqueueWakeup creates a separate deferred dispatch receipt; - // leave the original intent coalesced so the release drain cannot - // promote both rows (the original has no nativeStatusWakeIntentId - // provenance and would otherwise run once before the dispatch receipt). - const deliveredStatus = delivered?.status ?? "queued"; - await db - .update(agentWakeupRequests) - .set({ - status: wakeRun || deliveredStatus === "deferred_issue_execution" - ? "coalesced" - : deliveredStatus, - runId: wakeRun?.id ?? delivered?.runId ?? null, - finishedAt: - wakeRun || delivered?.finishedAt - ? (delivered?.finishedAt ?? new Date()) - : null, - error: delivered?.error ?? null, - updatedAt: new Date(), - }) - .where(eq(agentWakeupRequests.id, candidate.id)); - - if (scopeKey) { - deliveredByIssueScope.set(scopeKey, { - runId: wakeRun?.id ?? delivered?.runId ?? null, - status: wakeRun ? "coalesced" : (delivered?.status ?? "queued"), - }); - } - - if (wakeRun) dispatched += 1; - else deferred += 1; - } catch (error) { - await db - .update(agentWakeupRequests) - .set({ - status: "queued", - claimedAt: null, - error: - error instanceof Error - ? error.message.slice(0, 1_000) - : String(error).slice(0, 1_000), - updatedAt: new Date(), - }) - .where( - and( - eq(agentWakeupRequests.id, candidate.id), - eq(agentWakeupRequests.status, "claimed"), - isNull(agentWakeupRequests.runId), - ), - ); - logger.warn( - { - err: error, - wakeupRequestId: candidate.id, - agentId: candidate.agentId, - }, - "failed to dispatch persisted native status wake intent", - ); - deferred += 1; - } - } - - return { scanned: candidates.length, dispatched, recovered, deferred }; - } - async function listProjectScopedRunIds(companyId: string, projectId: string, createdBefore?: Date) { const runIssueId = sql< string | null @@ -26549,102 +13995,7 @@ export function heartbeatService( await new Promise((resolve) => setTimeout(resolve, intervalMs)); } }, - list: async ( - companyId: string, - agentId?: string, - limit?: number, - options: { summary?: boolean } = {}, - ) => { - const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase(); - const summary = options.summary === true; - const rows = await retryIdempotentDatabaseOperation(async () => { - const query = db - .select( - summary - ? { - ...heartbeatRunSummaryListColumns, - ...heartbeatRunListContextColumns, - } - : safeForLegacyEncoding - ? { - ...heartbeatRunListColumns, - error: sql`NULL`.as("error"), - ...heartbeatRunListContextColumns, - } - : { - ...heartbeatRunListColumns, - ...heartbeatRunListContextColumns, - ...heartbeatRunListResultColumns, - }, - ) - .from(heartbeatRuns) - .where( - agentId - ? and( - eq(heartbeatRuns.companyId, companyId), - eq(heartbeatRuns.agentId, agentId), - ) - : eq(heartbeatRuns.companyId, companyId), - ) - .orderBy(desc(heartbeatRuns.createdAt)); - - return limit ? await query.limit(limit) : await query; - }); - return rows.map((row) => { - const { - contextIssueId, - contextTaskId, - contextTaskKey, - contextCommentId, - contextWakeCommentId, - contextWakeReason, - contextWakeSource, - contextWakeTriggerDetail, - resultSummary, - resultResult, - resultMessage, - resultError, - resultTotalCostUsd, - resultCostUsd, - resultCostUsdCamel, - ...rest - } = row as typeof row & { - resultSummary?: string | null; - resultResult?: string | null; - resultMessage?: string | null; - resultError?: string | null; - resultTotalCostUsd?: string | null; - resultCostUsd?: string | null; - resultCostUsdCamel?: string | null; - }; - - return { - ...rest, - contextSnapshot: summarizeHeartbeatRunContextSnapshot({ - issueId: contextIssueId, - taskId: contextTaskId, - taskKey: contextTaskKey, - commentId: contextCommentId, - wakeCommentId: contextWakeCommentId, - wakeReason: contextWakeReason, - wakeSource: contextWakeSource, - wakeTriggerDetail: contextWakeTriggerDetail, - }), - resultJson: - safeForLegacyEncoding || summary - ? null - : summarizeHeartbeatRunListResultJson({ - summary: resultSummary, - result: resultResult, - message: resultMessage, - error: resultError, - totalCostUsd: resultTotalCostUsd, - costUsd: resultCostUsd, - costUsdCamel: resultCostUsdCamel, - }), - }; - }); - }, + list: listRuns, getRun, @@ -26654,125 +14005,15 @@ export function heartbeatService( getRunLogAccess, - getRuntimeState: async (agentId: string) => { - const state = await getRuntimeState(agentId); - const agent = await getAgent(agentId); - if (!agent) return null; - const ensured = state ?? (await ensureRuntimeState(agent)); - const latestTaskSession = await db - .select() - .from(agentTaskSessions) - .where( - and( - eq(agentTaskSessions.companyId, agent.companyId), - eq(agentTaskSessions.agentId, agent.id), - ), - ) - .orderBy(desc(agentTaskSessions.updatedAt)) - .limit(1) - .then((rows) => rows[0] ?? null); - return { - ...ensured, - sessionDisplayId: - latestTaskSession?.sessionDisplayId ?? ensured.sessionId, - sessionParamsJson: latestTaskSession?.sessionParamsJson ?? null, - }; - }, + getRuntimeState: getRuntimeStateWithSessions, - listTaskSessions: async (agentId: string) => { - const agent = await getAgent(agentId); - if (!agent) throw notFound("Agent not found"); + listTaskSessions, - return db - .select() - .from(agentTaskSessions) - .where( - and( - eq(agentTaskSessions.companyId, agent.companyId), - eq(agentTaskSessions.agentId, agentId), - ), - ) - .orderBy( - desc(agentTaskSessions.updatedAt), - desc(agentTaskSessions.createdAt), - ); - }, + resetRuntimeSession, - resetRuntimeSession: async ( - agentId: string, - opts?: { taskKey?: string | null }, - ) => { - const agent = await getAgent(agentId); - if (!agent) throw notFound("Agent not found"); - await ensureRuntimeState(agent); - const taskKey = readNonEmptyString(opts?.taskKey); - const clearedTaskSessions = await clearTaskSessions( - agent.companyId, - agent.id, - taskKey - ? { - taskKey, - adapterType: agent.adapterType, - includeIssueAliases: true, - } - : undefined, - ); - const runtimePatch: Partial = { - sessionId: null, - lastError: null, - updatedAt: new Date(), - }; - if (!taskKey) { - runtimePatch.stateJson = {}; - } + listEvents, - const updated = await db - .update(agentRuntimeState) - .set(runtimePatch) - .where(eq(agentRuntimeState.agentId, agentId)) - .returning() - .then((rows) => rows[0] ?? null); - - if (!updated) return null; - return { - ...updated, - sessionDisplayId: null, - sessionParamsJson: null, - clearedTaskSessions, - }; - }, - - listEvents: (runId: string, afterSeq = 0, limit = 200) => - db - .select() - .from(heartbeatRunEvents) - .where( - and( - eq(heartbeatRunEvents.runId, runId), - gt(heartbeatRunEvents.seq, afterSeq), - ), - ) - .orderBy(asc(heartbeatRunEvents.seq)) - .limit(Math.max(1, Math.min(limit, 1000))), - - getRetryExhaustedReason: async (runId: string) => { - const row = await db - .select({ - message: heartbeatRunEvents.message, - }) - .from(heartbeatRunEvents) - .where( - and( - eq(heartbeatRunEvents.runId, runId), - eq(heartbeatRunEvents.eventType, "lifecycle"), - sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`, - ), - ) - .orderBy(desc(heartbeatRunEvents.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - return row?.message ?? null; - }, + getRetryExhaustedReason, readLog: async ( runOrLookup: @@ -26860,23 +14101,7 @@ export function heartbeatService( resumeQueuedRuns, - scheduleBoundedRetry: async ( - runId: string, - opts?: { - now?: Date; - random?: () => number; - retryReason?: string; - wakeReason?: string; - maxAttempts?: number; - delayMs?: number; - }, - ) => { - const run = await getRun(runId, { unsafeFullResultJson: true }); - if (!run) return { outcome: "missing_run" as const }; - const agent = await getAgent(run.agentId); - if (!agent) return { outcome: "missing_agent" as const }; - return scheduleBoundedRetryForRun(run, agent, opts); - }, + scheduleBoundedRetry, reconcileStrandedAssignedIssues, recoverPendingSessionGoalActions, @@ -27006,43 +14231,10 @@ export function heartbeatService( cancelBudgetScopeWork, - getRunIssueSummary: async (runId: string) => { - const [run] = await db - .select(heartbeatRunIssueSummaryColumns) - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, runId)) - .limit(1); - return run ?? null; - }, + getRunIssueSummary, - getActiveRunForAgent: async (agentId: string) => { - const [run] = await db - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.agentId, agentId), - eq(heartbeatRuns.status, "running"), - ), - ) - .orderBy(desc(heartbeatRuns.startedAt)) - .limit(1); - return run ?? null; - }, + getActiveRunForAgent, - getActiveRunIssueSummaryForAgent: async (agentId: string) => { - const [run] = await db - .select(heartbeatRunIssueSummaryColumns) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.agentId, agentId), - eq(heartbeatRuns.status, "running"), - ), - ) - .orderBy(desc(heartbeatRuns.startedAt)) - .limit(1); - return run ?? null; - }, + getActiveRunIssueSummaryForAgent, }; } diff --git a/server/src/services/heartbeat/queue.test.ts b/server/src/services/heartbeat/queue.test.ts new file mode 100644 index 00000000000..c3139de1f8b --- /dev/null +++ b/server/src/services/heartbeat/queue.test.ts @@ -0,0 +1,186 @@ +import type { Db } from "@paperclipai/db"; +import { agents, agentWakeupRequests, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues } from "@paperclipai/db"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { createRunDispatch } from "../../modules/run-dispatch/index.js"; +import type { createWakeQueue } from "../../modules/wake-queue/index.js"; +import { createHeartbeatRunState } from "./run-state.js"; +import { createHeartbeatRunPreparation } from "./run-preparation.js"; +import { createHeartbeatQueue, type HeartbeatQueueDependencies } from "./queue.js"; + +type Run = typeof heartbeatRuns.$inferSelect; +function callbacks(db: Db) { + const state = createHeartbeatRunState(db); + const preparation = createHeartbeatRunPreparation(db); + return { + ...preparation, + ...state, + getAgent: vi.fn(async () => { throw new Error("Unexpected agent lookup"); }), + options: {}, + setRunStatus: vi.fn(async (id, status, patch) => + db.update(heartbeatRuns).set({ ...patch, status }).where(eq(heartbeatRuns.id, id)).returning().then(rows => rows[0] ?? null)), + setWakeupStatus: vi.fn(async () => undefined), + appendRunEvent: vi.fn(async () => undefined), + finalizeAgentStatus: vi.fn(async () => undefined), + getAgentInvokability: vi.fn(async () => ({ invokable: true })), + getWorktreeExecutionCutoff: vi.fn(async () => null), + applyRunDispatchPostCommitEffects: vi.fn(), + issuesSvc: { + getDependencyReadiness: vi.fn(), + listDependencyReadiness: vi.fn(async () => new Map()), + }, + treeControlSvc: { getActivePauseHoldGate: vi.fn(async () => null) }, + budgets: { getInvocationBlock: vi.fn(async () => null) }, + instanceSettings: { getExperimental: vi.fn() }, + runDispatch: createRunDispatch(db), + wakeQueue: { + createAdmissionTransactionScope: vi.fn["createAdmissionTransactionScope"]>(), + admitWakeBehindIssueExecution: vi.fn["admitWakeBehindIssueExecution"]>(), + }, + activeRunExecutions: new Set(), + activeRunExecutionPromises: new Set>(), + activeWakeupPromises: new Set>(), + liveRunExecutions: { has: vi.fn(() => false) }, + isHeartbeatRunTerminalStatus: (status: string | null | undefined) => ["succeeded", "failed", "cancelled", "timed_out"].includes(status ?? ""), + isSameTaskScope: (left: string | null, right: string | null) => left === right, + runTaskKey: (run: Run) => typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null, + filterZombieCoalesceTarget: (run: T | null) => run, + getSchedulingSuppression: vi.fn(async () => ({ suppressed: false, reason: null } as Awaited>)), + executeRun: vi.fn(async () => undefined), + releaseIssueExecutionAndPromote: vi.fn(async () => undefined), + publishRunLifecyclePluginEvent: vi.fn(), + cancelRunInternal: vi.fn(async () => undefined), + cancelActiveForAgentInternal: vi.fn(async () => undefined), + resumeExecutionWaitComments: vi.fn(async () => undefined), + resumeQueuedCommentInterrupt: vi.fn(async () => undefined), + resumeSavedLegacyComments: vi.fn(async () => undefined), + formatIssueIdentifierLink: (_identifier: string | null, fallback: string) => fallback, + sweepPendingCleanupLeases: vi.fn(), + } satisfies HeartbeatQueueDependencies; +} +function guardedDatabase() { + const access = vi.fn(() => { throw new Error("Unexpected queue database access"); }); + return { db: new Proxy({}, { get: access }) as Db, access }; +} + +describe("heartbeat queue module boundary", () => { + it("constructs without querying, dispatching, or registering background work", () => { + const database = guardedDatabase(); + const deps = callbacks(database.db); + createHeartbeatQueue(database.db, deps); + expect(database.access).not.toHaveBeenCalled(); + for (const callback of Object.values(deps)) if (vi.isMockFunction(callback)) expect(callback).not.toHaveBeenCalled(); + expect(deps.activeRunExecutionPromises.size).toBe(0); + expect(deps.activeWakeupPromises.size).toBe(0); + }); + + it.each(["startNextQueuedRunForAgent", "resumeQueuedRuns"] as const)("honors suppression before %s reads or resumes work", async (operation) => { + const database = guardedDatabase(); + const deps = callbacks(database.db); + deps.getSchedulingSuppression.mockResolvedValue({ suppressed: true, reason: "task_drain" }); + const queue = createHeartbeatQueue(database.db, deps); + await queue[operation]("agent"); + expect(database.access).not.toHaveBeenCalled(); + expect(deps.getAgent).not.toHaveBeenCalled(); + expect(deps.resumeExecutionWaitComments).not.toHaveBeenCalled(); + expect(deps.executeRun).not.toHaveBeenCalled(); + }); + + it("tracks pending wakes across instances and clears each rejected wake", async () => { + const database = guardedDatabase(); + const deps = callbacks(database.db); + let rejectFirst!: (reason: Error) => void; + let rejectSecond!: (reason: Error) => void; + deps.getAgent.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject; })); + deps.getAgent.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSecond = reject; })); + const first = createHeartbeatQueue(database.db, deps).trackWakeup("first"); + const second = createHeartbeatQueue(database.db, deps).trackWakeup("second"); + const firstCheck = expect(first).rejects.toThrow("first lookup failed"); + const secondCheck = expect(second).rejects.toThrow("second lookup failed"); + expect(deps.activeWakeupPromises).toEqual(new Set([first, second])); + rejectFirst(new Error("first lookup failed")); + await firstCheck; + await Promise.resolve(); + expect(deps.activeWakeupPromises).toEqual(new Set([second])); + rejectSecond(new Error("second lookup failed")); + await secondCheck; + await Promise.resolve(); + expect(deps.activeWakeupPromises.size).toBe(0); + expect(deps.activeRunExecutionPromises.size).toBe(0); + expect(database.access).not.toHaveBeenCalled(); + }); + + it("uses the same policy limits for dispatch and execution", () => { + const database = guardedDatabase(); + const queue = createHeartbeatQueue(database.db, callbacks(database.db)); + const agent = { adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot" }, runtimeConfig: { heartbeat: { maxConcurrentRuns: 20, maxDailyRuns: 3.8 } } } as unknown as typeof agents.$inferSelect; + expect(queue.parseHeartbeatPolicy(agent)).toMatchObject({ maxConcurrentRuns: 1, maxDailyRuns: 3 }); + expect(queue.parseHeartbeatPolicy({ ...agent, adapterConfig: { provider: "codex" } })).toMatchObject({ maxConcurrentRuns: 20, maxDailyRuns: 3 }); + expect(database.access).not.toHaveBeenCalled(); + }); +}); + +const support = await getEmbeddedPostgresTestSupport(); +if (!support.supported) console.warn(`Skipping queue module database tests: ${support.reason}`); +describe.skipIf(!support.supported)("heartbeat queue database wiring", () => { + let database: Awaited>; + let db: Db; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-queue-module"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { + await db?.$client.end(); + await database?.cleanup(); + }); + beforeEach(async () => { + await db.execute(sql`truncate table companies restart identity cascade`); + }); + + async function fixture(status = "running") { + const [company] = await db.insert(companies).values({ name: "Queue module", issuePrefix: "QM" }).returning(); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Queue agent", role: "engineer", status: "idle", adapterType: "process", adapterConfig: {} }).returning(); + const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Queue task", status: "todo", assigneeAgentId: agent.id }).returning(); + const [wake] = await db.insert(agentWakeupRequests).values({ companyId: company.id, agentId: agent.id, source: "assignment", status: "claimed", claimedAt: new Date() }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status, invocationSource: "assignment", wakeupRequestId: wake.id, startedAt: status === "running" ? new Date() : null, contextSnapshot: { issueId: issue.id } }).returning(); + await db.update(issues).set({ executionRunId: run.id, executionAgentNameKey: agent.name, executionLockedAt: new Date() }).where(eq(issues.id, issue.id)); + return { company, agent, issue, wake, run }; + } + + it.each(["claimed", "cancelled"] as const)("releases a running claim while preserving a %s wake", async (wakeStatus) => { + const { run, issue, wake } = await fixture(); + await db.update(agentWakeupRequests).set({ status: wakeStatus }).where(eq(agentWakeupRequests.id, wake.id)); + await createHeartbeatQueue(db, callbacks(db)).releaseRunClaimedJustBeforeSuppression(run.id); + const [released] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)); + const [releasedIssue] = await db.select().from(issues).where(eq(issues.id, issue.id)); + const [releasedWake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id)); + expect(released).toMatchObject({ status: "queued", startedAt: null, responsibleUserId: null }); + expect(releasedIssue).toMatchObject({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null }); + expect(releasedWake.status).toBe(wakeStatus === "cancelled" ? "cancelled" : "queued"); + if (wakeStatus === "claimed") expect(releasedWake.claimedAt).toBeNull(); + }); + + it("preserves a newer execution owner when releasing an old claim", async () => { + const { run, issue, agent, company } = await fixture(); + const [newOwner] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", invocationSource: "assignment" }).returning(); + await db.update(issues).set({ executionRunId: newOwner.id }).where(eq(issues.id, issue.id)); + await createHeartbeatQueue(db, callbacks(db)).releaseRunClaimedJustBeforeSuppression(run.id); + const [current] = await db.select().from(issues).where(eq(issues.id, issue.id)); + expect(current.executionRunId).toBe(newOwner.id); + }); + + it("cancels at the daily run cap before claiming or dispatching", async () => { + const { run, agent } = await fixture("queued"); + const cappedAgent = { ...agent, runtimeConfig: { heartbeat: { maxDailyRuns: 0 } } }; + const deps = callbacks(db); + deps.getAgent.mockResolvedValue(cappedAgent); + expect(await createHeartbeatQueue(db, deps).claimQueuedRun(run)).toBeNull(); + const [cancelled] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)); + expect(cancelled).toMatchObject({ status: "cancelled", errorCode: "heartbeat.daily_run_limit" }); + expect(deps.setWakeupStatus).toHaveBeenCalledWith(run.wakeupRequestId, "skipped", expect.any(Object)); + expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(cancelled, { suppressImmediateRecovery: true }); + expect(deps.treeControlSvc.getActivePauseHoldGate).not.toHaveBeenCalled(); + expect(deps.executeRun).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/heartbeat/queue.ts b/server/src/services/heartbeat/queue.ts new file mode 100644 index 00000000000..04e51012bfe --- /dev/null +++ b/server/src/services/heartbeat/queue.ts @@ -0,0 +1,4576 @@ +import { normalizeAgentNameKey } from "./retries.js"; +import { + type WakeupOptions, + mergeCoalescedContextSnapshot, + enrichWakeContextSnapshot, + hasInteractionContinuationWakeContext, + isInteractionResolutionWakePayload, +} from "./run-preparation.js"; +import { resolveExecutionWorkspaceReuseRequestForIssue } from "./workspaces.js"; +import { + CHAT_COMPLETION_WAKE_REASON, + isCompletedOnboardingHandoffWake, +} from "../chat-completion-delivery.js"; +import { + compareCents, + hasWorkspaceRestoreFailure, +} from "@paperclipai/shared"; +import { nonIdleSlackIssueCondition } from "../slack-conversation-state.js"; +import { readQueuedInteractionResponse } from "../queued-interaction-response.js"; +import { + isConversation, + isConversationExecutionWake, + isWaitingConversation, +} from "../agent-conversations.js"; +import { legacyControllerClaim } from "../legacy-controller-lease.js"; +import { + admitExplicitNativeContinuation, + undeliveredLegacyUserCommentIds, +} from "../explicit-native-continuation.js"; +import { canRetryStoppedRun } from "../cancelled-native-startup.js"; +import { getExecutionBlocker } from "../execution-blocker.js"; +import { isConversationAdapter } from "../conversation-continuation.js"; +import { recordExecutionWait } from "../execution-wait.js"; +import { + getNativeReviewAssignment, + readNativeReviewAssignmentContext, +} from "../native-runtime/native-review-participant.js"; +import { claimQueuedNativeReviewRun } from "../native-runtime/native-review-dispatch.js"; +import { adapterExecutionControls } from "../adapter-execution-control.js"; +import { executionFailureRetryCount } from "../execution-recovery-attempt.js"; +import { + assertDurableChatWakeupReceipt, + assertDurableChatWakeupRequest, + authorizeFailedChatRunRetryWake, + FailedChatRunRetryAuthorizationError, + unadmittedChatWakeupCondition, +} from "../durable-chat-wakeup.js"; +import { agentService } from "../agents.js"; +import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils"; +import { randomUUID } from "node:crypto"; +import { + and, + asc, + desc, + eq, + gt, + gte, + inArray, + isNull, + lt, + lte, + ne, + notInArray, + or, + sql, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS } from "@paperclipai/shared"; +import { + agents, + agentWakeupRequests, + activityLog, + chatConversations, + chatEndpoints, + companies, + costEvents, + environmentLeases, + executionWorkspaces, + heartbeatRunEvents, + heartbeatRuns, + issueComments, + issueRecoveryActions, + issueRelations, + issues, + nativeRunFinalizations, +} from "@paperclipai/db"; +import { + conflict, + HttpError, + notFound, +} from "../../errors.js"; +import { logger } from "../../middleware/logger.js"; +import { publishLiveEvent } from "../live-events.js"; +import { allocateHeartbeatRunEventSeq } from "../heartbeat-run-events.js"; +import { + queuedCommentIdsFromWakePayload, + queuedCommentIdsFromRunContext, + withQueuedCommentIdsInWakePayload, + withQueuedCommentIdsInRunContext, +} from "../issue-queued-comment-queue.js"; +import { + parseObject, + asBoolean, + asNumber, +} from "../../adapters/utils.js"; +import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js"; +import { emitAgentTaskRun } from "../agent-task-run-telemetry.js"; +import { + CHAT_CONTROL_RECOVERY_ADMISSION_KEY, + CHAT_CONTROL_RECOVERY_STOP_CODE, + CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE, + chatControlRecoveryAdmission, + readChatControlRecoveryAdmission, + readChatControlNativeParent, + readChatControlRecoveryStop, +} from "../chat-control-recovery-stop.js"; +import { + ISSUE_NEW_INPUT_ACTIVITY_ACTIONS, + ISSUE_PROGRESS_ACTIVITY_ACTIONS, + ISSUE_REWAKE_LOOKBACK_MS, + ISSUE_REWAKE_RUN_SAMPLE_LIMIT, + evaluateIssueRewakeThrottle, + isThrottleCandidateIssueRewake, +} from "../issue-rewake-throttle.js"; +import { logActivity } from "../activity-log.js"; +import { ISSUE_BLOCKERS_RESOLVED_WAKE_REASON } from "../issue-dependency-wakeups.js"; +import { + ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS, + isVerifiedIssueTreeControlInteractionWake, +} from "../issue-tree-control.js"; +import { + buildExecutionWorkspaceAdapterConfig, + isUnrunnableWorktreeCombo, + parseIssueExecutionWorkspaceSettings, + resolveEffectiveWorkspaceStrategyType, + resolveExecutionWorkspaceMode, + WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, + WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, + WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, +} from "../execution-workspace-policy.js"; +import { readContinuationAttempt } from "../recovery/index.js"; +import { allowsIssueInteractionWake } from "../../modules/run-dispatch/index.js"; +import { withAgentStartLock } from "../agent-start-lock.js"; +import { + evaluateAgentInvokability, + shouldCancelRunsForNonInvokableAgent, + type AgentOrgRow, +} from "../agent-invokability.js"; +import { isHeartbeatWakeOnDemandEnabled } from "../heartbeat-policy.js"; +import { isUuidLike } from "@paperclipai/shared"; +import { retryChatControlAdmission } from "../chat-control-admission-retry.js"; + +import type { HeartbeatServiceOptions } from "../heartbeat.js"; +import type { createHeartbeatRunState } from "./run-state.js"; +import type { createHeartbeatRunPreparation } from "./run-preparation.js"; +import type { HeartbeatRetryDependencies } from "./retries.js"; +import type { HeartbeatRecoveryDependencies, createHeartbeatRecovery } from "./recovery.js"; +import type { issueService } from "../issues.js"; +import type { issueTreeControlService } from "../issue-tree-control.js"; +import type { budgetService } from "../budgets.js"; +import type { instanceSettingsService } from "../instance-settings.js"; +import type { createRunDispatch } from "../../modules/run-dispatch/index.js"; +import type { createWakeQueue } from "../../modules/wake-queue/index.js"; + +type HeartbeatRun = typeof heartbeatRuns.$inferSelect; +type HeartbeatRunState = ReturnType; +type RunPreparation = ReturnType; + +/** Queue policy uses the service's lifecycle effects and process-wide ownership sets. */ +export interface HeartbeatQueueDependencies extends Pick, + Pick, + Pick, + Pick { + sweepPendingCleanupLeases: ReturnType["sweepPendingCleanupLeases"]; + options: Pick; + issuesSvc: Pick, "getDependencyReadiness" | "listDependencyReadiness">; + treeControlSvc: Pick, "getActivePauseHoldGate">; + budgets: Pick, "getInvocationBlock">; + instanceSettings: Pick, "getExperimental">; + runDispatch: Pick, "cancelStaleQueuedRun">; + wakeQueue: Pick, "createAdmissionTransactionScope" | "admitWakeBehindIssueExecution">; + activeRunExecutions: Set; + activeRunExecutionPromises: Set>; + activeWakeupPromises: Set>; + liveRunExecutions: { has(id: string): boolean }; + isHeartbeatRunTerminalStatus: (status: string | null | undefined) => boolean; + isSameTaskScope: (left: string | null, right: string | null) => boolean; + runTaskKey: (run: HeartbeatRun) => string | null; + filterZombieCoalesceTarget: (run: T | null, live: { has(id: string): boolean }) => T | null; + getSchedulingSuppression: () => Promise<{ suppressed: boolean; reason: "worktree_instance" | "database_restore_in_progress" | "task_drain" | null }>; + executeRun: (runId: string) => Promise; + releaseIssueExecutionAndPromote: (run: Pick, options?: { suppressImmediateRecovery?: boolean }) => Promise; + publishRunLifecyclePluginEvent: (run: HeartbeatRun) => void; + cancelRunInternal: (runId: string, reason?: string, options?: { errorCode?: string }) => Promise; + cancelActiveForAgentInternal: (agentId: string, reason: string) => Promise; + resumeExecutionWaitComments: () => Promise; + resumeQueuedCommentInterrupt: (companyId: string, queueId: string) => Promise; + resumeSavedLegacyComments: (companyId: string, queueId: string) => Promise; + formatIssueIdentifierLink: (identifier: string | null, fallback: string) => string; +} + +const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + +const HEARTBEAT_MAX_CONCURRENT_RUNS_MIN = 1; + +const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 50; + +export const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; + +export const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = [ + "queued", + "running", + "scheduled_retry", +] as const; + +const TIMER_ACTIONABLE_ISSUE_STATUSES = ["todo", "in_progress"] as const; + +export class ChatControlRecoveryUnresolvedError extends Error { + constructor() { + super( + "Run admission could not acquire its database locks after bounded retries. No provider work started. Review database contention and send a fresh request; this attempt will not automatically retry.", + ); + } +} + +const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set([ + CHAT_COMPLETION_WAKE_REASON, + "approval_approved", + ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, + "issue_recovery_action_restored", +]); + +function normalizeMaxConcurrentRuns(value: unknown) { + const parsed = Math.floor( + asNumber(value, HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT), + ); + if (!Number.isFinite(parsed)) return HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT; + return Math.max( + HEARTBEAT_MAX_CONCURRENT_RUNS_MIN, + Math.min(HEARTBEAT_MAX_CONCURRENT_RUNS_MAX, parsed), + ); +} + +export function shouldRequireIssueCommentForWake( + contextSnapshot: Record | null | undefined, +) { + if (contextSnapshot?.skipIssueComment === true) return false; + + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); + return ( + wakeReason === "issue_assigned" || + wakeReason === "execution_review_requested" || + wakeReason === "execution_approval_requested" || + wakeReason === "execution_changes_requested" + ); +} + +async function listUnresolvedBlockerSummaries( + dbOrTx: Pick, + companyId: string, + issueId: string, + unresolvedBlockerIssueIds: string[], +) { + const ids = [...new Set(unresolvedBlockerIssueIds.filter(Boolean))]; + if (ids.length === 0) return []; + return dbOrTx + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.companyId, companyId), + eq(issueRelations.type, "blocks"), + eq(issueRelations.relatedIssueId, issueId), + inArray(issues.id, ids), + ), + ) + .orderBy(asc(issues.title)); +} + +export function shouldDeferFollowupWakeForSameIssue(input: { + activeRunStatus: string | null | undefined; + isSameExecutionAgent: boolean; + wakeCommentId: string | null | undefined; + forceFreshSession: boolean; +}) { + // A comment follow-up or explicit fresh-session wake needs a new run boundary. + if (!input.isSameExecutionAgent) return false; + if (input.activeRunStatus !== "running") return false; + if (input.wakeCommentId) return true; + if (input.forceFreshSession) return true; + return false; +} + +export function shouldQueueFollowupForRunningIssueWake(input: { + contextSnapshot: Record | null | undefined; + wakeCommentId: string | null; +}) { + if (input.wakeCommentId) return true; + // A structured interaction response is new input just like a comment. It + // must run after the turn that created the interaction instead of being + // merged into that still-running turn. + if ( + readNonEmptyString(input.contextSnapshot?.interactionId) && + readNonEmptyString(input.contextSnapshot?.interactionStatus) + ) { + return true; + } + const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason); + if (wakeReason === "issue_children_completed" && ( + input.contextSnapshot?.onboardingCompletion === true || + (input.contextSnapshot?.statusDecisionSource === "native_status_decision" && + readNonEmptyString(input.contextSnapshot?.nativeChildCompletionDecisionId)) + )) return true; + return Boolean( + wakeReason && RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP.has(wakeReason), + ); +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** Queue admission and dispatch share the service lifecycle callbacks and execution ownership. */ +export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDependencies) { + const { + setRunStatus, + setWakeupStatus, + appendRunEvent, + releaseIssueExecutionAndPromote, + issuesSvc, + options, + publishRunLifecyclePluginEvent, + finalizeAgentStatus, + getAgent, + cancelRunInternal, + toAgentOrgRow, + getAgentInvokability, + budgets, + activeRunExecutions, + treeControlSvc, + runDispatch, + applyRunDispatchPostCommitEffects, + resolveResponsibleUserIdForRun, + getIssueExecutionContext, + isHeartbeatRunTerminalStatus, + liveRunExecutions, + getSchedulingSuppression, + resumeExecutionWaitComments, + getWorktreeExecutionCutoff, + resumeQueuedCommentInterrupt, + resumeSavedLegacyComments, + cancelActiveForAgentInternal, + listCompanyAgentOrgRows, + executeRun, + activeRunExecutionPromises, + activeWakeupPromises, + instanceSettings, + getRun, + sweepPendingCleanupLeases, + resolveExplicitResumeSessionOverride, + resolveSessionBeforeForWakeup, + hasResolvablePriorSessionWorkspaceForWake, + getRoutineEnvForExecutionIssue, + resolveResponsibleUserIdForRunSeed, + formatIssueIdentifierLink, + wakeQueue, + isSameTaskScope, + runTaskKey, + filterZombieCoalesceTarget, + } = dependencies; + + function parseHeartbeatPolicy(agent: typeof agents.$inferSelect) { + const runtimeConfig = parseObject(agent.runtimeConfig); + const heartbeat = parseObject(runtimeConfig.heartbeat); + + return { + enabled: asBoolean(heartbeat.enabled, false), + intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)), + wakeOnDemand: isHeartbeatWakeOnDemandEnabled(agent), + // A Dot binding has one external turn. Competing assignments must retain + // their queue position instead of claiming a second run that cannot bind. + maxConcurrentRuns: agent.adapterType === "paperclip_runner" && + parseObject(agent.adapterConfig).provider === "openai_dot" + ? 1 : normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns), + skipTimerWhenNoActionableWork: asBoolean( + heartbeat.skipTimerWhenNoActionableWork ?? + heartbeat.requireActionableTimerWork ?? + heartbeat.issueOnlyTimer, + false, + ), + maxDailyRuns: normalizeOptionalNonNegativeInteger( + heartbeat.maxDailyRuns ?? + heartbeat.dailyRunLimit ?? + heartbeat.dailyRunCap ?? + heartbeat.maxRunsPerDay, + ), + maxDailyCostCents: normalizeOptionalNonNegativeInteger( + heartbeat.maxDailyCostCents ?? + heartbeat.dailyCostCentsLimit ?? + heartbeat.dailySpendCentsLimit ?? + heartbeat.dailyBudgetCents, + ), + }; + } + + function normalizeOptionalNonNegativeInteger(value: unknown) { + if (value === null || value === undefined || value === "") return null; + const normalized = Math.floor(asNumber(value, 0)); + return normalized >= 0 ? normalized : null; + } + + function currentUtcDayWindow(now = new Date()) { + const start = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + 0, + 0, + 0, + 0, + ), + ); + const end = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + 1, + 0, + 0, + 0, + 0, + ), + ); + return { start, end }; + } + + async function getHeartbeatDailyCapBlock( + agent: typeof agents.$inferSelect, + policy: ReturnType, + options: { + checkRunCap?: boolean; + checkCostCap?: boolean; + excludeRunId?: string | null; + } = {}, + client: Pick = db, + ) { + const checkRunCap = options.checkRunCap ?? true; + const checkCostCap = options.checkCostCap ?? true; + const { start, end } = currentUtcDayWindow(); + if (checkRunCap && policy.maxDailyRuns !== null) { + const conditions = [ + eq(heartbeatRuns.companyId, agent.companyId), + eq(heartbeatRuns.agentId, agent.id), + gte(heartbeatRuns.startedAt, start), + lt(heartbeatRuns.startedAt, end), + notInArray(heartbeatRuns.status, ["queued", "scheduled_retry"]), + ]; + if (options.excludeRunId) { + conditions.push(sql`${heartbeatRuns.id} <> ${options.excludeRunId}`); + } + const [row] = await client + .select({ total: sql`count(*)::integer` }) + .from(heartbeatRuns) + .where(and(...conditions)); + const observed = Number(row?.total ?? 0); + if (observed >= policy.maxDailyRuns) { + return { + reason: "heartbeat.daily_run_limit", + observed, + limit: policy.maxDailyRuns, + }; + } + } + + if (checkCostCap && policy.maxDailyCostCents !== null) { + const [row] = await client + .select({ + total: sql`coalesce(sum(${costEvents.costCents}), 0)::text`, + }) + .from(costEvents) + .where( + and( + eq(costEvents.companyId, agent.companyId), + eq(costEvents.agentId, agent.id), + gte(costEvents.occurredAt, start), + lt(costEvents.occurredAt, end), + ), + ); + const observed = Number(row?.total ?? 0); + if (compareCents(String(row?.total ?? 0), policy.maxDailyCostCents) >= 0) { + return { + reason: "heartbeat.daily_cost_limit", + observed, + limit: policy.maxDailyCostCents, + }; + } + } + + return null; + } + + async function cancelQueuedRunForHeartbeatDailyCap( + run: typeof heartbeatRuns.$inferSelect, + dailyCapBlock: NonNullable< + Awaited> + >, + ) { + const now = new Date(); + const reason = + "Cancelled because the agent reached a per-day heartbeat budget cap before adapter invocation"; + const cancelled = await setRunStatus(run.id, "cancelled", { + finishedAt: now, + error: reason, + errorCode: dailyCapBlock.reason, + resultJson: { + ...parseObject(run.resultJson), + stopReason: dailyCapBlock.reason, + observed: dailyCapBlock.observed, + limit: dailyCapBlock.limit, + effectiveTimeoutSec: 0, + timeoutConfigured: false, + timeoutSource: "heartbeat_daily_cap_gate", + timeoutFired: false, + }, + }); + if (!cancelled) return null; + + await setWakeupStatus(run.wakeupRequestId, "skipped", { + finishedAt: now, + error: reason, + }); + + await appendRunEvent(cancelled, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: reason, + payload: { + reason: dailyCapBlock.reason, + observed: dailyCapBlock.observed, + limit: dailyCapBlock.limit, + }, + }); + + await releaseIssueExecutionAndPromote(cancelled, { + suppressImmediateRecovery: true, + }); + + return cancelled; + } + + async function hasActionableTimerWork(agent: typeof agents.$inferSelect) { + const row = await db + .select({ id: issues.id }) + .from(issues) + .where( + and( + eq(issues.companyId, agent.companyId), + eq(issues.assigneeAgentId, agent.id), + isNull(issues.assigneeUserId), + isNull(issues.hiddenAt), + inArray(issues.status, [...TIMER_ACTIONABLE_ISSUE_STATUSES]), + isNull(issues.conversationAgentId), + nonIdleSlackIssueCondition(), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + return Boolean(row); + } + + async function markTimerHeartbeatChecked( + agentId: string, + source: WakeupOptions["source"], + ) { + if (source !== "timer") return; + await db + .update(agents) + .set({ + lastHeartbeatAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(agents.id, agentId)); + } + + async function claimDueTimerHeartbeat( + agent: typeof agents.$inferSelect, + now: Date, + intervalSec: number, + ) { + const dueBefore = new Date(now.getTime() - intervalSec * 1000); + const claimed = await db + .update(agents) + .set({ + lastHeartbeatAt: now, + updatedAt: now, + }) + .where( + and( + eq(agents.id, agent.id), + eq(agents.companyId, agent.companyId), + or( + lte(agents.lastHeartbeatAt, dueBefore), + and( + isNull(agents.lastHeartbeatAt), + lte(agents.createdAt, dueBefore), + ), + ), + ), + ) + .returning({ id: agents.id }) + .then((rows) => rows[0] ?? null); + if (!claimed) return null; + return { wasFirstHeartbeat: !agent.lastHeartbeatAt }; + } + + function issueRunPriorityRank(priority: string | null | undefined) { + switch (priority) { + case "critical": + return 0; + case "high": + return 1; + case "medium": + return 2; + case "low": + return 3; + default: + return 4; + } + } + + async function listQueuedRunDependencyReadiness( + companyId: string, + queuedRuns: Array, + ) { + const issueIds = [ + ...new Set( + queuedRuns + .map((run) => + readNonEmptyString(parseObject(run.contextSnapshot).issueId), + ) + .filter((issueId): issueId is string => Boolean(issueId)), + ), + ]; + if (issueIds.length === 0) { + return new Map< + string, + Awaited> + >(); + } + return issuesSvc.listDependencyReadiness(companyId, issueIds); + } + + async function countRunningRunsForAgent(agentId: string) { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.status, "running"), + ), + ); + return Number(count ?? 0); + } + + async function withChatControlRecoveryGate( + run: typeof heartbeatRuns.$inferSelect, + stage: "claim" | "dispatch", + onClear: (tx: Db) => Promise, + nativeRecovery = false, + ) { + const issueId = readNonEmptyString( + parseObject(run.contextSnapshot).issueId, + ); + if (!issueId || run.invocationSource !== "automation") return onClear(db); + await options.beforeChatControlRecoveryCheck?.({ + runId: run.id, + issueId, + stage, + }); + if (stage === "dispatch" && nativeRecovery) { + // Protected historical/already-admitted owners did not previously take + // this fence's locks. A fresh exact read keeps unrelated Board contention + // from turning their recovery into a new admission failure. + const [current] = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, run.id), + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.agentId, run.agentId), + eq(heartbeatRuns.status, "running"), + ), + ) + .limit(1); + if ( + current && + current.wakeupRequestId === run.wakeupRequestId && + parseObject(current.contextSnapshot).issueId === issueId + ) { + const admission = readChatControlRecoveryAdmission(current); + const expected = readChatControlRecoveryAdmission(run); + if ( + expected !== "invalid" && + (admission === "admitted" || + (admission === "historical" && expected === "historical")) + ) + return onClear(db); + } + } + let terminal: typeof heartbeatRuns.$inferSelect | null = null; + try { + const attempt = () => db.transaction(async (tx) => { + terminal = null; + // Same queue-edit lock order, then the close committer's conversation + // row. NOWAIT releases partial locks on contention. Claim defers to the + // queue; dispatch retries this transaction before considering failure. + const [issue] = await tx + .select({ id: issues.id }) + .from(issues) + .where( + and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)), + ) + .for("update", { noWait: true }) + .limit(1); + if (!issue) return null; + if (run.wakeupRequestId) + await tx + .select({ id: agentWakeupRequests.id }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.id, run.wakeupRequestId), + eq(agentWakeupRequests.companyId, run.companyId), + eq(agentWakeupRequests.agentId, run.agentId), + ), + ) + .for("update", { noWait: true }) + .limit(1); + const [current] = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, run.id), + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.agentId, run.agentId), + ), + ) + .for("update", { noWait: true }) + .limit(1); + if ( + !current || + current.status !== (stage === "claim" ? "queued" : "running") + ) + return null; + const admission = readChatControlRecoveryAdmission(current); + const expectedAdmission = readChatControlRecoveryAdmission(run); + const admissionLost = + expectedAdmission !== "historical" && admission === "historical"; + if ( + stage === "dispatch" && + nativeRecovery && + !admissionLost && + (admission === "historical" || admission === "admitted") + ) + return onClear(tx as unknown as Db); + // A new warm child can inherit a prior runner's PID during preparation. + // Those fields do not prove this run's turn was dispatched. + // Exact admitted/historical recovery retains its existing ownership; + // a marked, unadmitted bootstrap must earn admission even after crash. + const proof = + admission === "invalid" || admissionLost + ? { kind: "unresolved" as const } + : await readChatControlRecoveryStop( + tx as unknown as Db, + { + companyId: run.companyId, + issueId, + agentId: run.agentId, + sourceRunId: run.id, + }, + true, + ); + if (proof.kind === "clear") { + if (stage === "claim" || admission === "required") + await tx + .update(heartbeatRuns) + .set({ + runnerProfileJson: { + ...parseObject(current.runnerProfileJson), + [CHAT_CONTROL_RECOVERY_ADMISSION_KEY]: + chatControlRecoveryAdmission( + current, + stage === "dispatch" + ? "admitted" + : admission === "admitted" + ? "admitted" + : "required", + ), + }, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, current.id)); + return onClear(tx as unknown as Db); + } + if (proof.kind === "unresolved" && stage === "claim") return null; + const now = new Date(); + const stopped = proof.kind === "stopped"; + const code = stopped + ? CHAT_CONTROL_RECOVERY_STOP_CODE + : CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE; + const error = stopped + ? "Automatic continuation stopped by the committed chat conversation close. Send a new request in chat or on the Board to start fresh work." + : "Automatic continuation source could not be verified before provider admission. Review the task and send a fresh request; this attempt will not automatically retry."; + [terminal] = await tx + .update(heartbeatRuns) + .set({ + status: stopped ? "cancelled" : "failed", + errorCode: code, + error, + finishedAt: now, + resultJson: { + ...parseObject(current.resultJson), + automaticRecovery: { + code, + providerDispatched: false, + ...(stopped + ? { + sourceRunId: proof.sourceRunId, + conversationId: proof.conversationId, + publicationId: proof.publicationId, + } + : {}), + }, + }, + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, current.id), + eq(heartbeatRuns.status, current.status), + ), + ) + .returning(); + if (!terminal) return null; + if (current.wakeupRequestId) + await tx + .update(agentWakeupRequests) + .set({ + status: stopped ? "skipped" : "failed", + error, + finishedAt: now, + updatedAt: now, + }) + .where( + and( + eq(agentWakeupRequests.id, current.wakeupRequestId), + eq(agentWakeupRequests.companyId, current.companyId), + eq(agentWakeupRequests.agentId, current.agentId), + eq(agentWakeupRequests.runId, current.id), + ne(agentWakeupRequests.status, "cancelled"), + ), + ); + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.companyId, current.companyId), + eq(issues.id, issueId), + eq(issues.executionRunId, current.id), + ), + ); + return null; + }); + const result = stage === "dispatch" + ? await retryChatControlAdmission(attempt) + : await attempt(); + if (terminal) { + const settled = terminal as typeof heartbeatRuns.$inferSelect; + publishLiveEvent({ + companyId: settled.companyId, + type: "heartbeat.run.status", + payload: { + runId: settled.id, + agentId: settled.agentId, + status: settled.status, + errorCode: settled.errorCode, + error: settled.error, + }, + }); + publishRunLifecyclePluginEvent(settled); + if (stage === "dispatch") + await finalizeAgentStatus(settled.agentId, "cancelled"); + } + return result; + } catch (error) { + if (!isExternalChatWaitAuthorizationContention(error)) throw error; + if (stage === "dispatch") { + // No effect was admitted. Let the existing setup-failure path settle + // this attempt distinctly; it must not become a successful close. + throw new ChatControlRecoveryUnresolvedError(); + } + return null; + } + } + + async function claimQueuedRun( + run: typeof heartbeatRuns.$inferSelect, + companyAgents?: AgentOrgRow[], + ) { + if (run.status !== "queued") return run; + const agent = await getAgent(run.agentId); + if (!agent) { + await cancelRunInternal( + run.id, + "Cancelled because the agent no longer exists", + ); + return null; + } + const invokability = companyAgents + ? evaluateAgentInvokability(toAgentOrgRow(agent), companyAgents) + : await getAgentInvokability(agent); + if (!invokability.invokable) { + await cancelRunInternal( + run.id, + `Cancelled because the agent is not invokable: ${invokability.reason}`, + ); + return null; + } + + const context = parseObject(run.contextSnapshot); + const budgetBlock = await budgets.getInvocationBlock( + run.companyId, + run.agentId, + { + issueId: readNonEmptyString(context.issueId), + projectId: readNonEmptyString(context.projectId), + }, + ); + if (budgetBlock) { + await cancelRunInternal(run.id, budgetBlock.reason); + return null; + } + + const dailyCapBlock = await getHeartbeatDailyCapBlock( + agent, + parseHeartbeatPolicy(agent), + { + excludeRunId: run.id, + checkRunCap: true, + checkCostCap: true, + }, + ); + if (dailyCapBlock) { + await cancelQueuedRunForHeartbeatDailyCap(run, dailyCapBlock); + return null; + } + + const issueId = readNonEmptyString(context.issueId); + if (issueId && activeRunExecutions.size > 0) { + // Native finalization publishes success before workspace synchronization, + // provider suspension, and lease release finish. A queued comment must + // not acquire a fresh sandbox while its predecessor still owns that work. + // The executor's finally block retries this agent after removing its owner. + const [settlingOwner] = await db.select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.agentId, run.agentId), + eq(heartbeatRuns.runtimeMode, "native"), + inArray(heartbeatRuns.id, [...activeRunExecutions]), + sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}`, + )) + .limit(1); + if (settlingOwner) return null; + } + if (issueId) { + const activePauseHold = await treeControlSvc.getActivePauseHoldGate( + run.companyId, + issueId, + ); + const treeHoldInteractionWake = + activePauseHold && + (await isVerifiedIssueTreeControlInteractionWake(db, { + companyId: run.companyId, + issueId, + agentId: run.agentId, + runId: run.id, + wakeupRequestId: run.wakeupRequestId, + contextSnapshot: context, + })); + if (activePauseHold && !treeHoldInteractionWake) { + await cancelRunInternal( + run.id, + "Cancelled because issue is held by an active subtree pause hold", + ); + await logActivity(db, { + companyId: run.companyId, + actorType: "system", + actorId: "system", + agentId: run.agentId, + runId: run.id, + action: "issue.tree_hold_run_interrupted", + entityType: "heartbeat_run", + entityId: run.id, + issueId: issueId, + details: { + issueId, + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + source: "heartbeat.claim_queued_run", + securityPrinciples: [ + "Complete Mediation", + "Fail Securely", + "Secure Defaults", + ], + }, + }); + return null; + } + + const dependencyReadiness = await issuesSvc.listDependencyReadiness( + run.companyId, + [issueId], + ); + const readiness = dependencyReadiness.get(issueId); + const unresolvedBlockerCount = readiness?.unresolvedBlockerCount ?? 0; + if ( + unresolvedBlockerCount > 0 && + !allowsIssueInteractionWake( + context, + ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS, + ) + ) { + await cancelQueuedRunForBlockedDependencies( + run, + issueId, + readiness?.unresolvedBlockerIssueIds ?? [], + ); + logger.info( + { runId: run.id, issueId, unresolvedBlockerCount }, + "claimQueuedRun: cancelled blocked queued run", + ); + return null; + } + + const staleness = await runDispatch.cancelStaleQueuedRun({ + runId: run.id, + companyId: run.companyId, + expectedStatus: "queued", + }); + if (staleness.outcome === "cancelled" || staleness.outcome === "deferred") { + applyRunDispatchPostCommitEffects(staleness.postCommitEffects); + logger.info( + { + runId: run.id, + issueId, + outcome: staleness.outcome, + errorCode: staleness.outcome === "cancelled" ? staleness.errorCode : undefined, + }, + "claimQueuedRun: withheld queued run at the execution gate", + ); + return null; + } + } + + const claimedAt = new Date(); + const responsibleUserId = await resolveResponsibleUserIdForRun({ + run, + contextSnapshot: context, + issueContext: issueId + ? await getIssueExecutionContext(run.companyId, issueId) + : null, + routineEnvContext: { + routineId: null, + env: null, + responsibleUserId: null, + }, + }); + // All ordinary and comment claims use the same company-scoped issue + // lock. A batch may claim several runs before executeRun tracks any owner. + async function lockIssueExecutionClaim(tx: Db) { + const [owner] = issueId ? await tx.select({ + assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, + checkoutRunId: issues.checkoutRunId, + }).from(issues).where(and( + eq(issues.id, issueId), eq(issues.companyId, run.companyId), + )).for("update") : []; + const ownsIssue = owner?.assigneeAgentId === run.agentId && + context.wakeReason !== "source_scoped_recovery_action"; + if (ownsIssue && ["native_safe_replacement", "native_provider_overloaded"].includes(run.scheduledRetryReason ?? "") && + owner.checkoutRunId && owner.checkoutRunId !== run.id) { + return { ownsIssue, blocked: true }; + } + if (run.scheduledRetryReason === "native_provider_overloaded" && owner?.executionRunId && + owner.executionRunId !== run.id && owner.executionRunId !== run.retryOfRunId) { + return { ownsIssue, blocked: true }; + } + const previousRunId = run.scheduledRetryReason === "native_provider_overloaded" + ? run.retryOfRunId : ownsIssue ? owner?.executionRunId : null; + if (previousRunId && previousRunId !== run.id) { + const [previous] = await tx.select({ status: heartbeatRuns.status }) + .from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, previousRunId), + eq(heartbeatRuns.companyId, run.companyId), + )); + // A terminal result can precede workspace/lease cleanup on this or + // another controller. Local absence alone is not a release receipt. + if (!isHeartbeatRunTerminalStatus(previous?.status) || + liveRunExecutions.has(previousRunId)) { + return { ownsIssue, blocked: true }; + } + const [pendingLease] = await tx.select({ id: environmentLeases.id }) + .from(environmentLeases).where(and( + eq(environmentLeases.companyId, run.companyId), + eq(environmentLeases.heartbeatRunId, previousRunId), + or(and(isNull(environmentLeases.releasedAt), + // Warm release deliberately retains the sandbox. Its successful + // receipt settles the old run without destroying the resource. + sql`not coalesce(${environmentLeases.status} = 'retained' + and ${environmentLeases.leasePolicy} = 'reuse_by_environment' + and ${environmentLeases.cleanupStatus} = 'success', false)`), + eq(environmentLeases.status, "pending_cleanup"), + eq(environmentLeases.cleanupStatus, "failed")), + )).limit(1); + const [finalization] = await tx.select({ + phase: nativeRunFinalizations.phase, leaseOwner: nativeRunFinalizations.leaseOwner, + }).from(nativeRunFinalizations).where(and( + eq(nativeRunFinalizations.companyId, run.companyId), + eq(nativeRunFinalizations.runId, previousRunId), + )); + if (pendingLease || (finalization && (finalization.leaseOwner || + !["committed", "applied", "terminal_failure"].includes(finalization.phase)))) { + return { ownsIssue, blocked: true }; + } + } + return { ownsIssue, blocked: false }; + } + async function bindClaimedIssueExecution(tx: Db, ownsIssue: boolean, claimedRun: typeof heartbeatRuns.$inferSelect | null | undefined) { + if (!claimedRun || !issueId || !ownsIssue) return; + await tx.update(issues).set({ + executionRunId: claimedRun.id, + executionAgentNameKey: normalizeAgentNameKey(agent.name), + executionLockedAt: claimedAt, + updatedAt: claimedAt, + }).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))); + } + const nativeReviewContext = readNativeReviewAssignmentContext(context); + const queuedCommentIds = queuedCommentIdsFromRunContext(context); + if ( + issueId && + run.invocationSource === "automation" && + queuedCommentIds.length > 0 + ) + await options.beforeChatControlRecoveryCheck?.({ + runId: run.id, + issueId, + stage: "claim", + }); + const queuedCommentClaim = + !nativeReviewContext && issueId && run.wakeupRequestId && queuedCommentIds.length > 0 + ? await db + .transaction(async (tx) => { + // Match the queue-edit lock order: issue, wake, then run. Once the + // run becomes running, a concurrent discard must observe the + // claimed wake and return an explicit conflict; if discard wins, + // this claim observes the cancelled queue and does no work. + const issueClaim = await lockIssueExecutionClaim(tx as unknown as Db); + if (issueClaim.blocked) return { kind: "stale" as const, run: null }; + const wake = await tx + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.id, run.wakeupRequestId!), + eq(agentWakeupRequests.companyId, run.companyId), + eq(agentWakeupRequests.agentId, run.agentId), + ), + ) + .for("update") + .limit(1) + .then((rows) => rows[0] ?? null); + const lockedRun = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, run.id), + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.agentId, run.agentId), + ), + ) + .for("update") + .limit(1) + .then((rows) => rows[0] ?? null); + if ( + !wake || + wake.status !== "queued" || + wake.runId !== run.id || + !lockedRun || + lockedRun.status !== "queued" || + lockedRun.wakeupRequestId !== wake.id + ) { + return { kind: "stale" as const, run: null }; + } + + if (lockedRun.invocationSource === "automation") { + const admission = readChatControlRecoveryAdmission(lockedRun); + if (admission === "invalid") + return { kind: "stale" as const, run: null }; + const proof = await readChatControlRecoveryStop( + tx as unknown as Db, + { + companyId: lockedRun.companyId, + issueId, + agentId: lockedRun.agentId, + sourceRunId: lockedRun.id, + }, + true, + ); + if (proof.kind === "unresolved") + return { kind: "stale" as const, run: null }; + if (proof.kind === "stopped") { + const error = + "Automatic continuation stopped by the committed chat conversation close. Send a fresh request in chat or on the Board."; + const [cancelled] = await tx + .update(heartbeatRuns) + .set({ + status: "cancelled", + errorCode: CHAT_CONTROL_RECOVERY_STOP_CODE, + error, + finishedAt: claimedAt, + resultJson: { + ...parseObject(lockedRun.resultJson), + automaticRecovery: { + code: CHAT_CONTROL_RECOVERY_STOP_CODE, + providerDispatched: false, + sourceRunId: proof.sourceRunId, + conversationId: proof.conversationId, + publicationId: proof.publicationId, + }, + }, + updatedAt: claimedAt, + }) + .where( + and( + eq(heartbeatRuns.id, lockedRun.id), + eq(heartbeatRuns.status, "queued"), + ), + ) + .returning(); + if (!cancelled) return { kind: "stale" as const, run: null }; + await tx + .update(agentWakeupRequests) + .set({ + status: "skipped", + error, + finishedAt: claimedAt, + updatedAt: claimedAt, + }) + .where(eq(agentWakeupRequests.id, wake.id)); + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: claimedAt, + }) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, run.companyId), + eq(issues.executionRunId, run.id), + ), + ); + return { kind: "cancelled" as const, run: cancelled }; + } + await tx + .update(heartbeatRuns) + .set({ + runnerProfileJson: { + ...parseObject(lockedRun.runnerProfileJson), + [CHAT_CONTROL_RECOVERY_ADMISSION_KEY]: + chatControlRecoveryAdmission( + lockedRun, + admission === "admitted" ? "admitted" : "required", + ), + }, + updatedAt: claimedAt, + }) + .where(eq(heartbeatRuns.id, lockedRun.id)); + } + const authoritativeIds = queuedCommentIdsFromWakePayload( + wake.payload, + ); + if (authoritativeIds.length === 0) { + // Legacy/direct comment wakes carry comment ids in their ordinary + // payload and context, not in the authoritative queued-message + // envelope. Preserve their established claim path; only an + // explicitly bound queued-message envelope is subject to the + // live-comment discard gate below. + const [claimedRun] = await tx + .update(heartbeatRuns) + .set({ + status: "running", + runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`, + ...legacyControllerClaim(run.runtimeMode), + responsibleUserId, + startedAt: lockedRun.startedAt ?? claimedAt, + updatedAt: claimedAt, + }) + .where( + and( + eq(heartbeatRuns.id, lockedRun.id), + eq(heartbeatRuns.status, "queued"), + ), + ) + .returning(); + await bindClaimedIssueExecution(tx as unknown as Db, issueClaim.ownsIssue, claimedRun); + return claimedRun + ? { kind: "claimed" as const, run: claimedRun } + : { kind: "stale" as const, run: null }; + } + const commentRows = await tx + .select({ + id: issueComments.id, + deletedAt: issueComments.deletedAt, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, run.companyId), + eq(issueComments.issueId, issueId), + inArray(issueComments.id, authoritativeIds), + ), + ); + const liveIds = authoritativeIds.filter((commentId) => { + const comment = commentRows.find((row) => row.id === commentId); + return Boolean(comment && !comment.deletedAt); + }); + if (liveIds.length === 0) { + const reason = "Queued messages were discarded before dispatch"; + const [cancelled] = await tx + .update(heartbeatRuns) + .set({ + status: "cancelled", + finishedAt: claimedAt, + error: reason, + errorCode: "queued_comment_discarded", + updatedAt: claimedAt, + }) + .where( + and( + eq(heartbeatRuns.id, lockedRun.id), + eq(heartbeatRuns.status, "queued"), + ), + ) + .returning(); + await tx + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: claimedAt, + error: reason, + updatedAt: claimedAt, + }) + .where(eq(agentWakeupRequests.id, wake.id)); + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: claimedAt, + }) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, run.companyId), + eq(issues.executionRunId, run.id), + ), + ); + return { + kind: "cancelled" as const, + run: cancelled ?? lockedRun, + }; + } + + await tx + .update(agentWakeupRequests) + .set({ + status: "claimed", + claimedAt, + payload: withQueuedCommentIdsInWakePayload( + wake.payload, + liveIds, + ), + updatedAt: claimedAt, + }) + .where(eq(agentWakeupRequests.id, wake.id)); + const [claimedRun] = await tx + .update(heartbeatRuns) + .set({ + status: "running", + runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`, + ...legacyControllerClaim(run.runtimeMode), + responsibleUserId, + startedAt: lockedRun.startedAt ?? claimedAt, + contextSnapshot: withQueuedCommentIdsInRunContext( + lockedRun.contextSnapshot, + liveIds, + ), + updatedAt: claimedAt, + }) + .where( + and( + eq(heartbeatRuns.id, lockedRun.id), + eq(heartbeatRuns.status, "queued"), + ), + ) + .returning(); + await bindClaimedIssueExecution(tx as unknown as Db, issueClaim.ownsIssue, claimedRun); + return claimedRun + ? { kind: "claimed" as const, run: claimedRun } + : { kind: "stale" as const, run: null }; + }) + .catch((error) => { + if (isExternalChatWaitAuthorizationContention(error)) + return { kind: "stale" as const, run: null }; + throw error; + }) + : null; + if (queuedCommentClaim?.kind === "cancelled") { + await appendRunEvent(queuedCommentClaim.run, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: + queuedCommentClaim.run.error ?? + "Queued messages were discarded before dispatch", + }); + publishLiveEvent({ + companyId: queuedCommentClaim.run.companyId, + type: "heartbeat.run.status", + payload: { + runId: queuedCommentClaim.run.id, + agentId: queuedCommentClaim.run.agentId, + status: queuedCommentClaim.run.status, + invocationSource: queuedCommentClaim.run.invocationSource, + triggerDetail: queuedCommentClaim.run.triggerDetail, + error: queuedCommentClaim.run.error ?? null, + errorCode: queuedCommentClaim.run.errorCode ?? null, + startedAt: queuedCommentClaim.run.startedAt + ? new Date(queuedCommentClaim.run.startedAt).toISOString() + : null, + finishedAt: queuedCommentClaim.run.finishedAt + ? new Date(queuedCommentClaim.run.finishedAt).toISOString() + : null, + }, + }); + publishRunLifecyclePluginEvent(queuedCommentClaim.run); + // Fire-and-forget: nothing else in this path depends on the emission, + // so it must not delay the return. + void emitAgentTaskRun(db, queuedCommentClaim.run); + return null; + } + const claimed = queuedCommentClaim + ? queuedCommentClaim.run + : await withChatControlRecoveryGate(run, "claim", async (tx) => { + const claimValues = { + status: "running", + runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`, + ...legacyControllerClaim(run.runtimeMode), + responsibleUserId, + startedAt: run.startedAt ?? claimedAt, + updatedAt: claimedAt, + }; + if (nativeReviewContext) { + if (run.scheduledRetryReason === "native_provider_overloaded") { + const predecessor = await lockIssueExecutionClaim(tx); + if (predecessor.blocked) return null; + } + return claimQueuedNativeReviewRun(tx, { + run, claimedAt, claimValues, + agentNameKey: normalizeAgentNameKey(agent.name), + }); + } + return tx.transaction(async (claimTx) => { + const issueClaim = await lockIssueExecutionClaim(claimTx as unknown as Db); + if (issueClaim.blocked) return null; + const claimedRun = await claimTx.update(heartbeatRuns).set(claimValues).where(and( + eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "queued"), + )).returning().then((rows) => rows[0] ?? null); + await bindClaimedIssueExecution(claimTx as unknown as Db, issueClaim.ownsIssue, claimedRun); + return claimedRun; + }); + }); + if (!claimed) return null; + + publishLiveEvent({ + companyId: claimed.companyId, + type: "heartbeat.run.status", + payload: { + runId: claimed.id, + agentId: claimed.agentId, + status: claimed.status, + invocationSource: claimed.invocationSource, + triggerDetail: claimed.triggerDetail, + error: claimed.error ?? null, + errorCode: claimed.errorCode ?? null, + startedAt: claimed.startedAt + ? new Date(claimed.startedAt).toISOString() + : null, + finishedAt: claimed.finishedAt + ? new Date(claimed.finishedAt).toISOString() + : null, + }, + }); + publishRunLifecyclePluginEvent(claimed); + + if (!nativeReviewContext) { + await setWakeupStatus(claimed.wakeupRequestId, "claimed", { claimedAt }); + } + + // Fix A (lazy locking): stamp executionRunId now that the run is actually running, + // not at queue time. Guard is idempotent — safe if called more than once. + const claimedContext = parseObject(claimed.contextSnapshot); + const claimedIssueId = readNonEmptyString(claimedContext.issueId); + const claimedWakeReason = readNonEmptyString(claimedContext.wakeReason); + if ( + !nativeReviewContext && claimedIssueId && + claimedWakeReason !== "source_scoped_recovery_action" + ) { + const claimedAgent = await getAgent(claimed.agentId); + await db + .update(issues) + .set({ + executionRunId: claimed.id, + executionAgentNameKey: normalizeAgentNameKey(claimedAgent?.name), + executionLockedAt: claimedAt, + updatedAt: claimedAt, + }) + .where( + and( + eq(issues.id, claimedIssueId), + eq(issues.companyId, claimed.companyId), + // Mention/context runs can touch an issue, but only the current assignee + // owns the issue execution lock shown as the active run. + eq(issues.assigneeAgentId, claimed.agentId), + ["native_safe_replacement", "native_provider_overloaded"].includes(claimed.scheduledRetryReason ?? "") + ? or( + isNull(issues.checkoutRunId), + eq(issues.checkoutRunId, claimed.id), + ) + : undefined, + or( + isNull(issues.executionRunId), + eq(issues.executionRunId, claimed.id), + ), + ), + ); + } + + return claimed; + } + + // startNextQueuedRunForAgent checks admission suppression once, then claims + // runs (sets status "running"), then dispatches each to executeRun, which + // checks suppression again before it does any work. Suppression (task + // drain, worktree mode, a database restore) can start in the gap between + // those two checks. When executeRun's check catches that, the run is + // already claimed — release it back to "queued" so it does not keep a + // running execution lock that nothing will ever process. This runs inside + // the same promise startNextQueuedRunForAgent already tracks in + // activeRunExecutionPromises, so getTaskDrainStatus() keeps reporting the + // run as active until the release finishes. + // + // The run row, the wakeup request, and the issue execution lock all guard + // the same claim, so one transaction commits all three writes together. A + // partial write (for example the run flips to "queued" but the wakeup or + // issue update then fails) would let the execution promise clear from + // activeRunExecutionPromises while the wakeup stayed "claimed" or the + // issue stayed locked to a queued run — task-drain status would then read + // quiescent while the database still held part of the old claim. + async function releaseRunClaimedJustBeforeSuppression(runId: string) { + const now = new Date(); + await db.transaction(async (tx) => { + const released = await tx + .update(heartbeatRuns) + .set({ + status: "queued", + startedAt: null, + responsibleUserId: null, + updatedAt: now, + }) + .where( + and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.status, "running")), + ) + .returning() + .then((rows) => rows[0] ?? null); + if (!released) return; + + if (released.wakeupRequestId) { + await tx + .update(agentWakeupRequests) + .set({ status: "queued", claimedAt: null, updatedAt: now }) + .where(and(eq(agentWakeupRequests.id, released.wakeupRequestId), ne(agentWakeupRequests.status, "cancelled"))); + } + + const context = parseObject(released.contextSnapshot); + const issueId = readNonEmptyString(context.issueId); + if (issueId) { + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, released.companyId), + eq(issues.executionRunId, released.id), + ), + ); + } + }); + } + + async function cancelQueuedRunForBlockedDependencies( + run: typeof heartbeatRuns.$inferSelect, + issueId: string, + unresolvedBlockerIssueIds: string[], + ) { + const now = new Date(); + const reason = + "Cancelled because issue dependencies are still blocked; Paperclip will wake the assignee when blockers resolve"; + const cancelled = await setRunStatus(run.id, "cancelled", { + finishedAt: now, + error: reason, + errorCode: "issue_dependencies_blocked", + resultJson: { + ...parseObject(run.resultJson), + stopReason: "issue_dependencies_blocked", + effectiveTimeoutSec: 0, + timeoutConfigured: false, + timeoutSource: "dependency_gate", + timeoutFired: false, + }, + }); + if (!cancelled) return null; + + await setWakeupStatus(run.wakeupRequestId, "skipped", { + finishedAt: now, + error: reason, + }); + + await db + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.companyId, run.companyId), + eq(issues.id, issueId), + eq(issues.executionRunId, run.id), + ), + ); + + await appendRunEvent(cancelled, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: reason, + payload: { + issueId, + unresolvedBlockerIssueIds, + }, + }); + + return cancelled; + } + + async function resumeQueuedRuns() { + if ((await getSchedulingSuppression()).suppressed) return; + await resumeExecutionWaitComments(); + const cutoff = await getWorktreeExecutionCutoff(); + const pendingInterrupts = await db.select({ id: agentWakeupRequests.id, companyId: agentWakeupRequests.companyId }) + .from(agentWakeupRequests).innerJoin(companies, eq(companies.id, agentWakeupRequests.companyId)) + .where(and(eq(agentWakeupRequests.status, "deferred_issue_execution"), + eq(companies.status, "active"), + sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is not null`, + lte(agentWakeupRequests.updatedAt, new Date(Date.now() - 30_000)), + cutoff ? gte(agentWakeupRequests.requestedAt, cutoff) : undefined)) + .orderBy(asc(agentWakeupRequests.updatedAt)).limit(50); + for (const wake of pendingInterrupts) { + await db.update(agentWakeupRequests).set({ updatedAt: new Date() }).where(and( + eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.status, "deferred_issue_execution"), + )); + await resumeQueuedCommentInterrupt(wake.companyId, wake.id).catch(err => { + logger.warn({ err, queueId: wake.id }, "failed to resume interrupted comment queue"); + }); + } + // A server restart or a message/cleanup race can leave a deferred wake + // after its owner has released the issue lock. Revisit it through the same + // release admission, so recovery holds and operator Stops still apply. + const strandedQueues = await db.select({ wake: agentWakeupRequests }) + .from(agentWakeupRequests) + .innerJoin(issues, and(eq(issues.companyId, agentWakeupRequests.companyId), + sql`${issues.id}::text = ${agentWakeupRequests.payload}->>'issueId'`, + eq(issues.assigneeAgentId, agentWakeupRequests.agentId))) + .innerJoin(companies, and(eq(companies.id, issues.companyId), eq(companies.status, "active"))) + .where(and(eq(agentWakeupRequests.status, "deferred_issue_execution"), + isNull(issues.executionRunId), + or(and( + sql`jsonb_typeof(${agentWakeupRequests.payload} #> '{_paperclipWakeContext,wakeCommentIds}') = 'array'`, + sql`${agentWakeupRequests.payload} #> '{_paperclipWakeContext,wakeCommentIds}' <> '[]'::jsonb`, + ), sql`${agentWakeupRequests.payload}->>'mutation' = 'interaction'`), + sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is null`, + cutoff ? gte(agentWakeupRequests.requestedAt, cutoff) : undefined)) + .orderBy(asc(agentWakeupRequests.updatedAt)).limit(50); + for (const { wake } of strandedQueues) { + if (!queuedCommentIdsFromWakePayload(wake.payload).length && + !await readQueuedInteractionResponse(db, wake.companyId, String(wake.payload?.issueId), wake.payload)) continue; + const [latest] = await db.select().from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, wake.companyId), eq(heartbeatRuns.agentId, wake.agentId), + sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${String(wake.payload?.issueId)}`, + )).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1); + await db.update(agentWakeupRequests).set({ updatedAt: new Date() }).where(and( + eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.status, "deferred_issue_execution"), + )); + if (!latest || latest.runtimeMode !== "legacy" || !isHeartbeatRunTerminalStatus(latest.status)) continue; + const cancelledAdmission = latest.status === "cancelled" && !latest.startedAt && + latest.errorCode === "execution_reconciliation_required"; + if ((latest.status !== "cancelled" || cancelledAdmission) && await getExecutionBlocker(db, wake.companyId, String(wake.payload?.issueId))) { + await resumeSavedLegacyComments(wake.companyId, wake.id).catch(err => { + logger.warn({ err, queueId: wake.id }, "failed to deliver saved legacy comment after recovery stopped"); + }); + } + await releaseIssueExecutionAndPromote(latest, { suppressImmediateRecovery: true }).catch(err => { + logger.warn({ err, queueId: wake.id }, "failed to promote stranded legacy comments"); + }); + } + + // The cancellation marker is durable intent. Retry while its exact queue + // is still deferred, including after a failed cleanup promotion or restart. + // Normal admission still checks process ownership, leases, pauses, and scope. + const interruptedQueues = await db + .select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId }) + .from(agentWakeupRequests) + .innerJoin(heartbeatRuns, and( + sql`${heartbeatRuns.resultJson}->>'queuedCommentInterruptQueueId' = ${agentWakeupRequests.id}::text`, + eq(heartbeatRuns.companyId, agentWakeupRequests.companyId), + eq(heartbeatRuns.agentId, agentWakeupRequests.agentId), + )) + .innerJoin(companies, eq(companies.id, heartbeatRuns.companyId)) + .where(and( + eq(agentWakeupRequests.status, "deferred_issue_execution"), + eq(heartbeatRuns.status, "cancelled"), + eq(heartbeatRuns.runtimeMode, "legacy"), + eq(companies.status, "active"), + cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, + )); + for (const run of interruptedQueues) { + await releaseIssueExecutionAndPromote(run, { suppressImmediateRecovery: true }).catch((err) => { + logger.error({ err, runId: run.id }, "failed to retry interrupted comment queue"); + }); + } + + const queuedRuns = await db + .select({ agentId: heartbeatRuns.agentId }) + .from(heartbeatRuns) + .innerJoin(companies, eq(companies.id, heartbeatRuns.companyId)) + .where( + and( + eq(heartbeatRuns.status, "queued"), + eq(companies.status, "active"), + cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, + ), + ); + + const agentIds = [...new Set(queuedRuns.map((r) => r.agentId))]; + for (const agentId of agentIds) { + await startNextQueuedRunForAgent(agentId); + } + } + + // A 403 from claimQueuedRun comes from the run's own persisted identity + // (an unverifiable interrupt receipt, a manual wake with no user). Those rows + // do not change, so the claim fails the same way on every pass and restart. + function isPermanentClaimRejection(err: unknown): err is HttpError { + return err instanceof HttpError && err.status === 403; + } + + // Other 4xx rejections can clear later (a responsible user gets assigned, a + // conflicting claim finishes). Keep the run queued, but do not let it stop + // the rest of the queue or startup recovery. + function isDeferrableClaimRejection(err: unknown): err is HttpError { + return err instanceof HttpError && err.status >= 400 && err.status < 500; + } + + // Settle runs that can never be claimed. Letting the error escape stalls the + // agent's queue and, during startup recovery, stops the server from booting. + async function cancelRejectedQueuedRuns( + rejected: Array<{ run: typeof heartbeatRuns.$inferSelect; err: HttpError }>, + ) { + for (const { run, err } of rejected) { + logger.warn( + { err, runId: run.id, agentId: run.agentId, companyId: run.companyId }, + "cancelling queued heartbeat run whose claim was rejected", + ); + try { + await cancelRunInternal( + run.id, + `Cancelled because the queued run cannot be claimed: ${err.message}`, + { errorCode: "queued_run_claim_rejected" }, + ); + } catch (cancelErr) { + logger.error( + { err: cancelErr, runId: run.id }, + "failed to cancel queued heartbeat run whose claim was rejected; it stays queued for the next recovery pass", + ); + } + } + } + + async function startNextQueuedRunForAgent(agentId: string) { + if ((await getSchedulingSuppression()).suppressed) return []; + const cutoff = await getWorktreeExecutionCutoff(); + // Cancelled after the start lock is released: cancelRunInternal promotes the + // agent's next queued run, which takes this same lock. + const rejectedClaims: Array<{ run: typeof heartbeatRuns.$inferSelect; err: HttpError }> = []; + + return withAgentStartLock(agentId, async () => { + const agent = await getAgent(agentId); + if (!agent) return []; + const invokability = await getAgentInvokability(agent); + if (!invokability.invokable) { + if (shouldCancelRunsForNonInvokableAgent(invokability)) { + await cancelActiveForAgentInternal( + agentId, + `Cancelled because the agent is not invokable: ${invokability.reason}`, + ); + } + return []; + } + const policy = parseHeartbeatPolicy(agent); + const runningCount = await countRunningRunsForAgent(agentId); + const availableSlots = Math.max( + 0, + policy.maxConcurrentRuns - runningCount, + ); + if (availableSlots <= 0) return []; + + const queuedRuns = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.status, "queued"), + cutoff ? gte(heartbeatRuns.createdAt, cutoff) : undefined, + ), + ) + .orderBy(asc(heartbeatRuns.createdAt)); + if (queuedRuns.length === 0) return []; + + const dependencyReadiness = await listQueuedRunDependencyReadiness( + agent.companyId, + queuedRuns, + ); + const queuedIssueIds = [ + ...new Set( + queuedRuns + .map((run) => + readNonEmptyString(parseObject(run.contextSnapshot).issueId), + ) + .filter((issueId): issueId is string => Boolean(issueId)), + ), + ]; + const issueRows = await db + .select({ + id: issues.id, + status: issues.status, + priority: issues.priority, + }) + .from(issues) + .where( + queuedIssueIds.length > 0 + ? and( + eq(issues.companyId, agent.companyId), + inArray(issues.id, queuedIssueIds), + ) + : sql`false`, + ); + const issueById = new Map(issueRows.map((row) => [row.id, row])); + const companyAgents = await listCompanyAgentOrgRows(agent.companyId); + const prioritizedRuns = [...queuedRuns].sort((left, right) => { + const leftIssueId = readNonEmptyString( + parseObject(left.contextSnapshot).issueId, + ); + const rightIssueId = readNonEmptyString( + parseObject(right.contextSnapshot).issueId, + ); + const leftReadiness = leftIssueId + ? dependencyReadiness.get(leftIssueId) + : null; + const rightReadiness = rightIssueId + ? dependencyReadiness.get(rightIssueId) + : null; + const leftReady = leftIssueId + ? (leftReadiness?.isDependencyReady ?? true) + : true; + const rightReady = rightIssueId + ? (rightReadiness?.isDependencyReady ?? true) + : true; + const leftIssue = leftIssueId ? issueById.get(leftIssueId) : null; + const rightIssue = rightIssueId ? issueById.get(rightIssueId) : null; + const leftRank = leftIssueId + ? leftReady + ? leftIssue?.status === "in_progress" + ? 0 + : 1 + : 3 + : 2; + const rightRank = rightIssueId + ? rightReady + ? rightIssue?.status === "in_progress" + ? 0 + : 1 + : 3 + : 2; + if (leftRank !== rightRank) return leftRank - rightRank; + const leftPriorityRank = issueRunPriorityRank(leftIssue?.priority); + const rightPriorityRank = issueRunPriorityRank(rightIssue?.priority); + if (leftPriorityRank !== rightPriorityRank) + return leftPriorityRank - rightPriorityRank; + return left.createdAt.getTime() - right.createdAt.getTime(); + }); + + const claimedRuns: Array = []; + for (const queuedRun of prioritizedRuns) { + if (claimedRuns.length >= availableSlots) break; + let claimed: typeof heartbeatRuns.$inferSelect | null; + try { + claimed = await claimQueuedRun(queuedRun, companyAgents); + } catch (err) { + if (isPermanentClaimRejection(err)) { + rejectedClaims.push({ run: queuedRun, err }); + continue; + } + if (!isDeferrableClaimRejection(err)) throw err; + logger.warn( + { err, runId: queuedRun.id, agentId: queuedRun.agentId, companyId: queuedRun.companyId }, + "queued heartbeat run claim was rejected; leaving it queued for the next recovery pass", + ); + continue; + } + if (claimed) claimedRuns.push(claimed); + } + if (claimedRuns.length === 0) return []; + + for (const claimedRun of claimedRuns) { + const execution = executeRun(claimedRun.id).catch((err) => { + logger.error( + { err, runId: claimedRun.id }, + "queued heartbeat execution failed", + ); + }); + // Register the in-flight execution so drainActiveRunExecutions() can await + // it. executeRun resolves only after its finally block finishes flushing + // run rows/events, so awaiting this promise guarantees the run's writes + // have landed before a caller (e.g. a test's afterEach) mutates the DB. + activeRunExecutionPromises.add(execution); + void execution.finally(() => { + // drainActiveRunExecutions loops on activeRunExecutionPromises.size, + // so an entry that never clears here would hang it forever. + activeRunExecutionPromises.delete(execution); + }); + } + return claimedRuns; + }).finally(() => cancelRejectedQueuedRuns(rejectedClaims)); + } + + // Public wakeup entry point. Callers dispatch it fire-and-forget, so register + // the promise in activeWakeupPromises before it starts its asynchronous + // prologue. drainActiveRunExecutions can then await a wake that is still before + // run registration. Internal callers reference enqueueWakeup directly and + // already await it, so they do not need this registration. + function trackWakeup( + agentId: string, + opts: WakeupOptions = {}, + ): ReturnType { + const promise = enqueueWakeup(agentId, opts); + activeWakeupPromises.add(promise); + void promise + .catch(() => {}) + .finally(() => { + activeWakeupPromises.delete(promise); + }); + return promise; + } + + async function enqueueWakeup(agentId: string, opts: WakeupOptions = {}, executionWaitRequestId?: string) { + const source = opts.source ?? "on_demand"; + const triggerDetail = opts.triggerDetail ?? null; + const contextSnapshot: Record = { + ...(opts.contextSnapshot ?? {}), + }; + const reason = opts.reason ?? null; + let payload = opts.payload ? { ...opts.payload } : null; + // Only the board queue route can record interruption authority on an + // existing receipt. Never accept this internal marker from a wake caller. + if (payload) { + delete payload.queuedCommentInterrupt; + delete payload.manualUserWake; + } + if (opts.manualUserWake) { + if (opts.requestedByActorType !== "user" || !opts.requestedByActorId || opts.failedRunId) { + throw new HttpError(403, "Manual wake requires an authenticated user"); + } + payload = { ...payload, manualUserWake: true }; + } + const executionReconciliationWake = + contextSnapshot.source === "execution.reconciled" || + opts.idempotencyKey?.startsWith("execution-reconciliation:") === true; + const { + contextSnapshot: enrichedContextSnapshot, + issueIdFromPayload, + taskKey, + wakeCommentId, + } = enrichWakeContextSnapshot({ + contextSnapshot, + reason, + source, + triggerDetail, + payload, + }); + // Keep each request's own server-derived origin, including coalesced wakes. + // A run's merged context cannot establish which caller authored one receipt. + // Overwrite caller-supplied nested context rather than trusting it. + payload = { ...payload, [DEFERRED_WAKE_CONTEXT_KEY]: { ...enrichedContextSnapshot } }; + let issueId = + readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; + if (executionReconciliationWake && !issueId) return null; + + let agent = await getAgent(agentId); + if (!agent) throw notFound("Agent not found"); + // Mentions only annotate comments. Ignore legacy callers before creating + // a run or deferred request; assignment and review have their own wakes. + if (reason === "issue_comment_mentioned" || enrichedContextSnapshot.wakeReason === "issue_comment_mentioned") return null; + if (issueId) { + const conversation = await getIssueExecutionContext(agent.companyId, issueId); + if (reason === "issue_children_completed" && conversation?.originKind === "onboarding_first_task") enrichedContextSnapshot.onboardingCompletion = true; + if (isConversation(conversation)) { + if (opts.manualUserWake && conversation!.conversationUserId !== opts.requestedByActorId) { + throw new HttpError(403, "Only the conversation owner can start a chat run"); + } + if (isConversationExecutionWake(conversation, reason ?? readNonEmptyString(enrichedContextSnapshot.wakeReason))) return null; + if (agent.id !== conversation!.conversationAgentId) return null; + if (!(await instanceSettings.getExperimental()).enableAgentChat) return null; + if (!wakeCommentId && isWaitingConversation(conversation) && !hasInteractionContinuationWakeContext(enrichedContextSnapshot) && reason !== CHAT_COMPLETION_WAKE_REASON) return null; + } + } + if (agent.adapterType === "paperclip_runner") { + const oldConfig = parseObject(agent.adapterConfig); + const nextConfig = normalizeLegacyRunnerProvider(oldConfig); + if (nextConfig !== oldConfig) { + await agentService(db).update( + agent.id, + { adapterConfig: nextConfig }, + { + recordRevision: { + source: "normalize_runner_provider", + createdByAgentId: null, + createdByUserId: null, + }, + }, + ); + await logActivity(db, { + companyId: agent.companyId, + actorType: "system", + actorId: "heartbeat", + action: "agent.updated", + entityType: "agent", + entityId: agent.id, + details: { provider: "codex", reason: "native_codex_provider" }, + }); + agent = (await getAgent(agentId))!; + } + } + + if (opts.failedRunId) { + const failed = await getRun(opts.failedRunId, { includeExecutionEvidence: true }); + if (opts.requestedByActorType !== "user" || !opts.requestedByActorId || + reason !== "retry_failed_run" || source !== "on_demand" || triggerDetail !== "manual" || + !failed || failed.companyId !== agent.companyId || failed.agentId !== agentId || + !(await canRetryStoppedRun(db, failed)) || + (failed.nativeIssueId ?? readNonEmptyString(failed.contextSnapshot?.issueId)) !== issueId) { + throw conflict("The selected failed run cannot be retried for this task."); + } + if (!activeRunExecutions.has(failed.id) && !adapterExecutionControls.has(failed.id)) { + await sweepPendingCleanupLeases({ explicitRetry: { + companyId: failed.companyId, runId: failed.id, actorId: opts.requestedByActorId, + } }); + } + if (isConversationAdapter(agent.adapterType) || agent.adapterType === "paperclip_runner") { + enrichedContextSnapshot.previousRunId = failed.id; + enrichedContextSnapshot.forceFreshSession = true; + } + } + + const durableRequest = opts.durableChatRequest; + if (durableRequest) { + assertDurableChatWakeupRequest(durableRequest, { + agentId, + companyId: agent.companyId, + issueId, + commentId: wakeCommentId ?? null, + requestedByActorType: opts.requestedByActorType, + requestedByActorId: opts.requestedByActorId, + }); + opts = { ...opts, idempotencyKey: durableRequest.idempotencyKey }; + } + if ( + Object.hasOwn(enrichedContextSnapshot, "chatFailedRunRetry") && + !durableRequest?.failedRunRetry + ) { + throw new FailedChatRunRetryAuthorizationError(); + } + if (durableRequest?.failedRunRetry) { + opts = { ...opts, allowRunCoalescing: false }; + } + const dotRequest = opts.durableDotRequest; + if (dotRequest && (durableRequest || dotRequest.agentId !== agentId || + dotRequest.companyId !== agent.companyId || dotRequest.issueId !== issueId || + dotRequest.requestId !== payload?.dotRequestId || source !== "assignment" || + opts.requestedByActorType !== "agent" || opts.requestedByActorId !== agentId || + agent.adapterType !== "paperclip_runner" || parseObject(agent.adapterConfig).provider !== "openai_dot" || + opts.idempotencyKey !== dotRequest.idempotencyKey)) { + throw conflict("Dot work request does not match its admission authority."); + } + const receiptRequest = durableRequest ?? dotRequest; + const durableReceiptFields = receiptRequest + ? { id: receiptRequest.id, requestedAt: receiptRequest.requestedAt } + : {}; + const existingDurableReceipt = async (queryDb: Db) => { + if (!receiptRequest) return null; + const receipt = await queryDb + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, receiptRequest.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + if (receipt && durableRequest) assertDurableChatWakeupReceipt(durableRequest, receipt); + if (receipt && dotRequest && (receipt.companyId !== dotRequest.companyId || + receipt.agentId !== dotRequest.agentId || receipt.source !== "assignment" || + receipt.requestedByActorType !== "agent" || receipt.requestedByActorId !== dotRequest.agentId || + receipt.idempotencyKey !== dotRequest.idempotencyKey || receipt.payload?.issueId !== dotRequest.issueId || + receipt.payload?.dotRequestId !== dotRequest.requestId)) { + throw conflict("requestId was reused for another task."); + } + return receipt; + }; + const priorReceipt = await existingDurableReceipt(db); + if (priorReceipt) { + // Replaying an admission receipt is not fresh authority to dispatch it. + // The normal queue owns dispatch and its current execution-policy checks. + return priorReceipt.runId ? getRun(priorReceipt.runId) : null; + } + + const agentDebug = parseObject(parseObject(agent.runtimeConfig).debug); + const runDebug = parseObject(enrichedContextSnapshot.debug); + if ( + agentDebug.providerTrace === "raw" && + runDebug.providerTrace !== "raw" + ) { + enrichedContextSnapshot.debug = { + ...runDebug, + providerTrace: "raw", + }; + enrichedContextSnapshot.providerTraceRequestedBy = `agent:${agent.id}:debug-setting`; + enrichedContextSnapshot.providerTraceRequestSource = + "agent_debug_setting"; + } + + // Automatic signals are replaceable; user input and interaction delivery + // retain distinct durable receipts even when the same gate blocks them. + const coalesceExecutionWait = + opts.requestedByActorType === "system" && + !receiptRequest && + !wakeCommentId && + queuedCommentIdsFromRunContext(enrichedContextSnapshot).length === 0 && + !isInteractionResolutionWakePayload(payload ?? {}) && + !hasInteractionContinuationWakeContext(enrichedContextSnapshot); + const writeSkippedRequest = async ( + skipReason: string, + patch: Partial = {}, + waitCondition?: Record, + ) => { + if (executionWaitRequestId) { + await db.update(agentWakeupRequests).set({ + payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}', + coalesce(${agentWakeupRequests.payload}->'executionWait', '{}'::jsonb) || ${JSON.stringify({ + reason: skipReason, message: patch.error ?? (skipReason === "issue_tree_hold_active" + ? "This task is paused. Resume it to send your saved message." + : "Waiting for task execution to be enabled. Your message is saved."), + })}::jsonb)`, + updatedAt: new Date(), + }).where(and(eq(agentWakeupRequests.id, executionWaitRequestId), + eq(agentWakeupRequests.companyId, agent.companyId), eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"))); + return { created: false }; + } + const request = { + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: skipReason, + payload, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + ...patch, + }; + if (waitCondition && issueId && isUuidLike(issueId)) { + const waitIssueId = issueId; + return db.transaction(async (tx) => { + await tx.execute(sql`select id from issues where id = ${waitIssueId} and company_id = ${agent.companyId} for update`); + return recordExecutionWait(tx as unknown as Db, { + issueId: waitIssueId, request, condition: waitCondition, coalesce: coalesceExecutionWait, + }); + }); + } + await db.insert(agentWakeupRequests).values(request); + return { created: true }; + }; + const writeSkippedHeartbeatRequest = async ( + skipReason: string, + details: Record, + ) => { + await writeSkippedRequest(skipReason, { + payload: { + ...(payload ?? {}), + heartbeatSkip: details, + }, + }); + }; + + const schedulingSuppression = await getSchedulingSuppression(); + // A task drain holds ADMISSION, not the request. The drain is a + // process-local pre-restart hold, so a wake that arrives while it is + // active still names real work that must run once the process comes + // back: leave it in the durable queue and let the dispatch-side checks + // (startNextQueuedRunForAgent / executeRun) keep it from starting until + // the drain lifts or the restart clears it. Writing it as `skipped` + // here dropped the wake permanently — an accepted plan whose + // continuation wake landed mid-drain left its issue in `todo` with no + // run and no path until a person noticed. + if ( + schedulingSuppression.suppressed && + schedulingSuppression.reason !== "task_drain" + ) { + await writeSkippedHeartbeatRequest("heartbeat.scheduling_suppressed", { + reason: schedulingSuppression.reason, + }); + return null; + } + + const worktreeExecutionCutoff = + opts.requestedByActorType === "user" + ? null + : await getWorktreeExecutionCutoff(); + + const company = await db + .select({ status: companies.status }) + .from(companies) + .where(eq(companies.id, agent.companyId)) + .then((rows) => rows[0] ?? null); + + if (!company || company.status !== "active") { + const companyStatus = company?.status ?? "missing"; + if (opts.requestedByActorType === "user") { + throw conflict("Company is not active", { status: companyStatus }); + } + await writeSkippedRequest("company.inactive", { + error: `Wake suppressed because company status is ${companyStatus}`, + }, { companyStatus }); + return null; + } + + const explicitResumeSession = await resolveExplicitResumeSessionOverride( + agent, + payload, + taskKey, + ); + if (explicitResumeSession) { + enrichedContextSnapshot.resumeFromRunId = + explicitResumeSession.resumeFromRunId; + enrichedContextSnapshot.resumeSessionDisplayId = + explicitResumeSession.sessionDisplayId; + enrichedContextSnapshot.resumeSessionParams = + explicitResumeSession.sessionParams; + if ( + !readNonEmptyString(enrichedContextSnapshot.issueId) && + explicitResumeSession.issueId + ) { + enrichedContextSnapshot.issueId = explicitResumeSession.issueId; + } + if ( + !readNonEmptyString(enrichedContextSnapshot.taskId) && + explicitResumeSession.taskId + ) { + enrichedContextSnapshot.taskId = explicitResumeSession.taskId; + } + if ( + !readNonEmptyString(enrichedContextSnapshot.taskKey) && + explicitResumeSession.taskKey + ) { + enrichedContextSnapshot.taskKey = explicitResumeSession.taskKey; + } + issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueId; + } + const effectiveTaskKey = + readNonEmptyString(enrichedContextSnapshot.taskKey) ?? taskKey; + const sessionBefore = + explicitResumeSession?.sessionDisplayId ?? + (await resolveSessionBeforeForWakeup(agent, effectiveTaskKey)); + let hasResolvablePriorSessionWorkspace: boolean | null = null; + const resolveHasResolvablePriorSessionWorkspace = async () => { + if (hasResolvablePriorSessionWorkspace !== null) + return hasResolvablePriorSessionWorkspace; + hasResolvablePriorSessionWorkspace = issueId + ? await hasResolvablePriorSessionWorkspaceForWake({ + agent, + contextSnapshot: enrichedContextSnapshot, + taskKey: effectiveTaskKey, + explicitResumeSession, + }) + : false; + return hasResolvablePriorSessionWorkspace; + }; + const continuationAttempt = readContinuationAttempt( + enrichedContextSnapshot.livenessContinuationAttempt, + ); + + let projectId = readNonEmptyString(enrichedContextSnapshot.projectId); + if (!projectId && issueId) { + // Look up by either UUID or identifier (e.g. "ENV-13"), but always scope + // by companyId so a row from another tenant can never be returned even + // when identifiers collide across companies. Guard the UUID arm because + // issues.id is a Postgres uuid column — passing "ENV-13" into eq(issues.id, …) + // would fail with an invalid-input-syntax cast error before the OR is + // evaluated. + const lookupIsUuid = isUuidLike(issueId); + const idMatch = lookupIsUuid + ? or( + eq(issues.id, issueId), + eq(issues.identifier, issueId.toUpperCase()), + ) + : eq(issues.identifier, issueId.toUpperCase()); + const resolvedIssue = await db + .select({ + id: issues.id, + projectId: issues.projectId, + createdAt: issues.createdAt, + }) + .from(issues) + .where(and(eq(issues.companyId, agent.companyId), idMatch)) + .then((rows) => rows[0] ?? null); + if (resolvedIssue) { + if ( + worktreeExecutionCutoff && + resolvedIssue.createdAt < worktreeExecutionCutoff + ) { + await writeSkippedHeartbeatRequest( + "heartbeat.worktree_execution_cutoff", + { + reason: "worktree_execution_cutoff", + cutoff: worktreeExecutionCutoff.toISOString(), + issueId: resolvedIssue.id, + }, + ); + return null; + } + projectId = resolvedIssue.projectId ?? null; + // Canonicalize context to the UUID so downstream lookups always use UUID + if (resolvedIssue.id !== issueId) { + issueId = resolvedIssue.id; + enrichedContextSnapshot.issueId = issueId; + if (readNonEmptyString(enrichedContextSnapshot.taskId)) { + enrichedContextSnapshot.taskId = issueId; + } + } + } + } + // Propagate projectId into context so resolveWorkspaceForRun can bind the + // project workspace even when context.projectId wasn't set by the caller. + if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) { + enrichedContextSnapshot.projectId = projectId; + } + const isolatedWorkspacesEnabled = issueId + ? (await instanceSettings.getExperimental()).enableIsolatedWorkspaces + : false; + let operatorResponsibleUserId: string | null = opts.manualUserWake ? opts.requestedByActorId! : null; + let queuedResponsibleUserIdPromise: Promise | null = null; + const resolveQueuedResponsibleUserId = () => { + if (operatorResponsibleUserId) return Promise.resolve(operatorResponsibleUserId); + queuedResponsibleUserIdPromise ??= (async () => { + const queuedIssueContext = issueId + ? await getIssueExecutionContext(agent.companyId, issueId) + : null; + const queuedRoutineEnvContext = await getRoutineEnvForExecutionIssue( + agent.companyId, + queuedIssueContext, + ); + const queuedResponsibleUserId = + await resolveResponsibleUserIdForRunSeed({ + companyId: agent.companyId, + contextSnapshot: enrichedContextSnapshot, + issueContext: queuedIssueContext, + routineEnvContext: queuedRoutineEnvContext, + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + source, + triggerDetail, + }); + if (!queuedResponsibleUserId) { + throw new HttpError( + 422, + "Unable to resolve responsible user for heartbeat run dispatch", + { + code: "responsible_user_unresolved", + agentId, + companyId: agent.companyId, + issueId: issueId ?? null, + source, + triggerDetail, + wakeReason: readNonEmptyString( + enrichedContextSnapshot.wakeReason, + ), + }, + ); + } + return queuedResponsibleUserId; + })(); + return queuedResponsibleUserIdPromise; + }; + + const budgetBlock = await budgets.getInvocationBlock( + agent.companyId, + agentId, + { + issueId, + projectId, + }, + ); + if (budgetBlock) { + await writeSkippedRequest("budget.blocked", { error: budgetBlock.reason }, { + scopeType: budgetBlock.scopeType, scopeId: budgetBlock.scopeId, + }); + throw conflict(budgetBlock.reason, { + scopeType: budgetBlock.scopeType, + scopeId: budgetBlock.scopeId, + }); + } + + const invokability = await getAgentInvokability(agent); + if (!invokability.invokable) { + if (opts.requestedByActorType !== "user" || executionWaitRequestId) { + await writeSkippedRequest("agent.not_invokable", { + error: invokability.message, + }, { status: agent.status, reason: invokability.reason }); + } + throw conflict(invokability.message, { + status: agent.status, + reason: invokability.reason, + invalidOrgChain: invokability.invalidOrgChain, + ...invokability.details, + }); + } + + const policy = parseHeartbeatPolicy(agent); + + if (source === "timer" && !policy.enabled) { + await writeSkippedRequest("heartbeat.disabled", {}, { enabled: false }); + return null; + } + if (source !== "timer" && !policy.wakeOnDemand) { + await writeSkippedRequest("heartbeat.wakeOnDemand.disabled", {}, { wakeOnDemand: false }); + return null; + } + + const genericTimerWake = + source === "timer" && + !issueId && + !wakeCommentId && + !readNonEmptyString(enrichedContextSnapshot.taskId) && + !readNonEmptyString(enrichedContextSnapshot.taskKey); + if ( + policy.skipTimerWhenNoActionableWork && + genericTimerWake && + !(await hasActionableTimerWork(agent)) + ) { + await writeSkippedHeartbeatRequest("heartbeat.timer.no_actionable_work", { + reason: + "No assigned todo or in_progress issue requires this agent before timer adapter invocation.", + }); + await markTimerHeartbeatChecked(agentId, source); + return null; + } + + if (issueId) { + const activePauseHold = await treeControlSvc.getActivePauseHoldGate( + agent.companyId, + issueId, + ); + if (activePauseHold) { + const treeHoldInteractionWake = + await isVerifiedIssueTreeControlInteractionWake(db, { + companyId: agent.companyId, + issueId, + agentId, + contextSnapshot: enrichedContextSnapshot, + requestedByActorType: opts.requestedByActorType, + requestedByActorId: opts.requestedByActorId, + }); + + if (!treeHoldInteractionWake) { + const wait = await writeSkippedRequest("issue_tree_hold_active", {}, { + holdId: activePauseHold.holdId, + }); + if (wait.created) await logActivity(db, { + companyId: agent.companyId, + actorType: "system", + actorId: "system", + agentId, + runId: null, + action: "issue.tree_hold_wakeup_deferred", + entityType: "issue", + entityId: issueId, + details: { + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + requestedReason: reason, + source, + triggerDetail, + securityPrinciples: [ + "Complete Mediation", + "Fail Securely", + "Secure Defaults", + ], + }, + }); + return null; + } + + enrichedContextSnapshot.treeHoldInteraction = true; + enrichedContextSnapshot.activeTreeHold = { + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + mode: activePauseHold.mode, + reason: activePauseHold.reason, + releasePolicy: activePauseHold.releasePolicy, + interaction: true, + }; + } + } + + if (issueId) { + // Mention-triggered wakes can request input from another agent, but they must + // still respect the issue execution lock so a second agent cannot start on the + // same issue workspace while the assignee already has a live run. + const agentNameKey = normalizeAgentNameKey(agent.name); + + const cancelledRunsToEmit: (typeof heartbeatRuns.$inferSelect)[] = []; + + const outcome = await db + .transaction(async (tx) => { + await tx.execute( + sql`select id from issues where id = ${issueId} and company_id = ${agent.companyId} for update`, + ); + + if (executionWaitRequestId) { + const [pending] = await tx.select().from(agentWakeupRequests).where(and( + eq(agentWakeupRequests.id, executionWaitRequestId), eq(agentWakeupRequests.companyId, agent.companyId), + eq(agentWakeupRequests.agentId, agentId), eq(agentWakeupRequests.status, "deferred_issue_execution"), + // A user message can join a queue originally created by a + // system wake. Admission validates the saved user comment or board click. + (opts.queuedCommentInterruptId ?? opts.queuedCommentRequestId) === executionWaitRequestId + ? undefined : eq(agentWakeupRequests.requestedByActorType, "user"), + opts.queuedCommentInterruptId === executionWaitRequestId + ? sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt'->>'actorId' = ${opts.requestedByActorId ?? ""}` + : opts.queuedCommentRequestId === executionWaitRequestId ? undefined + : eq(agentWakeupRequests.requestedByActorId, opts.requestedByActorId ?? ""), + sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`, + )); + // The issue lock serializes cleanup callbacks and periodic workers. + // An adopted, discarded, or edited receipt is no longer authority. + if (!pending || (!(wakeCommentId && queuedCommentIdsFromWakePayload(pending.payload).includes(wakeCommentId)) && + !(opts.queuedCommentInterruptId && await readQueuedInteractionResponse(tx as unknown as Db, + agent.companyId, issueId, pending.payload)))) { + return { kind: "deferred" as const }; + } + if (opts.queuedCommentRequestId) { + const ids = await undeliveredLegacyUserCommentIds(tx as unknown as Db, + agent.companyId, issueId, agentId, queuedCommentIdsFromWakePayload(pending.payload)); + if (!wakeCommentId || !ids.includes(wakeCommentId)) return { kind: "deferred" as const }; + pending.payload = withQueuedCommentIdsInWakePayload(parseObject(pending.payload), ids); + await tx.update(agentWakeupRequests).set({ payload: pending.payload }).where(and( + eq(agentWakeupRequests.id, pending.id), eq(agentWakeupRequests.companyId, agent.companyId), + )); + } + if (!opts.queuedCommentInterruptId && !opts.queuedCommentRequestId && pending.payload?.manualUserWake === true) { + // A persisted manual wake keeps its actor when an execution wait + // resumes. The locked receipt above has revalidated that actor. + payload = { ...payload, manualUserWake: true }; + operatorResponsibleUserId = opts.requestedByActorId!; + } + if (opts.queuedCommentInterruptId) { + // The locked board receipt supplies execution authority even when + // another user authored the messages. Dispatch revalidates the receipt. + operatorResponsibleUserId = opts.requestedByActorId!; + } + if (opts.queuedCommentInterruptId || opts.queuedCommentRequestId) { + // Edits/discards between the click and dispatch remain authoritative. + Object.assign(enrichedContextSnapshot, withQueuedCommentIdsInRunContext( + enrichedContextSnapshot, queuedCommentIdsFromWakePayload(pending.payload), + )); + } + } + let automaticParentRunId: string | null = null; + if ( + source === "automation" && + opts.requestedByActorType === "system" + ) { + const nativeParent = await readChatControlNativeParent( + tx as unknown as Db, + { companyId: agent.companyId, issueId, agentId }, + opts.requestedByActorId ?? null, + ); + const genericParent = + !opts.requestedByActorId && reason === "issue_continuation_needed" + ? readNonEmptyString(enrichedContextSnapshot.retryOfRunId) + : null; + automaticParentRunId = + nativeParent.kind === "parent" + ? nativeParent.runId + : genericParent; + if (automaticParentRunId) { + const [parent] = await tx + .select({ + agentId: heartbeatRuns.agentId, + context: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, agent.companyId), + eq(heartbeatRuns.id, automaticParentRunId), + ), + ) + .limit(1); + if ( + parent && + (parseObject(parent.context).issueId ?? + parseObject(parent.context).taskId) === issueId && + parent.agentId !== agentId + ) + automaticParentRunId = null; + } + const proof = + nativeParent.kind === "unresolved" + ? { kind: "unresolved" as const } + : automaticParentRunId + ? await readChatControlRecoveryStop( + tx as unknown as Db, + { + companyId: agent.companyId, + issueId, + agentId, + sourceRunId: automaticParentRunId, + }, + true, + ) + : { kind: "clear" as const }; + if (proof.kind !== "clear") { + await tx + .insert(agentWakeupRequests) + .values({ + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: + proof.kind === "stopped" + ? CHAT_CONTROL_RECOVERY_STOP_CODE + : CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE, + payload: { issueId }, + status: "skipped", + requestedByActorType: "system", + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + }); + return { kind: "skipped" as const }; + } + } + + const durableReceipt = await existingDurableReceipt( + tx as unknown as Db, + ); + if (durableReceipt) + return { kind: "durable" as const, receipt: durableReceipt }; + const failedChatRetry = await authorizeFailedChatRunRetryWake( + db, + tx as unknown as Db, + { + phase: "admission", + wakeupRequestId: durableRequest?.id ?? null, + companyId: agent.companyId, + agentId, + issueId, + contextSnapshot: enrichedContextSnapshot, + }, + ); + if ( + failedChatRetry !== Boolean(durableRequest?.failedRunRetry) || + (failedChatRetry && + durableRequest?.failedRunRetry?.failedRunId !== + readNonEmptyString(enrichedContextSnapshot.retryOfRunId)) + ) { + throw new FailedChatRunRetryAuthorizationError(); + } + if (durableRequest) { + if (issueId !== durableRequest.issueId) + throw new Error("chat_inbound_wakeup_binding_denied"); + await durableRequest.authorize(tx as unknown as Db); + } else { + if ( + source === "on_demand" && + triggerDetail === "manual" && + reason === "retry_failed_run" + ) { + // Generic Board retry does not carry server-authorized failed-run + // lineage. Caller-supplied source/comment/retry markers cannot + // restore it, including for a retired conversation generation. + const [chatBinding] = await tx + .select({ id: chatConversations.id }) + .from(chatConversations) + .innerJoin(chatEndpoints, eq(chatEndpoints.id, chatConversations.endpointId)) + .where( + and( + eq(chatEndpoints.externalExecutionPolicy, "restricted"), + eq(chatConversations.companyId, agent.companyId), + eq(chatConversations.issueId, issueId), + ), + ) + .limit(1); + if (chatBinding) { + throw conflict( + "Retry needs the exact failed chat request and current access. Send the request again in the current connected conversation.", + { code: "chat_failed_run_retry_requires_authorized_context" }, + ); + } + } + const [held] = await tx + .select({ id: issues.id }) + .from(issues) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, agent.companyId), + unadmittedChatWakeupCondition(issues.id, issues.companyId), + ), + ) + .limit(1); + if (held) { + throw conflict( + "This task has external chat input that has not been admitted. Check the connection's Activity; let pending input finish, or restore access and send a new authorized message before starting the task.", + { code: "chat_inbound_wakeup_unadmitted", issueId }, + ); + } + } + + const issue = await tx + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + conversationAgentId: issues.conversationAgentId, + conversationUserId: issues.conversationUserId, + conversationState: issues.conversationState, + status: issues.status, + statusVersion: issues.statusVersion, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + monitorNextCheckAt: issues.monitorNextCheckAt, + monitorWakeRequestedAt: issues.monitorWakeRequestedAt, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + createdAt: issues.createdAt, + }) + .from(issues) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, agent.companyId), + ), + ) + .then((rows) => rows[0] ?? null); + + if (!issue) { + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: "issue_execution_issue_not_found", + payload, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + }); + return { kind: "skipped" as const }; + } + + const issueStateGuard = opts.issueStateGuard; + const activeMonitorRun = issueStateGuard?.monitorNextCheckAt === undefined ? null + : await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.nativeIssueId, issue.id), + eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]), + )).limit(1).then(rows => rows[0] ?? null); + if ( + issueStateGuard && + (!issueStateGuard.statuses.includes(issue.status) || + issue.assigneeAgentId !== issueStateGuard.assigneeAgentId || + (issueStateGuard.statusVersion !== undefined && issue.statusVersion !== issueStateGuard.statusVersion) || + (issueStateGuard.monitorNextCheckAt !== undefined && ( + activeMonitorRun !== null || issue.assigneeUserId !== null || + issue.monitorNextCheckAt?.toISOString() !== issueStateGuard.monitorNextCheckAt || + issue.monitorWakeRequestedAt?.toISOString() !== issueStateGuard.monitorWakeRequestedAt + ))) + ) { + // A deferred monitor retains its schedule; do not create a receipt + // that could suppress its next admission attempt. + if (issueStateGuard.monitorNextCheckAt !== undefined) return { kind: "skipped" as const }; + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: "issue_state_guard_mismatch", + payload: { + ...(payload ?? {}), + heartbeatSkip: { + reason: + "Issue status or assignee changed before the wake could be queued.", + issueId: issue.id, + expectedStatuses: issueStateGuard.statuses, + actualStatus: issue.status, + expectedAssigneeAgentId: issueStateGuard.assigneeAgentId, + actualAssigneeAgentId: issue.assigneeAgentId, + }, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + }); + return { kind: "skipped" as const }; + } + + if (opts.failedRunId) { + // The issue lock makes double-clicks and network retries adopt the + // same successor, including after it has already finished. + const [previousRetry] = await tx.select().from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.retryOfRunId, opts.failedRunId), + sql`${heartbeatRuns.contextSnapshot}->>'wakeReason' = 'retry_failed_run'`, + )).orderBy(desc(heartbeatRuns.createdAt)).limit(1); + if (previousRetry) return { kind: "replayed" as const, run: previousRetry }; + } + + let reconciledSourceRunId: string | null = null; + let reconciledRestoreRetryCount: number | null = null; + if (executionReconciliationWake) { + const actionId = readNonEmptyString( + enrichedContextSnapshot.recoveryActionId, + ); + if ( + !actionId || + !isUuidLike(actionId) || + source !== "automation" || + triggerDetail !== "system" || + reason !== "issue_recovery_action_restored" || + opts.requestedByActorType !== "system" || + opts.requestedByActorId !== "execution-recovery" || + opts.idempotencyKey !== `execution-reconciliation:${actionId}` || + enrichedContextSnapshot.source !== "execution.reconciled" || + enrichedContextSnapshot.forceFreshSession !== true || + payload?.issueId !== issue.id || + payload?.recoveryActionId !== actionId || + issue.assigneeAgentId !== agentId || + ["done", "cancelled"].includes(issue.status) + ) + return { kind: "skipped" as const }; + + // The issue lock serializes all admissions for this source. Validate + // the durable operator decision, then reconcile a prior queue commit + // before considering a new wake (including a now-terminal successor). + const [action] = await tx + .select() + .from(issueRecoveryActions) + .where( + and( + eq(issueRecoveryActions.companyId, issue.companyId), + eq(issueRecoveryActions.sourceIssueId, issue.id), + eq(issueRecoveryActions.id, actionId), + ), + ) + .for("update"); + const decision = parseObject( + action?.evidence.executionReconciliation, + ); + const sourceRunId = readNonEmptyString(decision.runId); + if ( + !action || + action.status !== "resolved" || + action.kind !== "active_run_watchdog" || + action.returnOwnerAgentId !== agentId || + !sourceRunId || + !isUuidLike(sourceRunId) || + decision.providerStopped !== true || + !["completed", "not_performed", "mixed"].includes( + String(decision.actionOutcome), + ) || + !readNonEmptyString(decision.outcomeEvidence) || + enrichedContextSnapshot.previousRunId !== sourceRunId || + enrichedContextSnapshot.retryOfRunId !== sourceRunId || + !["pending", "delivered"].includes( + String(action.evidence.continuationDelivery), + ) + ) + return { kind: "skipped" as const }; + + const [existingWake] = await tx + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, issue.companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.idempotencyKey, opts.idempotencyKey), + ne(agentWakeupRequests.status, "skipped"), + ), + ) + .orderBy(asc(agentWakeupRequests.requestedAt)) + .limit(1); + if (existingWake) { + if ( + existingWake.payload?.issueId !== issue.id || + existingWake.payload?.recoveryActionId !== action.id || + existingWake.requestedByActorType !== "system" || + existingWake.requestedByActorId !== "execution-recovery" || + !existingWake.runId + ) + return { kind: "deferred" as const }; + const [existingRun] = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, issue.companyId), + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.id, existingWake.runId), + ), + ); + if ( + !existingRun || + existingRun.contextSnapshot?.issueId !== issue.id || + existingRun.contextSnapshot?.recoveryActionId !== action.id || + existingRun.contextSnapshot?.previousRunId !== sourceRunId + ) + return { kind: "deferred" as const }; + return { kind: "replayed" as const, run: existingRun }; + } + if (action.evidence.continuationDelivery !== "pending") + return { kind: "skipped" as const }; + const [reconciledRun] = await tx.select().from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.id, sourceRunId), + )); + if (hasWorkspaceRestoreFailure(reconciledRun?.resultJson)) { + if ((readNonEmptyString(decision.workspaceRepairEvidence)?.length ?? 0) < 20) + return { kind: "skipped" as const }; + // Repair does not reset the remaining automatic retry budget. + reconciledRestoreRetryCount = executionFailureRetryCount(reconciledRun!); + } + reconciledSourceRunId = sourceRunId; + } + + let continuationWait = { reason: "execution_recovery", message: "Waiting for execution recovery. Your message is saved." }; + const deferBlockedExecution = async ( + executionBlocker: NonNullable>>, + ) => { + const condition = { recoveryActionId: executionBlocker.recoveryActionId, ...continuationWait }; + if (executionWaitRequestId) { + await tx.update(agentWakeupRequests).set({ + payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}', ${JSON.stringify(condition)}::jsonb)`, + updatedAt: new Date(), + }).where(eq(agentWakeupRequests.id, executionWaitRequestId)); + return { kind: "deferred" as const }; + } + if (durableRequest || wakeCommentId || + hasInteractionContinuationWakeContext(enrichedContextSnapshot) || + readNonEmptyString(enrichedContextSnapshot.nativeStatusWakeIntentId)) { + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, agentId, source, triggerDetail, reason, + payload: withQueuedCommentIdsInWakePayload({ + ...payload, + issueId: issue.id, + [DEFERRED_WAKE_CONTEXT_KEY]: enrichedContextSnapshot, + executionWait: condition, + }, [...new Set([ + ...queuedCommentIdsFromRunContext(enrichedContextSnapshot), + ...(wakeCommentId ? [wakeCommentId] : []), + ])]), + status: "deferred_issue_execution", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + }); + } else { + await recordExecutionWait(tx as unknown as Db, { + issueId: issue.id, condition, coalesce: coalesceExecutionWait, + request: { + ...durableReceiptFields, + companyId: agent.companyId, agentId, source, triggerDetail, + reason: "execution_reconciliation_required", + error: executionBlocker.nextAction, + payload, + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + }, + }); + } + return { kind: "deferred" as const }; + }; + const explicitContinuationRunId = randomUUID(); + const executionBlocker = await getExecutionBlocker( + tx as unknown as Db, issue.companyId, issue.id, + { conversationResetCommentId: opts.requestedByActorType === "user" ? wakeCommentId : null }, + ); + // Prove eligibility without retiring the hold. Later gates can still + // decline this wake; hold retirement and successor creation stay atomic. + // A bound chat request has already rechecked its current principal + // above. Treat its new user message like a board comment, but keep + // failed-run retry actions on their separate exact-request path. + if (executionBlocker && !(await admitExplicitNativeContinuation({ + db: tx as unknown as Db, companyId: issue.companyId, issueId: issue.id, + agentId, actorType: opts.requestedByActorType, actorId: opts.requestedByActorId, + reason: durableRequest && !failedChatRetry ? "issue_commented" : reason, + commentId: wakeCommentId ?? null, failedRunId: opts.failedRunId, successorRunId: explicitContinuationRunId, + queuedCommentInterruptId: opts.queuedCommentInterruptId, + queuedCommentRequestId: opts.queuedCommentRequestId, + dryRun: true, + onBlocked: (reason, message) => { continuationWait = { reason, message }; }, + }))) return deferBlockedExecution(executionBlocker); + + + if ( + worktreeExecutionCutoff && + issue.createdAt < worktreeExecutionCutoff + ) { + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: "heartbeat.worktree_execution_cutoff", + payload: { + ...(payload ?? {}), + heartbeatSkip: { + reason: "worktree_execution_cutoff", + cutoff: worktreeExecutionCutoff.toISOString(), + issueId: issue.id, + }, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + }); + return { kind: "skipped" as const }; + } + + const cancelStaleScheduledRetry = async ( + scheduledRun: typeof heartbeatRuns.$inferSelect, + ) => { + const issueCancelled = issue.status === "cancelled"; + if ( + scheduledRun.status !== "scheduled_retry" || + (scheduledRun.agentId === issue.assigneeAgentId && + !issueCancelled) + ) { + return false; + } + + const now = new Date(); + const reason = issueCancelled + ? "Cancelled because the issue was cancelled before the scheduled retry became due" + : "Cancelled because the issue was reassigned before the scheduled retry became due"; + const cancelled = await tx + .update(heartbeatRuns) + .set({ + status: "cancelled", + finishedAt: now, + error: reason, + errorCode: issueCancelled + ? "issue_cancelled" + : "issue_reassigned", + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, scheduledRun.id), + eq(heartbeatRuns.status, "scheduled_retry"), + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + + if (!cancelled) return false; + + if (scheduledRun.wakeupRequestId) { + await tx + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: now, + error: reason, + updatedAt: now, + }) + .where( + eq(agentWakeupRequests.id, scheduledRun.wakeupRequestId), + ); + } + + if (issue.executionRunId === scheduledRun.id) { + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.id, issue.id), + eq(issues.executionRunId, scheduledRun.id), + ), + ); + } + + const eventSeq = await allocateHeartbeatRunEventSeq( + tx as unknown as Db, + cancelled.id, + ); + + await tx.insert(heartbeatRunEvents).values({ + companyId: cancelled.companyId, + runId: cancelled.id, + agentId: cancelled.agentId, + seq: eventSeq, + eventType: "lifecycle", + stream: "system", + level: "warn", + message: issueCancelled + ? "Scheduled retry cancelled because issue was cancelled before it became due" + : "Scheduled retry cancelled because issue ownership changed before it became due", + payload: { + issueId: issue.id, + issueStatus: issue.status, + scheduledRetryAttempt: cancelled.scheduledRetryAttempt, + scheduledRetryAt: cancelled.scheduledRetryAt + ? new Date(cancelled.scheduledRetryAt).toISOString() + : null, + scheduledRetryReason: cancelled.scheduledRetryReason, + previousRetryAgentId: cancelled.agentId, + currentAssigneeAgentId: issue.assigneeAgentId, + }, + }); + await tx + .update(heartbeatRuns) + .set({ nextEventSeq: eventSeq + 1, updatedAt: now }) + .where(eq(heartbeatRuns.id, cancelled.id)); + + cancelledRunsToEmit.push(cancelled); + + return true; + }; + + let activeExecutionRun = issue.executionRunId + ? await tx + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, issue.executionRunId)) + .then((rows) => rows[0] ?? null) + : null; + + if ( + activeExecutionRun && + !EXECUTION_PATH_HEARTBEAT_RUN_STATUSES.includes( + activeExecutionRun.status as (typeof EXECUTION_PATH_HEARTBEAT_RUN_STATUSES)[number], + ) + ) { + activeExecutionRun = null; + } + + if ( + activeExecutionRun && + (await cancelStaleScheduledRetry(activeExecutionRun)) + ) { + activeExecutionRun = null; + } + + // A queued/scheduled run holding the lock for an agent that is + // no longer the issue's assignee is stale by design — the issue + // has been re-routed (e.g. blocked → in_review with a different + // assignee). Cancel it and release the lock; otherwise the new + // assignee's wake gets parked in `deferred_issue_execution` + // forever, because the original queued holder will never run + // (the issue's status / target now belongs to someone else). + // + // Race guard: pin the cancel UPDATE to the exact non-running + // status we read above. A worker could transition the holder + // from `queued` → `running` between the SELECT and this UPDATE; + // the status predicate ensures we never clobber a freshly- + // claimed running run. If zero rows matched, leave + // `activeExecutionRun` populated so the defer path runs + // normally against the now-running holder. + if ( + activeExecutionRun && + activeExecutionRun.status !== "running" && + issue.assigneeAgentId && + activeExecutionRun.agentId !== issue.assigneeAgentId + ) { + const cancelled = await tx + .update(heartbeatRuns) + .set({ + status: "cancelled", + finishedAt: new Date(), + error: + "Execution lock released after issue reassigned to a different agent", + errorCode: "lock_released_on_reassignment", + updatedAt: new Date(), + }) + .where( + and( + eq(heartbeatRuns.id, activeExecutionRun.id), + eq(heartbeatRuns.status, activeExecutionRun.status), + ), + ) + .returning(); + if (cancelled.length > 0) { + cancelledRunsToEmit.push(cancelled[0]); + if (activeExecutionRun.wakeupRequestId) { + await tx + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: new Date(), + error: + "Execution lock released after issue reassigned to a different agent", + updatedAt: new Date(), + }) + .where( + eq( + agentWakeupRequests.id, + activeExecutionRun.wakeupRequestId, + ), + ); + } + activeExecutionRun = null; + } + } + + if (!activeExecutionRun && issue.executionRunId) { + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issue.id)); + } + + if (!activeExecutionRun) { + const legacyRun = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, issue.companyId), + inArray(heartbeatRuns.status, [ + ...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES, + ]), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, + ), + ) + .orderBy( + sql`case when ${heartbeatRuns.status} = 'running' then 0 else 1 end`, + asc(heartbeatRuns.createdAt), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (legacyRun) { + if (await cancelStaleScheduledRetry(legacyRun)) { + activeExecutionRun = null; + } else { + activeExecutionRun = legacyRun; + const legacyAgent = await tx + .select({ name: agents.name }) + .from(agents) + .where(eq(agents.id, legacyRun.agentId)) + .then((rows) => rows[0] ?? null); + await tx + .update(issues) + .set({ + executionRunId: legacyRun.id, + executionAgentNameKey: normalizeAgentNameKey( + legacyAgent?.name, + ), + executionLockedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(issues.id, issue.id)); + } + } + } + + const dependencyReadiness = await issuesSvc + .listDependencyReadiness(issue.companyId, [issue.id], tx) + .then((rows) => rows.get(issue.id) ?? null); + + // Blocked descendants should stay idle until the final blocker resolves. + // Human comment/mention wakes are the exception: they may run in a + // bounded interaction mode so the assignee can answer or triage. + const blockedInteractionWake = + dependencyReadiness && + !dependencyReadiness.isDependencyReady && + allowsIssueInteractionWake( + enrichedContextSnapshot, + ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS, + ); + + if (blockedInteractionWake) { + enrichedContextSnapshot.dependencyBlockedInteraction = true; + enrichedContextSnapshot.unresolvedBlockerIssueIds = + dependencyReadiness.unresolvedBlockerIssueIds; + enrichedContextSnapshot.unresolvedBlockerCount = + dependencyReadiness.unresolvedBlockerCount; + enrichedContextSnapshot.unresolvedBlockerSummaries = + await listUnresolvedBlockerSummaries( + tx, + issue.companyId, + issue.id, + dependencyReadiness.unresolvedBlockerIssueIds, + ); + } + + if ( + !activeExecutionRun && + dependencyReadiness && + !dependencyReadiness.isDependencyReady && + !blockedInteractionWake + ) { + await recordExecutionWait(tx as unknown as Db, { + issueId: issue.id, + coalesce: coalesceExecutionWait, + condition: { unresolvedBlockerIssueIds: [...dependencyReadiness.unresolvedBlockerIssueIds].sort() }, + request: { + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: "issue_dependencies_blocked", + payload: { + ...(payload ?? {}), + issueId, + unresolvedBlockerIssueIds: + dependencyReadiness.unresolvedBlockerIssueIds, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + }, + }); + return { kind: "skipped" as const }; + } + + if ( + isolatedWorkspacesEnabled && + !activeExecutionRun && + issue.status !== "done" && + issue.status !== "cancelled" + ) { + const issueSettings = parseIssueExecutionWorkspaceSettings( + issue.executionWorkspaceSettings, + ); + const resolvedMode = resolveExecutionWorkspaceMode({ + projectPolicy: null, + issueSettings, + legacyUseProjectWorkspace: null, + }); + const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig( + { + agentConfig: parseObject(agent.adapterConfig), + projectPolicy: null, + issueSettings, + mode: resolvedMode, + legacyUseProjectWorkspace: null, + }, + ); + const resolvedStrategy = resolveEffectiveWorkspaceStrategyType( + resolvedMode, + workspaceManagedConfig, + ); + const existingExecutionWorkspaceStatus = issue.executionWorkspaceId + ? await tx + .select({ status: executionWorkspaces.status }) + .from(executionWorkspaces) + .where( + and( + eq(executionWorkspaces.id, issue.executionWorkspaceId), + eq(executionWorkspaces.companyId, issue.companyId), + ), + ) + .then((rows) => rows[0]?.status ?? null) + : null; + const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({ + issueExecutionWorkspaceId: issue.executionWorkspaceId, + issueExecutionWorkspacePreference: + issue.executionWorkspacePreference, + existingExecutionWorkspaceStatus, + }); + const hasResolvablePriorSessionWorkspace = + await resolveHasResolvablePriorSessionWorkspace(); + + if ( + isUnrunnableWorktreeCombo({ + issue: { + projectId: issue.projectId ?? projectId ?? null, + projectWorkspaceId: issue.projectWorkspaceId, + executionWorkspaceId: issue.executionWorkspaceId, + executionWorkspacePreference: + issue.executionWorkspacePreference, + }, + resolvedMode, + resolvedStrategy, + reusableExecutionWorkspaceAvailable: + reuseRequest.existingExecutionWorkspaceAvailable, + hasResolvablePriorSessionWorkspace, + }) + ) { + const now = new Date(); + const issueLabel = formatIssueIdentifierLink( + issue.identifier, + issue.id, + ); + const blockedComment = [ + `Paperclip blocked ${issueLabel} before dispatch because its workspace settings are not runnable.`, + "", + `- Code: \`${WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE}\``, + `- Reason: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE}`, + `- Next action: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION}`, + ].join("\n"); + await tx + .update(issues) + .set({ + status: "blocked", + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where(eq(issues.id, issue.id)); + await tx.insert(issueComments).values({ + companyId: issue.companyId, + issueId: issue.id, + body: blockedComment, + createdAt: now, + updatedAt: now, + }); + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, + payload: { + ...(payload ?? {}), + issueId, + heartbeatSkip: { + code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, + reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, + remediation: + WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, + }, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: now, + }); + await logActivity(tx as unknown as Db, { + companyId: issue.companyId, + actorType: "system", + actorId: "system", + agentId, + runId: null, + action: "issue.workspace_preflight_blocked", + entityType: "issue", + entityId: issue.id, + details: { + code: WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, + reason: WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, + remediation: WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION, + requestedReason: reason, + source, + triggerDetail, + resolvedMode, + resolvedStrategy, + hasResolvablePriorSessionWorkspace, + }, + }); + return { kind: "skipped" as const }; + } + } + + if (activeExecutionRun) { + // The resolved action is already a durable retry outbox. Do not merge + // its fresh-session contract into unrelated work or create a second + // deferred wake that could later replay the same reconciliation. + if (reconciledSourceRunId) return { kind: "deferred" as const }; + + const admissionScope = wakeQueue.createAdmissionTransactionScope( + agent.companyId, + tx as unknown as Db, + ); + const admission = await wakeQueue.admitWakeBehindIssueExecution( + admissionScope, + { + companyId: agent.companyId, + issueId: issue.id, + agentId, + agentNameKey, + issueExecutionAgentNameKey: issue.executionAgentNameKey, + activeExecutionRun: { + id: activeExecutionRun.id, + agentId: activeExecutionRun.agentId, + status: activeExecutionRun.status, + contextSnapshot: activeExecutionRun.contextSnapshot, + wakeupRequestId: activeExecutionRun.wakeupRequestId, + }, + allowRunCoalescing: isConversation(issue) ? false : opts.allowRunCoalescing, + durableReceipt: receiptRequest + ? { + id: receiptRequest.id, + requestedAt: receiptRequest.requestedAt, + } + : undefined, + reason, + liveRunExecutions, + wakeCommentId, + forceFreshSession: + enrichedContextSnapshot.forceFreshSession === true, + contextSnapshot: enrichedContextSnapshot, + source, + triggerDetail, + payload, + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + }, + ); + + if (admission.kind === "coalesced") { + return { + kind: "coalesced" as const, + run: admission.run as typeof heartbeatRuns.$inferSelect, + }; + } + if (admission.kind === "deferred") { + return { kind: "deferred" as const }; + } + // admission.kind === "proceed": no active run absorbed this wake, + // so fall through to the ordinary queue path below. + } + + // PAP-13775: no live run holds the lock, so this wake would start a + // fresh adapter session. If this agent's recent runs on this issue + // keep succeeding without any issue-visible progress and the wake + // carries no new information, hold it back for an escalating cooldown + // so external pollers/reconcilers can't storm full-price sessions. + // Server-side recovery retries insert runs directly and never reach + // this gate. + if ( + isThrottleCandidateIssueRewake({ + reason, + wakeCommentId: wakeCommentId ?? null, + requestedByActorType: opts.requestedByActorType ?? null, + forceFreshSession: + enrichedContextSnapshot.forceFreshSession === true, + hasExplicitResume: Boolean(explicitResumeSession), + }) + ) { + const throttleNow = new Date(); + const recentTerminalRuns = await tx + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + finishedAt: heartbeatRuns.finishedAt, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, agent.companyId), + eq(heartbeatRuns.agentId, agentId), + sql`${heartbeatRuns.finishedAt} is not null`, + gte( + heartbeatRuns.finishedAt, + new Date(throttleNow.getTime() - ISSUE_REWAKE_LOOKBACK_MS), + ), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, + ), + ) + .orderBy(desc(heartbeatRuns.finishedAt)) + .limit(ISSUE_REWAKE_RUN_SAMPLE_LIMIT); + + if (recentTerminalRuns.length > 0) { + const sampleRunIds = recentTerminalRuns.map( + (sampleRun) => sampleRun.id, + ); + const progressRows = await tx + .select({ runId: activityLog.runId }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, agent.companyId), + eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, issue.id), + inArray(activityLog.runId, sampleRunIds), + inArray( + activityLog.action, + ISSUE_PROGRESS_ACTIVITY_ACTIONS, + ), + ), + ); + const lastRunFinishedAt = + recentTerminalRuns[0]?.finishedAt ?? null; + const newInputRows = lastRunFinishedAt + ? await tx + .select({ id: activityLog.id }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, agent.companyId), + eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, issue.id), + gt(activityLog.createdAt, lastRunFinishedAt), + inArray( + activityLog.action, + ISSUE_NEW_INPUT_ACTIVITY_ACTIONS, + ), + wakeCommentId && opts.requestedByActorType === "agent" + ? ne(activityLog.actorType, "agent") + : undefined, + ), + ) + .limit(1) + : []; + + const throttleDecision = evaluateIssueRewakeThrottle({ + now: throttleNow, + recentTerminalRuns, + runIdsWithIssueProgress: new Set( + progressRows + .map((row) => row.runId) + .filter((runId): runId is string => Boolean(runId)), + ), + // For an agent comment wake, the query excludes agent-authored + // activity while preserving genuinely new user/system input. + // Presentation/author metadata therefore cannot smuggle human + // wake privilege, nor can it mask an actual human response. + hasNewIssueInputSinceLastRun: newInputRows.length > 0, + }); + + if (throttleDecision.blocked) { + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: "issue_rewake_throttled", + payload: { + ...(payload ?? {}), + issueId, + heartbeatSkip: { + reason: "issue_rewake_throttled", + requestedReason: reason, + noProgressStreak: throttleDecision.noProgressStreak, + cooldownMs: throttleDecision.cooldownMs, + lastRunFinishedAt: + throttleDecision.lastRunFinishedAt.toISOString(), + nextAllowedAt: + throttleDecision.nextAllowedAt.toISOString(), + }, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: throttleNow, + }); + return { kind: "skipped" as const }; + } + } + } + + const dailyCapBlock = await getHeartbeatDailyCapBlock( + agent, + policy, + {}, + tx, + ); + if (dailyCapBlock) { + if (executionWaitRequestId && executionBlocker) { + continuationWait = { reason: dailyCapBlock.reason, + message: "The agent has reached its daily limit. Your message is saved until work can resume." }; + return deferBlockedExecution(executionBlocker); + } + const now = new Date(); + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: dailyCapBlock.reason, + payload: { + ...(payload ?? {}), + heartbeatSkip: { + reason: + "Per-agent heartbeat daily cap reached before adapter invocation.", + observed: dailyCapBlock.observed, + limit: dailyCapBlock.limit, + }, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: now, + }); + if (source === "timer") { + await tx + .update(agents) + .set({ + lastHeartbeatAt: now, + updatedAt: now, + }) + .where(eq(agents.id, agentId)); + } + return { kind: "skipped" as const }; + } + + const explicitContinuation = await admitExplicitNativeContinuation({ + db: tx as unknown as Db, companyId: issue.companyId, issueId: issue.id, + agentId, actorType: opts.requestedByActorType, actorId: opts.requestedByActorId, + reason: durableRequest && !failedChatRetry ? "issue_commented" : reason, + commentId: wakeCommentId ?? null, failedRunId: opts.failedRunId, successorRunId: explicitContinuationRunId, + queuedCommentInterruptId: opts.queuedCommentInterruptId, + queuedCommentRequestId: opts.queuedCommentRequestId, + }); + if (!explicitContinuation && executionBlocker) return deferBlockedExecution(executionBlocker); + if (explicitContinuation) { + enrichedContextSnapshot.forceFreshSession = true; + enrichedContextSnapshot.previousRunId = explicitContinuation.previousRunId; + enrichedContextSnapshot.explicitUserContinuation = explicitContinuation; + } + + const wakeupRequest = await tx + .insert(agentWakeupRequests) + .values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason, + payload, + status: "queued", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + }) + .returning() + .then((rows) => rows[0]); + + // A handoff changes the executor, not the owner of saved user input. + // Validate its exact stopped source while the issue row is locked; + // unrelated agents and dedicated continuations keep their own wakes. + const interruptedRunId = readNonEmptyString(enrichedContextSnapshot.interruptedRunId); + const handoffSource = source === "assignment" && reason === "issue_assigned" && + issue.assigneeAgentId === agentId && interruptedRunId && isUuidLike(interruptedRunId) + ? await tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, interruptedRunId), eq(heartbeatRuns.companyId, issue.companyId), + eq(heartbeatRuns.status, "cancelled"), eq(heartbeatRuns.errorCode, "issue_reassigned"), + ne(heartbeatRuns.agentId, agentId), + sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id}`, + or(isNull(heartbeatRuns.nativeIssueId), eq(heartbeatRuns.nativeIssueId, issue.id)), + )).then(rows => rows[0] ?? null) + : null; + const canCoalesceComments = !isConversation(issue) && opts.allowRunCoalescing !== false; + // A resumed receipt already passed admission under this issue lock. + // Consume it with its successor even when chat keeps other messages + // in separate turns, or finalization will deliver it a second time. + const pendingComments = + (executionWaitRequestId || canCoalesceComments) && + !(await getExecutionBlocker(tx as unknown as Db, issue.companyId, issue.id)) + ? await tx + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, issue.companyId), + inArray(agentWakeupRequests.agentId, handoffSource ? [agentId, handoffSource.agentId] : [agentId]), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + sql`${agentWakeupRequests.payload}->>'issueId' = ${issue.id}`, + canCoalesceComments ? undefined : eq(agentWakeupRequests.id, executionWaitRequestId!), + ), + ) + .orderBy(asc(agentWakeupRequests.requestedAt)) + : []; + const adoptedComments = pendingComments.filter((wake) => { + if (wake.id === executionWaitRequestId) return true; + if (wake.id === opts.queuedCommentInterruptId || wake.id === opts.queuedCommentRequestId) return true; + const deferredPayload = parseObject(wake.payload); + const deferredContext = parseObject( + deferredPayload[DEFERRED_WAKE_CONTEXT_KEY], + ); + // Dedicated interaction wakes carry their own source and session + // contract. ID-only adoption must not erase that continuation. + return ( + // Durable chat work must keep its receipt, actor, source, and + // session contract through normal promotion and authorization. + !wake.idempotencyKey?.startsWith("chat-inbound:") && + !isInteractionResolutionWakePayload(deferredPayload) && + !hasInteractionContinuationWakeContext(deferredContext) && + ["issue_commented", "issue_reopened_via_comment"].includes(String(deferredContext.wakeReason ?? wake.reason)) && + queuedCommentIdsFromWakePayload(wake.payload).length > 0 + ); + }); + let adoptedCommentIds = [ + ...new Set([ + ...adoptedComments.flatMap((wake) => + queuedCommentIdsFromWakePayload(wake.payload), + ), + ...queuedCommentIdsFromRunContext(enrichedContextSnapshot), + ]), + ]; + if (opts.queuedCommentRequestId || handoffSource) { + adoptedCommentIds = await undeliveredLegacyUserCommentIds(tx as unknown as Db, + agent.companyId, issueId, agentId, adoptedCommentIds); + } + const newRun = await tx + .insert(heartbeatRuns) + .values({ + ...(explicitContinuation ? { id: explicitContinuationRunId } : {}), + companyId: agent.companyId, + agentId, + scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company", + issueId: readNonEmptyString(enrichedContextSnapshot.issueId), + invocationSource: source, + triggerDetail, + status: "queued", + responsibleUserId: await resolveQueuedResponsibleUserId(), + wakeupRequestId: wakeupRequest.id, + retryOfRunId: failedChatRetry + ? durableRequest!.failedRunRetry!.failedRunId + : opts.failedRunId ?? automaticParentRunId, + contextSnapshot: adoptedComments.length + ? withQueuedCommentIdsInRunContext( + enrichedContextSnapshot, + adoptedCommentIds, + ) + : enrichedContextSnapshot, + sessionIdBefore: explicitContinuation ? null : sessionBefore, + continuationAttempt, + ...(reconciledSourceRunId + ? { retryOfRunId: reconciledSourceRunId } + : {}), + ...(reconciledRestoreRetryCount !== null ? { + scheduledRetryAttempt: reconciledRestoreRetryCount, + scheduledRetryReason: "transient_failure", + } : {}), + }) + .returning() + .then((rows) => rows[0]); + + await tx + .update(agentWakeupRequests) + .set({ + runId: newRun.id, + updatedAt: new Date(), + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + + if (adoptedComments.length) { + await tx + .update(agentWakeupRequests) + .set({ + status: "coalesced", + runId: newRun.id, + finishedAt: new Date(), + updatedAt: new Date(), + }) + .where( + inArray( + agentWakeupRequests.id, + adoptedComments.map((wake) => wake.id), + ), + ); + await tx + .update(agentWakeupRequests) + .set({ + payload: withQueuedCommentIdsInWakePayload(payload, adoptedCommentIds), + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + } + + // executionRunId is NOT stamped here (enqueueWakeup queues the run but + // doesn't start it). It will be stamped in claimQueuedRun() once the run + // transitions to "running" — Fix A (lazy locking). + + return { kind: "queued" as const, run: newRun }; + }) + .catch((error) => { + if (isExternalChatWaitAuthorizationContention(error)) + return { kind: "deferred" as const }; + throw error; + }); + + // Telemetry for the cancelled runs is best-effort background work. + // Fire it here and never await it: none of the lifecycle work below, + // nor this function's return, depends on it, so a slow telemetry + // lookup must not delay them. + for (const cancelledRun of cancelledRunsToEmit) { + void emitAgentTaskRun(db, cancelledRun); + } + + if (outcome.kind === "durable") { + return outcome.receipt.runId ? getRun(outcome.receipt.runId) : null; + } + if (outcome.kind === "deferred" || outcome.kind === "skipped") { + return null; + } + if (outcome.kind === "coalesced") { + await startNextQueuedRunForAgent(agent.id); + return outcome.run; + } + if (outcome.kind === "replayed") { + if (outcome.run.status === "queued") + await startNextQueuedRunForAgent(agent.id); + return outcome.run; + } + + const newRun = outcome.run; + publishLiveEvent({ + companyId: newRun.companyId, + type: "heartbeat.run.queued", + payload: { + runId: newRun.id, + agentId: newRun.agentId, + invocationSource: newRun.invocationSource, + triggerDetail: newRun.triggerDetail, + wakeupRequestId: newRun.wakeupRequestId, + }, + }); + + await startNextQueuedRunForAgent(agent.id); + return newRun; + } + + if (durableRequest) throw new Error("chat_inbound_wakeup_binding_denied"); + + const activeRuns = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agentId), + inArray(heartbeatRuns.status, [ + ...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES, + ]), + ), + ) + .orderBy(desc(heartbeatRuns.createdAt)); + + const sameScopeQueuedRun = activeRuns.find( + (candidate) => + candidate.status === "queued" && + isSameTaskScope(runTaskKey(candidate), taskKey), + ); + const sameScopeScheduledRetryRun = activeRuns.find( + (candidate) => + candidate.status === "scheduled_retry" && + isSameTaskScope(runTaskKey(candidate), taskKey), + ); + const sameScopeRunningRun = activeRuns.find( + (candidate) => + candidate.status === "running" && + isSameTaskScope(runTaskKey(candidate), taskKey), + ); + const shouldQueueFollowupForRunningWake = + Boolean(sameScopeRunningRun) && + !sameScopeQueuedRun && + shouldQueueFollowupForRunningIssueWake({ + contextSnapshot: enrichedContextSnapshot, + wakeCommentId, + }); + // Unscoped manual wakes need their own receipt and execution identity too. + const rawCoalescedTarget = + opts.allowRunCoalescing === false || opts.manualUserWake + ? null + : (sameScopeQueuedRun ?? + sameScopeScheduledRetryRun ?? + (shouldQueueFollowupForRunningWake + ? null + : (sameScopeRunningRun ?? null))); + + const coalescedTargetRun = filterZombieCoalesceTarget( + rawCoalescedTarget, + liveRunExecutions, + ); + + if (coalescedTargetRun) { + const mergedContextSnapshot = mergeCoalescedContextSnapshot( + coalescedTargetRun.contextSnapshot, + enrichedContextSnapshot, + { + preserveExistingInteractionContinuation: + coalescedTargetRun.status === "queued" || + coalescedTargetRun.status === "scheduled_retry", + }, + ); + const mergedRun = await db + .update(heartbeatRuns) + .set({ + contextSnapshot: mergedContextSnapshot, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, coalescedTargetRun.id)) + .returning() + .then((rows) => rows[0] ?? coalescedTargetRun); + + await db.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason, + payload, + status: "coalesced", + coalescedCount: 1, + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + runId: mergedRun.id, + finishedAt: new Date(), + }); + return mergedRun; + } + + const queueOutcome = await db.transaction(async (tx) => { + await tx.execute( + sql`select id from agents where id = ${agentId} and company_id = ${agent.companyId} for update`, + ); + + const dailyCapBlock = await getHeartbeatDailyCapBlock( + agent, + policy, + {}, + tx, + ); + if (dailyCapBlock) { + const now = new Date(); + await tx.insert(agentWakeupRequests).values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: dailyCapBlock.reason, + payload: { + ...(payload ?? {}), + heartbeatSkip: { + reason: + "Per-agent heartbeat daily cap reached before adapter invocation.", + observed: dailyCapBlock.observed, + limit: dailyCapBlock.limit, + }, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: now, + }); + if (source === "timer") { + await tx + .update(agents) + .set({ + lastHeartbeatAt: now, + updatedAt: now, + }) + .where(eq(agents.id, agentId)); + } + return { kind: "skipped" as const }; + } + + const wakeupRequest = await tx + .insert(agentWakeupRequests) + .values({ + ...durableReceiptFields, + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason, + payload, + status: "queued", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + }) + .returning() + .then((rows) => rows[0]); + + const newRun = await tx + .insert(heartbeatRuns) + .values({ + companyId: agent.companyId, + agentId, + scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company", + issueId: readNonEmptyString(enrichedContextSnapshot.issueId), + invocationSource: source, + triggerDetail, + status: "queued", + responsibleUserId: await resolveQueuedResponsibleUserId(), + wakeupRequestId: wakeupRequest.id, + contextSnapshot: enrichedContextSnapshot, + sessionIdBefore: sessionBefore, + continuationAttempt, + }) + .returning() + .then((rows) => rows[0]); + + await tx + .update(agentWakeupRequests) + .set({ + runId: newRun.id, + updatedAt: new Date(), + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + + return { kind: "queued" as const, run: newRun }; + }); + + if (queueOutcome.kind === "skipped") return null; + const newRun = queueOutcome.run; + + publishLiveEvent({ + companyId: newRun.companyId, + type: "heartbeat.run.queued", + payload: { + runId: newRun.id, + agentId: newRun.agentId, + invocationSource: newRun.invocationSource, + triggerDetail: newRun.triggerDetail, + wakeupRequestId: newRun.wakeupRequestId, + }, + }); + + await startNextQueuedRunForAgent(agent.id); + + return newRun; + } + + /** + * Native status commitment deliberately persists dependency/parent wake + * intents in the same transaction as the authoritative status projection. + * Those rows are not runnable until the heartbeat scheduler has applied its + * normal policy, workspace, concurrency, and responsible-user checks. Bridge + * the durable intent into that scheduler here instead of treating a bare + * `agent_wakeup_requests` row as if it were already a queued heartbeat run. + * + * The intent is claimed before dispatch. A deterministic dispatcher actor id + * lets a later sweep recover the narrow process-crash window after the real + * wake was inserted but before the intent was linked to it. Dispatch failure + * only requeues the intent; it never changes the provider run outcome. + */ + async function dispatchPendingNativeStatusWakeups( + input: { + companyId?: string; + limit?: number; + staleClaimMs?: number; + } = {}, + ) { + const now = new Date(); + const staleClaimMs = Math.max(1_000, input.staleClaimMs ?? 60_000); + const candidates = await db + .select() + .from(agentWakeupRequests) + .where( + and( + input.companyId + ? eq(agentWakeupRequests.companyId, input.companyId) + : undefined, + eq(agentWakeupRequests.requestedByActorType, "system"), + eq(agentWakeupRequests.requestedByActorId, "native-status-committer"), + inArray(agentWakeupRequests.status, ["queued", "claimed"]), + isNull(agentWakeupRequests.runId), + ), + ) + .orderBy(asc(agentWakeupRequests.requestedAt)) + .limit(Math.max(1, Math.min(input.limit ?? 100, 500))); + + let dispatched = 0; + let recovered = 0; + let deferred = 0; + const deliveredByIssueScope = new Map< + string, + { runId: string | null; status: string } + >(); + + for (const candidate of candidates) { + const dispatchActorId = `native-status-wake-dispatch:${candidate.id}`; + const existingDispatch = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, candidate.companyId), + eq(agentWakeupRequests.requestedByActorType, "system"), + eq(agentWakeupRequests.requestedByActorId, dispatchActorId), + ), + ) + .orderBy(desc(agentWakeupRequests.requestedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existingDispatch) { + const recoveredStatus = existingDispatch.runId + ? "coalesced" + : existingDispatch.status === "deferred_issue_execution" + ? "coalesced" + : existingDispatch.status; + await db + .update(agentWakeupRequests) + .set({ + status: recoveredStatus, + runId: existingDispatch.runId, + finishedAt: + (existingDispatch.runId ?? existingDispatch.finishedAt) + ? (existingDispatch.finishedAt ?? now) + : null, + error: existingDispatch.error, + updatedAt: now, + }) + .where( + and( + eq(agentWakeupRequests.id, candidate.id), + isNull(agentWakeupRequests.runId), + ), + ); + recovered += 1; + continue; + } + + if ( + candidate.status === "claimed" && + candidate.claimedAt && + now.getTime() - candidate.claimedAt.getTime() < staleClaimMs + ) { + deferred += 1; + continue; + } + + const claimed = await db + .update(agentWakeupRequests) + .set({ + status: "claimed", + claimedAt: now, + error: null, + updatedAt: now, + }) + .where( + and( + eq(agentWakeupRequests.id, candidate.id), + isNull(agentWakeupRequests.runId), + candidate.status === "claimed" + ? eq(agentWakeupRequests.status, "claimed") + : eq(agentWakeupRequests.status, "queued"), + ), + ) + .returning({ id: agentWakeupRequests.id }); + if (claimed.length === 0) continue; + + const payload = parseObject(candidate.payload); + const wakeContext = parseObject(payload._paperclipWakeContext); + const issueId = + readNonEmptyString(payload.issueId) ?? + readNonEmptyString(payload.taskId) ?? + readNonEmptyString(wakeContext.issueId) ?? + null; + const scopeKey = issueId + ? `${candidate.companyId}:${candidate.agentId}:${issueId}:${readNativeReviewAssignmentContext(wakeContext)?.nativeReviewInteractionId ?? ""}` + : null; + const priorDelivery = scopeKey + ? deliveredByIssueScope.get(scopeKey) + : null; + if (priorDelivery) { + await db + .update(agentWakeupRequests) + .set({ + status: "coalesced", + runId: priorDelivery.runId, + finishedAt: new Date(), + error: null, + updatedAt: new Date(), + }) + .where(eq(agentWakeupRequests.id, candidate.id)); + recovered += 1; + continue; + } + + let completedOnboardingGuard: WakeupOptions["issueStateGuard"]; + if (issueId) { + const targetIssue = await db + .select({ + status: issues.status, + statusVersion: issues.statusVersion, + assigneeAgentId: issues.assigneeAgentId, + }) + .from(issues) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, candidate.companyId), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + const nativeReview = candidate.reason === "native_completion_review" + ? await getNativeReviewAssignment(db, { + companyId: candidate.companyId, issueId, agentId: candidate.agentId, + contextSnapshot: wakeContext, + }) + : null; + const onboardingResultReport = targetIssue?.status === "done" && await isCompletedOnboardingHandoffWake(db, { + companyId: candidate.companyId, issueId, agentId: candidate.agentId, + reason: candidate.reason, contextSnapshot: wakeContext, + }); + if (onboardingResultReport) completedOnboardingGuard = { assigneeAgentId: candidate.agentId, statuses: ["done"], statusVersion: targetIssue!.statusVersion }; + if ( + !targetIssue || + (["done", "cancelled"].includes(targetIssue.status) && !onboardingResultReport) || + (targetIssue.assigneeAgentId !== candidate.agentId && !nativeReview) || + (candidate.reason === "native_completion_review" && !nativeReview) + ) { + await db + .update(agentWakeupRequests) + .set({ + status: "skipped", + finishedAt: new Date(), + error: !targetIssue + ? "Native status wake target no longer exists" + : ["done", "cancelled"].includes(targetIssue.status) + ? `Native status wake target is already ${targetIssue.status}` + : "Native status wake target has a different assignee", + updatedAt: new Date(), + }) + .where(eq(agentWakeupRequests.id, candidate.id)); + recovered += 1; + continue; + } + } + + try { + const wakeRun = await enqueueWakeup(candidate.agentId, { + source: candidate.source as WakeupOptions["source"], + triggerDetail: (candidate.triggerDetail ?? + "system") as WakeupOptions["triggerDetail"], + reason: candidate.reason, + payload, + idempotencyKey: candidate.idempotencyKey, + requestedByActorType: "system", + requestedByActorId: dispatchActorId, + ...(completedOnboardingGuard ? { issueStateGuard: completedOnboardingGuard } : {}), + contextSnapshot: { + ...wakeContext, + ...(issueId ? { issueId, taskId: issueId } : {}), + wakeReason: candidate.reason, + source: "native_status_decision", + statusDecisionSource: "native_status_decision", + nativeStatusWakeIntentId: candidate.id, + }, + }); + + const delivered = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, candidate.companyId), + eq(agentWakeupRequests.requestedByActorType, "system"), + eq(agentWakeupRequests.requestedByActorId, dispatchActorId), + ), + ) + .orderBy(desc(agentWakeupRequests.requestedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + + // The committer intent is the durable outbox entry. When admission is + // blocked, enqueueWakeup creates a separate deferred dispatch receipt; + // leave the original intent coalesced so the release drain cannot + // promote both rows (the original has no nativeStatusWakeIntentId + // provenance and would otherwise run once before the dispatch receipt). + const deliveredStatus = delivered?.status ?? "queued"; + await db + .update(agentWakeupRequests) + .set({ + status: wakeRun || deliveredStatus === "deferred_issue_execution" + ? "coalesced" + : deliveredStatus, + runId: wakeRun?.id ?? delivered?.runId ?? null, + finishedAt: + wakeRun || delivered?.finishedAt + ? (delivered?.finishedAt ?? new Date()) + : null, + error: delivered?.error ?? null, + updatedAt: new Date(), + }) + .where(eq(agentWakeupRequests.id, candidate.id)); + + if (scopeKey) { + deliveredByIssueScope.set(scopeKey, { + runId: wakeRun?.id ?? delivered?.runId ?? null, + status: wakeRun ? "coalesced" : (delivered?.status ?? "queued"), + }); + } + + if (wakeRun) dispatched += 1; + else deferred += 1; + } catch (error) { + await db + .update(agentWakeupRequests) + .set({ + status: "queued", + claimedAt: null, + error: + error instanceof Error + ? error.message.slice(0, 1_000) + : String(error).slice(0, 1_000), + updatedAt: new Date(), + }) + .where( + and( + eq(agentWakeupRequests.id, candidate.id), + eq(agentWakeupRequests.status, "claimed"), + isNull(agentWakeupRequests.runId), + ), + ); + logger.warn( + { + err: error, + wakeupRequestId: candidate.id, + agentId: candidate.agentId, + }, + "failed to dispatch persisted native status wake intent", + ); + deferred += 1; + } + } + + return { scanned: candidates.length, dispatched, recovered, deferred }; + } + + return { + enqueueWakeup, + dispatchPendingNativeStatusWakeups, + startNextQueuedRunForAgent, + countRunningRunsForAgent, + releaseRunClaimedJustBeforeSuppression, + claimQueuedRun, + withChatControlRecoveryGate, + trackWakeup, + resumeQueuedRuns, + parseHeartbeatPolicy, + claimDueTimerHeartbeat, + }; +} diff --git a/server/src/services/heartbeat/recovery.test.ts b/server/src/services/heartbeat/recovery.test.ts new file mode 100644 index 00000000000..a925edfaa79 --- /dev/null +++ b/server/src/services/heartbeat/recovery.test.ts @@ -0,0 +1,268 @@ +import { randomUUID } from "node:crypto"; +import type { Db } from "@paperclipai/db"; +import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues, nativeRunFinalizations } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { environmentRuntimeService } from "../environment-runtime.js"; +import { environmentService } from "../environments.js"; +import { createHeartbeatRecovery, type HeartbeatRecoveryDependencies } from "./recovery.js"; +import type { HotRestartIntent } from "../hot-restart.js"; +import type { NativeRestartRecoveryClaim, NativeRestartRecoveryDisposition } from "../native-runtime/index.js"; +import { NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE } from "../native-runtime/native-runner-ownership.js"; + +type Run = typeof heartbeatRuns.$inferSelect; + +const native = vi.hoisted(() => ({ + closeIdle: vi.fn(async () => ({ closed: 0, failed: 0 })), + detach: vi.fn(async () => 0), + finalizations: vi.fn(async (_db: Db, _ids?: string[], _options?: unknown) => undefined), + cleanups: vi.fn(async () => undefined), + drainMaintenance: vi.fn<() => Promise>(async () => undefined), + claims: vi.fn<() => Promise>(async () => []), +})); +const hotRestart = vi.hoisted(() => ({ + read: vi.fn<() => Promise>(async () => null), +})); +vi.mock("../hot-restart.js", async (importOriginal) => ({ + ...await importOriginal(), + readHotRestartIntent: hotRestart.read, +})); +vi.mock("../native-runtime/index.js", async (importOriginal) => ({ + ...await importOriginal(), + closeIdleWarmNativeSessionsForRestart: native.closeIdle, + detachNativeSessionsForRestart: native.detach, + reconcileNativeFinalizations: native.finalizations, + reconcileRetainedNativeSessionCleanups: native.cleanups, + claimNativeRestartRecoveries: native.claims, +})); +vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => ({ + ...await importOriginal(), + drainRetainedRunnerdMaintenanceOperations: native.drainMaintenance, +})); + +function callbacks(db: Db) { + return { + enterShutdown: vi.fn(), + getRun: vi.fn(async () => null), + getAgent: vi.fn(async () => null), + appendRunEvent: vi.fn(async () => undefined), + setRunStatusIfRunning: vi.fn(async () => ({ updated: false, run: null })), + setRunStatusFromLive: vi.fn(async () => ({ updated: false, run: null })), + setRunStatus: vi.fn(async () => null), + setWakeupStatus: vi.fn(async () => undefined), + releaseIssueExecutionAndPromote: vi.fn(async () => undefined), + finalizeAgentStatus: vi.fn(async () => undefined), + environmentRuntime: environmentRuntimeService(db), + environmentsSvc: environmentService(db), + instructionCopies: { + recoverStopped: vi.fn(async () => 0), + recoverCaptured: vi.fn(async () => 0), + }, + runtimeEnv: {}, + activeRunExecutions: new Set(), + activeRunExecutionPromises: new Set>(), + scheduleBoundedRetryForRun: vi.fn(async () => ({ outcome: "retry_exhausted", retryReason: "transient_failure", attempt: 1, maxAttempts: 0 })), + scheduleInteractionContinuationInfrastructureRetryIfEligible: vi.fn(async () => null), + timerClaimWasFirstHeartbeat: vi.fn(() => undefined), + executeRun: vi.fn(async () => undefined), + scheduleNativeSessionResumeDispatch: vi.fn(), + cancelHeartbeatNativeRun: vi.fn(async () => undefined), + terminateHeartbeatRunProcess: vi.fn(async () => undefined), + mergeRunStopMetadataForAgent: vi.fn((_agent, _outcome, options) => options?.resultJson ?? null), + classifyAndPersistRunLiveness: vi.fn(async run => run), + releaseEnvironmentLeasesForRun: vi.fn(async () => undefined), + acknowledgeRemoteStop: vi.fn(async () => undefined), + resumeRemoteStopComments: vi.fn(async () => undefined), + dispatchPendingNativeStatusWakeups: vi.fn(async () => undefined), + cancelRunInternal: vi.fn(async () => undefined), + startNextQueuedRunForAgent: vi.fn(async () => undefined), + } satisfies HeartbeatRecoveryDependencies; +} + +function guardedDatabase() { + const access = vi.fn(() => { throw new Error("Unexpected recovery database access"); }); + return { db: new Proxy({}, { get: access }) as Db, access }; +} + +beforeEach(() => { + vi.clearAllMocks(); + hotRestart.read.mockResolvedValue(null); + native.closeIdle.mockResolvedValue({ closed: 0, failed: 0 }); + native.finalizations.mockResolvedValue(undefined); + native.cleanups.mockResolvedValue(undefined); + native.drainMaintenance.mockResolvedValue(undefined); + native.claims.mockResolvedValue([]); +}); + +describe("heartbeat recovery module callbacks", () => { + it("constructs without database access, shutdown, or background work", () => { + const database = guardedDatabase(); + const deps = callbacks(database.db); + createHeartbeatRecovery(database.db, deps); + expect(database.access).not.toHaveBeenCalled(); + for (const callback of Object.values(deps)) if (typeof callback === "function") expect(callback).not.toHaveBeenCalled(); + for (const callback of Object.values(native)) expect(callback).not.toHaveBeenCalled(); + }); + + it.each(["not_requested", "read_error"] as const)("enters shutdown before checkpointing for %s", async (mode) => { + const database = guardedDatabase(); + const deps = callbacks(database.db); + if (mode === "read_error") hotRestart.read.mockRejectedValueOnce(new Error("unreadable intent")); + const recovery = createHeartbeatRecovery(database.db, deps); + expect(await recovery.prepareHotRestartShutdown("SIGTERM")).toEqual({ mode, skipDrain: false, activeRunIds: [] }); + expect(native.closeIdle).toHaveBeenCalledAfter(deps.enterShutdown); + expect(hotRestart.read).toHaveBeenCalledAfter(native.closeIdle); + expect(database.access).not.toHaveBeenCalled(); + }); + + it("does not adopt or query runs without a restart intent", async () => { + const database = guardedDatabase(); + expect(await createHeartbeatRecovery(database.db, callbacks(database.db)).reconcileHotRestartAdoption()).toEqual({ + mode: "not_requested", adoptedRunIds: [], finalizedWhileDownRunIds: [], lostRunIds: [], skippedRunIds: [], + }); + expect(database.access).not.toHaveBeenCalled(); + }); + + it("does not broaden an empty selective shutdown drain to every run", async () => { + const database = guardedDatabase(); + const deps = callbacks(database.db); + expect(await createHeartbeatRecovery(database.db, deps).drainRunningRunsForShutdown("SIGINT", new Date(), [])).toEqual({ + interrupted: 0, interruptedRunIds: [], retryRunIds: [], restartSuspendedRunIds: [], + }); + expect(database.access).not.toHaveBeenCalled(); + expect(deps.cancelHeartbeatNativeRun).not.toHaveBeenCalled(); + expect(deps.terminateHeartbeatRunProcess).not.toHaveBeenCalled(); + }); + + it.each(["running", "failed"] as const)("keeps a %s ownership hold when a status race wins", async (status) => { + const database = guardedDatabase(); + const deps = callbacks(database.db); + const run = { id: "run", runtimeMode: "native", status } as Run; + const winner = { ...run, status: "cancelled" }; + deps.setRunStatusFromLive.mockResolvedValue({ updated: false, run: winner }); + expect(await createHeartbeatRecovery(database.db, deps).markNativeOwnershipUnverified(run, { reason: "live_process_identifier" })).toBe(winner); + expect(deps.setRunStatusFromLive).toHaveBeenCalledWith("run", status, [status], expect.objectContaining({ errorCode: NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE })); + expect(deps.appendRunEvent).not.toHaveBeenCalled(); + expect(database.access).not.toHaveBeenCalled(); + }); + + it("persists an authentication ownership hold before its event without terminating or retrying", async () => { + const database = guardedDatabase(); + const deps = callbacks(database.db); + const run = { id: "run", runtimeMode: "native", status: "running" } as Run; + const blocked = { ...run, nativePhase: "terminal_failure", errorCode: NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE }; + deps.setRunStatusFromLive.mockResolvedValue({ updated: true, run: blocked }); + expect(await createHeartbeatRecovery(database.db, deps).markNativeOwnershipUnverified(run, { reason: "adopted_runner_authentication_timeout" })).toBe(blocked); + expect(deps.setRunStatusFromLive).toHaveBeenCalledWith("run", "running", ["running"], expect.objectContaining({ nativePhase: "terminal_failure" })); + expect(deps.appendRunEvent).toHaveBeenCalledAfter(deps.setRunStatusFromLive); + expect(deps.cancelHeartbeatNativeRun).not.toHaveBeenCalled(); + expect(deps.terminateHeartbeatRunProcess).not.toHaveBeenCalled(); + expect(deps.scheduleBoundedRetryForRun).not.toHaveBeenCalled(); + expect(database.access).not.toHaveBeenCalled(); + }); +}); + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; +if (!support.supported) console.warn(`Skipping recovery module database tests: ${support.reason}`); + +describePostgres("heartbeat recovery module ownership", () => { + let db: Db; + let database: Awaited>; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-module-"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await database?.cleanup(); }); + + async function seedRun() { + const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(), issueId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Recovery", issuePrefix: `T${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Recovery", role: "engineer", adapterType: "process" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Recover this run", status: "in_progress", assigneeAgentId: agentId }); + return (await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, invocationSource: "automation", status: "running", runtimeMode: "legacy" }).returning())[0]; + } + + it("uses the shared live-execution set after construction across two factories", async () => { + const run = await seedRun(); + const deps = callbacks(db); + const first = createHeartbeatRecovery(db, deps); + const second = createHeartbeatRecovery(db, deps); + deps.activeRunExecutions.add(run.id); + expect(await first.reapOrphanedRuns()).toEqual({ reaped: 0, runIds: [] }); + expect(await second.reapOrphanedRuns()).toEqual({ reaped: 0, runIds: [] }); + expect(deps.setRunStatusFromLive).not.toHaveBeenCalled(); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0].status).toBe("running"); + await Promise.all([...deps.activeRunExecutionPromises]); + await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)); + }); + + it("settles persisted native results before claiming restart authority and tracks the exact execution", async () => { + const deps = callbacks(db); + const claim: NativeRestartRecoveryClaim = { kind: "bootstrap_incomplete", runId: randomUUID(), leaseOwner: "claimed-owner", controllerGeneration: 3, providerAttempt: 2, restartKind: "hard", recoveryRequestId: null }; + native.claims.mockResolvedValue([claim]); + let finish!: () => void; + const execution = new Promise(resolve => { finish = resolve; }); + deps.executeRun.mockReturnValue(execution); + const result = await createHeartbeatRecovery(db, deps).recoverNativeRunsAfterRestart(); + expect(result.claims).toEqual([claim]); + expect(native.claims).toHaveBeenCalledAfter(native.finalizations); + expect(deps.executeRun).toHaveBeenCalledWith(claim.runId, { nativeLeaseOwner: claim.leaseOwner, nativeRestartRecovery: claim }); + expect(deps.activeRunExecutionPromises.size).toBeGreaterThan(0); + finish(); + await Promise.all([...deps.activeRunExecutionPromises]); + await vi.waitFor(() => expect(deps.activeRunExecutionPromises.size).toBe(0)); + }); + + it("does not claim or execute recovery when persisted finalization fails", async () => { + const deps = callbacks(db); + native.finalizations.mockRejectedValueOnce(new Error("result settlement failed")); + await expect(createHeartbeatRecovery(db, deps).recoverNativeRunsAfterRestart()).rejects.toThrow("result settlement failed"); + expect(native.claims).not.toHaveBeenCalled(); + expect(deps.executeRun).not.toHaveBeenCalled(); + expect(native.cleanups).not.toHaveBeenCalled(); + }); + + it("keeps blocked and ambiguous ownership out of execution", async () => { + const deps = callbacks(db); + const dispositions: NativeRestartRecoveryDisposition[] = [ + { kind: "blocked", runId: randomUUID(), reason: "conflicting_owner" }, + { kind: "awaiting_evidence", runId: randomUUID(), reason: "missing_stop_evidence" }, + ]; + native.claims.mockResolvedValue(dispositions); + const result = await createHeartbeatRecovery(db, deps).recoverNativeRunsAfterRestart(); + expect(result.blockedRunIds).toEqual([dispositions[0].runId]); + expect(result.awaitingEvidenceRunIds).toEqual([dispositions[1].runId]); + expect(result.claims).toEqual([]); + expect(deps.executeRun).not.toHaveBeenCalled(); + await Promise.all([...deps.activeRunExecutionPromises]); + }); + + it("rearms the persisted native retry deadline through the service callback", async () => { + const run = await seedRun(); + const nextAttemptAt = new Date(Date.now() + 60_000); + await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: run.issueId, status: "failed" }).where(eq(heartbeatRuns.id, run.id)); + await db.insert(nativeRunFinalizations).values({ runId: run.id, companyId: run.companyId, issueId: run.issueId!, phase: "retryable_failure", nextAttemptAt }); + const deps = callbacks(db); + const result = await createHeartbeatRecovery(db, deps).recoverNativeRunsAfterRestart(); + expect(result.scheduledRetryRunIds).toEqual([run.id]); + expect(deps.scheduleNativeSessionResumeDispatch).toHaveBeenCalledWith(run.id, nextAttemptAt); + expect(deps.executeRun).not.toHaveBeenCalled(); + await Promise.all([...deps.activeRunExecutionPromises]); + await db.delete(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id)); + await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)); + }); + + it("retains shutdown ownership until background native cleanup physically settles", async () => { + const deps = callbacks(db); + let settle!: () => void; + native.drainMaintenance.mockReturnValueOnce(new Promise(resolve => { settle = resolve; })); + await createHeartbeatRecovery(db, deps).recoverNativeRunsAfterRestart(); + await vi.waitFor(() => expect(native.drainMaintenance).toHaveBeenCalled()); + expect(deps.activeRunExecutionPromises.size).toBe(1); + settle(); + await Promise.all([...deps.activeRunExecutionPromises]); + await vi.waitFor(() => expect(deps.activeRunExecutionPromises.size).toBe(0)); + }); +}); diff --git a/server/src/services/heartbeat/recovery.ts b/server/src/services/heartbeat/recovery.ts new file mode 100644 index 00000000000..4798251a1fd --- /dev/null +++ b/server/src/services/heartbeat/recovery.ts @@ -0,0 +1,2423 @@ +import { preserveWorkspaceRestoreRecoveryMetadataSql } from "../legacy-workspace-restore-recovery.js"; +import { CHAT_COMPLETION_WAKE_REASON } from "../chat-completion-delivery.js"; +import { + hasStopOnlyCleanup, + settleStopOnlyCleanup, +} from "../sandbox-stop-and-retain.js"; +import { recordNativeLocalProcessStop } from "../native-local-process-stop.js"; +import { + legacyControllerBootId, + hasLiveLegacyController, + revokeExpiredLegacyController, +} from "../legacy-controller-lease.js"; +import { remoteTerminationReceipt } from "../remote-execution-termination.js"; +import { runUsedConversationAdapter } from "../conversation-continuation.js"; +import { legacyExecutionNeedsReconciliationWithEvidence } from "../legacy-execution-recovery.js"; +import { randomUUID } from "node:crypto"; +import { + and, + asc, + eq, + gt, + inArray, + isNull, + lte, + ne, + or, + sql, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + environmentLeases, + heartbeatRuns, + issues, + nativeRunFinalizations, +} from "@paperclipai/db"; +import { logger } from "../../middleware/logger.js"; +import { + claimNativeRestartRecoveries, + closeIdleWarmNativeSessionsForRestart, + currentNativeControllerIdentity, + dispatchNativeSessionResumptions, + detachNativeSessionsForRestart, + isNativeSessionId, + readNativeWorkspaceSyncReference, + type NativeRestartRecoveryClaim, + reconcileNativeFinalizations, + reconcileRetainedNativeSessionCleanup, + reconcileRetainedNativeSessionCleanups, +} from "../native-runtime/index.js"; +import { drainRetainedRunnerdMaintenanceOperations } from "../../vendor/paperclip-runner/index.js"; +import { runningProcesses } from "../../adapters/index.js"; +import { parseObject } from "../../adapters/utils.js"; +import { + isNativeRunnerOwnershipHeld, + NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE, +} from "../native-runtime/native-runner-ownership.js"; +import { performance } from "node:perf_hooks"; +import { buildProcessLossDiagnostic } from "../process-loss-diagnostics.js"; +import { logActivity } from "../activity-log.js"; +import { releaseRuntimeServicesForRun } from "../workspace-runtime.js"; +import { isProcessGroupAlive } from "../local-service-supervisor.js"; +import { + findMissingHotRestartSnapshotRunIds, + readHotRestartIntent, + removeHotRestartIntent, + shouldHonorHotRestartIntentForProcess, + writeHotRestartReport, + writeHotRestartShutdownSnapshot, + type HotRestartIntentRun, + type HotRestartReportRun, +} from "../hot-restart.js"; +import { serverVersion } from "../../version.js"; + +import type { environmentService } from "../environments.js"; +import type { environmentRuntimeService, ProviderResourceDisposition } from "../environment-runtime.js"; +import type { agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js"; +import type { createHeartbeatRunState } from "./run-state.js"; +import type { HeartbeatRetryDependencies, createHeartbeatRetries } from "./retries.js"; + +type HeartbeatRun = typeof heartbeatRuns.$inferSelect; +type HeartbeatRetries = ReturnType; + +/** Recovery owns reconciliation; the service retains execution and lifecycle effects. */ +export interface HeartbeatRecoveryDependencies extends Pick { + enterShutdown: () => void; + getRun: (runId: string, options?: Parameters["getRun"]>[1]) => Promise< + Awaited["getRun"]>> | null + >; + environmentRuntime: ReturnType; + environmentsSvc: Pick, "getById" | "getLeaseById" | "releaseLease">; + instructionCopies: Pick, "recoverStopped" | "recoverCaptured">; + runtimeEnv: Record; + // Preserve the module-wide sets shared by scheduler and route service instances. + activeRunExecutions: Set; + activeRunExecutionPromises: Set>; + scheduleBoundedRetryForRun: HeartbeatRetries["scheduleBoundedRetryForRun"]; + scheduleInteractionContinuationInfrastructureRetryIfEligible: HeartbeatRetries["scheduleInteractionContinuationInfrastructureRetryIfEligible"]; + timerClaimWasFirstHeartbeat: HeartbeatRetries["timerClaimWasFirstHeartbeat"]; + executeRun: (runId: string, options?: { + nativeLeaseOwner?: string; + nativeRestartRecovery?: NativeRestartRecoveryClaim; + }) => Promise; + scheduleNativeSessionResumeDispatch: (runId: string, nextAttemptAt: Date) => void; + cancelHeartbeatNativeRun: (input: { + db: Db; runId: string; reason: string; runtimeMode: string | null; + }) => Promise; + terminateHeartbeatRunProcess: (input: { + pid: number | null | undefined; + processGroupId: number | null | undefined; + graceMs?: number; + }) => Promise; + setRunStatus: (runId: string, status: string, patch?: Partial) => Promise; + setRunStatusFromLive: (runId: string, status: string, fromStatuses: string[], patch?: Partial) => Promise<{ + updated: boolean; + run: HeartbeatRun | null; + }>; + mergeRunStopMetadataForAgent: ( + agent: Pick, + outcome: "succeeded" | "interrupted" | "failed" | "cancelled" | "timed_out", + options?: { + resultJson?: Record | null; + conversationContinuationEligible?: boolean; + errorCode?: string | null; + errorMessage?: string | null; + }, + ) => Record | null; + classifyAndPersistRunLiveness: (run: HeartbeatRun, resultJson?: Record | null) => Promise; + releaseEnvironmentLeasesForRun: (input: { + runId: string; + companyId: string; + agentId: string; + status: string | null | undefined; + failureReason?: string | null; + providerResourceDisposition?: ProviderResourceDisposition; + }) => Promise; + acknowledgeRemoteStop: (runId: string, companyId: string) => Promise; + resumeRemoteStopComments: (run: HeartbeatRun) => Promise; + dispatchPendingNativeStatusWakeups: () => Promise; + cancelRunInternal: (runId: string, reason?: string, options?: { resultJson?: Record }) => Promise; + startNextQueuedRunForAgent: (agentId: string) => Promise; +} + +export const DETACHED_PROCESS_ERROR_CODE = "process_detached"; + +const NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE = + "Native execution ownership could not be verified; automatic recovery is blocked"; + +// The reaper sweeps at most this many pending_cleanup leases per tick. +const PENDING_CLEANUP_SWEEP_PAGE_SIZE = 20; + +const pendingCleanupAttemptsInFlight = new Set(); + +// Escalate and slow cleanup after this many attempts; never abandon a live lease. +const PENDING_CLEANUP_SWEEP_ATTEMPT_CAP = 5; + +// The reaper stores its retry state under these keys in the lease metadata. +const PENDING_CLEANUP_ATTEMPTS_METADATA_KEY = "pendingCleanupRetryAttempts"; + +const PENDING_CLEANUP_CAP_WARNED_METADATA_KEY = "pendingCleanupRetryCapWarned"; + +// The reaper sweeps at most this many orphaned active leases per tick. +const ORPHANED_ACTIVE_LEASE_SWEEP_PAGE_SIZE = 20; + +// A provider or plugin destroy rejection can carry a bearer credential, a +// signed URL, or provider response detail in its name, code, message, cause, or +// stack. The exception fields cross the server boundary, so they are not a +// trusted enum. The pending_cleanup sweep logs never read the exception. Each +// catch site logs a constant, locally generated `errorKind` instead. +const PENDING_CLEANUP_RETRY_ERROR_KIND = "destroy_failed"; + +const PENDING_CLEANUP_SWEEP_ERROR_KIND = "sweep_failed"; + +const ORPHANED_ACTIVE_LEASE_SWEEP_ERROR_KIND = "orphaned_active_lease_sweep_failed"; + +// Read the stored retry attempt count as a safe value, directly in SQL. A +// provider can write a malformed value under the attempts key. The type guard +// makes any non-number value read as zero. The reader computes as numeric and +// never casts to int, so a finite number outside the 32-bit range (for example +// 1e300) never throws. The reader clamps a negative value to zero and a positive +// value to the attempt cap. One malformed lease therefore never aborts the page +// sweep. This matches the TypeScript reader `readPendingCleanupRetryAttempts`, +// which clamps to the same range. The claim predicate compares the two readers, +// so both must yield the same value for every input. +function pendingCleanupAttemptsSql() { + return sql` + case + when jsonb_typeof(${environmentLeases.metadata} -> ${PENDING_CLEANUP_ATTEMPTS_METADATA_KEY}) = 'number' + then least( + greatest( + floor((${environmentLeases.metadata} ->> ${PENDING_CLEANUP_ATTEMPTS_METADATA_KEY})::numeric), + 0 + ), + ${PENDING_CLEANUP_SWEEP_ATTEMPT_CAP} + ) + else 0 + end`; +} + +function pendingCleanupRetryDueSql(explicitRetry = false) { + // Ownership renewal and retry cooldown are separate clocks. A late renewal + // cannot change when a completed attempt may retry. Older claims used the + // retry field for both clocks, so retain that fallback until they settle. + const deadline = sql`case + when ${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true' + and ${environmentLeases.metadata} ? 'pendingCleanupLeaseExpiresAtMs' + then ${environmentLeases.metadata}->'pendingCleanupLeaseExpiresAtMs' + else ${environmentLeases.metadata}->'pendingCleanupRetryAfterMs' end`; + return sql`case + when ${explicitRetry} and coalesce(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') != 'true' then true + when jsonb_typeof(${deadline}) = 'number' + then (${deadline})::numeric <= ${Date.now()} + or (${deadline})::numeric > ${Date.now() + 30 * 60_000 + 1_000} + else true end`; +} + +// Choose the `jsonb_set` target root. A provider can write a scalar or array +// metadata root. `jsonb_set` fails on a non-object root, so the reader uses the +// stored metadata only when its root is an object. A NULL, scalar, or array root +// reads as an empty object. `jsonb_typeof(NULL)` is NULL, so the else branch also +// covers a NULL root. +function pendingCleanupMetadataObjectSql() { + return sql`case when jsonb_typeof(${environmentLeases.metadata}) = 'object' then ${environmentLeases.metadata} else '{}'::jsonb end`; +} + +// Read the stored cap-warned flag as a safe boolean, directly in SQL. A +// malformed value reads as false, so the boolean cast never throws. +function pendingCleanupCapWarnedSql() { + return sql`coalesce( + case + when jsonb_typeof(${environmentLeases.metadata} -> ${PENDING_CLEANUP_CAP_WARNED_METADATA_KEY}) = 'boolean' + then (${environmentLeases.metadata} ->> ${PENDING_CLEANUP_CAP_WARNED_METADATA_KEY})::boolean + else false + end, + false + )`; +} + +export const CANCELLABLE_HEARTBEAT_RUN_STATUSES = [ + "queued", + "running", + "scheduled_retry", +] as const; + +const NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY = "nativeQuestionCancellation"; + +export const HEARTBEAT_RUN_TERMINAL_STATUSES = [ + "succeeded", + "interrupted", + "failed", + "cancelled", + "timed_out", +] as const; + +const SESSIONED_LOCAL_ADAPTERS = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "hermes_local", + "kimi_local", + "opencode_local", + "pi_local", +]); + +function isTrackedLocalChildProcessAdapter(adapterType: string) { + return SESSIONED_LOCAL_ADAPTERS.has(adapterType); +} + +// A positive liveness check means some process currently owns the PID. +// On Linux, PIDs can be recycled, so this is a best-effort signal rather +// than proof that the original child is still alive. +export function isProcessAlive(pid: number | null | undefined) { + if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) + return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "EPERM") return true; + if (code === "ESRCH") return false; + return false; + } +} + +function buildProcessLossMessage( + run: { + processPid: number | null; + processGroupId: number | null; + }, + options?: { descendantOnly?: boolean }, +) { + if (options?.descendantOnly && run.processGroupId) { + return `Process lost -- parent pid ${run.processPid ?? "unknown"} exited, but descendant process group ${run.processGroupId} was still alive and was terminated`; + } + if (run.processPid) { + return `Process lost -- child pid ${run.processPid} is no longer running`; + } + if (run.processGroupId) { + return `Process lost -- process group ${run.processGroupId} is no longer running`; + } + return "Process lost -- server may have restarted"; +} + +function readHotRestartAdoptionMetadata( + resultJson: Record | null | undefined, +) { + const result = parseObject(resultJson); + const hotRestart = parseObject(result.hotRestart); + if (hotRestart.adopted !== true || typeof hotRestart.adoptedAt !== "string") + return null; + return hotRestart; +} + +function mergeHotRestartAdoptionResultJson( + resultJson: Record | null | undefined, + input: { + adoptedAt: Date; + previousServerPid: number; + newServerPid: number; + previousServerVersion: string | null; + newServerVersion: string; + processPid: number | null; + processGroupId: number | null; + }, +) { + const result = parseObject(resultJson); + const existing = parseObject(result.hotRestart); + return { + ...result, + hotRestart: { + ...existing, + adopted: true, + adoptedAt: input.adoptedAt.toISOString(), + previousServerPid: input.previousServerPid, + newServerPid: input.newServerPid, + previousServerVersion: input.previousServerVersion, + newServerVersion: input.newServerVersion, + processPid: input.processPid, + processGroupId: input.processGroupId, + }, + }; +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** Restart recovery shares the service lifecycle callbacks and execution ownership. */ +export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryDependencies) { + const { + enterShutdown, + scheduleBoundedRetryForRun, + appendRunEvent, + environmentRuntime, + scheduleNativeSessionResumeDispatch, + getRun, + executeRun, + activeRunExecutionPromises, + cancelHeartbeatNativeRun, + terminateHeartbeatRunProcess, + setRunStatusIfRunning, + mergeRunStopMetadataForAgent, + setWakeupStatus, + classifyAndPersistRunLiveness, + releaseEnvironmentLeasesForRun, + releaseIssueExecutionAndPromote, + finalizeAgentStatus, + timerClaimWasFirstHeartbeat, + environmentsSvc, + acknowledgeRemoteStop, + resumeRemoteStopComments, + setRunStatusFromLive, + instructionCopies, + dispatchPendingNativeStatusWakeups, + runtimeEnv, + cancelRunInternal, + activeRunExecutions, + setRunStatus, + getAgent, + scheduleInteractionContinuationInfrastructureRetryIfEligible, + startNextQueuedRunForAgent, + } = dependencies; + + async function enqueueProcessLossRetry( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + now: Date, + ) { + // Completion deliveries own their durable bounded retry and reply identity. + // A second process-loss retry would compete for the same outbox input. + if (run.contextSnapshot?.wakeReason === CHAT_COMPLETION_WAKE_REASON && + Array.isArray(run.contextSnapshot?.chatCompletionDeliveryIds)) return null; + // Native sessions have their own fenced same-run controller. Legacy + // bootstrap recovery shares the durable delay and incident counter with + // transient retries; process loss must not open a second retry budget. + if (run.runtimeMode === "native" || await legacyExecutionNeedsReconciliationWithEvidence(db, run)) + return null; + const scheduled = await scheduleBoundedRetryForRun(run, agent, { now }); + return scheduled.outcome === "scheduled" ? scheduled.run : null; + } + + function toHotRestartIntentRun(input: { + run: typeof heartbeatRuns.$inferSelect; + adapterType: string; + }): HotRestartIntentRun { + const context = parseObject(input.run.contextSnapshot); + return { + runId: input.run.id, + companyId: input.run.companyId, + agentId: input.run.agentId, + adapterType: input.adapterType, + status: input.run.status, + processPid: input.run.processPid ?? null, + processGroupId: input.run.processGroupId ?? null, + issueId: readNonEmptyString(context.issueId), + runtimeMode: input.run.runtimeMode, + nativeSessionId: input.run.nativeSessionId, + runnerInstanceId: input.run.runnerInstanceId, + processStartedAt: input.run.processStartedAt?.toISOString() ?? null, + }; + } + + function isServerStdioBoundHotRestartRun(input: { + run: typeof heartbeatRuns.$inferSelect; + adapterType: string; + adapterConfig: unknown; + }) { + const context = parseObject(input.run.contextSnapshot); + if ( + context.processTopology === "server_stdio" || + context.executionEngine === "acp" + ) { + return true; + } + if ( + context.processTopology === "detached" || + context.executionEngine === "cli" + ) { + return false; + } + if ( + !["claude_local", "codex_local", "gemini_local"].includes( + input.adapterType, + ) + ) { + return false; + } + return ( + readNonEmptyString(parseObject(input.adapterConfig).engine) !== "cli" + ); + } + + async function prepareHotRestartShutdown( + signal: "SIGINT" | "SIGTERM", + now = new Date(), + ) { + enterShutdown(); + const idleSessions = await closeIdleWarmNativeSessionsForRestart(); + if (idleSessions.failed > 0) { + logger.warn({ idleSessions }, "idle native sessions could not checkpoint before controller shutdown"); + } + let intent: Awaited>; + try { + intent = await readHotRestartIntent(); + } catch (err) { + logger.warn( + { err }, + "failed to read hot-restart intent; falling back to normal shutdown drain", + ); + return { + mode: "read_error" as const, + skipDrain: false as const, + activeRunIds: [] as string[], + }; + } + + if (!intent) + return { + mode: "not_requested" as const, + skipDrain: false as const, + activeRunIds: [] as string[], + }; + if (intent.drainRequired) + return { + mode: "drain_required" as const, + skipDrain: false as const, + activeRunIds: [] as string[], + }; + if (!shouldHonorHotRestartIntentForProcess(intent)) { + logger.warn( + { expectedPid: intent.previousServerPid, currentPid: process.pid }, + "hot-restart intent targets a different server pid; falling back to normal shutdown drain", + ); + return { + mode: "pid_mismatch" as const, + skipDrain: false as const, + activeRunIds: [] as string[], + }; + } + + const activeRuns = await db + .select({ + run: heartbeatRuns, + adapterType: agents.adapterType, + adapterConfig: agents.adapterConfig, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .where(eq(heartbeatRuns.status, "running")); + const snapshotRuns = activeRuns.map(toHotRestartIntentRun); + const intentWithVersion = { + ...intent, + previousServerVersion: intent.previousServerVersion ?? serverVersion, + }; + + const serverStdioRuns = activeRuns.filter(isServerStdioBoundHotRestartRun); + if (serverStdioRuns.length > 0) { + const activeServerStdioRunIds = serverStdioRuns.map(({ run }) => run.id); + await writeHotRestartShutdownSnapshot({ + intent: intentWithVersion, + signal, + activeRuns: snapshotRuns, + drainReason: "active_acp_run", + drainRunIds: activeServerStdioRunIds, + capturedAt: now, + }); + + logger.warn( + { + signal, + previousServerPid: intent.previousServerPid, + activeRunIds: snapshotRuns.map((run) => run.runId), + activeServerStdioRunIds, + drainReason: "active_acp_run", + }, + "server-stdio agent run prevents hot-restart adoption; using graceful drain and retry", + ); + + return { + mode: "acp_drain_required" as const, + skipDrain: false as const, + activeRunIds: snapshotRuns.map((run) => run.runId), + activeAcpRunIds: activeServerStdioRunIds, + drainRunIds: activeServerStdioRunIds, + drainReason: "active_acp_run" as const, + }; + } + + await writeHotRestartShutdownSnapshot({ + intent: intentWithVersion, + signal, + activeRuns: snapshotRuns, + capturedAt: now, + }); + + for (const { run } of activeRuns) { + await appendRunEvent(run, { + eventType: "lifecycle", + stream: "system", + level: "info", + message: + "Hot restart requested; leaving child process alive for startup adoption", + payload: { + signal, + previousServerPid: intent.previousServerPid, + previousServerVersion: intentWithVersion.previousServerVersion, + processPid: run.processPid ?? null, + processGroupId: run.processGroupId ?? null, + }, + }); + } + + const nativeRunIds = activeRuns + .filter( + ({ run, adapterType }) => + adapterType === "paperclip_runner" && + isNativeSessionId(run.nativeSessionId), + ) + .map(({ run }) => run.id); + const detachedNativeSessions = + await detachNativeSessionsForRestart(nativeRunIds); + + logger.info( + { + signal, + previousServerPid: intent.previousServerPid, + activeRunIds: snapshotRuns.map((run) => run.runId), + detachedNativeSessions, + }, + "hot-restart shutdown snapshot captured; skipping graceful run drain", + ); + + return { + mode: "hot_restart" as const, + skipDrain: true as const, + activeRunIds: snapshotRuns.map((run) => run.runId), + }; + } + + async function reconcileHotRestartAdoption(now = new Date()) { + let intent: Awaited>; + try { + intent = await readHotRestartIntent(); + } catch (err) { + logger.warn( + { err }, + "failed to read hot-restart intent on startup; skipping adoption", + ); + return { + mode: "read_error" as const, + adoptedRunIds: [] as string[], + finalizedWhileDownRunIds: [] as string[], + lostRunIds: [] as string[], + skippedRunIds: [] as string[], + }; + } + if (!intent) { + return { + mode: "not_requested" as const, + adoptedRunIds: [] as string[], + finalizedWhileDownRunIds: [] as string[], + lostRunIds: [] as string[], + skippedRunIds: [] as string[], + }; + } + + if (!intent.shutdownSnapshot) { + const log = intent.drainRequired + ? logger.info.bind(logger) + : logger.warn.bind(logger); + log( + { + previousServerPid: intent.previousServerPid, + preflightActiveRunIds: intent.preflightActiveRunIds, + drainReason: intent.drainReason ?? null, + }, + intent.drainRequired + ? "drain-required restart intent has no adoption snapshot" + : "hot-restart intent present but shutdown snapshot is missing; no runs can be adopted", + ); + } + const candidates = intent.shutdownSnapshot?.activeRuns ?? []; + const missingSnapshotRunIds = findMissingHotRestartSnapshotRunIds(intent); + const reconciliationRunIds = [ + ...new Set([ + ...candidates.map((run) => run.runId), + ...missingSnapshotRunIds, + ]), + ]; + const currentRows = + reconciliationRunIds.length > 0 + ? await db + .select({ + run: heartbeatRuns, + adapterType: agents.adapterType, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .where(inArray(heartbeatRuns.id, reconciliationRunIds)) + : []; + const currentByRunId = new Map(currentRows.map((row) => [row.run.id, row])); + + const reportRuns: HotRestartReportRun[] = []; + const adoptedRunIds: string[] = []; + const finalizedWhileDownRunIds: string[] = []; + const lostRunIds: string[] = []; + const skippedRunIds: string[] = []; + + const classify = ( + candidate: HotRestartIntentRun, + classification: HotRestartReportRun["classification"], + reason: string, + patch?: Partial, + ) => { + const run = { + ...candidate, + ...patch, + classification, + reason, + } satisfies HotRestartReportRun; + reportRuns.push(run); + if (classification === "adopted") adoptedRunIds.push(candidate.runId); + else if (classification === "finalized_while_down") + finalizedWhileDownRunIds.push(candidate.runId); + else if (classification === "lost") lostRunIds.push(candidate.runId); + else skippedRunIds.push(candidate.runId); + }; + + for (const runId of missingSnapshotRunIds) { + const current = currentByRunId.get(runId); + if (!current) { + finalizedWhileDownRunIds.push(runId); + continue; + } + + const candidate = toHotRestartIntentRun(current); + if (current.run.status !== "running") { + classify( + candidate, + "finalized_while_down", + `run_status_${current.run.status}`, + ); + } else { + classify(candidate, "lost", "missing_shutdown_snapshot"); + } + } + + if (lostRunIds.length > 0) { + logger.error( + { previousServerPid: intent.previousServerPid, lostRunIds }, + "hot-restart shutdown snapshot omitted live preflight runs; reporting them as lost", + ); + } + + for (const candidate of candidates) { + const current = currentByRunId.get(candidate.runId); + if (!current) { + classify(candidate, "finalized_while_down", "run_row_missing"); + continue; + } + + const { run, adapterType } = current; + const patch = { + adapterType, + status: run.status, + processPid: run.processPid ?? candidate.processPid, + processGroupId: run.processGroupId ?? candidate.processGroupId, + }; + + if (run.status !== "running") { + classify( + candidate, + "finalized_while_down", + `run_status_${run.status}`, + patch, + ); + continue; + } + + const hasSelectiveAcpDrain = + intent.drainReason === "active_acp_run" && + (intent.drainRunIds?.length ?? 0) > 0; + if ( + hasSelectiveAcpDrain && + intent.drainRunIds?.includes(candidate.runId) + ) { + // A selective ACP drain is expected to persist a terminal row before + // the new server starts. If the process was terminated but that write + // failed, surface the run as lost instead of hiding it as an expected + // drain skip. + classify(candidate, "lost", "selective_drain_not_finalized", patch); + continue; + } + if (intent.drainRequired && !hasSelectiveAcpDrain) { + classify(candidate, "skipped", "drain_required", patch); + continue; + } + + if (run.runtimeMode === "native" && adapterType === "paperclip_runner") { + classify(candidate, "skipped", "native_restart_recovery_owned", patch); + continue; + } + + if (!isTrackedLocalChildProcessAdapter(adapterType)) { + classify( + candidate, + "skipped", + "adapter_not_local_child_process", + patch, + ); + continue; + } + + const processPid = run.processPid ?? candidate.processPid; + const processGroupId = run.processGroupId ?? candidate.processGroupId; + const processPidAlive = isProcessAlive(processPid); + const processGroupAlive = isProcessGroupAlive(processGroupId); + if (!processPid && !processGroupId) { + classify(candidate, "lost", "missing_process_metadata", patch); + continue; + } + if (!processPidAlive && !processGroupAlive) { + classify(candidate, "lost", "process_not_alive", patch); + continue; + } + + const resultJson = mergeHotRestartAdoptionResultJson( + parseObject(run.resultJson), + { + adoptedAt: now, + previousServerPid: intent.previousServerPid, + newServerPid: process.pid, + previousServerVersion: intent.previousServerVersion, + newServerVersion: serverVersion, + processPid, + processGroupId, + }, + ); + const updated = await db + .update(heartbeatRuns) + .set({ + resultJson: preserveWorkspaceRestoreRecoveryMetadataSql(resultJson, true), + error: + run.errorCode === DETACHED_PROCESS_ERROR_CODE ? null : run.error, + errorCode: + run.errorCode === DETACHED_PROCESS_ERROR_CODE + ? null + : run.errorCode, + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, run.id), + eq(heartbeatRuns.status, "running"), + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + + if (!updated) { + const latest = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, run.id)) + .then((rows) => rows[0] ?? null); + if (latest && latest.status !== "running") { + classify( + candidate, + "finalized_while_down", + `run_status_${latest.status}`, + patch, + ); + } else { + classify(candidate, "lost", "adoption_update_not_applied", patch); + } + continue; + } + + await appendRunEvent(updated, { + eventType: "lifecycle", + stream: "system", + level: "info", + message: "Adopted live child process after hot restart", + payload: { + previousServerPid: intent.previousServerPid, + newServerPid: process.pid, + previousServerVersion: intent.previousServerVersion, + newServerVersion: serverVersion, + processPid, + processGroupId, + }, + }); + classify( + candidate, + "adopted", + processPidAlive ? "process_pid_alive" : "process_group_alive", + patch, + ); + } + + const report = await writeHotRestartReport({ + version: 1, + requestedAt: intent.requestedAt, + completedAt: now.toISOString(), + drainRequired: intent.drainRequired, + drainReason: + intent.drainReason ?? (intent.drainRequired ? "requested" : null), + previousServerPid: intent.previousServerPid, + newServerPid: process.pid, + previousServerVersion: intent.previousServerVersion, + newServerVersion: serverVersion, + adoptedRunIds, + finalizedWhileDownRunIds, + lostRunIds, + skippedRunIds, + runs: reportRuns, + }); + await removeHotRestartIntent(undefined, intent); + + logger.info( + { + previousServerPid: report.previousServerPid, + newServerPid: report.newServerPid, + adoptedRunIds, + finalizedWhileDownRunIds, + lostRunIds, + missingSnapshotRunIds, + skippedRunIds, + }, + "hot-restart adoption report written", + ); + + return { + mode: "reported" as const, + adoptedRunIds, + finalizedWhileDownRunIds, + lostRunIds, + skippedRunIds, + }; + } + + async function recoverNativeRunsAfterRestart(now = new Date()) { + // A result committed before the old controller stopped outranks process + // recovery. Finish its durable workspace/status suffix before deciding + // whether any provider authority needs to be reopened. + await reconcileNativeFinalizations(db, undefined, { + environmentRuntime, + onWorkspaceSettled: settleRecoveredNativeWorkspace, + }); + scheduleRetainedNativeSessionCleanup(); + const intent = await readHotRestartIntent().catch((error) => { + logger.warn( + { err: error }, + "failed to read hot-restart intent before native startup recovery", + ); + return null; + }); + const restartKind = intent ? ("hot" as const) : ("hard" as const); + const previousStartedAt = intent?.previousServerStartedAt + ? new Date(intent.previousServerStartedAt) + : null; + const scheduledNativeRetries = await db + .select({ + runId: nativeRunFinalizations.runId, + nextAttemptAt: nativeRunFinalizations.nextAttemptAt, + }) + .from(nativeRunFinalizations) + .innerJoin( + heartbeatRuns, + eq(heartbeatRuns.id, nativeRunFinalizations.runId), + ) + .where( + and( + eq(heartbeatRuns.runtimeMode, "native"), + inArray(heartbeatRuns.status, ["running", "failed"]), + isNull(nativeRunFinalizations.resultId), + eq(nativeRunFinalizations.phase, "retryable_failure"), + gt(nativeRunFinalizations.nextAttemptAt, now), + ), + ); + for (const scheduled of scheduledNativeRetries) { + if (scheduled.nextAttemptAt) { + scheduleNativeSessionResumeDispatch( + scheduled.runId, + scheduled.nextAttemptAt, + ); + } + } + const dispositions = await claimNativeRestartRecoveries({ + db, + restartKind, + recoveryRequestId: intent?.recoveryRequestId ?? null, + coordinatedPreviousController: intent + ? { + pid: intent.previousServerPid, + processStartedAt: + previousStartedAt && !Number.isNaN(previousStartedAt.getTime()) + ? previousStartedAt + : null, + } + : null, + now, + }); + + const claims = dispositions.filter( + (disposition): disposition is NativeRestartRecoveryClaim => + disposition.kind === "reattach_existing_runner" || + disposition.kind === "reattach_remote_runner" || + disposition.kind === "resume_dead_runner" || + disposition.kind === "bootstrap_incomplete", + ); + for (const disposition of dispositions) { + const run = await getRun(disposition.runId); + if (run) { + const isClaim = + disposition.kind === "reattach_existing_runner" || + disposition.kind === "reattach_remote_runner" || + disposition.kind === "resume_dead_runner" || + disposition.kind === "bootstrap_incomplete"; + await appendRunEvent(run, { + eventType: "native.recovery.transition", + stream: "system", + level: disposition.kind === "blocked" ? "warn" : "info", + message: + disposition.kind === "reattach_existing_runner" || + disposition.kind === "reattach_remote_runner" + ? "Recovering the existing native runner process after server restart" + : disposition.kind === "resume_dead_runner" + ? "Resuming the durable native provider session after runner process loss" + : disposition.kind === "bootstrap_incomplete" + ? "Restarting an incomplete native runner bootstrap on the same heartbeat run" + : disposition.kind === "awaiting_evidence" + ? "Native restart recovery is waiting for safe ownership evidence" + : disposition.kind === "already_finalized" + ? "Native restart recovery found an already-finalized result" + : "Native restart recovery blocked ambiguous or conflicting ownership", + payload: { + restartKind: isClaim ? disposition.restartKind : restartKind, + recoveryRequestId: isClaim + ? disposition.recoveryRequestId + : (intent?.recoveryRequestId ?? null), + runnerDisposition: disposition.kind, + ...(isClaim + ? { + controllerGeneration: disposition.controllerGeneration, + providerAttempt: disposition.providerAttempt, + } + : { reason: disposition.reason }), + ...(disposition.kind === "reattach_existing_runner" + ? { + processPid: disposition.process.pid, + processGroupId: disposition.process.processGroupId, + processStartedAt: disposition.process.startedAt, + } + : {}), + }, + }); + } + } + for (const claim of claims) { + const execution = executeRun(claim.runId, { + nativeLeaseOwner: claim.leaseOwner, + nativeRestartRecovery: claim, + }).catch((error) => { + logger.error( + { err: error, runId: claim.runId, disposition: claim.kind }, + "native restart recovery execution failed", + ); + }); + activeRunExecutionPromises.add(execution); + void execution.finally(() => + activeRunExecutionPromises.delete(execution), + ); + } + + return { + restartKind, + claims, + dispositions, + scheduledRetryRunIds: scheduledNativeRetries.map((entry) => entry.runId), + awaitingEvidenceRunIds: dispositions + .filter((entry) => entry.kind === "awaiting_evidence") + .map((entry) => entry.runId), + blockedRunIds: dispositions + .filter((entry) => entry.kind === "blocked") + .map((entry) => entry.runId), + }; + } + + async function drainRunningRunsForShutdown( + signal: "SIGINT" | "SIGTERM", + now = new Date(), + runIds: readonly string[] | null = null, + ) { + const selectedRunIds = runIds ? [...new Set(runIds)] : null; + if (selectedRunIds?.length === 0) { + return { + interrupted: 0, + interruptedRunIds: [], + retryRunIds: [], + restartSuspendedRunIds: [], + }; + } + const activeRuns = await db + .select({ + run: heartbeatRuns, + agent: agents, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .where( + selectedRunIds + ? and( + eq(heartbeatRuns.status, "running"), + inArray(heartbeatRuns.id, selectedRunIds), + ) + : eq(heartbeatRuns.status, "running"), + ); + + const interruptedRunIds: string[] = []; + const retryRunIds: string[] = []; + const restartSuspendedRunIds: string[] = []; + + for (const { run, agent } of activeRuns) { + // Shutdown owns only this boot's legacy executions. Expired foreign + // owners belong to the reaper, not another container's drain. + if (run.runtimeMode === "legacy" && run.controllerBootId && + run.controllerBootId !== legacyControllerBootId) continue; + if (isNativeRunnerOwnershipHeld(run)) continue; + if ( + run.runtimeMode === "native" && + agent.adapterType === "paperclip_runner" + ) { + // A graceful shutdown relinquishes controller authority just like a + // hot restart. Leaving the old event consumer attached lets its + // finalizer interrupt/suspend Claude while the next server is adopting + // the same turn. + await detachNativeSessionsForRestart([run.id]); + const recoveryHistoryEntry = JSON.stringify({ + at: now.toISOString(), + restartKind: "graceful", + disposition: "restart_suspended", + reason: signal, + processPid: run.processPid, + processStartedAt: run.processStartedAt?.toISOString() ?? null, + }); + await db + .update(nativeRunFinalizations) + .set({ + recoveryState: "awaiting_runner_reattach", + recoveryHistory: sql`( + select coalesce(jsonb_agg(item order by ordinal), '[]'::jsonb) + from jsonb_array_elements( + coalesce(${nativeRunFinalizations.recoveryHistory}, '[]'::jsonb) + || jsonb_build_array(${recoveryHistoryEntry}::jsonb) + ) with ordinality as history(item, ordinal) + where ordinal > greatest( + jsonb_array_length( + coalesce(${nativeRunFinalizations.recoveryHistory}, '[]'::jsonb) + || jsonb_build_array(${recoveryHistoryEntry}::jsonb) + ) - 20, + 0 + ) + )`, + updatedAt: now, + }) + .where( + and( + eq(nativeRunFinalizations.runId, run.id), + isNull(nativeRunFinalizations.resultId), + ), + ); + await appendRunEvent(run, { + eventType: "native.recovery.transition", + stream: "system", + level: "info", + message: + "Server shutdown suspended native controller ownership without cancelling provider work", + payload: { + restartKind: "graceful", + signal, + runnerDisposition: "awaiting_runner_reattach", + processPid: run.processPid, + processGroupId: run.processGroupId, + processStartedAt: run.processStartedAt?.toISOString() ?? null, + retryRunCreated: false, + }, + }); + restartSuspendedRunIds.push(run.id); + continue; + } + const message = `Interrupted by graceful server shutdown (${signal})`; + const running = runningProcesses.get(run.id); + try { + if (run.runtimeMode === "native") { + await cancelHeartbeatNativeRun({ + db, + runId: run.id, + reason: message, + runtimeMode: run.runtimeMode, + }); + } + if (running) { + await terminateHeartbeatRunProcess({ + pid: running.child.pid, + processGroupId: running.processGroupId, + graceMs: Math.max(1, running.graceSec) * 1000, + }); + } + } finally { + runningProcesses.delete(run.id); + } + + const persistedCancellationResult = + run.runtimeMode === "native" + ? await getRun(run.id).then((current) => + parseObject(current?.resultJson), + ) + : parseObject(run.resultJson); + + const interruptedStatus = await setRunStatusIfRunning( + run.id, + "interrupted", + { + finishedAt: now, + error: message, + errorCode: "server_shutdown_interrupted", + signal, + resultJson: mergeRunStopMetadataForAgent(agent, "interrupted", { + conversationContinuationEligible: await runUsedConversationAdapter(db, run), + resultJson: persistedCancellationResult, + errorCode: "server_shutdown_interrupted", + errorMessage: message, + }), + }, + ); + if (!interruptedStatus.updated || !interruptedStatus.run) continue; + let interrupted = interruptedStatus.run; + await setWakeupStatus(run.wakeupRequestId, "cancelled", { + finishedAt: now, + error: null, + }); + interrupted = + (await classifyAndPersistRunLiveness( + interrupted, + parseObject(interrupted.resultJson), + )) ?? interrupted; + + await releaseEnvironmentLeasesForRun({ + runId: interrupted.id, + companyId: interrupted.companyId, + agentId: interrupted.agentId, + status: interrupted.status, + failureReason: interrupted.error ?? undefined, + }); + + const retry = await enqueueProcessLossRetry(interrupted, agent, now); + if (!retry) { + await releaseIssueExecutionAndPromote(interrupted); + } else { + retryRunIds.push(retry.id); + } + + await appendRunEvent(interrupted, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message, + payload: { + signal, + ...(run.processPid ? { processPid: run.processPid } : {}), + ...(run.processGroupId ? { processGroupId: run.processGroupId } : {}), + ...(retry ? { retryRunId: retry.id } : {}), + }, + }); + + await finalizeAgentStatus(run.agentId, "interrupted", message, { + wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run), + }); + interruptedRunIds.push(interrupted.id); + } + + if (interruptedRunIds.length > 0) { + logger.warn( + { + signal, + interrupted: interruptedRunIds.length, + interruptedRunIds, + retryRunIds, + }, + "interrupted running heartbeat runs for graceful shutdown", + ); + } + + return { + interrupted: interruptedRunIds.length, + interruptedRunIds, + retryRunIds, + restartSuspendedRunIds, + }; + } + + // Clamp the stored attempt count to the range [0, cap]. The SQL reader + // `pendingCleanupAttemptsSql` clamps to the same range, so both readers yield + // the same value for every input. The claim predicate compares the two values, + // so this alignment lets the claim match for a malformed lease. + function readPendingCleanupRetryAttempts( + metadata: Record, + ): number { + const value = metadata[PENDING_CLEANUP_ATTEMPTS_METADATA_KEY]; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) + return 0; + return Math.min(Math.floor(value), PENDING_CLEANUP_SWEEP_ATTEMPT_CAP); + } + + // Atomically claim one retry attempt on a pending_cleanup lease. The update + // only matches when the lease is still pending_cleanup and its stored attempt + // count still equals `expectedAttempts`. Two concurrent sweeps read the same + // count, but Postgres serializes the two updates on the row and only the first + // matches the guard. The loser gets zero rows and skips the lease. The persisted in-flight deadline also prevents a later tick + // from starting another attempt while this one is still running. + // Returns the attempt identity only for the sweep that won the claim. + // + // The update patches cleanup ownership fields without writing a copied + // metadata object, so a concurrent write to an unrelated metadata key + // survives. The guard reads the stored count through the safe SQL reader, so a + // malformed value never throws. + async function claimPendingCleanupRetryAttempt( + leaseId: string, + expectedAttempts: number, + manualAttempt?: { previousId: unknown }, + ): Promise { + const now = new Date(); + const attemptId = randomUUID(); + const claimed = await db + .update(environmentLeases) + .set({ + metadata: sql`jsonb_set(${pendingCleanupMetadataObjectSql()}, array[${PENDING_CLEANUP_ATTEMPTS_METADATA_KEY}], to_jsonb(${expectedAttempts + 1}::int), true) + || ${JSON.stringify({ ...(manualAttempt ? { pendingCleanupManualAttemptId: randomUUID() } : {}), + pendingCleanupAttemptId: attemptId, pendingCleanupInFlight: true, + pendingCleanupLeaseExpiresAtMs: Date.now() + 15 * 60_000, + })}::jsonb`, + lastUsedAt: now, + updatedAt: now, + }) + .where( + and( + eq(environmentLeases.id, leaseId), + eq(environmentLeases.status, "pending_cleanup"), + sql`${pendingCleanupAttemptsSql()} = ${expectedAttempts}`, + manualAttempt ? sql`coalesce(${environmentLeases.metadata}->'pendingCleanupManualAttemptId', 'null'::jsonb) is not distinct from ${JSON.stringify(manualAttempt.previousId ?? null)}::jsonb` : undefined, + pendingCleanupRetryDueSql(Boolean(manualAttempt)), + ), + ) + .returning({ id: environmentLeases.id }); + return claimed.length > 0 ? attemptId : null; + } + + // Atomically claim the one-time cap warning for a lease. The update only + // matches when the lease is still pending_cleanup, its stored attempt count is + // at or above the cap, and it has not yet carried the warned flag. Two + // concurrent sweeps that both reach the cap race here, but only one update + // sets the flag and returns a row. The loser skips the warning. This keeps the + // warning to one log line per lease. + // + // The status and cap predicates are the last line of defense. They stop a warn + // flag write to a lease that left pending_cleanup or dropped below the cap + // between the read and this claim. The update writes only the warned key with + // `jsonb_set`, so a concurrent write to an unrelated metadata key survives. + async function claimPendingCleanupCapWarning( + leaseId: string, + ): Promise { + const now = new Date(); + const claimed = await db + .update(environmentLeases) + .set({ + metadata: sql`jsonb_set(${pendingCleanupMetadataObjectSql()}, array[${PENDING_CLEANUP_CAP_WARNED_METADATA_KEY}], to_jsonb(true), true)`, + updatedAt: now, + }) + .where( + and( + eq(environmentLeases.id, leaseId), + eq(environmentLeases.status, "pending_cleanup"), + sql`${pendingCleanupAttemptsSql()} >= ${PENDING_CLEANUP_SWEEP_ATTEMPT_CAP}`, + sql`${pendingCleanupCapWarnedSql()} = false`, + ), + ) + .returning({ id: environmentLeases.id }); + return claimed.length > 0; + } + + // Defer a pending_cleanup lease whose provider plugin is not ready this tick. + // The sweep reads one page of the oldest rows, ordered by `updatedAt`. A lease + // that the sweep only skips keeps its old `updatedAt`, so it stays the oldest + // and refills the page on every tick. That starves a newer lease whose + // provider is ready. The defer bumps `updatedAt` to now, so the unavailable + // lease moves to the back of the queue and a ready lease takes its page slot. + // The defer never writes the attempt count, so a long provider outage never + // consumes a finite retry. The status guard keeps the write on a lease that is + // still pending_cleanup. + async function deferPendingCleanupLease(leaseId: string): Promise { + const now = new Date(); + await db + .update(environmentLeases) + .set({ updatedAt: now }) + .where( + and( + eq(environmentLeases.id, leaseId), + eq(environmentLeases.status, "pending_cleanup"), + ), + ); + } + + // Move a guarded orphan candidate to the back of the sweep order. The + // shared-resource guard below can skip a row on every tick as long as the + // other owner stays live, retained, or pending cleanup. The select orders + // by `updatedAt` ascending and takes only the oldest page, so an untouched + // skipped row keeps refilling that same page and blocks every row behind + // it. The bump pushes the row past the fixed-size page, so the next tick + // reaches the rows behind it. It costs the row one extra backoff wait, + // which is safe because the guard means a physical sandbox still exists. + async function deferOrphanedActiveLease(leaseId: string): Promise { + const now = new Date(); + await db + .update(environmentLeases) + .set({ updatedAt: now }) + .where( + and( + eq(environmentLeases.id, leaseId), + eq(environmentLeases.status, "active"), + ), + ); + } + + // An active lease is reachable only while its heartbeat run keeps the + // running status. The reaper writes the run status and the lease release as + // two separate statements, so a restart between them can leave a terminal + // run and an active lease. `releaseRunLeases` also skips a lease whose + // environment row is gone, so that lease stays active too. No later query + // finds either lease, because every production query selects an active + // lease by its environment or by its heartbeat run id, never by age. This + // sweep finds both stranded classes and moves each lease to pending_cleanup, + // so the existing pending_cleanup sweep tears the sandbox down from the + // data already on the lease row. + async function sweepOrphanedActiveLeases(opts: { + backoffMs: number; + }): Promise<{ recovered: number }> { + const cutoff = new Date(Date.now() - opts.backoffMs); + + const rows = await db + .select({ lease: environmentLeases }) + .from(environmentLeases) + .leftJoin( + heartbeatRuns, + eq(environmentLeases.heartbeatRunId, heartbeatRuns.id), + ) + .where( + and( + eq(environmentLeases.status, "active"), + or( + isNull(environmentLeases.heartbeatRunId), + inArray(heartbeatRuns.status, [ + ...HEARTBEAT_RUN_TERMINAL_STATUSES, + ]), + ), + lte(environmentLeases.updatedAt, cutoff), + ), + ) + .orderBy(asc(environmentLeases.updatedAt)) + .limit(ORPHANED_ACTIVE_LEASE_SWEEP_PAGE_SIZE); + + let recovered = 0; + for (const { lease } of rows) { + // A provider resource id names one physical sandbox. A different lease + // row can still hold that same resource in a live status, so this sweep + // must not tear down a sandbox that a different lease still owns. + if (lease.provider && lease.providerLeaseId) { + const [otherOwner] = await db + .select({ id: environmentLeases.id }) + .from(environmentLeases) + .where( + and( + ne(environmentLeases.id, lease.id), + eq(environmentLeases.provider, lease.provider), + eq(environmentLeases.providerLeaseId, lease.providerLeaseId), + inArray(environmentLeases.status, [ + "active", + "retained", + "pending_cleanup", + ]), + ), + ) + .limit(1); + if (otherOwner) { + // Defer this row so the fixed-size page reaches the rows behind + // it next tick, instead of re-selecting the same guarded rows + // forever. + await deferOrphanedActiveLease(lease.id); + continue; + } + } + + // Keep the row's existing updatedAt value. The select above already + // proved the row is older than the backoff cutoff, so the + // pending_cleanup sweep can accept the same row in this same tick. A + // fresh timestamp here would push the row inside that sweep's own + // backoff window and delay the teardown by one full tick. + const flipped = await db + .update(environmentLeases) + .set({ + status: "pending_cleanup", + failureReason: "orphaned_active_lease_recovered", + }) + .where( + and( + eq(environmentLeases.id, lease.id), + eq(environmentLeases.status, "active"), + ), + ) + .returning({ id: environmentLeases.id }); + if (flipped.length > 0) recovered += 1; + } + + return { recovered }; + } + + // Retry the leases stranded in "pending_cleanup". A failed destroy leaves a + // lease in that state forever without this sweep. The reaper tick runs the + // sweep. The backoff equals the reaper staleness threshold, so a lease waits + // for that period between attempts. The sweep reads and writes the attempt + // count in the lease metadata. It warns once when a lease reaches the attempt + // threshold, then continues with slower retries. Live attempts renew their + // cleanup claim; after controller loss, exact-resource destruction may repeat. + async function sweepPendingCleanupLeases(opts?: { + backoffMs?: number; + /** One cleanup attempt per explicit user Retry, for this failed run only. + * A later user Retry may bypass the cooldown after a provider failure, + * but cannot take over an in-flight cleanup attempt. + */ + explicitRetry?: { companyId: string; runId: string; actorId: string; reason?: "retry_failed_run" | "queued_comment_interrupt" }; + }): Promise<{ + swept: number; + destroyed: number; + capped: number; + }> { + const backoffMs = opts?.backoffMs ?? 0; + const now = new Date(); + const cutoff = new Date(now.getTime() - backoffMs); + + // Flush the in-process orphan-cleanup buffer first. A failed acquire buffers + // an orphan there when every synchronous pending-cleanup write failed after a + // failed teardown. The flush re-inserts each buffered record, so a durable + // `pending_cleanup` row lands once the database recovers. The flush runs + // before the read below, so this same tick tears down a freshly-landed row. + try { + const flushed = opts?.explicitRetry ? null : await environmentRuntime.flushDeferredOrphanCleanups?.(); + if (flushed && (flushed.recovered > 0 || flushed.pending > 0)) { + logger.info( + { recovered: flushed.recovered, pending: flushed.pending }, + "flushed the in-process orphan sandbox cleanup buffer to the database", + ); + } + } catch { + // A flush failure never stops the sweep. The buffer keeps the orphan for a + // later tick, and the database rows below still need this sweep. The caught + // exception never enters the log, because a write error can carry a + // credential in its message, code, cause, or stack. + logger.warn( + "orphan sandbox cleanup buffer flush failed; the sweep continues", + ); + } + + const rows = await db + .select() + .from(environmentLeases) + .where( + and( + eq(environmentLeases.status, "pending_cleanup"), + opts?.explicitRetry ? eq(environmentLeases.companyId, opts.explicitRetry.companyId) : undefined, + opts?.explicitRetry ? eq(environmentLeases.heartbeatRunId, opts.explicitRetry.runId) : undefined, + pendingCleanupRetryDueSql(Boolean(opts?.explicitRetry)), + backoffMs > 0 ? lte(environmentLeases.updatedAt, cutoff) : undefined, + ), + ) + .orderBy(asc(environmentLeases.updatedAt)) + .limit(PENDING_CLEANUP_SWEEP_PAGE_SIZE); + + let destroyed = 0; + let capped = 0; + for (const row of rows) { + if (pendingCleanupAttemptsInFlight.has(row.id)) continue; + const metadata = { ...(row.metadata ?? {}) } as Record; + const attempts = readPendingCleanupRetryAttempts(metadata); + + const environment = row.environmentId + ? await environmentsSvc.getById(row.environmentId) + : null; + const lease = await environmentsSvc.getLeaseById(row.id); + if (!lease) continue; + + // An orphan ephemeral lease keeps its provider, its provider lease id, and + // its sandbox config in the lease row. A failed acquire records it, and its + // environment row may be gone or foreign-bound. A reuse_by_environment lease + // whose environment a delete removed keeps the same recorded data, because + // the schema sets the environment reference to null on delete and preserves + // the row. Both leases tear down from the recorded lease data through + // `retryPendingSandboxTeardown`, which never reads the environment row. So + // the sweep uses that path whenever the lease is an orphan ephemeral lease + // or its environment row is gone. A reuse_by_environment lease whose + // environment still exists tears down through `destroyRunLease`. That + // path uses the provider and configuration recorded on the lease first; + // the environment is lifecycle context and only a legacy fallback. + const isOrphanEphemeralLease = lease.leasePolicy === "ephemeral"; + const useRecordedTeardown = isOrphanEphemeralLease || !environment || hasStopOnlyCleanup(lease); + + // Do not consume a finite cleanup attempt while the provider plugin is + // briefly unavailable. A plugin worker restart, a plugin reload, or a + // plugin reinstall makes the provider unavailable for a short window. The + // plugin can be missing or not ready in that window. A teardown then throws, + // and the atomic claim below would count that throw against the cap, so a + // long restart or reload could exhaust the retries and strand a live + // sandbox. So probe the provider first, and defer the lease this tick when + // the provider is not ready. The sweep preserves the pending_cleanup row, + // and a later sweep retries after the provider recovers. The probe reports + // ready only for a permanent condition (a missing provider string, a + // built-in provider, or no worker manager), so a genuine teardown failure + // still runs, throws, and counts toward the cap. A runtime with no probe + // method treats the lease as ready, so the sweep keeps its earlier + // behavior. + const workerReady = environmentRuntime.isPendingCleanupWorkerReady + ? await environmentRuntime.isPendingCleanupWorkerReady({ + environment, + lease, + }) + : true; + if (!workerReady) { + // Move the unavailable lease to the back of the sweep queue. Otherwise + // the oldest unavailable rows refill the page on every tick and starve a + // newer lease that has a ready provider. The defer bumps `updatedAt` + // only, so it consumes no finite retry attempt. + await deferPendingCleanupLease(row.id); + continue; + } + + // Atomically claim the attempt before the retry. Only the winning sweep + // increments the count and tears the sandbox down, so an overlapping sweep + // cannot start another attempt while this one holds the cleanup lease. + // The deadline survives a server restart; the counter saturates at the + // escalation threshold while attempt identities remain unique. + const claimed = await claimPendingCleanupRetryAttempt(row.id, attempts, + opts?.explicitRetry ? { previousId: metadata.pendingCleanupManualAttemptId } : undefined); + if (!claimed) continue; + pendingCleanupAttemptsInFlight.add(row.id); + lease.metadata = { ...lease.metadata, pendingCleanupAttemptId: claimed }; + let activeRenewal: Promise | null = null; + const renewal = setInterval(() => { + if (activeRenewal) return; + activeRenewal = db.update(environmentLeases).set({ + metadata: sql`jsonb_set(${pendingCleanupMetadataObjectSql()}, '{pendingCleanupLeaseExpiresAtMs}', to_jsonb(${Date.now() + 15 * 60_000}::bigint))`, + }).where(and(eq(environmentLeases.id, row.id), eq(environmentLeases.status, "pending_cleanup"), + sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${claimed}`, + sql`${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'`)) + .then(() => {}) + .catch(() => logger.warn({ leaseId: row.id }, "cleanup ownership renewal failed")) + .finally(() => { activeRenewal = null; }); + }, 30_000); + renewal.unref(); + + try { + if (opts?.explicitRetry) await logActivity(db, { + companyId: row.companyId, actorType: "user", actorId: opts.explicitRetry.actorId, + action: "environment_lease.cleanup_retried", entityType: "environment_lease", entityId: row.id, + runId: opts.explicitRetry.runId, details: { attempt: attempts + 1, reason: opts.explicitRetry.reason ?? "retry_failed_run" }, + }); + if (useRecordedTeardown) { + // Tear the sandbox down from the recorded provider config and the + // cleanup-authorized secret versions. Preserve any provider receipt; + // a completed retry must grant the same evidence as initial cleanup. + const receipt = await environmentRuntime.retryPendingSandboxTeardown({ + environment, + lease, + }); + const released = hasStopOnlyCleanup(lease) + ? await settleStopOnlyCleanup(db, lease, { attemptId: claimed, receipt }) + : await environmentsSvc.releaseLease(lease.id, "expired", { + expectedPendingCleanupAttemptId: claimed, + cleanupStatus: "success", + failureReason: "pending_cleanup_retry", + remoteExecutionTermination: remoteTerminationReceipt(lease, receipt), + }); + if (released) destroyed += 1; + } else if (environment) { + const result = await environmentRuntime.destroyRunLease({ + environment, + lease, + failureReason: "pending_cleanup_retry", + }); + if (result && result.status !== "pending_cleanup") { + destroyed += 1; + } + } + } catch { + // The recorded-data teardown throws on failure, so revert the lease to + // pending_cleanup for a later sweep. The claimed attempt still counts + // for backoff, so requests stay bounded. The `destroyRunLease` + // path reverts the lease itself, so this revert only runs for the + // recorded-data teardown path. + if (useRecordedTeardown) { + await environmentsSvc.releaseLease(lease.id, "pending_cleanup", { + expectedPendingCleanupAttemptId: claimed, + cleanupStatus: "failed", + failureReason: "pending_cleanup_retry", + }); + } + // Log a constant errorKind only. The exception can carry a credential in + // its name, code, message, cause, or stack, so the sweep never reads it. + logger.warn( + { + errorKind: PENDING_CLEANUP_RETRY_ERROR_KIND, + leaseId: row.id, + environmentId: row.environmentId, + attempts: attempts + 1, + }, + "pending_cleanup lease retry failed", + ); + } finally { + clearInterval(renewal); + // A hung renewal must not retain process-local cleanup ownership. + // Late renewals are attempt-fenced and never write the retry cooldown. + pendingCleanupAttemptsInFlight.delete(row.id); + } + if (attempts + 1 >= PENDING_CLEANUP_SWEEP_ATTEMPT_CAP) { + capped += 1; + // Warn once, then continue automatic cleanup with backoff. The atomic claim + // keeps the warning to one log line even when two sweeps overlap. + if (metadata[PENDING_CLEANUP_CAP_WARNED_METADATA_KEY] !== true) { + const warned = await claimPendingCleanupCapWarning(row.id); + if (warned) { + logger.warn( + { leaseId: row.id, environmentId: row.environmentId, attempts }, + "environment lease needs operator attention; automatic cleanup continues with backoff", + ); + } + } + } + // Persist the cooldown independently of process memory. A crash before + // this write leaves the bounded in-flight lease for a later sweep. + await db.update(environmentLeases).set({ + metadata: sql`${pendingCleanupMetadataObjectSql()} || ${JSON.stringify({ + pendingCleanupInFlight: false, + pendingCleanupRetryAfterMs: Date.now() + (attempts + 1 >= PENDING_CLEANUP_SWEEP_ATTEMPT_CAP + ? 30 * 60_000 : Math.min(30 * 60_000, Math.max(30_000, backoffMs))), + })}::jsonb`, + }).where(and(eq(environmentLeases.id, lease.id), + sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${claimed}`)); + if (lease.heartbeatRunId) { + // Delivery failure must not revert successful provider cleanup. A new + // message can still use the persisted receipt on its next admission. + await (async () => { + await acknowledgeRemoteStop(lease.heartbeatRunId!, lease.companyId); + const run = await getRun(lease.heartbeatRunId!); + if (run) await resumeRemoteStopComments(run); + })().catch(() => logger.warn({ leaseId: lease.id }, "could not reconsider messages after cleanup retry")); + } + } + + return { swept: rows.length, destroyed, capped }; + } + + async function markNativeOwnershipUnverified( + run: typeof heartbeatRuns.$inferSelect, + evidence: { + reason: + | "live_process_identifier" + | "observed_owner_unverified" + | "adopted_runner_authentication_timeout" + | "native_chat_workspace_scope_mismatch"; + processPidAlive?: boolean; + processGroupAlive?: boolean; + }, + ) { + const durableOwnershipHold = + evidence.reason === "adopted_runner_authentication_timeout" || + evidence.reason === "native_chat_workspace_scope_mismatch"; + if ( + run.errorCode === NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE && + run.error === NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE && + (!durableOwnershipHold || isNativeRunnerOwnershipHeld(run)) + ) + return run; + const blockedStatus = run.status === "failed" ? "failed" : "running"; + const blockedWrite = await setRunStatusFromLive( + run.id, + blockedStatus, + [blockedStatus], + { + error: NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE, + errorCode: NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE, + ...(durableOwnershipHold + ? { + nativePhase: "terminal_failure", + nativePhaseUpdatedAt: new Date(), + } + : {}), + }, + ); + if (!blockedWrite.updated || !blockedWrite.run) { + return blockedWrite.run ?? run; + } + const blocked = blockedWrite.run; + await appendRunEvent(blocked, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: NATIVE_OWNERSHIP_UNVERIFIED_MESSAGE, + payload: { + reason: evidence.reason, + ...(evidence.processPidAlive === true ? { processPidAlive: true } : {}), + ...(evidence.processGroupAlive === true + ? { processGroupAlive: true } + : {}), + }, + }); + return blocked; + } + + async function settleRecoveredNativeWorkspace(input: { + runId: string; + companyId: string; + agentId: string; + succeeded: boolean; + }) { + const settledRun = await getRun(input.runId); + const workspaceSyncReference = readNativeWorkspaceSyncReference( + parseObject(settledRun?.runnerProfileJson).nativeWorkspaceSync, + ); + await releaseEnvironmentLeasesForRun({ + runId: input.runId, + companyId: input.companyId, + agentId: input.agentId, + status: settledRun?.status, + failureReason: settledRun?.error ?? undefined, + providerResourceDisposition: input.succeeded && (!parseObject(settledRun?.resultJson).workspaceExportRetry || workspaceSyncReference?.resourceDisposition === "destroy") + ? (workspaceSyncReference?.resourceDisposition ?? "stop_and_retain") + : "stop_and_retain", + }); + await releaseRuntimeServicesForRun(input.runId).catch(() => undefined); + await finalizeAgentStatus( + input.agentId, + input.succeeded ? "succeeded" : "failed", + input.succeeded + ? null + : (settledRun?.error ?? "native_workspace_sync_out_failed"), + { + wasFirstHeartbeat: settledRun + ? timerClaimWasFirstHeartbeat(settledRun) + : undefined, + }, + ).catch(() => undefined); + } + + function scheduleRetainedNativeSessionCleanup() { + // The per-database sweep joins startup and periodic callers. One bounded + // control-only repair must not hold up unrelated provider ingress or the + // entire orphan reaper, but shutdown must still await its physical owner. + const cleanup = reconcileRetainedNativeSessionCleanups(db, { + cleanup: (input) => reconcileRetainedNativeSessionCleanup(db, input), + onError: (error, runId) => { + logger.warn( + { err: error, runId }, + "retained native session cleanup failed", + ); + }, + }) + .then(() => undefined) + .catch((error) => { + logger.warn({ err: error }, "retained native cleanup discovery failed"); + }) + // The bounded maintenance attempt may fail before an already-started + // database callback settles. Keep shutdown ownership until the original + // operations finish; their timeout cannot authorize closing the database. + .finally(() => drainRetainedRunnerdMaintenanceOperations()); + activeRunExecutionPromises.add(cleanup); + void cleanup.finally(() => activeRunExecutionPromises.delete(cleanup)); + } + + async function reapOrphanedRuns(opts?: { staleThresholdMs?: number }) { + const staleThresholdMs = opts?.staleThresholdMs ?? 0; + const now = new Date(); + // Recovery never launches a provider or infers stopped ownership from + // terminal status. Uncaptured local copies require durable stop evidence. + await instructionCopies.recoverStopped().catch(error => { + logger.warn({ err: error }, "failed to recover stopped instruction copies"); + }); + await instructionCopies.recoverCaptured().catch(error => { + logger.warn({ err: error }, "failed to retry captured instruction revisions"); + }); + + // Complete persisted native results before generic orphan recovery. The + // reconciler reads the durable workspace barrier and persisted runtime + // mode, never the current feature flag. + await reconcileNativeFinalizations(db, undefined, { + environmentRuntime, + onWorkspaceSettled: settleRecoveredNativeWorkspace, + }).catch((error) => { + logger.warn( + { err: error }, + "failed to reconcile persisted native finalizations before orphan reaping", + ); + }); + scheduleRetainedNativeSessionCleanup(); + await dispatchPendingNativeStatusWakeups().catch((error) => { + logger.warn( + { err: error }, + "failed to dispatch persisted native status wake intents before orphan reaping", + ); + }); + + // A retryable native run can retain process identifiers from the failed + // attempt. Inspect them before the recovery claim: a live identifier is + // unowned and blocks recovery, while identifiers that are all dead can be + // cleared with a compare-and-set so the explicit retryable failure becomes + // claimable in this same sweep. + const retryableNativeProcesses = await db + .select({ run: heartbeatRuns }) + .from(heartbeatRuns) + .innerJoin( + nativeRunFinalizations, + eq(nativeRunFinalizations.runId, heartbeatRuns.id), + ) + .where( + and( + inArray(heartbeatRuns.status, ["running", "failed"]), + eq(heartbeatRuns.runtimeMode, "native"), + eq(nativeRunFinalizations.phase, "retryable_failure"), + isNull(nativeRunFinalizations.resultId), + ), + ); + const claimableNativeRunIds = new Set(); + for (const { run } of retryableNativeProcesses) { + if (isNativeRunnerOwnershipHeld(run)) continue; + if (!run.processPid && !run.processGroupId) { + claimableNativeRunIds.add(run.id); + continue; + } + const processPidAlive = + !!run.processPid && isProcessAlive(run.processPid); + const processGroupAlive = + !!run.processGroupId && isProcessGroupAlive(run.processGroupId); + if (processPidAlive || processGroupAlive) { + await markNativeOwnershipUnverified(run, { + reason: "live_process_identifier", + processPidAlive, + processGroupAlive, + }); + continue; + } + const cleared = await db.transaction(async tx => { + const cleared = await tx + .update(heartbeatRuns) + .set({ + processPid: null, + processGroupId: null, + processStartedAt: null, + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, run.id), + eq(heartbeatRuns.runtimeMode, "native"), + run.processPid === null + ? isNull(heartbeatRuns.processPid) + : eq(heartbeatRuns.processPid, run.processPid), + run.processGroupId === null + ? isNull(heartbeatRuns.processGroupId) + : eq(heartbeatRuns.processGroupId, run.processGroupId), + run.processStartedAt === null + ? isNull(heartbeatRuns.processStartedAt) + : eq(heartbeatRuns.processStartedAt, run.processStartedAt), + ), + ) + .returning({ id: heartbeatRuns.id }) + .then((rows) => rows[0] ?? null); + if (cleared) await recordNativeLocalProcessStop(tx as unknown as Db, run); + return cleared; + }); + if (cleared) claimableNativeRunIds.add(cleared.id); + } + + // An explicit result-less retryable failure resumes on the original run. + // The database lease is claimed before dispatch so concurrent service + // instances cannot open competing recoveries; executeRun receives the exact + // claimed owner. Expired `observed` ownership never enters this set. + const nativeResumeClaims = + claimableNativeRunIds.size === 0 + ? [] + : await dispatchNativeSessionResumptions({ + db, + runnerInstanceId: + runtimeEnv.PAPERCLIP_INSTANCE_ID?.trim() || "paperclip-heartbeat", + now, + runIds: [...claimableNativeRunIds], + dispatch: (claim) => { + const execution = executeRun(claim.runId, { + nativeLeaseOwner: claim.leaseOwner, + }).catch((error) => { + logger.error( + { err: error, runId: claim.runId }, + "persisted native session resume failed", + ); + }); + activeRunExecutionPromises.add(execution); + void execution.finally(() => + activeRunExecutionPromises.delete(execution), + ); + }, + }).catch((error) => { + logger.warn( + { err: error }, + "failed to claim persisted native session resumptions", + ); + return []; + }); + const resumedRunIds = new Set( + nativeResumeClaims.map((claim) => claim.runId), + ); + + // A terminal issue transition writes this intent in the same transaction + // that closes the question's task, even when its card is retained. Consume + // it before generic orphan recovery so a restart preserves cancellation. + const cancellationRequests = await db + .select({ + id: heartbeatRuns.id, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where( + and( + inArray(heartbeatRuns.status, [ + ...CANCELLABLE_HEARTBEAT_RUN_STATUSES, + ]), + sql`${heartbeatRuns.contextSnapshot} -> ${NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY} is not null`, + ), + ); + for (const request of cancellationRequests) { + const marker = parseObject( + parseObject(request.contextSnapshot)[ + NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY + ], + ); + const issueId = readNonEmptyString(marker.issueId); + const issueStatus = readNonEmptyString(marker.issueStatus); + const interactionId = readNonEmptyString(marker.interactionId); + const kind = readNonEmptyString(marker.kind); + const reason = + kind === "interaction_withdrawn" + ? "Question withdrawn while waiting for operator input" + : kind === "interaction_cancelled" + ? "Cancelled while waiting for operator input" + : "Task closed while waiting for operator input"; + try { + await cancelRunInternal(request.id, reason, { + resultJson: { + ...(kind === "interaction_withdrawn" && interactionId + ? { withdrawnInteractionId: interactionId } + : {}), + ...(kind === "interaction_cancelled" && interactionId + ? { cancelledInteractionId: interactionId } + : {}), + ...((!kind || kind === "issue_terminal") && issueStatus + ? { cancelledByIssueStatus: issueStatus } + : {}), + ...(issueId ? { cancelledIssueId: issueId } : {}), + }, + }); + } catch (err) { + // Keep the marker intact for the next startup/periodic sweep. + logger.warn( + { err, runId: request.id }, + "native question cancellation recovery attempt failed", + ); + } + } + + // Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them) + const activeRuns = await db + .select({ + run: heartbeatRuns, + adapterType: agents.adapterType, + adapterConfig: agents.adapterConfig, + nativeCoordinatorPhase: nativeRunFinalizations.phase, + nativeRecoveryState: nativeRunFinalizations.recoveryState, + nativeControllerBootId: nativeRunFinalizations.controllerBootId, + nativeControllerPid: nativeRunFinalizations.controllerPid, + nativeControllerProcessStartedAt: + nativeRunFinalizations.controllerProcessStartedAt, + nativeControllerLeaseExpiresAt: nativeRunFinalizations.leaseExpiresAt, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .leftJoin( + nativeRunFinalizations, + eq(nativeRunFinalizations.runId, heartbeatRuns.id), + ) + .where(eq(heartbeatRuns.status, "running")); + + const monitorIssueIds = [ + ...new Set( + activeRuns.flatMap(({ run }) => { + const runContext = parseObject(run.contextSnapshot); + if (readNonEmptyString(runContext.wakeReason) !== "issue_monitor_due") + return []; + const issueId = readNonEmptyString(runContext.issueId); + return issueId ? [issueId] : []; + }), + ), + ]; + const monitorIssues = + monitorIssueIds.length > 0 + ? await db + .select({ + id: issues.id, + companyId: issues.companyId, + monitorNextCheckAt: issues.monitorNextCheckAt, + }) + .from(issues) + .where(inArray(issues.id, monitorIssueIds)) + : []; + const monitorNextCheckAtByIssue = new Map( + monitorIssues.map((issue) => [ + `${issue.companyId}:${issue.id}`, + issue.monitorNextCheckAt, + ]), + ); + + const reaped: string[] = []; + const currentNativeController = await currentNativeControllerIdentity(); + + for (const { + run, + adapterType, + adapterConfig, + nativeCoordinatorPhase, + nativeRecoveryState, + nativeControllerBootId, + nativeControllerPid, + nativeControllerProcessStartedAt, + nativeControllerLeaseExpiresAt, + } of activeRuns) { + // Authentication timeout requires an explicit ownership resolution, not + // repeated reattachment or a process-gone guess on subsequent sweeps. + if (isNativeRunnerOwnershipHeld(run)) continue; + const nativeRun = run.runtimeMode === "native"; + const nativeProcessPidAlive = + nativeRun && !!run.processPid && isProcessAlive(run.processPid); + const nativeProcessGroupAlive = + nativeRun && + !!run.processGroupId && + isProcessGroupAlive(run.processGroupId); + const coordinatorOwnedByCurrentController = + nativeRun && + nativeControllerBootId === currentNativeController.bootId && + nativeControllerPid === currentNativeController.pid && + nativeControllerProcessStartedAt?.getTime() === + currentNativeController.processStartedAt.getTime() && + !!nativeControllerLeaseExpiresAt && + nativeControllerLeaseExpiresAt.getTime() > now.getTime(); + const locallyTracked = + runningProcesses.has(run.id) || + activeRunExecutions.has(run.id) || + coordinatorOwnedByCurrentController; + if ( + nativeRun && + ([ + "awaiting_evidence", + "awaiting_runner_reattach", + "resuming_session", + "bootstrap_incomplete", + ].includes(nativeRecoveryState ?? "") || + nativeCoordinatorPhase === "retryable_failure") + ) { + continue; + } + const observedOwnerUnverified = + nativeRun && + (nativeCoordinatorPhase === "observed" || + (nativeCoordinatorPhase === null && + run.nativePhase === "observed")) && + !resumedRunIds.has(run.id) && + !locallyTracked; + // Persisted numeric process identifiers prove only that some process is + // alive, not that Paperclip still owns it. Likewise an observed native + // coordinator without a live in-process execution has no durable proof + // that its prior provider owner stopped. Keep both cases running but + // blocked: never signal, finalize, or retry them automatically. This gate + // intentionally precedes resumedRunIds so a claim cannot bypass the + // ownership check. + if ( + !locallyTracked && + (nativeProcessPidAlive || + nativeProcessGroupAlive || + observedOwnerUnverified) + ) { + await markNativeOwnershipUnverified(run, { + reason: + nativeProcessPidAlive || nativeProcessGroupAlive + ? "live_process_identifier" + : "observed_owner_unverified", + processPidAlive: nativeProcessPidAlive, + processGroupAlive: nativeProcessGroupAlive, + }); + continue; + } + if (resumedRunIds.has(run.id)) continue; + if (locallyTracked) continue; + if (await hasLiveLegacyController(db, run)) continue; + + // Apply staleness threshold to avoid false positives + if (staleThresholdMs > 0) { + const refTime = run.updatedAt ? new Date(run.updatedAt).getTime() : 0; + if (now.getTime() - refTime < staleThresholdMs) continue; + } + + const currentAdapterTracksLocalChild = + isTrackedLocalChildProcessAdapter(adapterType); + const tracksLegacyLocalChild = + run.runtimeMode !== "native" && currentAdapterTracksLocalChild; + // Native runner processes also persist child metadata, but they must not + // inherit legacy retry or termination authority. Use their PID/group only + // for a read-only liveness check so a lost in-memory handle cannot cause + // overlapping provider/tool execution while that child is still alive. + const checksPersistedChildLiveness = + currentAdapterTracksLocalChild || run.runtimeMode === "native"; + const processPidAlive = + checksPersistedChildLiveness && + run.processPid && + isProcessAlive(run.processPid); + const processGroupAlive = + checksPersistedChildLiveness && + run.processGroupId && + isProcessGroupAlive(run.processGroupId); + if ( + (processPidAlive || processGroupAlive) && + readHotRestartAdoptionMetadata(parseObject(run.resultJson)) + ) { + continue; + } + if (processPidAlive || processGroupAlive) { + if (run.errorCode !== DETACHED_PROCESS_ERROR_CODE) { + const detachedMessage = processPidAlive + ? `Lost in-memory process handle, but child pid ${run.processPid} is still alive` + : `Lost in-memory process handle, but persisted process group ${run.processGroupId} is still alive`; + const detachedRun = await setRunStatus(run.id, "running", { + error: detachedMessage, + errorCode: DETACHED_PROCESS_ERROR_CODE, + }); + if (detachedRun) { + await appendRunEvent(detachedRun, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: detachedMessage, + payload: { + processPid: run.processPid ?? null, + processGroupId: run.processGroupId ?? null, + ownedProcessHandle: false, + }, + }); + } + } + continue; + } + + const runContext = parseObject(run.contextSnapshot); + const monitorIssueId = readNonEmptyString(runContext.issueId); + const monitorNextCheckAt = monitorIssueId + ? monitorNextCheckAtByIssue.get(`${run.companyId}:${monitorIssueId}`) + : undefined; + const monitorDispatchLostWithoutFutureWake = + readNonEmptyString(runContext.wakeReason) === "issue_monitor_due" && + monitorNextCheckAt !== undefined && + (!monitorNextCheckAt || monitorNextCheckAt.getTime() <= now.getTime()); + const shouldRetry = + (run.processLossRetryCount ?? 0) < 1 && + ((tracksLegacyLocalChild && + (!!run.processPid || !!run.processGroupId)) || + monitorDispatchLostWithoutFutureWake); + if (!(await revokeExpiredLegacyController(db, run))) continue; + const baseMessage = buildProcessLossMessage(run); + const processLossDiagnostic = buildProcessLossDiagnostic({ + run, + nowMs: Date.now(), + observerStartedAtMs: performance.timeOrigin, + checksPersistedChildLiveness, + retryEligible: shouldRetry, + }); + const conversationContinuationEligible = await runUsedConversationAdapter(db, run); + + const failureWrite = await setRunStatusFromLive( + run.id, + "failed", + ["running"], + { + error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, + errorCode: "process_lost", + finishedAt: now, + resultJson: (() => { + const result = mergeRunStopMetadataForAgent( + { adapterType, adapterConfig }, + "failed", + { + conversationContinuationEligible, + resultJson: { ...parseObject(run.resultJson), processLossDiagnostic }, + errorCode: "process_lost", + errorMessage: shouldRetry + ? `${baseMessage}; retrying once` + : baseMessage, + }, + ); + return result; + })(), + }, + ); + if (!failureWrite.updated || !failureWrite.run) continue; + let finalizedRun: typeof heartbeatRuns.$inferSelect | null = + failureWrite.run; + await setWakeupStatus(run.wakeupRequestId, "failed", { + finishedAt: now, + error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, + }); + if (!finalizedRun) finalizedRun = await getRun(run.id); + if (!finalizedRun) continue; + finalizedRun = + (await classifyAndPersistRunLiveness( + finalizedRun, + parseObject(finalizedRun.resultJson), + )) ?? finalizedRun; + await releaseEnvironmentLeasesForRun({ + runId: finalizedRun.id, + companyId: finalizedRun.companyId, + agentId: finalizedRun.agentId, + status: finalizedRun.status, + failureReason: finalizedRun.error ?? undefined, + }); + + let retriedRun: typeof heartbeatRuns.$inferSelect | null = null; + const retryAgent = await getAgent(run.agentId); + if (shouldRetry) { + if (retryAgent) { + retriedRun = await enqueueProcessLossRetry( + finalizedRun, + retryAgent, + now, + ); + } + } else if (retryAgent) { + const scheduled = + await scheduleInteractionContinuationInfrastructureRetryIfEligible( + finalizedRun, + retryAgent, + ); + retriedRun = scheduled?.outcome === "scheduled" ? scheduled.run : null; + } + + if (!retriedRun) { + await releaseIssueExecutionAndPromote(finalizedRun); + } + + await appendRunEvent(finalizedRun, { + eventType: "lifecycle", + stream: "system", + level: "error", + message: shouldRetry + ? `${baseMessage}; queued retry ${retriedRun?.id ?? ""}`.trim() + : baseMessage, + payload: { + ...(run.processPid ? { processPid: run.processPid } : {}), + ...(run.processGroupId ? { processGroupId: run.processGroupId } : {}), + ...(retriedRun ? { retryRunId: retriedRun.id } : {}), + }, + }); + + await finalizeAgentStatus(run.agentId, "failed", baseMessage, { + wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run), + }); + await startNextQueuedRunForAgent(run.agentId); + runningProcesses.delete(run.id); + reaped.push(run.id); + } + + if (reaped.length > 0) { + logger.warn( + { reapedCount: reaped.length, runIds: reaped }, + "reaped orphaned heartbeat runs", + ); + } + + // Recover an active lease whose run already ended before the same-tick + // pending_cleanup sweep, so this tick can stop the recovered sandbox. + // Isolate the sweep so its failure never hides the reaper result. + try { + const orphanedActiveLeaseSweep = await sweepOrphanedActiveLeases({ + backoffMs: staleThresholdMs, + }); + if (orphanedActiveLeaseSweep.recovered > 0) { + logger.warn( + { recovered: orphanedActiveLeaseSweep.recovered }, + "recovered orphaned active environment leases", + ); + } + } catch { + // Log a constant errorKind only. The exception can carry a credential in + // its name, code, message, cause, or stack, so the sweep never reads it. + logger.error( + { errorKind: ORPHANED_ACTIVE_LEASE_SWEEP_ERROR_KIND }, + "orphaned active environment lease sweep failed", + ); + } + + // Retry stranded pending_cleanup leases on the same tick. Isolate the sweep + // so its failure never hides the reaper result. The backoff equals the + // reaper staleness threshold. + try { + const sweep = await sweepPendingCleanupLeases({ + backoffMs: staleThresholdMs, + }); + if (sweep.destroyed > 0 || sweep.capped > 0) { + logger.warn( + { + destroyed: sweep.destroyed, + capped: sweep.capped, + swept: sweep.swept, + }, + "swept pending_cleanup environment leases", + ); + } + } catch { + // Log a constant errorKind only. The exception can carry a credential in + // its name, code, message, cause, or stack, so the sweep never reads it. + logger.error( + { errorKind: PENDING_CLEANUP_SWEEP_ERROR_KIND }, + "pending_cleanup lease sweep failed", + ); + } + + return { reaped: reaped.length, runIds: reaped }; + } + + return { + sweepPendingCleanupLeases, + markNativeOwnershipUnverified, + prepareHotRestartShutdown, + reconcileHotRestartAdoption, + recoverNativeRunsAfterRestart, + reapOrphanedRuns, + sweepOrphanedActiveLeases, + drainRunningRunsForShutdown, + }; +} diff --git a/server/src/services/heartbeat/retries.test.ts b/server/src/services/heartbeat/retries.test.ts new file mode 100644 index 00000000000..635e8ca9a5f --- /dev/null +++ b/server/src/services/heartbeat/retries.test.ts @@ -0,0 +1,286 @@ +import { randomUUID } from "node:crypto"; +import type { Db } from "@paperclipai/db"; +import { agents, agentWakeupRequests, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { HttpError } from "../../errors.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { createRunDispatch, type PostCommitEffect } from "../../modules/run-dispatch/index.js"; +import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js"; +import * as legacy from "../heartbeat.js"; +import { createHeartbeatRunState } from "./run-state.js"; +import * as extracted from "./retries.js"; +import type { HeartbeatRetryDependencies } from "./retries.js"; + +type Run = typeof heartbeatRuns.$inferSelect; +type Agent = typeof agents.$inferSelect; + +function callbacks() { + return { + getRun: vi.fn(async () => null), + getAgent: vi.fn(async () => undefined), + resolveSessionBeforeForWakeup: vi.fn(async () => null), + resolveResponsibleUserIdForRunContext: vi.fn(async () => "retry-user"), + getAgentInvokability: vi.fn(async () => ({ invokable: true })), + appendRunEvent: vi.fn(async () => undefined), + escalatePlanApprovalResumeFailureNeedsAttention: vi.fn(async () => null), + recordPlanApprovalResumeFailureRetry: vi.fn(async () => null), + setRunStatusIfRunning: vi.fn(async () => ({ updated: false, run: null })), + setWakeupStatus: vi.fn(async () => undefined), + releaseIssueExecutionAndPromote: vi.fn(async () => undefined), + finalizeAgentStatus: vi.fn(async () => undefined), + getWorktreeExecutionCutoff: vi.fn(async (): Promise => null), + applyRunDispatchPostCommitEffects: vi.fn<(effects: PostCommitEffect[]) => void>(), + runDispatch: { + evaluateScheduledRetryGate: vi.fn(async () => ({ allowed: true })), + promoteDueScheduledRetries: vi.fn(async () => ({ promoted: 0, runIds: [], postCommitEffects: [] })), + promoteScheduledRetry: vi.fn(async () => ({ outcome: "not_promoted" })), + }, + } satisfies HeartbeatRetryDependencies; +} + +// These tests must never query: only the injected lifecycle callbacks may run. +function guardedDatabase() { + const access = vi.fn(() => { throw new Error("Unexpected retry database access"); }); + return { db: new Proxy({}, { get: access }) as Db, access }; +} + +function unitRun(): Run { + const run: Partial = { + id: "run", companyId: "company", agentId: "agent", status: "running", + scopeKind: "issue", issueId: "issue", + wakeupRequestId: "wake", scheduledRetryAttempt: 0, + contextSnapshot: { issueId: "issue", timerClaimWasFirstHeartbeat: true }, + }; + return run as Run; +} + +function deferral() { + return new extracted.WorkspaceBusyDeferral({ + holder: { runId: "holder", agentId: "other-agent", issueId: "other-issue", issueIdentifier: "T-2" }, + projectWorkspaceId: "workspace", deferralAttempt: 0, wasIssueAssignee: true, + }); +} + +describe("heartbeat retry module callbacks", () => { + it("preserves legacy helper and error-class identity", () => { + expect(legacy.computeBoundedTransientHeartbeatRetrySchedule).toBe(extracted.computeBoundedTransientHeartbeatRetrySchedule); + expect(legacy.computeWorkspaceBusyRetryDelayMs).toBe(extracted.computeWorkspaceBusyRetryDelayMs); + expect(legacy.BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS).toBe(extracted.BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS); + expect(legacy.WorkspaceBusyDeferral).toBe(extracted.WorkspaceBusyDeferral); + expect(deferral()).toBeInstanceOf(legacy.WorkspaceBusyDeferral); + }); + + it("constructs without database access or lifecycle effects", () => { + const database = guardedDatabase(); + const deps = callbacks(); + extracted.createHeartbeatRetries(database.db, deps); + expect(database.access).not.toHaveBeenCalled(); + for (const callback of Object.values(deps)) { + if (typeof callback === "function") expect(callback).not.toHaveBeenCalled(); + } + for (const callback of Object.values(deps.runDispatch)) expect(callback).not.toHaveBeenCalled(); + }); + + it.each(["missing_run", "missing_agent"] as const)("keeps %s lookup behavior and requests the full source result", async (outcome) => { + const database = guardedDatabase(); + const deps = callbacks(); + if (outcome === "missing_agent") deps.getRun.mockResolvedValue(unitRun()); + expect(await extracted.createHeartbeatRetries(database.db, deps).scheduleBoundedRetry("run")).toEqual({ outcome }); + expect(deps.getRun).toHaveBeenCalledWith("run", { unsafeFullResultJson: true }); + expect(deps.getAgent).toHaveBeenCalledTimes(outcome === "missing_agent" ? 1 : 0); + expect(database.access).not.toHaveBeenCalled(); + }); + + it("records exhaustion before escalating a plan-resume failure", async () => { + const database = guardedDatabase(); + const deps = callbacks(); + const run = unitRun(); + const retry = await extracted.createHeartbeatRetries(database.db, deps).scheduleBoundedRetryForRun( + run, { adapterType: "codex_local" } as Agent, + { retryReason: legacy.INTERACTION_CONTINUATION_INFRA_RETRY_REASON, maxAttempts: 0 }, + ); + expect(retry).toMatchObject({ outcome: "retry_exhausted", attempt: 1, maxAttempts: 0 }); + expect(deps.appendRunEvent).toHaveBeenCalledWith(run, expect.objectContaining({ + retryExhaustion: { retryReason: legacy.INTERACTION_CONTINUATION_INFRA_RETRY_REASON, scheduledRetryAttempt: 0, maxAttempts: 0 }, + })); + expect(deps.escalatePlanApprovalResumeFailureNeedsAttention).toHaveBeenCalledAfter(deps.appendRunEvent); + expect(database.access).not.toHaveBeenCalled(); + }); + + it("passes the service's worktree cutoff and publishes only returned effects", async () => { + const database = guardedDatabase(); + const deps = callbacks(); + const now = new Date("2026-10-08T12:00:00Z"); + const cutoff = new Date("2026-10-08T11:00:00Z"); + const effect: PostCommitEffect = { + kind: "run_queued", runId: "retry", companyId: "company", agentId: "agent", + invocationSource: "automation", triggerDetail: "system", wakeupRequestId: "wake", + }; + deps.getWorktreeExecutionCutoff.mockResolvedValue(cutoff); + deps.runDispatch.promoteDueScheduledRetries.mockResolvedValue({ promoted: 1, runIds: ["retry"], postCommitEffects: [effect] }); + expect(await extracted.createHeartbeatRetries(database.db, deps).promoteDueScheduledRetries(now)).toEqual({ promoted: 1, runIds: ["retry"] }); + expect(deps.runDispatch.promoteDueScheduledRetries).toHaveBeenCalledWith({ now, cutoff }); + expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledWith([effect]); + expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledAfter(deps.runDispatch.promoteDueScheduledRetries); + expect(database.access).not.toHaveBeenCalled(); + }); + + it("does not publish effects when promotion rejects", async () => { + const deps = callbacks(); + deps.runDispatch.promoteDueScheduledRetries.mockRejectedValue(new Error("promotion failed")); + await expect(extracted.createHeartbeatRetries(guardedDatabase().db, deps).promoteDueScheduledRetries()).rejects.toThrow("promotion failed"); + expect(deps.applyRunDispatchPostCommitEffects).not.toHaveBeenCalled(); + }); + + it.each(["workspace", "connection"] as const)("leaves a %s cancellation race winner alone", async (kind) => { + const database = guardedDatabase(); + const deps = callbacks(); + const retries = extracted.createHeartbeatRetries(database.db, deps); + if (kind === "workspace") await retries.finalizeWorkspaceBusyDeferral(unitRun(), deferral()); + else await retries.finalizeAiConnectionBusyDeferral(unitRun(), new HttpError(409, "busy"), true); + expect(deps.setRunStatusIfRunning).toHaveBeenCalledTimes(1); + expect(deps.setWakeupStatus).not.toHaveBeenCalled(); + expect(deps.getAgent).not.toHaveBeenCalled(); + expect(deps.releaseIssueExecutionAndPromote).not.toHaveBeenCalled(); + expect(deps.finalizeAgentStatus).not.toHaveBeenCalled(); + expect(database.access).not.toHaveBeenCalled(); + }); + + it.each(["workspace", "connection"] as const)("settles the agent after a %s deferral cannot keep its issue lock", async (kind) => { + const database = guardedDatabase(); + const deps = callbacks(); + const run = { ...unitRun(), status: "cancelled" }; + deps.setRunStatusIfRunning.mockResolvedValue({ updated: true, run }); + deps.releaseIssueExecutionAndPromote.mockRejectedValue(new Error("release failed")); + const retries = extracted.createHeartbeatRetries(database.db, deps); + if (kind === "workspace") await retries.finalizeWorkspaceBusyDeferral(run, deferral()); + else await expect(retries.finalizeAiConnectionBusyDeferral(run, new HttpError(409, "busy"), true)).rejects.toThrow("release failed"); + expect(deps.setWakeupStatus).toHaveBeenCalledWith("wake", "cancelled", expect.any(Object)); + expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(run); + expect(deps.finalizeAgentStatus).toHaveBeenCalledWith("agent", "cancelled", null, { wasFirstHeartbeat: true }); + expect(deps.finalizeAgentStatus).toHaveBeenCalledAfter(deps.releaseIssueExecutionAndPromote); + expect(database.access).not.toHaveBeenCalled(); + }); +}); + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; +if (!support.supported) console.warn(`Skipping retry PostgreSQL tests: ${support.reason ?? "unsupported host"}`); + +describePostgres("heartbeat retry module database wiring", () => { + let database: Awaited>; + let db: Db; + const now = new Date("2026-10-08T12:00:00Z"); + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("heartbeat-retries-"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + + afterAll(async () => { await database?.cleanup(); }); + + afterEach(async () => { + await db.delete(issues); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(agents); + await db.delete(companies); + }); + + function boundRetries() { + const state = createHeartbeatRunState(db); + const deps = { + ...callbacks(), getRun: state.getRun, getAgent: state.getAgent, + resolveSessionBeforeForWakeup: state.resolveSessionBeforeForWakeup, + getAgentInvokability: (agent: Agent | null | undefined) => evaluateAgentInvokabilityFromDb(db, agent), + runDispatch: createRunDispatch(db), + }; + return { retries: extracted.createHeartbeatRetries(db, deps), deps }; + } + + async function fixture() { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Retries", issuePrefix: `R${companyId.slice(0, 8)}` }); + const [agent] = await db.insert(agents).values({ companyId, name: "Retry agent", adapterType: "codex_local", status: "active" }).returning(); + const [issue] = await db.insert(issues).values({ companyId, title: "Retry task", status: "in_progress", assigneeAgentId: agent.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ + companyId, agentId: agent.id, scopeKind: "issue", issueId: issue.id, + status: "failed", errorCode: "codex_transient_upstream", finishedAt: now, + resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + contextSnapshot: { issueId: issue.id }, + }).returning(); + await db.update(issues).set({ executionRunId: run.id, checkoutRunId: run.id }).where(eq(issues.id, issue.id)); + return { companyId, agent, issue, run }; + } + + it("reuses one successor across concurrent factories and keeps writes committed before callbacks", async () => { + const { run, agent, issue, companyId } = await fixture(); + const first = boundRetries(), second = boundRetries(); + for (const binding of [first, second]) { + binding.deps.appendRunEvent.mockImplementation(async (_source, event) => { + const retryId = event.payload?.retryRunId; + expect(typeof retryId).toBe("string"); + expect(await binding.deps.getRun(retryId as string)).toMatchObject({ retryOfRunId: run.id }); + }); + } + const results = await Promise.all([ + first.retries.scheduleBoundedRetryForRun(run, agent, { now, random: () => 0 }), + second.retries.scheduleBoundedRetryForRun(run, agent, { now, random: () => 0 }), + ]); + expect(results.every((result) => result.outcome === "scheduled")).toBe(true); + const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.retryOfRunId, run.id))); + expect(successors).toHaveLength(1); + const successor = successors[0]; + expect(successor).toMatchObject({ + status: "scheduled_retry", issueId: issue.id, responsibleUserId: "retry-user", + scheduledRetryAttempt: 1, scheduledRetryReason: "transient_failure", + }); + expect(successor.contextSnapshot).toMatchObject({ retryOfRunId: run.id, codexTransientFallbackMode: "same_session" }); + expect(first.deps.resolveResponsibleUserIdForRunContext).toHaveBeenCalledWith(run, expect.objectContaining({ retryOfRunId: run.id })); + const [locked] = await db.select().from(issues).where(eq(issues.id, issue.id)); + expect(locked).toMatchObject({ executionRunId: successor.id, checkoutRunId: null }); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.runId, successor.id))).toHaveLength(1); + expect(await boundRetries().retries.scheduleBoundedRetry(run.id, { now })).toMatchObject({ outcome: "scheduled", run: { id: successor.id }, reusedExisting: true }); + expect(first.deps.appendRunEvent).toHaveBeenCalledTimes(1); + expect(second.deps.appendRunEvent).toHaveBeenCalledTimes(1); + }); + + it("promotes only due retries and publishes after commit", async () => { + const { run } = await fixture(); + const { retries, deps } = boundRetries(); + const scheduled = await retries.scheduleBoundedRetry(run.id, { now, delayMs: 60_000 }); + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") throw new Error("Expected retry"); + expect(await retries.promoteDueScheduledRetries(now)).toEqual({ promoted: 0, runIds: [] }); + deps.applyRunDispatchPostCommitEffects.mockClear(); + const committedStatuses: string[] = []; + const reads: Promise[] = []; + deps.applyRunDispatchPostCommitEffects.mockImplementation(() => { + // Read outside the dispatch transaction after the callback is invoked. + // The assertion below waits for that read without making publication async. + reads.push(deps.getRun(scheduled.run.id).then((row) => { committedStatuses.push(row!.status); })); + }); + expect(await retries.promoteDueScheduledRetries(scheduled.dueAt)).toEqual({ promoted: 1, runIds: [scheduled.run.id] }); + await Promise.all(reads); + expect(committedStatuses).toEqual(["queued"]); + expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledWith([expect.objectContaining({ kind: "run_queued", runId: scheduled.run.id })]); + }); + + it("binds retry-now to the requested issue and persists its actor before promotion", async () => { + const selected = await fixture(), other = await fixture(); + const { retries, deps } = boundRetries(); + const scheduled = await retries.scheduleBoundedRetry(selected.run.id, { now, delayMs: 60_000 }); + const foreign = await retries.scheduleBoundedRetry(other.run.id, { now, delayMs: 60_000 }); + if (scheduled.outcome !== "scheduled" || foreign.outcome !== "scheduled") throw new Error("Expected retries"); + expect(await retries.retryScheduledRetryNow({ issueId: selected.issue.id, actor: { actorType: "user", actorId: "retry-user" }, now })).toMatchObject({ + outcome: "promoted", scheduledRetry: { runId: scheduled.run.id, status: "queued" }, + }); + expect(await deps.getRun(scheduled.run.id)).toMatchObject({ contextSnapshot: { + retryNowRequestedByActorType: "user", retryNowRequestedByActorId: "retry-user", retryNowRequestedAt: now.toISOString(), + } }); + expect(await deps.getRun(foreign.run.id)).toMatchObject({ status: "scheduled_retry", scheduledRetryAt: foreign.dueAt }); + expect(await retries.retryScheduledRetryNow({ issueId: selected.issue.id, now })).toMatchObject({ outcome: "already_promoted" }); + }); +}); diff --git a/server/src/services/heartbeat/retries.ts b/server/src/services/heartbeat/retries.ts new file mode 100644 index 00000000000..d2e7cb0e5b7 --- /dev/null +++ b/server/src/services/heartbeat/retries.ts @@ -0,0 +1,1853 @@ +import { deriveTaskKeyWithHeartbeatFallback } from "./run-state.js"; +import { + WORKSPACE_VALIDATION_FAILURE_CODE, + isWorkspaceValidationFailedRun, + readWorkspaceValidationPayloadFromRun, +} from "./workspaces.js"; +import { admitExplicitContinuationRetry } from "../explicit-native-continuation.js"; +import { + CONVERSATION_CONTINUATION_POLICY, + hasConversationContinuationPolicy, +} from "../conversation-continuation.js"; +import { legacyExecutionNeedsReconciliationWithEvidence } from "../legacy-execution-recovery.js"; +import { + executionFailureRetryCount, + executionRetryAttemptCount, + accountingForScheduledRetry, +} from "../execution-recovery-attempt.js"; +import { WORKSPACE_GIT_SCAN_ERROR_CODES } from "../workspace-git-operation-scheduler.js"; +import { randomUUID } from "node:crypto"; +import { + and, + asc, + desc, + eq, + inArray, + ne, + notInArray, + or, + sql, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + agentWakeupRequests, + executionWorkspaces, + heartbeatRuns, + issues, +} from "@paperclipai/db"; +import { + HttpError, + notFound, +} from "../../errors.js"; +import { logger } from "../../middleware/logger.js"; +import { parseObject } from "../../adapters/utils.js"; +import { logActivity } from "../activity-log.js"; +import { readContinuationAttempt } from "../recovery/index.js"; +import { withRecoveryContext } from "../recovery/status-only-context.js"; +import { + MAX_TURN_CONTINUATION_RETRY_REASON, + WORKSPACE_BUSY_RETRY_REASON, + AI_CONNECTION_BUSY_RETRY_REASON, + AI_CONNECTION_POOL_WAIT_RETRY_REASON, + INTERACTION_CONTINUATION_INFRA_RETRY_REASON, + INTERACTION_CONTINUATION_INFRA_WAKE_REASON, + isNonAssigneeWorkspaceBusyRetry, + isResolvedInteractionContinuationWakeContext, +} from "../../modules/run-dispatch/index.js"; + +import type { AppendHeartbeatRunEventInput } from "../heartbeat-run-events.js"; +import type { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js"; +import type { createHeartbeatRunState } from "./run-state.js"; +import type { createHeartbeatRunPreparation } from "./run-preparation.js"; +import type { createRunDispatch, PostCommitEffect } from "../../modules/run-dispatch/index.js"; + +type HeartbeatRun = typeof heartbeatRuns.$inferSelect; +type HeartbeatRunState = ReturnType; +type PlanApprovalResumeFailure = { + run: HeartbeatRun; + issueId: string | null; + attempt: number; + maxAttempts: number; +}; + +/** The retry module owns scheduling writes; the service owns lifecycle effects. */ +export interface HeartbeatRetryDependencies { + runDispatch: Pick, + "evaluateScheduledRetryGate" | "promoteDueScheduledRetries" | "promoteScheduledRetry">; + getAgent: (agentId: string) => Promise; + getRun: ( + runId: string, + options?: Parameters[1], + ) => Promise> | null | undefined>; + resolveSessionBeforeForWakeup: HeartbeatRunState["resolveSessionBeforeForWakeup"]; + resolveResponsibleUserIdForRunContext: ReturnType["resolveResponsibleUserIdForRunContext"]; + getAgentInvokability: (agent: typeof agents.$inferSelect | null | undefined) => ReturnType; + appendRunEvent: (run: HeartbeatRun, event: { + eventType: string; + stream?: "system" | "stdout" | "stderr"; + level?: "info" | "warn" | "error"; + color?: string; + message?: string; + payload?: Record; + retryExhaustion?: AppendHeartbeatRunEventInput["retryExhaustion"]; + }) => Promise; + escalatePlanApprovalResumeFailureNeedsAttention: (input: PlanApprovalResumeFailure) => Promise; + recordPlanApprovalResumeFailureRetry: (input: PlanApprovalResumeFailure & { retryRunId: string | null }) => Promise; + setRunStatusIfRunning: (runId: string, status: string, patch?: Partial) => Promise<{ + updated: boolean; + run: HeartbeatRun | null; + }>; + setWakeupStatus: (wakeupRequestId: string | null | undefined, status: string, patch?: Partial) => Promise; + releaseIssueExecutionAndPromote: (run: Pick) => Promise; + finalizeAgentStatus: ( + agentId: string, + outcome: "succeeded" | "interrupted" | "failed" | "cancelled" | "timed_out", + failureReason?: string | null, + options?: { keepIdleOnFailure?: boolean; wasFirstHeartbeat?: boolean }, + ) => Promise; + getWorktreeExecutionCutoff: () => Promise; + applyRunDispatchPostCommitEffects: (effects: PostCommitEffect[]) => void; +} + +export const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS = [ + 30_000, 30_000, +] as const; + +const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO = 0; + +const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON = "transient_failure"; + +const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry"; + +export function isTransientWorkspaceGitScanCode(code: string | null | undefined): boolean { + return code === WORKSPACE_GIT_SCAN_ERROR_CODES.timeout || code === WORKSPACE_GIT_SCAN_ERROR_CODES.saturated; +} + +export const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = + BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length; + +const INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS = 2; + +const MAX_TURN_CONTINUATION_LIVE_RUN_STATUSES = [ + "scheduled_retry", + "queued", + "running", +] as const; + +export const WORKSPACE_BUSY_RETRY_WAKE_REASON = "workspace_busy_retry"; + +export const WORKSPACE_BUSY_ERROR_CODE = "workspace_busy"; + +export const WORKSPACE_BUSY_RETRY_BASE_DELAY_MS = 60 * 1000; + +export const WORKSPACE_BUSY_RETRY_JITTER_MS = 60 * 1000; + +// Preserve the one-hour shared-workspace holder cutoff independently of the +// informational output-silence warnings. Warning sooner must not let another +// run overtake a quiet holder and mutate its shared workspace. +export const WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS = 60 * 60 * 1000; + +// Issue-level executionWorkspaceSettings.mode values that unambiguously opt an +// issue's runs out of the shared project workspace, and therefore out of +// shared-workspace serialization ("isolated" is the legacy alias +// parseIssueExecutionWorkspaceSettings normalizes to isolated_workspace). Any +// other value — including agent_default and an absent mode — may still resolve +// to the shared workspace and counts as a holder. +const ISOLATED_EXECUTION_WORKSPACE_MODES = [ + "isolated_workspace", + "operator_branch", + "isolated", +] as const; + +type CodexTransientFallbackMode = + | "same_session" + | "safer_invocation" + | "fresh_session" + | "fresh_session_safer_invocation"; + +export interface SharedWorkspaceHolder { + runId: string; + agentId: string; + issueId: string; + issueIdentifier: string | null; +} + +// Pre-dispatch gate outcome: another running run currently holds the issue's +// shared project workspace. Not a failure — the run is parked as a bounded +// scheduled retry and re-attempted once the holder finishes, so two agents +// never mutate the same working tree concurrently. +export class WorkspaceBusyDeferral extends Error { + code = WORKSPACE_BUSY_ERROR_CODE; + holder: SharedWorkspaceHolder; + projectWorkspaceId: string; + deferralAttempt: number; + wasIssueAssignee: boolean; + + constructor(input: { + holder: SharedWorkspaceHolder; + projectWorkspaceId: string; + deferralAttempt: number; + wasIssueAssignee: boolean; + }) { + super( + `Shared project workspace is busy: run ${input.holder.runId} (issue ${ + input.holder.issueIdentifier ?? input.holder.issueId + }) is still running`, + ); + this.name = "WorkspaceBusyDeferral"; + this.holder = input.holder; + this.projectWorkspaceId = input.projectWorkspaceId; + this.deferralAttempt = input.deferralAttempt; + this.wasIssueAssignee = input.wasIssueAssignee; + } +} + +export function isWorkspaceBusyDeferral( + error: unknown, +): error is WorkspaceBusyDeferral { + return error instanceof WorkspaceBusyDeferral; +} + +export function computeWorkspaceBusyRetryDelayMs( + random: () => number = Math.random, +) { + const jitter = Math.min(Math.max(random(), 0), 1); + return ( + WORKSPACE_BUSY_RETRY_BASE_DELAY_MS + + Math.floor(jitter * WORKSPACE_BUSY_RETRY_JITTER_MS) + ); +} + +function resolveCodexTransientFallbackMode( + attempt: number, +): CodexTransientFallbackMode { + if (attempt <= 1) return "same_session"; + if (attempt === 2) return "safer_invocation"; + if (attempt === 3) return "fresh_session"; + return "fresh_session_safer_invocation"; +} + +export function readHeartbeatRunErrorFamily( + run: Pick, +) { + const resultJson = parseObject(run.resultJson); + const persistedFamily = readNonEmptyString(resultJson.errorFamily); + if (persistedFamily) return persistedFamily; + + if (run.errorCode === "provider_quota") { + return "provider_quota"; + } + if ( + run.errorCode === "codex_transient_upstream" || + run.errorCode === "claude_transient_upstream" || + run.errorCode === "codex_harness_crash" + ) { + return "transient_upstream"; + } + return null; +} + +function readTransientRetryNotBeforeFromRun( + run: Pick, +) { + const resultJson = parseObject(run.resultJson); + const value = resultJson.retryNotBefore ?? resultJson.transientRetryNotBefore; + if (!( + typeof value === "string" || + typeof value === "number" || + value instanceof Date + )) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export function readTransientRecoveryContractFromRun( + run: Pick, +) { + const errorFamily = readHeartbeatRunErrorFamily(run); + return errorFamily === "transient_upstream" || + errorFamily === "provider_quota" + ? { + errorFamily, + retryNotBefore: readTransientRetryNotBeforeFromRun(run), + } + : null; +} + +function isSpawnLikeFailureMessage(value: unknown) { + if (typeof value !== "string") return false; + return /failed to start command|spawn\b|\bENOENT\b/i.test(value); +} + +// A sandbox provider plugin's worker can be briefly down during its own +// restart window (e.g. a rolling deploy of the plugin worker process). Lease +// acquisition fails immediately in that window, but the condition is +// transient and self-healing, so it must be treated as retryable +// infrastructure rather than a terminal setup failure. See +// resolveSandboxProviderPlugin's "worker_unavailable" message in +// environment-runtime.ts (":808"), e.g. 'Sandbox provider "kubernetes" is +// installed via plugin "acme.kubernetes-sandbox-provider", but its worker is +// not running.' +// +// This is anchored on both "is installed via plugin" and "but its worker is +// not running" so it does not also match plugin-environment-driver.ts's +// unrelated, permanent "provider not installed" message ('Sandbox provider +// "X" is not installed or its plugin worker is not running.'), which +// coincidentally contains the same "worker is not running" substring but +// describes a terminal condition that must not be retried. +function isSandboxProviderWorkerUnavailableFailureMessage(value: unknown) { + if (typeof value !== "string") return false; + return /sandbox provider .* is installed via plugin .* but its worker is not running/i.test( + value, + ); +} + +function isRetryableInteractionContinuationInfrastructureFailure( + run: Pick< + typeof heartbeatRuns.$inferSelect, + "error" | "errorCode" | "resultJson" + >, +) { + if ( + run.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE || + run.errorCode === "process_lost" + ) { + return true; + } + + if (run.errorCode !== "adapter_failed" && run.errorCode !== "setup_failed") + return false; + + const resultJson = parseObject(run.resultJson); + return ( + isSpawnLikeFailureMessage(run.error) || + isSpawnLikeFailureMessage(resultJson.errorMessage) || + isSpawnLikeFailureMessage(resultJson.message) || + isSandboxProviderWorkerUnavailableFailureMessage(run.error) || + isSandboxProviderWorkerUnavailableFailureMessage(resultJson.errorMessage) || + isSandboxProviderWorkerUnavailableFailureMessage(resultJson.message) + ); +} + +export function computeBoundedTransientHeartbeatRetrySchedule( + attempt: number, + now = new Date(), + random: () => number = Math.random, +) { + if (!Number.isInteger(attempt) || attempt <= 0) return null; + const baseDelayMs = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS[attempt - 1]; + if (typeof baseDelayMs !== "number") return null; + const sample = Math.min(1, Math.max(0, random())); + const jitterMultiplier = + 1 + (sample * 2 - 1) * BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO; + const delayMs = Math.max(1_000, Math.round(baseDelayMs * jitterMultiplier)); + return { + attempt, + baseDelayMs, + delayMs, + dueAt: new Date(now.getTime() + delayMs), + maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, + }; +} + +export function normalizeAgentNameKey(value: string | null | undefined) { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** Retry persistence uses the service database and explicit lifecycle callbacks. */ +export function createHeartbeatRetries(db: Db, dependencies: HeartbeatRetryDependencies) { + const { + appendRunEvent, + escalatePlanApprovalResumeFailureNeedsAttention, + getAgentInvokability, + runDispatch, + resolveSessionBeforeForWakeup, + resolveResponsibleUserIdForRunContext, + recordPlanApprovalResumeFailureRetry, + setRunStatusIfRunning, + setWakeupStatus, + getRun, + getAgent, + releaseIssueExecutionAndPromote, + finalizeAgentStatus, + getWorktreeExecutionCutoff, + applyRunDispatchPostCommitEffects, + } = dependencies; + + async function scheduleBoundedRetryForRun( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + opts?: { + now?: Date; + random?: () => number; + retryReason?: string; + wakeReason?: string; + maxAttempts?: number; + delayMs?: number; + }, + ) { + if (parseObject(agent.adapterConfig).provider === "openai_dot" + || parseObject(parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput).provider).kind === "openai_dot") { + return { outcome: "not_scheduled" as const, reason: "Dot external execution must be reconciled before a new assignment; Paperclip cannot confirm its external stop.", issueId: readNonEmptyString(run.contextSnapshot?.issueId) }; + } + if (run.errorCode === "provider_tool_definition_invalid") { + return { outcome: "not_scheduled" as const, + reason: "Repair the invalid tool definitions before starting a new attempt.", + issueId: readNonEmptyString(run.contextSnapshot?.issueId) }; + } + if (Array.isArray(run.contextSnapshot?.chatCompletionDeliveryIds) && + run.contextSnapshot.chatCompletionDeliveryIds.some(id => typeof id === "string")) { + return { outcome: "not_scheduled" as const, reason: "The completion outbox owns this reply's retry budget and publication identity.", + errorCode: "chat_completion_outbox_owns_retry" as const, issueId: readNonEmptyString(run.contextSnapshot.issueId) }; + } + const now = opts?.now ?? new Date(); + const retryReason = + opts?.retryReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON; + const wakeReason = + opts?.wakeReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON; + const maxAttempts = Math.max( + 0, + Math.floor( + opts?.maxAttempts ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, + ), + ); + const consumedAttempts = executionRetryAttemptCount(run, retryReason); + const nextAttempt = consumedAttempts + 1; + const computedBaseSchedule = + opts?.delayMs != null + ? nextAttempt <= maxAttempts + ? { + attempt: nextAttempt, + baseDelayMs: Math.max(0, Math.floor(opts.delayMs)), + delayMs: Math.max(0, Math.floor(opts.delayMs)), + dueAt: new Date( + now.getTime() + Math.max(0, Math.floor(opts.delayMs)), + ), + maxAttempts, + } + : null + : nextAttempt <= maxAttempts + ? computeBoundedTransientHeartbeatRetrySchedule( + nextAttempt, + now, + opts?.random, + ) + : null; + const baseSchedule = computedBaseSchedule + ? { ...computedBaseSchedule, maxAttempts } + : null; + const transientRecovery = + retryReason === BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON + ? readTransientRecoveryContractFromRun(run) + : null; + const codexTransientFallbackMode = + agent.adapterType === "codex_local" && + transientRecovery?.errorFamily === "transient_upstream" + ? resolveCodexTransientFallbackMode(nextAttempt) + : null; + const transientRetryNotBefore = transientRecovery?.retryNotBefore ?? null; + const contextSnapshot = parseObject(run.contextSnapshot); + // A retry inherits the durable authorization scope of its source run. Do + // not promote an untrusted or legacy contextSnapshot.issueId into a new + // issue binding: that could either violate the FK or misclassify history. + const issueId = run.scopeKind === "issue" ? run.issueId : null; + + if (!baseSchedule) { + const exhaustion = { + retryReason, + scheduledRetryAttempt: consumedAttempts, + maxAttempts, + }; + await appendRunEvent(run, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: `Bounded retry exhausted after ${consumedAttempts} scheduled attempts; no further automatic retry will be queued`, + payload: exhaustion, + retryExhaustion: exhaustion, + }); + if (retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON) { + await escalatePlanApprovalResumeFailureNeedsAttention({ + run, + issueId, + attempt: Math.min( + consumedAttempts, + maxAttempts, + ), + maxAttempts, + }).catch((error) => { + logger.warn( + { err: error, runId: run.id, issueId }, + "failed to escalate exhausted plan-approval resume failure", + ); + }); + } + return { + outcome: "retry_exhausted" as const, + attempt: nextAttempt, + maxAttempts, + }; + } + + if (await legacyExecutionNeedsReconciliationWithEvidence(db, run)) { + return { + outcome: "not_scheduled" as const, + reason: + "Reconcile the previous execution before retrying; safe provider recovery is unavailable.", + errorCode: "legacy_execution_requires_reconciliation" as const, + issueId: readNonEmptyString(run.contextSnapshot?.issueId), + }; + } + if (retryReason !== MAX_TURN_CONTINUATION_RETRY_REASON) { + const invokability = await getAgentInvokability(agent); + if (!invokability.invokable) { + await appendRunEvent(run, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: + "Scheduled retry suppressed because the agent is not invokable", + payload: { + retryReason, + scheduledRetryAttempt: nextAttempt, + maxAttempts, + reason: invokability.reason, + invalidOrgChain: invokability.invalidOrgChain, + ...invokability.details, + }, + }); + return { + outcome: "not_scheduled" as const, + reason: + "Scheduled retry suppressed because the agent is not invokable", + errorCode: "agent_not_invokable" as const, + issueId, + }; + } + } + + const schedule = + transientRetryNotBefore && + transientRetryNotBefore.getTime() > baseSchedule.dueAt.getTime() + ? { + ...baseSchedule, + dueAt: transientRetryNotBefore, + delayMs: Math.max( + 0, + transientRetryNotBefore.getTime() - now.getTime(), + ), + } + : baseSchedule; + + const requiresIssueGate = + isTransientWorkspaceGitScanCode(run.errorCode) || + hasConversationContinuationPolicy(run.resultJson) || + (retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) || + retryReason === MAX_TURN_CONTINUATION_RETRY_REASON || + retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON; + if (requiresIssueGate) { + const gate = await runDispatch.evaluateScheduledRetryGate({ + runId: run.id, + companyId: run.companyId, + retryReasonOverride: retryReason, + now, + }); + if (!gate.allowed) { + await appendRunEvent(run, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: gate.reason, + payload: { + retryReason, + scheduledRetryAttempt: nextAttempt, + maxAttempts, + ...gate.details, + }, + }); + return { + outcome: "not_scheduled" as const, + reason: gate.reason, + errorCode: gate.errorCode, + issueId: gate.issueId, + }; + } + } + const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null); + const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); + const interactionContinuationPayload = + retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON + ? { + mutation: "interaction", + interactionId: readNonEmptyString(contextSnapshot.interactionId), + interactionKind: readNonEmptyString( + contextSnapshot.interactionKind, + ), + interactionStatus: readNonEmptyString( + contextSnapshot.interactionStatus, + ), + continuationPolicy: readNonEmptyString( + contextSnapshot.continuationPolicy, + ), + } + : {}; + const workspaceValidationRetryPayload = + retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON && + isWorkspaceValidationFailedRun(run) + ? readWorkspaceValidationPayloadFromRun(run) + : null; + const shouldQuarantineWorkspaceForRetry = + workspaceValidationRetryPayload !== null && + Object.keys(workspaceValidationRetryPayload).length > 0; + const retryContextSnapshot: Record = withRecoveryContext( + { + ...contextSnapshot, + executionRetryAccounting: accountingForScheduledRetry(run, retryReason, schedule.attempt), + retryOfRunId: run.id, + wakeReason, + retryReason, + ...(retryReason === WORKSPACE_BUSY_RETRY_REASON + ? { + failureRetriesBeforeWorkspaceWait: + executionFailureRetryCount(run), + } + : {}), + ...((retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) + ? { failureRetriesBeforeAiConnectionWait: executionFailureRetryCount(run) } + : {}), + ...(shouldQuarantineWorkspaceForRetry + ? { + workspaceValidationRecovery: { + strategy: "quarantine_failed_workspace_and_retry_clean", + sourceRunId: run.id, + reason: + readNonEmptyString(workspaceValidationRetryPayload?.reason) ?? + WORKSPACE_VALIDATION_FAILURE_CODE, + fingerprint: readNonEmptyString( + workspaceValidationRetryPayload?.fingerprint, + ), + failedExecutionWorkspaceId: readNonEmptyString( + workspaceValidationRetryPayload?.executionWorkspaceId, + ), + }, + } + : {}), + ...(transientRecovery + ? { errorFamily: transientRecovery.errorFamily } + : {}), + scheduledRetryAttempt: schedule.attempt, + scheduledRetryAt: schedule.dueAt.toISOString(), + ...(transientRetryNotBefore + ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } + : {}), + ...(transientRecovery?.errorFamily === "provider_quota" && + transientRetryNotBefore + ? { + providerQuotaRetryNotBefore: + transientRetryNotBefore.toISOString(), + } + : {}), + ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), + }, + "normal_model", + ); + const responsibleUserId = await resolveResponsibleUserIdForRunContext( + run, + retryContextSnapshot, + ); + const continuationRetryIdempotencyKey = + retryReason === MAX_TURN_CONTINUATION_RETRY_REASON + ? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}` + : retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON + ? `interaction-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}` + : null; + + type ScheduledRetryTransactionResult = + | { + outcome: "scheduled"; + run: typeof heartbeatRuns.$inferSelect; + reusedExisting: boolean; + } + | { + outcome: "not_scheduled"; + reason: string; + errorCode: + | "issue_not_found" + | "issue_reassigned" + | "issue_cancelled" + | "issue_terminal_status" + | "issue_not_in_progress" + | "continuation_user_authorization_missing" + | "issue_execution_lock_changed"; + issueId: string | null; + details: Record; + }; + + const scheduleResult = await db.transaction( + async (tx): Promise => { + // All automatic failure paths share the same predecessor claim. A + // duplicate monitor, restart sweep or wake must reuse its successor. + if ( + retryReason !== MAX_TURN_CONTINUATION_RETRY_REASON && + retryReason !== INTERACTION_CONTINUATION_INFRA_RETRY_REASON + ) { + if (issueId) + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and id = ${issueId} for update`, + ); + await tx.execute( + sql`select id from heartbeat_runs where company_id = ${run.companyId} and id = ${run.id} for update`, + ); + const [existing] = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.retryOfRunId, run.id), + ), + ) + .limit(1); + if (existing) + return { + outcome: "scheduled", + run: existing, + reusedExisting: true, + }; + } + if (retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON) { + if (issueId) { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and id = ${issueId} for update`, + ); + } else { + await tx.execute( + sql`select id from heartbeat_runs where company_id = ${run.companyId} and id = ${run.id} for update`, + ); + } + + const existingContinuation = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.retryOfRunId, run.id), + eq(heartbeatRuns.scheduledRetryReason, retryReason), + eq(heartbeatRuns.scheduledRetryAttempt, schedule.attempt), + inArray(heartbeatRuns.status, [ + ...MAX_TURN_CONTINUATION_LIVE_RUN_STATUSES, + ]), + issueId + ? sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}` + : sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is null`, + ), + ) + .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existingContinuation) { + if (existingContinuation.wakeupRequestId) { + const existingWakeup = await tx + .select({ coalescedCount: agentWakeupRequests.coalescedCount }) + .from(agentWakeupRequests) + .where( + eq( + agentWakeupRequests.id, + existingContinuation.wakeupRequestId, + ), + ) + .then((rows) => rows[0] ?? null); + + await tx + .update(agentWakeupRequests) + .set({ + coalescedCount: (existingWakeup?.coalescedCount ?? 0) + 1, + updatedAt: now, + }) + .where( + eq( + agentWakeupRequests.id, + existingContinuation.wakeupRequestId, + ), + ); + } + + return { + outcome: "scheduled", + run: existingContinuation, + reusedExisting: true, + }; + } + } + + if (retryReason === MAX_TURN_CONTINUATION_RETRY_REASON) { + if (issueId) { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and id = ${issueId} for update`, + ); + } else { + await tx.execute( + sql`select id from heartbeat_runs where company_id = ${run.companyId} and id = ${run.id} for update`, + ); + } + + const existingContinuation = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.retryOfRunId, run.id), + eq(heartbeatRuns.scheduledRetryReason, retryReason), + eq(heartbeatRuns.scheduledRetryAttempt, schedule.attempt), + inArray(heartbeatRuns.status, [ + ...MAX_TURN_CONTINUATION_LIVE_RUN_STATUSES, + ]), + issueId + ? sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}` + : sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is null`, + ), + ) + .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existingContinuation) { + if (existingContinuation.wakeupRequestId) { + const existingWakeup = await tx + .select({ coalescedCount: agentWakeupRequests.coalescedCount }) + .from(agentWakeupRequests) + .where( + eq( + agentWakeupRequests.id, + existingContinuation.wakeupRequestId, + ), + ) + .then((rows) => rows[0] ?? null); + + await tx + .update(agentWakeupRequests) + .set({ + coalescedCount: (existingWakeup?.coalescedCount ?? 0) + 1, + updatedAt: now, + }) + .where( + eq( + agentWakeupRequests.id, + existingContinuation.wakeupRequestId, + ), + ); + } + + return { + outcome: "scheduled", + run: existingContinuation, + reusedExisting: true, + }; + } + + if (issueId) { + const lockedIssue = await tx + .select({ + id: issues.id, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, + }) + .from(issues) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, run.companyId), + ), + ) + .then((rows) => rows[0] ?? null); + + if (!lockedIssue) { + return { + outcome: "not_scheduled", + reason: + "Scheduled max-turn continuation suppressed because the target issue no longer exists", + errorCode: "issue_not_found", + issueId, + details: { issueId }, + }; + } + + if (lockedIssue.assigneeAgentId !== run.agentId) { + return { + outcome: "not_scheduled", + reason: + "Scheduled max-turn continuation suppressed because issue ownership changed", + errorCode: "issue_reassigned", + issueId, + details: { + issueId, + previousAssigneeAgentId: run.agentId, + currentAssigneeAgentId: lockedIssue.assigneeAgentId, + }, + }; + } + + if ( + lockedIssue.status === "cancelled" || + lockedIssue.status === "done" + ) { + return { + outcome: "not_scheduled", + reason: `Scheduled max-turn continuation suppressed because issue reached terminal status (${lockedIssue.status})`, + errorCode: + lockedIssue.status === "cancelled" + ? "issue_cancelled" + : "issue_terminal_status", + issueId, + details: { issueId, currentStatus: lockedIssue.status }, + }; + } + + if (lockedIssue.status !== "in_progress") { + return { + outcome: "not_scheduled", + reason: `Scheduled max-turn continuation suppressed because issue is no longer in_progress (current status: ${lockedIssue.status})`, + errorCode: "issue_not_in_progress", + issueId, + details: { + issueId, + currentStatus: lockedIssue.status, + requiredStatus: "in_progress", + }, + }; + } + + if (lockedIssue.executionRunId !== run.id) { + return { + outcome: "not_scheduled", + reason: + "Scheduled max-turn continuation suppressed because the issue execution lock belongs to a different run", + errorCode: "issue_execution_lock_changed", + issueId, + details: { + issueId, + expectedExecutionRunId: run.id, + currentExecutionRunId: lockedIssue.executionRunId, + }, + }; + } + } + } + + if ( + (retryReason === AI_CONNECTION_BUSY_RETRY_REASON || retryReason === AI_CONNECTION_POOL_WAIT_RETRY_REASON) && issueId && + !isNonAssigneeWorkspaceBusyRetry(retryReason, contextSnapshot) + ) { + // The issue row is locked above. Recheck after the preflight gate so + // cancellation or recovery cannot leave a successor without its lock. + const [lockedIssue] = await tx.select({ executionRunId: issues.executionRunId }) + .from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))); + if (lockedIssue?.executionRunId !== run.id) { + return { + outcome: "not_scheduled", issueId, errorCode: "issue_execution_lock_changed", + reason: "Subscription retry suppressed because the task execution lock changed", + details: { issueId, expectedExecutionRunId: run.id, currentExecutionRunId: lockedIssue?.executionRunId ?? null }, + }; + } + } + + const scheduledRunId = randomUUID(); + if (contextSnapshot.explicitUserContinuation) { + const continuation = issueId && retryReason === "transient_failure" ? await admitExplicitContinuationRetry({ + db: tx as unknown as Db, companyId: run.companyId, issueId, agentId: run.agentId, + parentRunId: run.id, successorRunId: scheduledRunId, now, + }) : null; + if (!continuation) return { + outcome: "not_scheduled", issueId, + errorCode: "continuation_user_authorization_missing", + reason: "The automatic retry could not revalidate the original user continuation.", + details: {}, + }; + retryContextSnapshot.explicitUserContinuation = continuation; + retryContextSnapshot.previousRunId = continuation.previousRunId; + } + + const wakeupRequest = await tx + .insert(agentWakeupRequests) + .values({ + companyId: run.companyId, + agentId: run.agentId, + source: "automation", + triggerDetail: "system", + reason: wakeReason, + payload: withRecoveryContext( + { + ...(issueId ? { issueId } : {}), + retryOfRunId: run.id, + ...interactionContinuationPayload, + retryReason, + ...(transientRecovery + ? { errorFamily: transientRecovery.errorFamily } + : {}), + scheduledRetryAttempt: schedule.attempt, + scheduledRetryAt: schedule.dueAt.toISOString(), + ...(transientRetryNotBefore + ? { + transientRetryNotBefore: + transientRetryNotBefore.toISOString(), + } + : {}), + ...(transientRecovery?.errorFamily === "provider_quota" && + transientRetryNotBefore + ? { + providerQuotaRetryNotBefore: + transientRetryNotBefore.toISOString(), + } + : {}), + ...(codexTransientFallbackMode + ? { codexTransientFallbackMode } + : {}), + }, + "normal_model", + ), + status: "queued", + requestedByActorType: "system", + requestedByActorId: null, + idempotencyKey: continuationRetryIdempotencyKey, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + const scheduledRun = await tx + .insert(heartbeatRuns) + .values({ + id: scheduledRunId, + companyId: run.companyId, + agentId: run.agentId, + scopeKind: run.scopeKind, + issueId, + invocationSource: "automation", + triggerDetail: "system", + status: "scheduled_retry", + wakeupRequestId: wakeupRequest.id, + contextSnapshot: retryContextSnapshot, + ...(hasConversationContinuationPolicy(run.resultJson) + ? { resultJson: { conversationContinuation: CONVERSATION_CONTINUATION_POLICY } } : {}), + responsibleUserId, + sessionIdBefore: sessionBefore, + retryOfRunId: run.id, + scheduledRetryAt: schedule.dueAt, + scheduledRetryAttempt: schedule.attempt, + scheduledRetryReason: retryReason, + continuationAttempt: readContinuationAttempt( + retryContextSnapshot.livenessContinuationAttempt, + ), + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + await tx + .update(agentWakeupRequests) + .set({ + runId: scheduledRun.id, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + + let detachWorkspaceFromIssue = false; + if (issueId && shouldQuarantineWorkspaceForRetry) { + const issueWorkspace = await tx + .select({ + id: issues.id, + companyId: issues.companyId, + executionWorkspaceId: issues.executionWorkspaceId, + }) + .from(issues) + .where( + and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const failedExecutionWorkspaceId = + readNonEmptyString( + workspaceValidationRetryPayload?.executionWorkspaceId, + ) ?? readNonEmptyString(issueWorkspace?.executionWorkspaceId); + + if (issueWorkspace && failedExecutionWorkspaceId) { + const failedWorkspace = await tx + .select({ + id: executionWorkspaces.id, + companyId: executionWorkspaces.companyId, + sourceIssueId: executionWorkspaces.sourceIssueId, + status: executionWorkspaces.status, + metadata: executionWorkspaces.metadata, + }) + .from(executionWorkspaces) + .where( + and( + eq(executionWorkspaces.id, failedExecutionWorkspaceId), + eq(executionWorkspaces.companyId, run.companyId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + + const workspaceBelongsToIssue = failedWorkspace + ? failedWorkspace.sourceIssueId === issueId + : false; + + if ( + failedWorkspace && + workspaceBelongsToIssue && + issueWorkspace.executionWorkspaceId === failedExecutionWorkspaceId + ) { + const existingMetadata = parseObject(failedWorkspace.metadata); + const quarantine = { + reason: WORKSPACE_VALIDATION_FAILURE_CODE, + retryReason, + sourceRunId: run.id, + retryRunId: scheduledRun.id, + issueId, + sourceIssueId: failedWorkspace.sourceIssueId ?? null, + quarantinedAt: now.toISOString(), + workspaceValidation: workspaceValidationRetryPayload ?? {}, + }; + await tx + .update(executionWorkspaces) + .set({ + status: "archived", + closedAt: now, + cleanupEligibleAt: null, + cleanupReason: WORKSPACE_VALIDATION_FAILURE_CODE, + metadata: { + ...existingMetadata, + workspaceValidationQuarantine: quarantine, + }, + updatedAt: now, + }) + .where( + and( + eq(executionWorkspaces.id, failedWorkspace.id), + eq(executionWorkspaces.companyId, run.companyId), + ), + ); + + await logActivity(tx as unknown as Db, { + companyId: run.companyId, + actorType: "system", + actorId: "heartbeat", + agentId: run.agentId, + runId: run.id, + action: "execution_workspace.workspace_validation_quarantined", + entityType: "execution_workspace", + entityId: failedWorkspace.id, + details: quarantine, + }); + detachWorkspaceFromIssue = + issueWorkspace.executionWorkspaceId === + failedExecutionWorkspaceId; + } + } + } + + if (issueId) { + await tx + .update(issues) + .set({ + executionRunId: scheduledRun.id, + checkoutRunId: sql`case when ${issues.checkoutRunId} = ${run.id} then null else ${issues.checkoutRunId} end`, + executionAgentNameKey: normalizeAgentNameKey(agent.name), + executionLockedAt: now, + ...(detachWorkspaceFromIssue + ? { + executionWorkspaceId: null, + executionWorkspacePreference: null, + } + : {}), + updatedAt: now, + }) + .where( + and( + eq(issues.id, issueId), + eq(issues.companyId, run.companyId), + eq(issues.executionRunId, run.id), + ), + ); + } + + return { + outcome: "scheduled", + run: scheduledRun, + reusedExisting: false, + }; + }, + ); + + if (scheduleResult.outcome === "not_scheduled") { + await appendRunEvent(run, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: scheduleResult.reason, + payload: { + retryReason, + scheduledRetryAttempt: nextAttempt, + maxAttempts, + ...scheduleResult.details, + }, + }); + return { + outcome: "not_scheduled" as const, + reason: scheduleResult.reason, + errorCode: scheduleResult.errorCode, + issueId: scheduleResult.issueId, + }; + } + + const retryRun = scheduleResult.run; + const dueAt = retryRun.scheduledRetryAt + ? new Date(retryRun.scheduledRetryAt) + : schedule.dueAt; + + if (scheduleResult.reusedExisting) { + await appendRunEvent(run, { + eventType: "lifecycle", + stream: "system", + level: "info", + message: `Reused existing continuation retry ${retryRun.scheduledRetryAttempt}/${schedule.maxAttempts}`, + payload: { + retryRunId: retryRun.id, + retryReason, + idempotencyKey: continuationRetryIdempotencyKey, + scheduledRetryAttempt: retryRun.scheduledRetryAttempt, + scheduledRetryAt: dueAt.toISOString(), + }, + }); + + return { + outcome: "scheduled" as const, + run: retryRun, + dueAt, + attempt: retryRun.scheduledRetryAttempt, + maxAttempts: schedule.maxAttempts, + reusedExisting: true, + }; + } + + await appendRunEvent(run, { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: `Scheduled bounded retry ${schedule.attempt}/${schedule.maxAttempts} for ${schedule.dueAt.toISOString()}`, + payload: { + retryRunId: retryRun.id, + retryReason, + ...(transientRecovery + ? { errorFamily: transientRecovery.errorFamily } + : {}), + scheduledRetryAttempt: schedule.attempt, + scheduledRetryAt: schedule.dueAt.toISOString(), + baseDelayMs: schedule.baseDelayMs, + delayMs: schedule.delayMs, + ...(transientRetryNotBefore + ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } + : {}), + ...(transientRecovery?.errorFamily === "provider_quota" && + transientRetryNotBefore + ? { + providerQuotaRetryNotBefore: + transientRetryNotBefore.toISOString(), + } + : {}), + ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), + }, + }); + + if (retryReason === INTERACTION_CONTINUATION_INFRA_RETRY_REASON) { + await recordPlanApprovalResumeFailureRetry({ + run, + issueId, + retryRunId: retryRun.id, + attempt: schedule.attempt, + maxAttempts: schedule.maxAttempts, + }).catch((error) => { + logger.warn( + { err: error, runId: run.id, issueId, retryRunId: retryRun.id }, + "failed to record plan-approval resume retry failure", + ); + }); + } + + return { + outcome: "scheduled" as const, + run: retryRun, + dueAt, + attempt: schedule.attempt, + maxAttempts: schedule.maxAttempts, + }; + } + + // Finds a running heartbeat run (other than the caller's) whose context + // issue shares the same project workspace, i.e. the run that currently + // "holds" the shared working tree. Runs that have been silent past + // WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS do not count — a zombie holder must + // not park other work forever. This cutoff is independent of informational + // silence warnings. When isolated workspaces are enabled, holders whose + // issue explicitly opted into an isolated workspace never touch the shared + // tree, so they are excluded; a NULL/agent_default mode may resolve to the + // shared tree and counts as a holder (over-serializing is the safe + // direction). When the isolated-workspaces experiment is off, every run + // resolves to the shared tree, so no holder is excluded. + async function findSharedWorkspaceHolder(input: { + companyId: string; + projectWorkspaceId: string; + excludeIssueId: string; + excludeRunId: string; + honorIsolatedWorkspaceModes: boolean; + now?: Date; + }): Promise { + const staleCutoff = new Date( + (input.now ?? new Date()).getTime() - + WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS, + ); + return await db + .select({ + runId: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + issueId: sql`${issues.id}::text`, + issueIdentifier: issues.identifier, + }) + .from(heartbeatRuns) + .innerJoin( + issues, + and( + eq(issues.companyId, heartbeatRuns.companyId), + sql`${issues.id}::text = ${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + ), + ) + .where( + and( + eq(heartbeatRuns.companyId, input.companyId), + eq(heartbeatRuns.status, "running"), + ne(heartbeatRuns.id, input.excludeRunId), + // Last observed activity: output beats start beats creation. A run + // that started recently but has not written output yet is live. + sql`coalesce(${heartbeatRuns.lastOutputAt}, ${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${staleCutoff.toISOString()}::timestamptz`, + eq(issues.projectWorkspaceId, input.projectWorkspaceId), + ne(sql`${issues.id}::text`, input.excludeIssueId), + ...(input.honorIsolatedWorkspaceModes + ? [ + or( + // Covers both a NULL settings blob and a blob without a mode + // key; either may still resolve to the shared workspace. + sql`${issues.executionWorkspaceSettings} ->> 'mode' is null`, + notInArray( + sql`${issues.executionWorkspaceSettings} ->> 'mode'`, + [...ISOLATED_EXECUTION_WORKSPACE_MODES], + ), + ), + ] + : []), + ), + ) + .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + // Credential rotation can briefly contend with a fresh runtime read. Keep + // the task on its automatic pre-provider retry path while the lock clears. + async function finalizeAiConnectionBusyDeferral( + run: typeof heartbeatRuns.$inferSelect, + error: HttpError, + wasIssueAssignee: boolean, + ) { + const now = new Date(); + const cancelled = await setRunStatusIfRunning(run.id, "cancelled", { + error: error.message, errorCode: AI_CONNECTION_BUSY_RETRY_REASON, finishedAt: now, + resultJson: { + executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false }, + cancellation: { + source: "control_plane", + expected: true, + initiator: { type: "system" }, + reason: "Waiting for shared AI credentials", + recordedAt: now.toISOString(), + }, + }, + contextSnapshot: { + ...parseObject(run.contextSnapshot), + aiConnectionBusyDeferredWhileAssignee: wasIssueAssignee, + }, + }); + if (!cancelled.updated) return; + await setWakeupStatus(run.wakeupRequestId, "cancelled", { finishedAt: now, error: error.message }).catch(() => undefined); + const cancelledRun = cancelled.run ?? await getRun(run.id); + const agent = await getAgent(run.agentId); + let scheduled = false; + try { + if (cancelledRun && agent) { + const retry = await scheduleBoundedRetryForRun(cancelledRun, agent, { + now, retryReason: AI_CONNECTION_BUSY_RETRY_REASON, wakeReason: "ai_connection_busy_retry", + maxAttempts: (cancelledRun.scheduledRetryAttempt ?? 0) + 1, + delayMs: computeWorkspaceBusyRetryDelayMs(), + }); + scheduled = retry.outcome === "scheduled"; + await appendRunEvent(cancelledRun, { + eventType: "lifecycle", stream: "system", level: "info", + message: scheduled ? "Waiting for the shared AI subscription. This task will retry automatically." : "The AI subscription is busy; this task can no longer retry automatically.", + payload: { retryScheduled: scheduled }, + }); + } + } finally { + try { + if (cancelledRun && !scheduled) await releaseIssueExecutionAndPromote(cancelledRun); + } finally { + await finalizeAgentStatus(run.agentId, "cancelled", null, { wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run) }); + } + } + } + + // Terminal handling for a WorkspaceBusyDeferral thrown by the pre-dispatch + // gate: cancel the run (contention is not a failure), schedule a + // workspace_busy retry, and leave the agent idle. The issue execution lock + // transfers to the scheduled retry run inside scheduleBoundedRetryForRun, so + // the issue keeps an active execution path and recovery leaves it alone. + // Deferral has no attempt ceiling — the retry keeps rescheduling while a + // live holder exists, and holder staleness (not a counter) is what prevents + // waiting on a zombie. If no retry could be scheduled (agent no longer + // invokable), the lock is released so the issue does not strand on a + // cancelled run. + async function finalizeWorkspaceBusyDeferral( + run: typeof heartbeatRuns.$inferSelect, + deferral: WorkspaceBusyDeferral, + ) { + const now = new Date(); + const cancelWrite = await setRunStatusIfRunning(run.id, "cancelled", { + error: deferral.message, + errorCode: WORKSPACE_BUSY_ERROR_CODE, + finishedAt: now, + resultJson: { + executionRecovery: { + kind: "workspace_wait", + providerWorkStarted: false, + }, + cancellation: { + source: "control_plane", + expected: true, + initiator: { type: "system" }, + reason: "Waiting for the shared project workspace", + recordedAt: now.toISOString(), + }, + workspaceBusy: { + projectWorkspaceId: deferral.projectWorkspaceId, + holderRunId: deferral.holder.runId, + holderIssueId: deferral.holder.issueId, + deferralAttempt: deferral.deferralAttempt, + }, + }, + // Recorded on the run (and inherited by the scheduled retry's context) + // so the retry promotion gate can tell a non-assignee wake — where an + // assignee mismatch is the expected state — from a reassignment race. + contextSnapshot: { + ...parseObject(run.contextSnapshot), + workspaceBusyDeferredWhileAssignee: deferral.wasIssueAssignee, + }, + }); + if (!cancelWrite.updated) { + logger.info( + { runId: run.id, currentStatus: cancelWrite.run?.status ?? null }, + "skipping workspace-busy deferral finalization because the run already left running state", + ); + return; + } + await setWakeupStatus(run.wakeupRequestId, "cancelled", { + finishedAt: now, + error: deferral.message, + }).catch(() => undefined); + + const cancelledRun = + cancelWrite.run ?? (await getRun(run.id).catch(() => null)); + const agentRow = await getAgent(run.agentId).catch(() => null); + let scheduleOutcome: string | null = null; + if (cancelledRun && agentRow) { + const scheduleResult = await scheduleBoundedRetryForRun( + cancelledRun, + agentRow, + { + now, + retryReason: WORKSPACE_BUSY_RETRY_REASON, + wakeReason: WORKSPACE_BUSY_RETRY_WAKE_REASON, + // Always admit the next attempt: workspace-busy deferral is bounded by + // holder liveness, not by an attempt counter. + maxAttempts: (cancelledRun.scheduledRetryAttempt ?? 0) + 1, + delayMs: computeWorkspaceBusyRetryDelayMs(), + }, + ).catch((scheduleErr) => { + logger.error( + { err: scheduleErr, runId: run.id }, + "failed to schedule workspace-busy retry after deferral", + ); + return null; + }); + scheduleOutcome = scheduleResult?.outcome ?? null; + } + + if (cancelledRun) { + await appendRunEvent(cancelledRun, { + eventType: "lifecycle", + stream: "system", + level: "info", + message: + scheduleOutcome === "scheduled" + ? `Deferred: ${deferral.message}. Retry ${deferral.deferralAttempt + 1} scheduled; the run waits for the workspace to free.` + : `Deferred: ${deferral.message}. No retry could be scheduled; releasing the issue for other runs.`, + payload: { + projectWorkspaceId: deferral.projectWorkspaceId, + holderRunId: deferral.holder.runId, + holderIssueId: deferral.holder.issueId, + deferralAttempt: deferral.deferralAttempt, + retryScheduled: scheduleOutcome === "scheduled", + }, + }).catch(() => undefined); + } + + if (cancelledRun && scheduleOutcome !== "scheduled") { + await releaseIssueExecutionAndPromote(cancelledRun).catch( + (releaseErr) => { + logger.error( + { err: releaseErr, runId: run.id }, + "failed to release issue execution after workspace-busy deferral", + ); + }, + ); + } + + await finalizeAgentStatus(run.agentId, "cancelled", null, { + wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run), + }).catch(() => undefined); + } + + async function scheduleInteractionContinuationInfrastructureRetryIfEligible( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + ) { + if (!run.wakeupRequestId) return null; + if (!isResolvedInteractionContinuationWakeContext(run.contextSnapshot)) + return null; + if (!isRetryableInteractionContinuationInfrastructureFailure(run)) { + const context = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(context.issueId); + await escalatePlanApprovalResumeFailureNeedsAttention({ + run, + issueId, + attempt: Math.min( + run.scheduledRetryAttempt ?? + INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS, + INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS, + ), + maxAttempts: INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS, + }).catch((error) => { + logger.warn( + { err: error, runId: run.id, issueId }, + "failed to escalate non-retryable plan-approval resume failure", + ); + }); + return null; + } + + return scheduleBoundedRetryForRun(run, agent, { + retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON, + wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON, + maxAttempts: INTERACTION_CONTINUATION_INFRA_MAX_ATTEMPTS, + }); + } + + async function promoteDueScheduledRetries(now = new Date()) { + const cutoff = await getWorktreeExecutionCutoff(); + const result = await runDispatch.promoteDueScheduledRetries({ + now, + cutoff, + }); + applyRunDispatchPostCommitEffects(result.postCommitEffects); + return { promoted: result.promoted, runIds: result.runIds }; + } + + async function getIssueRetryRun( + companyId: string, + issueId: string, + statuses: Array<"scheduled_retry" | "queued" | "running" | "cancelled">, + ) { + if (statuses.length === 0) return null; + return db + .select({ + run: heartbeatRuns, + agentName: agents.name, + }) + .from(heartbeatRuns) + .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.status, statuses), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + sql`${heartbeatRuns.retryOfRunId} is not null`, + ), + ) + .orderBy( + desc(heartbeatRuns.updatedAt), + desc(heartbeatRuns.createdAt), + desc(heartbeatRuns.id), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + function summarizeIssueScheduledRetryRun(row: { + run: typeof heartbeatRuns.$inferSelect; + agentName: string | null; + }) { + return { + runId: row.run.id, + status: row.run.status as + "scheduled_retry" | "queued" | "running" | "cancelled", + agentId: row.run.agentId, + agentName: row.agentName, + retryOfRunId: row.run.retryOfRunId, + scheduledRetryAt: row.run.scheduledRetryAt, + scheduledRetryAttempt: row.run.scheduledRetryAttempt, + scheduledRetryReason: row.run.scheduledRetryReason, + error: row.run.error, + errorCode: row.run.errorCode, + }; + } + + async function retryScheduledRetryNow(input: { + issueId: string; + actor?: { + actorType?: "user" | "agent" | "system"; + actorId?: string | null; + }; + now?: Date; + }) { + const now = input.now ?? new Date(); + const issue = await db + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, input.issueId)) + .then((rows) => rows[0] ?? null); + if (!issue) throw notFound("Issue not found"); + + const scheduled = await getIssueRetryRun(issue.companyId, issue.id, [ + "scheduled_retry", + ]); + if (!scheduled) { + const alreadyPromoted = await getIssueRetryRun( + issue.companyId, + issue.id, + ["queued", "running"], + ); + if (alreadyPromoted) { + return { + outcome: "already_promoted" as const, + message: "Scheduled retry was already promoted", + scheduledRetry: summarizeIssueScheduledRetryRun(alreadyPromoted), + }; + } + return { + outcome: "no_scheduled_retry" as const, + message: "No live scheduled retry exists for this issue", + scheduledRetry: null, + }; + } + + const contextSnapshot = { + ...parseObject(scheduled.run.contextSnapshot), + scheduledRetryAt: now.toISOString(), + retryNowRequestedAt: now.toISOString(), + retryNowRequestedByActorType: input.actor?.actorType ?? null, + retryNowRequestedByActorId: input.actor?.actorId ?? null, + }; + + const updated = await db.transaction(async (tx) => { + const row = await tx + .update(heartbeatRuns) + .set({ + scheduledRetryAt: now, + contextSnapshot, + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, scheduled.run.id), + eq(heartbeatRuns.status, "scheduled_retry"), + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) return null; + + if (row.wakeupRequestId) { + const wakeupPayload = { + ...parseObject( + await tx + .select({ payload: agentWakeupRequests.payload }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, row.wakeupRequestId)) + .then((rows) => rows[0]?.payload ?? null), + ), + scheduledRetryAt: now.toISOString(), + retryNowRequestedAt: now.toISOString(), + }; + await tx + .update(agentWakeupRequests) + .set({ + payload: wakeupPayload, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, row.wakeupRequestId)); + } + + return row; + }); + + if (!updated) { + const alreadyPromoted = await getIssueRetryRun( + issue.companyId, + issue.id, + ["queued", "running"], + ); + if (alreadyPromoted) { + return { + outcome: "already_promoted" as const, + message: "Scheduled retry was already promoted", + scheduledRetry: summarizeIssueScheduledRetryRun(alreadyPromoted), + }; + } + return { + outcome: "no_scheduled_retry" as const, + message: "No live scheduled retry exists for this issue", + scheduledRetry: null, + }; + } + + await appendRunEvent(updated, { + eventType: "lifecycle", + stream: "system", + level: "info", + message: "Scheduled retry was requested to run now", + payload: { + issueId: issue.id, + scheduledRetryAttempt: updated.scheduledRetryAttempt, + scheduledRetryAt: updated.scheduledRetryAt + ? new Date(updated.scheduledRetryAt).toISOString() + : null, + scheduledRetryReason: updated.scheduledRetryReason, + requestedByActorType: input.actor?.actorType ?? null, + requestedByActorId: input.actor?.actorId ?? null, + }, + }); + + const promotion = await runDispatch.promoteScheduledRetry({ + runId: updated.id, + companyId: updated.companyId, + now, + }); + if (promotion.outcome === "promoted") { + applyRunDispatchPostCommitEffects(promotion.postCommitEffects); + } + // Promotion can preserve this row as a cleanup wait. Read that exact run, + // not an older cancelled retry or the pre-promotion schedule. + const currentRun = await getRun(updated.id); + const scheduledRetry = currentRun + ? summarizeIssueScheduledRetryRun({ + run: currentRun, + agentName: scheduled.agentName, + }) + : null; + + if (currentRun?.status === "scheduled_retry") { + return { + outcome: "waiting" as const, + message: parseObject(currentRun.resultJson?.executionWait).cause === "execution_owner_active" + ? "Waiting for execution cleanup. Paperclip will retry automatically once cleanup finishes." + : "The retry remains scheduled. Paperclip will check again at the scheduled time.", + scheduledRetry, + }; + } + + if (promotion.outcome === "promoted") { + return { + outcome: "promoted" as const, + message: "Scheduled retry was promoted to the queued run pool", + scheduledRetry, + }; + } + if (promotion.outcome === "gate_suppressed") { + return { + outcome: "gate_suppressed" as const, + message: promotion.reason, + scheduledRetry, + }; + } + if (currentRun && ["queued", "running"].includes(currentRun.status)) { + return { + outcome: "already_promoted" as const, + message: "Scheduled retry was already promoted", + scheduledRetry, + }; + } + return { + outcome: "no_scheduled_retry" as const, + message: "No live scheduled retry exists for this issue", + scheduledRetry: null, + }; + } + + function timerClaimWasFirstHeartbeat( + run: Pick, + ): true | undefined { + return parseObject(run.contextSnapshot).timerClaimWasFirstHeartbeat === true + ? true + : undefined; + } + + return { + scheduleBoundedRetryForRun, + timerClaimWasFirstHeartbeat, + scheduleInteractionContinuationInfrastructureRetryIfEligible, + findSharedWorkspaceHolder, + finalizeAiConnectionBusyDeferral, + finalizeWorkspaceBusyDeferral, + promoteDueScheduledRetries, + retryScheduledRetryNow, + + scheduleBoundedRetry: async ( + runId: string, + opts?: { + now?: Date; + random?: () => number; + retryReason?: string; + wakeReason?: string; + maxAttempts?: number; + delayMs?: number; + }, + ) => { + const run = await getRun(runId, { unsafeFullResultJson: true }); + if (!run) return { outcome: "missing_run" as const }; + const agent = await getAgent(run.agentId); + if (!agent) return { outcome: "missing_agent" as const }; + return scheduleBoundedRetryForRun(run, agent, opts); + }, + }; +} diff --git a/server/src/services/heartbeat/run-preparation.test.ts b/server/src/services/heartbeat/run-preparation.test.ts new file mode 100644 index 00000000000..10a447e1a74 --- /dev/null +++ b/server/src/services/heartbeat/run-preparation.test.ts @@ -0,0 +1,141 @@ +import type { Db } from "@paperclipai/db"; +import { PgDialect } from "drizzle-orm/pg-core"; +import type { SQL } from "drizzle-orm"; +import { describe, expect, it, vi } from "vitest"; +import { + buildPaperclipRuntimeMcpServers as legacyBuildRuntimeMcpServers, + buildPaperclipWakePayload as legacyBuildWakePayload, + ConfigurationIncompleteFailure as LegacyConfigurationIncompleteFailure, + resolveExecutionRunAdapterConfig as legacyResolveAdapterConfig, +} from "../heartbeat.js"; +import { + buildPaperclipRuntimeMcpServers, + buildPaperclipWakePayload, + ConfigurationIncompleteFailure, + createHeartbeatRunPreparation, + resolveExecutionRunAdapterConfig, +} from "./run-preparation.js"; + +// Existing integration suites exercise the real database and dispatch path. +// These results isolate the factory binding and preparation query order. +function databaseWithResults(...results: unknown[][]) { + const scopes: unknown[][] = []; + const select = vi.fn(() => { + const rows = results.shift() ?? []; + const query = { + from: () => query, + leftJoin: () => query, + where: (condition: SQL) => { + scopes.push(new PgDialect().sqlToQuery(condition).params); + return query; + }, + orderBy: () => query, + limit: () => query, + then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve), + }; + return query; + }); + return { db: { select } as unknown as Db, select, scopes }; +} + +const emptyRoutineContext = { routineId: null, env: null, responsibleUserId: null }; + +describe("heartbeat run preparation module", () => { + it("preserves the legacy error class and public preparation functions", () => { + expect(LegacyConfigurationIncompleteFailure).toBe(ConfigurationIncompleteFailure); + expect(legacyResolveAdapterConfig).toBe(resolveExecutionRunAdapterConfig); + expect(legacyBuildWakePayload).toBe(buildPaperclipWakePayload); + expect(legacyBuildRuntimeMcpServers).toBe(buildPaperclipRuntimeMcpServers); + const failure = new ConfigurationIncompleteFailure("missing credentials", { reason: "test" }); + expect(failure).toBeInstanceOf(LegacyConfigurationIncompleteFailure); + expect(failure.code).toBe("configuration_incomplete"); + }); + + it("binds each context loader to its database and company scope", async () => { + const first = databaseWithResults([{ id: "issue-one", title: "First company" }]); + const second = databaseWithResults([{ id: "issue-two", title: "Second company" }]); + const firstPreparation = createHeartbeatRunPreparation(first.db); + const secondPreparation = createHeartbeatRunPreparation(second.db); + expect(first.select).not.toHaveBeenCalled(); + expect(second.select).not.toHaveBeenCalled(); + const [firstIssue, secondIssue] = await Promise.all([ + firstPreparation.getIssueExecutionContext("company-one", "issue-one"), + secondPreparation.getIssueExecutionContext("company-two", "issue-two"), + ]); + expect(firstIssue).toMatchObject({ id: "issue-one", title: "First company" }); + expect(secondIssue).toMatchObject({ id: "issue-two", title: "Second company" }); + expect(first.scopes[0]).toEqual(expect.arrayContaining(["company-one", "issue-one"])); + expect(second.scopes[0]).toEqual(expect.arrayContaining(["company-two", "issue-two"])); + expect(first.select).toHaveBeenCalledTimes(1); + expect(second.select).toHaveBeenCalledTimes(1); + }); + + it("keeps the pinned routine environment and original run identity", async () => { + const database = databaseWithResults( + [{ routineRevisionId: "revision-one", responsibleUserId: "run-owner" }], + [{ snapshot: { version: 1, routine: { env: { PINNED_FLAG: "original" }, responsibleUserId: "snapshot-owner" } }, responsibleUserId: "revision-owner" }], + ); + const result = await createHeartbeatRunPreparation(database.db).getRoutineEnvForExecutionIssue( + "company-one", + { originKind: "routine_execution", originId: "routine-one", originRunId: "routine-run-one" }, + ); + expect(result).toEqual({ routineId: "routine-one", env: { PINNED_FLAG: "original" }, responsibleUserId: "run-owner" }); + expect(database.scopes[0]).toEqual(expect.arrayContaining(["company-one", "routine-one", "routine-run-one"])); + expect(database.scopes[1]).toEqual(expect.arrayContaining(["company-one", "routine-one", "revision-one"])); + expect(database.select).toHaveBeenCalledTimes(2); + }); + + it("uses the latest wake comment's author even when query rows arrive out of order", async () => { + const database = databaseWithResults([ + { id: "comment-two", authorUserId: "latest-author" }, + { id: "comment-one", authorUserId: "older-author" }, + ]); + const contextSnapshot: Record = { + wakeCommentIds: ["comment-one", "comment-two"], + responsibleUserId: "previous-owner", + executionIdentityCause: "company_default", + }; + const owner = await createHeartbeatRunPreparation(database.db).resolveResponsibleUserIdForRunSeed({ + companyId: "company-one", contextSnapshot, + issueContext: { id: "issue-one", responsibleUserId: "issue-owner", parentId: null }, + routineEnvContext: emptyRoutineContext, + }); + expect(owner).toBe("latest-author"); + expect(contextSnapshot).not.toHaveProperty("executionIdentityCause"); + expect(database.scopes[0]).toEqual(expect.arrayContaining(["company-one", "issue-one"])); + expect(database.select).toHaveBeenCalledTimes(1); + }); + + it("resolves a system wake through the same company's active owner", async () => { + const database = databaseWithResults([{ defaultResponsibleUserId: null }], [{ userId: "company-owner" }]); + const contextSnapshot: Record = {}; + const owner = await createHeartbeatRunPreparation(database.db).resolveResponsibleUserIdForRunSeed({ + companyId: "company-one", contextSnapshot, issueContext: null, + routineEnvContext: emptyRoutineContext, requestedByActorType: "system", + }); + expect(owner).toBe("company-owner"); + expect(contextSnapshot.executionIdentityCause).toBe("company_default"); + expect(database.scopes[0]).toContain("company-one"); + expect(database.scopes[1]).toEqual(expect.arrayContaining(["company-one", "user", "active", "owner"])); + expect(database.select).toHaveBeenCalledTimes(2); + }); + + it("keeps missing scoped credentials as a pre-dispatch failure", async () => { + const secretsSvc = { + resolveAdapterConfigForRuntime: vi.fn(), + resolveEnvBindings: vi.fn(), + collectMissingRuntimeBindings: vi.fn(), + collectMissingAdapterConfigRuntimeBindings: vi.fn(), + }; + await expect(resolveExecutionRunAdapterConfig({ + companyId: "company-one", executionRunConfig: { env: {} }, projectEnv: null, + requiredScopedEnvBinding: { + keys: ["GH_TOKEN"], consumerScopes: ["agent", "project"], + reason: "github_credentials_missing", remediation: "Configure GitHub access.", + }, + secretsSvc, + })).rejects.toBeInstanceOf(LegacyConfigurationIncompleteFailure); + expect(secretsSvc.resolveAdapterConfigForRuntime).not.toHaveBeenCalled(); + expect(secretsSvc.resolveEnvBindings).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/heartbeat/run-preparation.ts b/server/src/services/heartbeat/run-preparation.ts new file mode 100644 index 00000000000..de22ffee6fa --- /dev/null +++ b/server/src/services/heartbeat/run-preparation.ts @@ -0,0 +1,3237 @@ +import { CONFIGURED_ENVIRONMENT_KEYS } from "../../vendor/paperclip-runner/index.js"; +import { ASSIGNED_MCP_SERVER_NAME } from "../mcp-tool-names.js"; +import { externalConversationStateSql } from "../slack-conversation-state.js"; +import { githubBotConnectionIdsForRun } from "../chat-github-tools.js"; +import { isBrowserUseConnection } from "../browser-use-client.js"; +import { explicitOperatorRunIdentity } from "../run-identity.js"; +import { + type DurableChatWakeupRequest, +} from "../durable-chat-wakeup.js"; +import { createHash } from "node:crypto"; +import { + and, + asc, + desc, + eq, + inArray, + isNull, + sql, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + CHAT_PROVIDERS, + CONNECTION_INTENT_AGENT_GUIDANCE, + CONNECTION_RUNTIME_TOOL_NAMES, + envBindingSchema, + isToolConnectionAttentionHealth, + type ChatProvider, + type RoutineRevisionSnapshotV1, + type SourceTrustMetadata, +} from "@paperclipai/shared"; +import { + agents, + assets, + chatConversations, + chatDeliveries, + chatEndpoints, + chatMessageLinks, + companyMemberships, + companySkillTestRuns, + companySkillVersions, + companySkills as companySkillsTable, + companies, + documentAnnotationComments, + documentAnnotationThreads, + heartbeatRuns, + issueAttachments, + issueComments, + issuePlanDecompositions, + issueRecoveryActions, + issues, + routineRevisions, + routineRuns, + routines, + toolMcpGateways, + toolMcpGatewayTokens, + toolCatalogEntries, + toolConnectionInstalls, + toolConnections, + toolProfileEntries, + toolProfiles, +} from "@paperclipai/db"; +import { HttpError } from "../../errors.js"; +import { filterResolvedGitHubConnectionsForRun } from "../git-credentials.js"; +import type { + AdapterRuntimeMcpAccess, + AdapterRuntimeMcpServer, + AdapterRuntimeToolAccess, +} from "../../adapters/index.js"; +import { createRuntimeToolsToken } from "../../runtime-tools-token.js"; +import { parseObject } from "../../adapters/utils.js"; +import { + authorizeChatConversationForBoundRun, + isExternalChatWaitAuthorizationContention, +} from "../native-runtime/chat-attachment-reuse.js"; +import { + EXTERNAL_CHAT_QUESTION_RESPONSE_KEY, + resolveExternalChatQuestionResponse, +} from "../native-runtime/external-chat-question-response.js"; +import { + secretService, + type MissingRuntimeBinding, +} from "../secrets.js"; +import { createToolGatewayService } from "../tool-gateway.js"; +import { toolAccessService } from "../tool-access.js"; +import { + buildDocumentReviewContext, + buildPlanReviewContext, +} from "../plan-review-context.js"; +import { + WAKE_COMMENT_IDS_KEY, + extractWakeCommentIds, + deriveCommentId, +} from "../../modules/run-dispatch/index.js"; +import { + DIRECT_NON_INVOKABLE_STATUSES, + type AgentOrgRow, +} from "../agent-invokability.js"; +import { + isLowTrustQuarantined, + redactQuarantinedBodyForHigherTrust, + sanitizeQuarantinedCommentForHigherTrust, +} from "../source-trust.js"; +import { redactSensitiveText } from "../../redaction.js"; +import { createRunSecretRedactionRegistry } from "../run-secret-redaction.js"; +import { + readPaperclipSkillSyncPreference, + writePaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; +import { + extractSkillMentionIds, + isUuidLike, +} from "@paperclipai/shared"; +import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-local/server"; +import type { TrustPresetResolution } from "../trust-preset-resolver.js"; + +const EXTERNAL_ATTACHMENT_OMISSIONS_KEY = "externalAttachmentOmissions"; + +export const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake"; + +const PAPERCLIP_AGENT_MESSAGE_KEY = "paperclipAgentMessage"; + +export const PAPERCLIP_HARNESS_CHECKOUT_KEY = "paperclipHarnessCheckedOut"; + +export const PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY = + "paperclipExternalChatExecutionBound"; + +const MAX_INLINE_WAKE_COMMENTS = 8; + +const MAX_INLINE_WAKE_ATTACHMENTS = 20; + +const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; + +const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; + +const MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS = 12_000; + +const MAX_AGENT_SESSION_MESSAGE_CHARS = 12_000; + +export const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete"; + +// Pre-dispatch gate outcome: required secret/env bindings are missing, so the +// run must not be dispatched. Surfaced as a configuration-incomplete blocker +// routed to a human owner instead of N opaque dispatched-then-failed runs. +export class ConfigurationIncompleteFailure extends Error { + code = CONFIGURATION_INCOMPLETE_FAILURE_CODE; + resultJson: Record; + + constructor(message: string, resultJson: Record) { + super(message); + this.name = "ConfigurationIncompleteFailure"; + this.resultJson = resultJson; + } +} + +const ISSUE_RESPONSIBLE_USER_WAKE_REASONS = new Set([ + "issue_assigned", + "issue_checked_out", + "issue_commented", + "issue_comment_mentioned", + "issue_reopened_via_comment", + "issue_blockers_resolved", + "issue_children_completed", + "issue_status_changed", + "issue_tree_restored", + "issue_recovery_action_restored", + "execution_review_requested", + "execution_approval_requested", + "execution_changes_requested", + "approval_approved", +]); + +type RuntimeConfigSecretResolver = Pick< + ReturnType, + | "resolveAdapterConfigForRuntime" + | "resolveEnvBindings" + | "collectMissingRuntimeBindings" + | "collectMissingAdapterConfigRuntimeBindings" +>; + +function formatMissingBindingForOperator( + missing: MissingRuntimeBinding, +): string { + if (missing.bindingType === "user_secret_ref") { + const definitionLabel = missing.userSecretDefinitionName + ? `"${missing.userSecretDefinitionName}"` + : missing.userSecretDefinitionKey + ? `"${missing.userSecretDefinitionKey}"` + : "declared user secret"; + const ownerLabel = missing.responsibleUserId + ? ` for responsible user ${missing.responsibleUserId}` + : ""; + return `user secret ${definitionLabel}${ownerLabel} not available at ${missing.consumerType} ${missing.configPath}`; + } + const secretLabel = missing.secretName + ? `"${missing.secretName}"` + : (missing.secretId ?? "unknown"); + return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`; +} + +function isConfiguredEnvBindingValue(binding: unknown) { + const parsed = envBindingSchema.safeParse(binding); + if (!parsed.success) return false; + const value = parsed.data; + if (typeof value === "string") return value.trim().length > 0; + if (value.type === "plain") return value.value.trim().length > 0; + return true; +} + +const LOW_TRUST_SENSITIVE_ENV_KEY_RE = + /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; + +// PAPERCLIP_* env binding policy: +// 1. PAPERCLIP_API_KEY is never accepted from user/adapter/project/routine +// config — the harness-minted run token is the only source. +// 2. A PAPERCLIP_* runtime var the harness assigns for the run (RUN_ID, +// AGENT_ID, wake/workspace vars, ...) always wins over a same-named +// binding; adapters enforce this at env-merge time. +// 3. Any other PAPERCLIP_*-named binding is user data and flows through to +// the run env like any non-prefixed binding. +const FORBIDDEN_ENV_BINDING_KEYS = new Set([ + CONFIGURED_ENVIRONMENT_KEYS, + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + "PAPERCLIP_RUNNER_NETWORK_ACCESS", + "PAPERCLIP_RUNNER_NETWORK_ROOTS", + "PAPERCLIP_API_KEY", + "PAPERCLIP_GITHUB_AUTH_MODE", + "PAPERCLIP_GITHUB_HOST_HOME", + "PAPERCLIP_GIT_METADATA_ROOTS", + "PAPERCLIP_GITHUB_BROKER_TOKEN", + "PAPERCLIP_GITHUB_BROKER_URL", + "PAPERCLIP_GITHUB_BRIDGE_TOKEN", + "PAPERCLIP_GITHUB_LAUNCHER_DIR", +]); + +export const MANAGED_GITHUB_TOKEN_KEYS = new Set([ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "PAPERCLIP_GIT_TOKEN", +]); + +function stripForbiddenEnvBindings( + envValue: unknown, + managedGitHubCredentials = false, +): Record | null { + const record = parseObject(envValue); + const filtered = Object.fromEntries( + Object.entries(record).filter( + ([key]) => + !FORBIDDEN_ENV_BINDING_KEYS.has(key.toUpperCase()) && + !(managedGitHubCredentials && MANAGED_GITHUB_TOKEN_KEYS.has(key)), + ), + ); + return Object.keys(filtered).length > 0 ? filtered : null; +} + +function stripForbiddenEnvFromAdapterConfig( + config: Record, + managedGitHubCredentials = false, +): Record { + if (!Object.prototype.hasOwnProperty.call(config, "env")) return config; + return { + ...config, + env: stripForbiddenEnvBindings(config.env, managedGitHubCredentials) ?? {}, + }; +} + +function assertLowTrustEnvConfigAllowed(envValue: unknown, source: string) { + const record = stripForbiddenEnvBindings(envValue); + if (!record) return; + for (const [key, rawBinding] of Object.entries(record)) { + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) continue; + const binding = parsed.data; + const isPlainBinding = + typeof binding === "string" || + (typeof binding === "object" && + binding !== null && + binding.type === "plain"); + if (isPlainBinding && LOW_TRUST_SENSITIVE_ENV_KEY_RE.test(key)) { + throw new HttpError( + 422, + `Low-trust execution cannot use inline sensitive env value ${source}.${key}`, + { + code: "low_trust_inline_sensitive_env_denied", + }, + ); + } + } +} + +export async function resolveExecutionRunAdapterConfig(input: { + managedAiCredentials?: boolean; + companyId: string; + agentId?: string | null; + adapterType?: string | null; + issueId?: string | null; + heartbeatRunId?: string | null; + responsibleUserId?: string | null; + environmentId?: string | null; + environmentEnv?: unknown; + environmentDriver?: string | null; + projectId?: string | null; + routineId?: string | null; + executionRunConfig: Record; + projectEnv: unknown; + routineEnv?: unknown; + secretsSvc: RuntimeConfigSecretResolver; + trustPreset?: TrustPresetResolution; + requiredScopedEnvBinding?: { + keys: string[]; + consumerScopes: Array<"agent" | "project">; + reason: string; + remediation: string; + }; + /** Managed GitHub tokens are resolved only when operations start. */ + managedGitHubCredentials?: boolean; + /** Audited class-3 values resolved by an internal credential broker. */ + trustedEnvProjection?: Record; + trustedEnvSecretKeys?: string[]; +}) { + const executionRunConfig = stripForbiddenEnvFromAdapterConfig( + input.executionRunConfig, + input.managedGitHubCredentials, + ); + const environmentEnv = stripForbiddenEnvBindings( + input.environmentEnv, + input.managedGitHubCredentials, + ); + const projectEnv = stripForbiddenEnvBindings( + input.projectEnv, + input.managedGitHubCredentials, + ); + const routineEnv = stripForbiddenEnvBindings( + input.routineEnv, + input.managedGitHubCredentials, + ); + const agentEnv = parseObject(executionRunConfig.env); + const lowTrustAllowedBindingIds = + input.trustPreset?.kind === "low_trust_review" + ? (input.trustPreset.boundary.allowedSecretBindingIds ?? []) + : undefined; + const allowTrustedEnvProjection = + input.trustPreset?.kind !== "low_trust_review"; + if (input.trustPreset?.kind === "low_trust_review") { + assertLowTrustEnvConfigAllowed(environmentEnv, "environment.env"); + assertLowTrustEnvConfigAllowed(executionRunConfig.env, "agent.env"); + assertLowTrustEnvConfigAllowed(projectEnv, "project.env"); + assertLowTrustEnvConfigAllowed(routineEnv, "routine.env"); + } + const requiredScopedEnvBinding = input.requiredScopedEnvBinding ?? null; + const requiredScopedBindingsConfigured = requiredScopedEnvBinding + ? requiredScopedEnvBinding.keys.some( + (key) => + (allowTrustedEnvProjection && + typeof input.trustedEnvProjection?.[key] === "string") || + (requiredScopedEnvBinding.consumerScopes.includes("agent") && + isConfiguredEnvBindingValue(agentEnv[key])) || + (requiredScopedEnvBinding.consumerScopes.includes("project") && + isConfiguredEnvBindingValue(projectEnv?.[key])), + ) + : false; + if (requiredScopedEnvBinding && !requiredScopedBindingsConfigured) { + throw new ConfigurationIncompleteFailure( + `configuration incomplete: ${requiredScopedEnvBinding.remediation}`, + { + configurationIncomplete: { + reason: requiredScopedEnvBinding.reason, + companyId: input.companyId, + agentId: input.agentId ?? null, + issueId: input.issueId ?? null, + projectId: input.projectId ?? null, + routineId: input.routineId ?? null, + requiredEnvKeys: requiredScopedEnvBinding.keys, + requiredScopes: requiredScopedEnvBinding.consumerScopes, + missingBindings: [], + }, + }, + ); + } + // Pre-dispatch binding-validation gate: detect declared secret refs that have + // no binding before resolving any secret value. Missing bindings short-circuit + // to a configuration-incomplete blocker routed to a human owner instead of a + // dispatched-then-failed run (which previously surfaced as opaque setup_failed). + if (typeof input.secretsSvc.collectMissingRuntimeBindings === "function") { + const missingBindings: MissingRuntimeBinding[] = []; + if (environmentEnv && input.environmentId) { + missingBindings.push( + ...(await input.secretsSvc.collectMissingRuntimeBindings( + input.companyId, + environmentEnv, + { + consumerType: "environment", + consumerId: input.environmentId, + responsibleUserId: input.responsibleUserId ?? null, + }, + )), + ); + } + if (input.agentId) { + missingBindings.push( + ...(await input.secretsSvc.collectMissingRuntimeBindings( + input.companyId, + parseObject(executionRunConfig.env), + { + consumerType: "agent", + consumerId: input.agentId, + responsibleUserId: input.responsibleUserId ?? null, + }, + )), + ); + if ( + typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === + "function" + ) { + missingBindings.push( + ...(await input.secretsSvc.collectMissingAdapterConfigRuntimeBindings( + input.companyId, + executionRunConfig, + input.adapterType ?? null, + { + consumerType: "agent", + consumerId: input.agentId, + responsibleUserId: input.responsibleUserId ?? null, + }, + )), + ); + } + } + if (projectEnv && input.projectId) { + missingBindings.push( + ...(await input.secretsSvc.collectMissingRuntimeBindings( + input.companyId, + projectEnv, + { + consumerType: "project", + consumerId: input.projectId, + responsibleUserId: input.responsibleUserId ?? null, + }, + )), + ); + } + if (routineEnv && input.routineId) { + missingBindings.push( + ...(await input.secretsSvc.collectMissingRuntimeBindings( + input.companyId, + routineEnv, + { + consumerType: "routine", + consumerId: input.routineId, + responsibleUserId: input.responsibleUserId ?? null, + }, + )), + ); + } + if (requiredScopedEnvBinding) { + const requiredEnvKeys = new Set(requiredScopedEnvBinding.keys); + const requiredScopes = new Set(requiredScopedEnvBinding.consumerScopes); + const requiredMissingBindings = missingBindings.filter( + (binding) => + requiredScopes.has(binding.consumerType as "agent" | "project") && + requiredEnvKeys.has(binding.envKey), + ); + if (requiredMissingBindings.length > 0) { + const detail = requiredMissingBindings + .map(formatMissingBindingForOperator) + .join("; "); + throw new ConfigurationIncompleteFailure( + `configuration incomplete: ${requiredScopedEnvBinding.remediation}; ${detail}`, + { + configurationIncomplete: { + reason: requiredScopedEnvBinding.reason, + companyId: input.companyId, + agentId: input.agentId ?? null, + issueId: input.issueId ?? null, + projectId: input.projectId ?? null, + routineId: input.routineId ?? null, + requiredEnvKeys: requiredScopedEnvBinding.keys, + requiredScopes: requiredScopedEnvBinding.consumerScopes, + missingBindings: requiredMissingBindings, + }, + }, + ); + } + } + if (missingBindings.length > 0) { + const detail = missingBindings + .map(formatMissingBindingForOperator) + .join("; "); + throw new ConfigurationIncompleteFailure( + `configuration incomplete: ${detail}`, + { + configurationIncomplete: { + reason: "secret_binding_missing", + companyId: input.companyId, + agentId: input.agentId ?? null, + issueId: input.issueId ?? null, + projectId: input.projectId ?? null, + routineId: input.routineId ?? null, + missingBindings, + }, + }, + ); + } + } + const environmentEnvResolution = environmentEnv + ? await input.secretsSvc.resolveEnvBindings( + input.companyId, + environmentEnv, + input.environmentId + ? { + consumerType: "environment", + consumerId: input.environmentId, + actorType: "agent", + actorId: input.agentId ?? null, + responsibleUserId: input.responsibleUserId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + ...(lowTrustAllowedBindingIds !== undefined + ? { allowedBindingIds: lowTrustAllowedBindingIds } + : {}), + } + : undefined, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; + const { + config: resolvedConfig, + secretKeys, + manifest, + } = await input.secretsSvc.resolveAdapterConfigForRuntime( + input.companyId, + executionRunConfig, + input.agentId + ? { + consumerType: "agent", + consumerId: input.agentId, + actorType: "agent", + actorId: input.agentId, + responsibleUserId: input.responsibleUserId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + ...(lowTrustAllowedBindingIds !== undefined + ? { allowedBindingIds: lowTrustAllowedBindingIds } + : {}), + } + : undefined, + { adapterType: input.adapterType ?? null }, + ); + if (Object.keys(environmentEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...environmentEnvResolution.env, + ...parseObject(resolvedConfig.env), + }; + for (const key of environmentEnvResolution.secretKeys) { + secretKeys.add(key); + } + } + const projectEnvResolution = projectEnv + ? await input.secretsSvc.resolveEnvBindings( + input.companyId, + projectEnv, + input.projectId + ? { + consumerType: "project", + consumerId: input.projectId, + actorType: "agent", + actorId: input.agentId ?? null, + responsibleUserId: input.responsibleUserId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + ...(lowTrustAllowedBindingIds !== undefined + ? { allowedBindingIds: lowTrustAllowedBindingIds } + : {}), + } + : undefined, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; + if (Object.keys(projectEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...projectEnvResolution.env, + }; + for (const key of projectEnvResolution.secretKeys) { + secretKeys.add(key); + } + } + const routineEnvResolution = routineEnv + ? await input.secretsSvc.resolveEnvBindings( + input.companyId, + routineEnv, + input.routineId + ? { + consumerType: "routine", + consumerId: input.routineId, + actorType: "agent", + actorId: input.agentId ?? null, + responsibleUserId: input.responsibleUserId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + ...(lowTrustAllowedBindingIds !== undefined + ? { allowedBindingIds: lowTrustAllowedBindingIds } + : {}), + } + : undefined, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; + if (Object.keys(routineEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...routineEnvResolution.env, + }; + for (const key of routineEnvResolution.secretKeys) { + secretKeys.add(key); + } + } + if ( + allowTrustedEnvProjection && + input.trustedEnvProjection && + Object.keys(input.trustedEnvProjection).length > 0 + ) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...input.trustedEnvProjection, + }; + for (const key of input.trustedEnvSecretKeys ?? []) secretKeys.add(key); + } + // Pre-dispatch credential gate for codex_local: a managed Codex home with no + // usable auth.json and an empty OPENAI_API_KEY would dispatch a run that + // immediately fails with "no Codex credentials provisioned" (adapter_failed), + // making a configuration problem look like a runtime failure. Surface it as a + // configuration-incomplete blocker instead, naming the missing credential + // action and owner without leaking any secret value. This runs after secret + // resolution so a per-agent OPENAI_API_KEY (plain or resolved secret) counts + // as satisfying the credential. It shares the exact readiness predicate the + // adapter uses at execute time, so the two cannot drift. + // + // Sandbox-destined runs are exempt: the sandbox image may carry its own + // Codex login (`~/.codex/auth.json` baked in at image setup), which only the + // adapter can probe once the sandbox is up — and on managed cloud hosts a + // host-side login never exists at all. The adapter's execute-time gate + // remains the authority there; it probes the sandbox before failing. + if ( + !input.managedAiCredentials && (input.adapterType ?? null) === "codex_local" && + (input.environmentDriver ?? null) !== "sandbox" + ) { + const resolvedEnv = parseObject(resolvedConfig.env); + const readiness = await evaluateCodexCredentialReadiness({ + env: process.env, + companyId: input.companyId, + configuredCodexHome: readNonEmptyString(resolvedEnv.CODEX_HOME), + configuredApiKey: readNonEmptyString(resolvedEnv.OPENAI_API_KEY), + }); + if (readiness.managed && !readiness.ready) { + throw new ConfigurationIncompleteFailure( + `configuration incomplete: no Codex credentials available for managed home "${readiness.effectiveHome}". ` + + `Sign in to Codex on the host with a ChatGPT subscription, or bind a per-agent OPENAI_API_KEY secret for this agent.`, + { + configurationIncomplete: { + reason: "codex_credentials_missing", + companyId: input.companyId, + agentId: input.agentId ?? null, + issueId: input.issueId ?? null, + projectId: input.projectId ?? null, + routineId: input.routineId ?? null, + responsibleUserId: input.responsibleUserId ?? null, + adapterType: "codex_local", + requiredEnvKeys: ["OPENAI_API_KEY"], + effectiveCodexHome: readiness.effectiveHome, + missingBindings: [], + }, + }, + ); + } + } + return { + resolvedConfig, + // Capture resolved task values before provider credential injection or host + // inheritance. Native-only launch limits are checked at native dispatch. + configuredTaskEnvironment: Object.fromEntries( + Object.entries(parseObject(resolvedConfig.env)).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), + secretKeys, + secretManifest: [ + ...(environmentEnvResolution.manifest ?? []), + ...(manifest ?? []), + ...(projectEnvResolution.manifest ?? []), + ...(routineEnvResolution.manifest ?? []), + ], + }; +} + +export function extractMentionedSkillIdsFromSources( + sources: Array, +): string[] { + const mentionedIds = new Set(); + for (const source of sources) { + if (typeof source !== "string" || source.length === 0) continue; + for (const skillId of extractSkillMentionIds(source)) { + if (!isUuidLike(skillId)) continue; + mentionedIds.add(skillId); + } + } + return [...mentionedIds]; +} + +export function applyRunScopedMentionedSkillKeys( + config: Record, + skillKeys: string[], +): Record { + const normalizedSkillKeys = Array.from( + new Set(skillKeys.map((value) => value.trim()).filter(Boolean)), + ); + if (normalizedSkillKeys.length === 0) return config; + + const existingPreference = readPaperclipSkillSyncPreference(config); + return writePaperclipSkillSyncPreference(config, [ + ...existingPreference.desiredSkillEntries, + ...normalizedSkillKeys, + ]); +} + +export async function resolveRunScopedMentionedSkillKeys(input: { + db: Db; + companyId: string; + issueId: string | null; +}): Promise { + if (!input.issueId) return []; + + const issue = await input.db + .select({ + title: issues.title, + description: issues.description, + }) + .from(issues) + .where( + and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)), + ) + .then((rows) => rows[0] ?? null); + if (!issue) return []; + + const comments = await input.db + .select({ body: issueComments.body }) + .from(issueComments) + .where( + and( + eq(issueComments.issueId, input.issueId), + eq(issueComments.companyId, input.companyId), + isNull(issueComments.deletedAt), + ), + ); + const mentionedSkillIds = extractMentionedSkillIdsFromSources([ + issue.title, + issue.description ?? "", + ...comments.map((comment) => comment.body), + ]); + if (mentionedSkillIds.length === 0) return []; + + const skillRows = await input.db + .select({ + id: companySkillsTable.id, + key: companySkillsTable.key, + }) + .from(companySkillsTable) + .where( + and( + eq(companySkillsTable.companyId, input.companyId), + inArray(companySkillsTable.id, mentionedSkillIds), + ), + ); + const skillKeyById = new Map(skillRows.map((row) => [row.id, row.key])); + return mentionedSkillIds + .map((skillId) => skillKeyById.get(skillId) ?? null) + .filter((skillKey): skillKey is string => Boolean(skillKey)); +} + +export interface WakeupOptions { + /** Set only by authenticated board wake routes; never copied from caller payloads. */ + manualUserWake?: boolean; + /** Internal resume of a queue with persisted board interruption intent. */ + queuedCommentInterruptId?: string; + /** Internal delivery of an existing undelivered user comment. */ + queuedCommentRequestId?: string; + /** Exact failed run selected by an authenticated board Retry request. */ + failedRunId?: string | null; + durableChatRequest?: DurableChatWakeupRequest; + /** Server-owned Dot admission receipt; never accepted from API payloads. */ + durableDotRequest?: { + id: string; + companyId: string; + agentId: string; + issueId: string; + requestId: string; + idempotencyKey: string; + requestedAt: Date; + }; + source?: "timer" | "assignment" | "on_demand" | "automation"; + triggerDetail?: "manual" | "ping" | "callback" | "system"; + reason?: string | null; + payload?: Record | null; + idempotencyKey?: string | null; + requestedByActorType?: "user" | "agent" | "system"; + requestedByActorId?: string | null; + contextSnapshot?: Record; + issueStateGuard?: { + statuses: string[]; + assigneeAgentId: string; + statusVersion?: number; + monitorNextCheckAt?: string; + monitorWakeRequestedAt?: string; + }; + /** Keep causally distinct external chat continuations out of an existing run. */ + allowRunCoalescing?: boolean; +} + +function sanitizeAgentSessionMessageText(value: unknown): string | null { + const text = readNonEmptyString(value); + if (!text) return null; + const redacted = redactSensitiveText(text).slice( + 0, + MAX_AGENT_SESSION_MESSAGE_CHARS, + ); + return redacted.trim().length > 0 ? redacted : null; +} + +type ManagedMcpGatewayRunConfig = { + version: 1; + managedMcpOnly: boolean; + gateways: Array<{ + id: string; + name: string; + endpointPath: string; + bearerToken: string; + tokenPrefix: string; + }>; +}; + +export function configuredPaperclipApiBaseUrl(): string | null { + const configured = readNonEmptyString(process.env.PAPERCLIP_API_URL); + return configured + ? configured.replace(/\/+$/, "").replace(/\/api$/, "") + : null; +} + +export function paperclipApiBaseUrl(): string { + const configured = configuredPaperclipApiBaseUrl(); + if (!configured) { + throw new Error( + "PAPERCLIP_API_URL is required to deliver managed runtime MCP servers", + ); + } + return configured; +} + +export async function revokeHeartbeatRunGatewayTokens(input: { + db: Db; + companyId: string; + runId: string; +}): Promise { + const now = new Date(); + await input.db + .update(toolMcpGatewayTokens) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(toolMcpGatewayTokens.companyId, input.companyId), + eq(toolMcpGatewayTokens.subjectType, "heartbeat_run"), + eq(toolMcpGatewayTokens.subjectId, input.runId), + isNull(toolMcpGatewayTokens.revokedAt), + ), + ); +} + +export async function buildPaperclipRuntimeMcpServers(input: { + db: Db; + agent: Pick; + runId: string; + expectedAssignmentDigest?: string | null; +}): Promise { + const access = toolAccessService(input.db); + const effective = await access.getEffectiveProfilesForAgent( + input.agent.companyId, + input.agent.id, + ); + const [runIdentity] = await input.db + .select({ + responsibleUserId: heartbeatRuns.responsibleUserId, + activeIdentityContextId: heartbeatRuns.activeIdentityContextId, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, input.runId), + eq(heartbeatRuns.companyId, input.agent.companyId), + eq(heartbeatRuns.agentId, input.agent.id), + ), + ) + .limit(1); + const resolvedInstalledConnections = runIdentity?.activeIdentityContextId + ? effective.installedConnections + : await filterResolvedGitHubConnectionsForRun({ + db: input.db, + companyId: input.agent.companyId, + agentId: input.agent.id, + responsibleUserId: runIdentity?.responsibleUserId ?? null, + connections: effective.installedConnections, + }); + const permittedConnectionIds = new Set([ + ...effective.entries + .filter((entry) => entry.effect === "include" && entry.connectionId) + .map((entry) => entry.connectionId!), + ...effective.allowedTools.map((tool) => tool.connectionId), + ]); + const allInstalledConnectionIds = new Set( + effective.installedConnections.map((connection) => connection.id), + ); + const permittedConnections = + permittedConnectionIds.size > 0 + ? await input.db + .select({ + id: toolConnections.id, + name: toolConnections.name, + transport: toolConnections.transport, + config: toolConnections.config, + }) + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, input.agent.companyId), + inArray(toolConnections.id, [...permittedConnectionIds]), + ), + ) + : []; + const permittedNotInstalledConnections = permittedConnections + .filter( + (connection) => + (connection.transport === "mcp_remote" || + connection.transport === "local_stdio" || isBrowserUseConnection(connection)) && + !allInstalledConnectionIds.has(connection.id), + ) + .map(({ id, name }) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name)); + const githubBotConnectionIds = await githubBotConnectionIdsForRun(input.db, input.agent.companyId, input.agent.id, input.runId); + const assignedConnections = resolvedInstalledConnections.filter( + (connection) => + permittedConnectionIds.has(connection.id) && + connection.status === "active" && + connection.enabled && + ((Boolean(runIdentity?.activeIdentityContextId) && + (connection.config?.sourceTemplateKey === "github" || + connection.transportConfig?.sourceTemplateKey === "github")) || + connection.credentialPolicy === "per_user" || + !isToolConnectionAttentionHealth(connection.healthStatus)) && + (connection.transport === "mcp_remote" || + connection.transport === "local_stdio" || isBrowserUseConnection(connection) || githubBotConnectionIds.has(connection.id)), + ); + const assignedConnectionIds = new Set( + assignedConnections.map((connection) => connection.id), + ); + const assignedTools = effective.allowedTools.filter((tool) => + assignedConnectionIds.has(tool.connectionId), + ); + const service = createToolGatewayService(input.db); + if (assignedConnections.length === 0) { + await service.recordRuntimeMcpDeliveryDiagnostic({ + companyId: input.agent.companyId, + agentId: input.agent.id, + runId: input.runId, + permittedNotInstalledConnections, + }); + return []; + } + const assignment = { + version: 1, + agentId: input.agent.id, + connections: assignedConnections.map((connection) => connection.id).sort(), + tools: assignedTools.map((tool) => tool.id).sort(), + }; + const assignmentDigest = createHash("sha256") + .update(JSON.stringify(assignment)) + .digest("hex"); + // Native runs may lose access after their immutable context is captured, but + // they must never gain a new or changed assignment during dispatch. + if ( + input.expectedAssignmentDigest !== undefined && + input.expectedAssignmentDigest !== assignmentDigest + ) { + return []; + } + const profileKey = `native:${input.agent.id}:${assignmentDigest}`; + let [profile] = await input.db + .select() + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, input.agent.companyId), + eq(toolProfiles.profileKey, profileKey), + ), + ) + .limit(1); + + if (!profile) { + const fullConnectionIds = new Set( + effective.entries + .filter( + (entry) => + entry.effect === "include" && + entry.selectorType === "connection" && + entry.connectionId, + ) + .map((entry) => entry.connectionId!), + ); + const entries = [ + ...assignedConnections + .filter((connection) => fullConnectionIds.has(connection.id)) + .map((connection) => ({ + selectorType: "connection" as const, + effect: "include" as const, + applicationId: connection.applicationId, + connectionId: connection.id, + })), + ...assignedTools + .filter((tool) => !fullConnectionIds.has(tool.connectionId)) + .map((tool) => ({ + selectorType: "catalog_entry" as const, + effect: "include" as const, + applicationId: tool.applicationId, + connectionId: tool.connectionId, + catalogEntryId: tool.id, + })), + ]; + // The 250-entry limit bounds a public profile-edit request, not the + // effective assignment assembled from existing profiles. Keep every exact + // selector here: truncating or replacing them with connection-wide grants + // would either lose assigned tools or authorize tools outside this snapshot. + try { + const created = await access.createProfile(input.agent.companyId, { + profileKey, + name: `Native ${input.agent.id.slice(0, 8)} ${assignmentDigest.slice(0, 12)}`, + description: "Immutable Paperclip Runner MCP assignment profile.", + status: "active", + defaultAction: "deny", + metadata: { + source: "paperclip_runner", + agentId: input.agent.id, + assignmentDigest, + }, + entries, + }); + [profile] = await input.db + .select() + .from(toolProfiles) + .where(eq(toolProfiles.id, created.id)) + .limit(1); + } catch (error) { + [profile] = await input.db + .select() + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, input.agent.companyId), + eq(toolProfiles.profileKey, profileKey), + ), + ) + .limit(1); + if (!profile) throw error; + } + } + + if (profile!.metadata?.source !== "paperclip_runner" || profile!.metadata?.agentId !== input.agent.id || + profile!.metadata?.assignmentDigest !== assignmentDigest) { + throw new Error("Invalid native runtime profile provenance"); + } + + let [gateway] = ( + await input.db + .select() + .from(toolMcpGateways) + .where( + and( + eq(toolMcpGateways.companyId, input.agent.companyId), + eq(toolMcpGateways.status, "active"), + isNull(toolMcpGateways.archivedAt), + ), + ) + ).filter( + (candidate) => + candidate.metadata?.nativeRuntimeAssignmentDigest === assignmentDigest && + candidate.metadata?.agentId === input.agent.id && + candidate.profileId === profile!.id && + (!candidate.agentId || candidate.agentId === input.agent.id), + ); + // Gateways created before the agent binding existed have a null agentId, and + // named-gateway auth only rejects another agent's run token when it is set. + // The assignment digest includes the agent id, so a reused gateway is this + // agent's own: bind it here instead of waiting for the assignment to change. + if (gateway && !gateway.agentId) { + await input.db + .update(toolMcpGateways) + .set({ agentId: input.agent.id, contextScopeType: "agent", contextScopeId: input.agent.id, updatedAt: new Date() }) + .where( + and( + eq(toolMcpGateways.id, gateway.id), + eq(toolMcpGateways.companyId, input.agent.companyId), + isNull(toolMcpGateways.agentId), + ), + ); + [gateway] = await input.db + .select() + .from(toolMcpGateways) + .where(eq(toolMcpGateways.id, gateway.id)) + .limit(1); + } + if (!gateway) { + const slug = `native-${input.agent.id.replaceAll("-", "").slice(0, 12)}-${assignmentDigest.slice(0, 16)}`; + try { + const created = await service.createNamedGateway({ + companyId: input.agent.companyId, + body: { + name: `Native ${input.agent.name} ${assignmentDigest.slice(0, 8)}`, + slug, + description: "Run-scoped Paperclip Runner MCP gateway.", + profileId: profile!.id, + defaultProfileMode: "gateway_only", + agentId: input.agent.id, + contextScopeType: "agent", + contextScopeId: input.agent.id, + metadata: { + nativeRuntimeAssignmentDigest: assignmentDigest, + agentId: input.agent.id, + }, + }, + actor: { agentId: input.agent.id }, + }); + [gateway] = await input.db + .select() + .from(toolMcpGateways) + .where(eq(toolMcpGateways.id, created.id)) + .limit(1); + } catch (error) { + [gateway] = await input.db + .select() + .from(toolMcpGateways) + .where( + and( + eq(toolMcpGateways.companyId, input.agent.companyId), + eq(toolMcpGateways.slug, slug), + ), + ) + .limit(1); + if (!gateway) throw error; + } + } + + if (gateway!.agentId !== input.agent.id || gateway!.profileId !== profile!.id || + gateway!.metadata?.agentId !== input.agent.id || + gateway!.metadata?.nativeRuntimeAssignmentDigest !== assignmentDigest) { + throw new Error("Invalid native runtime gateway provenance"); + } + + const token = await service.createNamedGatewayToken({ + companyId: input.agent.companyId, + gatewayId: gateway!.id, + body: { + name: `Run ${input.runId.slice(0, 8)}`, + subjectType: "heartbeat_run", + subjectId: input.runId, + clientLabel: `${input.agent.name} heartbeat run`, + ownerNote: `Short-lived runtime MCP token for heartbeat run ${input.runId}.`, + allowedActions: ["tools/list", "tools/call"], + expiresAt: new Date(Date.now() + 60 * 60 * 1_000), + }, + actor: { agentId: input.agent.id }, + }); + + return [ + { + name: ASSIGNED_MCP_SERVER_NAME, + url: `${paperclipApiBaseUrl()}/mcp/gateways/${gateway!.gatewayPublicId}`, + token: token.token, + connectionId: `assignment:${assignmentDigest}`, + }, + ]; +} + +export function createAdapterRuntimeMcpAccess( + servers: AdapterRuntimeMcpServer[], +): AdapterRuntimeMcpAccess | undefined { + if (servers.length === 0) return undefined; + const snapshot = servers.map((server) => Object.freeze({ ...server })); + return Object.freeze({ + getServers: () => snapshot.map((server) => ({ ...server })), + }); +} + +export function createAdapterRuntimeToolAccess(input: { + agentId: string; + companyId: string; + runId: string; + responsibleUserId: string | null; +}): AdapterRuntimeToolAccess | undefined { + if (!input.responsibleUserId) return undefined; + const minted = createRuntimeToolsToken({ + agentId: input.agentId, + companyId: input.companyId, + runId: input.runId, + responsibleUserId: input.responsibleUserId, + }); + if (!minted) return undefined; + // The normal server bootstrap always exports PAPERCLIP_API_URL. Some service + // tests invoke heartbeat execution without booting an HTTP server, however; + // in that context there is no reachable endpoint to advertise and runtime + // tools should simply remain unavailable instead of failing the run. + const baseUrl = configuredPaperclipApiBaseUrl(); + if (!baseUrl) return undefined; + return Object.freeze({ + version: 1, + guidance: CONNECTION_INTENT_AGENT_GUIDANCE, + mcpEndpoint: `${baseUrl}/mcp/runtime-tools`, + rest: { + connectionsSearch: `${baseUrl}/runtime-tools/connections/search`, + connectionRequest: `${baseUrl}/runtime-tools/connections/request`, + }, + bearerToken: minted.token, + expiresAt: minted.expiresAt, + tools: CONNECTION_RUNTIME_TOOL_NAMES, + }); +} + +const MANAGED_MCP_LOCAL_ADAPTERS = new Set(["codex_local"]); + +function adapterSupportsManagedMcpConfig(adapterType: string): boolean { + return MANAGED_MCP_LOCAL_ADAPTERS.has(adapterType); +} + +function gatewayAppliesToRun(input: { + gateway: typeof toolMcpGateways.$inferSelect; + agentId: string; + projectId: string | null; + issueId: string | null; +}): boolean { + const { gateway, agentId, projectId, issueId } = input; + if (gateway.agentId && gateway.agentId !== agentId) return false; + if (gateway.projectId && gateway.projectId !== projectId) return false; + if (gateway.issueId && gateway.issueId !== issueId) return false; + if ( + gateway.contextScopeType === "agent" && + gateway.contextScopeId && + gateway.contextScopeId !== agentId + ) + return false; + if ( + gateway.contextScopeType === "project" && + gateway.contextScopeId && + gateway.contextScopeId !== projectId + ) + return false; + if ( + gateway.contextScopeType === "issue" && + gateway.contextScopeId && + gateway.contextScopeId !== issueId + ) + return false; + return true; +} + +async function gatewayConnectionIds(input: { + db: Db; + companyId: string; + gateway: typeof toolMcpGateways.$inferSelect; +}): Promise> { + const managedRuntimeConnectionId = readNonEmptyString( + input.gateway.metadata?.managedRuntimeConnectionId, + ); + if (managedRuntimeConnectionId) return new Set([managedRuntimeConnectionId]); + + const [profile, entries, catalog, connections] = await Promise.all([ + input.db + .select({ defaultAction: toolProfiles.defaultAction }) + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, input.companyId), + eq(toolProfiles.id, input.gateway.profileId), + ), + ) + .then((rows) => rows[0] ?? null), + input.db + .select() + .from(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, input.companyId), + eq(toolProfileEntries.profileId, input.gateway.profileId), + ), + ), + input.db + .select({ + id: toolCatalogEntries.id, + connectionId: toolCatalogEntries.connectionId, + applicationId: toolCatalogEntries.applicationId, + toolName: toolCatalogEntries.toolName, + riskLevel: toolCatalogEntries.riskLevel, + }) + .from(toolCatalogEntries) + .where( + and( + eq(toolCatalogEntries.companyId, input.companyId), + eq(toolCatalogEntries.status, "active"), + ), + ), + input.db + .select({ + id: toolConnections.id, + applicationId: toolConnections.applicationId, + }) + .from(toolConnections) + .where(eq(toolConnections.companyId, input.companyId)), + ]); + if (!profile) return new Set(); + if (profile.defaultAction === "allow") + return new Set(catalog.map((entry) => entry.connectionId)); + + const connectionIds = new Set(); + for (const entry of entries) { + if (entry.effect !== "include") continue; + if (entry.connectionId) connectionIds.add(entry.connectionId); + if (entry.applicationId) { + for (const connection of connections) { + if (connection.applicationId === entry.applicationId) + connectionIds.add(connection.id); + } + } + for (const catalogEntry of catalog) { + if ( + (entry.catalogEntryId && entry.catalogEntryId === catalogEntry.id) || + (entry.toolName && entry.toolName === catalogEntry.toolName) || + (entry.riskLevel && entry.riskLevel === catalogEntry.riskLevel) + ) { + connectionIds.add(catalogEntry.connectionId); + } + } + } + return connectionIds; +} + +export async function createManagedMcpRunConfig(input: { + db: Db; + agent: Pick< + typeof agents.$inferSelect, + "id" | "companyId" | "name" | "adapterType" + >; + runId: string; + config: Record; + projectId: string | null; + issueId: string | null; +}): Promise { + if (!adapterSupportsManagedMcpConfig(input.agent.adapterType)) return null; + if (input.config.managedMcpOnly === false) return null; + + const rows = await input.db + .select() + .from(toolMcpGateways) + .where( + and( + eq(toolMcpGateways.companyId, input.agent.companyId), + eq(toolMcpGateways.status, "active"), + isNull(toolMcpGateways.archivedAt), + // A native profile remains an immutable assignment even if gateway + // metadata is cleared. Explicit shared gateways use ordinary profiles. + sql`not exists ( + select 1 from ${toolProfiles} + where ${toolProfiles.id} = ${toolMcpGateways.profileId} + and ${toolProfiles.companyId} = ${toolMcpGateways.companyId} + and (${toolProfiles.profileKey} like 'native:%' + or ${toolProfiles.metadata}->>'source' = 'paperclip_runner') + )`, + ), + ) + .orderBy(asc(toolMcpGateways.name)); + + const installRows = await input.db + .select({ + connectionId: toolConnectionInstalls.connectionId, + enabled: toolConnections.enabled, + status: toolConnections.status, + healthStatus: toolConnections.healthStatus, + config: toolConnections.config, + transportConfig: toolConnections.transportConfig, + }) + .from(toolConnectionInstalls) + .innerJoin( + toolConnections, + and( + eq(toolConnections.id, toolConnectionInstalls.connectionId), + eq(toolConnections.companyId, toolConnectionInstalls.companyId), + ), + ) + .where( + and( + eq(toolConnectionInstalls.companyId, input.agent.companyId), + sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${input.agent.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agent.id}))`, + ), + ); + const [runIdentity] = await input.db + .select({ + responsibleUserId: heartbeatRuns.responsibleUserId, + activeIdentityContextId: heartbeatRuns.activeIdentityContextId, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, input.runId), + eq(heartbeatRuns.companyId, input.agent.companyId), + eq(heartbeatRuns.agentId, input.agent.id), + ), + ) + .limit(1); + const resolvedAvailableInstalls = runIdentity?.activeIdentityContextId + ? installRows + .filter((install) => install.enabled && install.status === "active") + .map((install) => ({ id: install.connectionId })) + : await filterResolvedGitHubConnectionsForRun({ + db: input.db, + companyId: input.agent.companyId, + agentId: input.agent.id, + responsibleUserId: runIdentity?.responsibleUserId ?? null, + connections: installRows + .filter( + (install) => + install.enabled && + install.status === "active" && + !["degraded", "failed", "error", "missing_secret"].includes( + install.healthStatus, + ), + ) + .map((install) => ({ + id: install.connectionId, + config: install.config, + transportConfig: install.transportConfig, + })), + }); + const availableInstalledConnectionIds = new Set( + resolvedAvailableInstalls.map((install) => install.id), + ); + + const applicableGateways = rows.filter((gateway) => + // The immutable native assignment is delivered separately. Including any + // historical native gateway here duplicates and broadens that assignment. + !Object.hasOwn(gateway.metadata ?? {}, "nativeRuntimeAssignmentDigest") && gatewayAppliesToRun({ + gateway, + agentId: input.agent.id, + projectId: input.projectId, + issueId: input.issueId, + }), + ); + const gateways = ( + await Promise.all( + applicableGateways.map(async (gateway) => ({ + gateway, + connectionIds: await gatewayConnectionIds({ + db: input.db, + companyId: input.agent.companyId, + gateway, + }), + })), + ) + ) + .filter( + ({ connectionIds }) => + connectionIds.size > 0 && + [...connectionIds].every((connectionId) => + availableInstalledConnectionIds.has(connectionId), + ), + ) + .map(({ gateway }) => gateway); + if (gateways.length === 0) return null; + + const service = createToolGatewayService(input.db); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + const managedGateways: ManagedMcpGatewayRunConfig["gateways"] = []; + for (const gateway of gateways) { + const token = await service.createNamedGatewayToken({ + companyId: input.agent.companyId, + gatewayId: gateway.id, + body: { + name: `Managed ${input.agent.name} ${input.runId.slice(0, 8)}`, + subjectType: "heartbeat_run", + subjectId: input.runId, + clientLabel: `${input.agent.name} managed local adapter`, + ownerNote: `Short-lived Paperclip-managed MCP token for heartbeat run ${input.runId}.`, + allowedActions: ["tools/list", "tools/call"], + expiresAt, + }, + actor: { agentId: input.agent.id }, + }); + managedGateways.push({ + id: gateway.id, + name: gateway.name, + // This path must bypass the normal /api agent-JWT middleware. The MCP + // gateway performs its own bearer validation for the run-scoped token. + endpointPath: `/mcp/gateways/${gateway.gatewayPublicId}`, + bearerToken: token.token, + tokenPrefix: token.tokenPrefix, + }); + } + + return { + version: 1, + managedMcpOnly: true, + gateways: managedGateways, + }; +} + +export function deriveTaskKey( + contextSnapshot: Record | null | undefined, + payload: Record | null | undefined, +) { + return ( + readNonEmptyString(contextSnapshot?.taskKey) ?? + readNonEmptyString(contextSnapshot?.taskId) ?? + readNonEmptyString(contextSnapshot?.issueId) ?? + readNonEmptyString(payload?.taskKey) ?? + readNonEmptyString(payload?.taskId) ?? + readNonEmptyString(payload?.issueId) ?? + null + ); +} + +function mergeWakeCommentIds(...values: Array): string[] { + const merged: string[] = []; + const append = (value: unknown) => { + const normalized = readNonEmptyString(value); + if (!normalized || merged.includes(normalized)) return; + merged.push(normalized); + }; + + for (const value of values) { + if (Array.isArray(value)) { + for (const entry of value) append(entry); + continue; + } + if (typeof value === "object" && value !== null) { + const candidate = value as Record; + const batched = extractWakeCommentIds(candidate); + if (batched.length > 0) { + for (const entry of batched) append(entry); + continue; + } + append(candidate.wakeCommentId); + append(candidate.commentId); + continue; + } + append(value); + } + + return merged; +} + +const EXTERNAL_ATTACHMENT_OMISSION_REASONS = new Set([ + "attachment_limit", + "storage_unavailable", + "declared_too_large", + "download_unavailable", + "unsupported_type", + "empty_download", + "downloaded_too_large", + "processing_failed", +]); + +type ExternalAttachmentOmission = { + commentId: string; + reasons: Record; +}; + +function readExternalAttachmentOmissions( + value: unknown, +): ExternalAttachmentOmission[] { + if (!Array.isArray(value)) return []; + const byCommentId = new Map(); + for (const candidate of value) { + const record = parseObject(candidate); + const commentId = readNonEmptyString(record.commentId); + if (!commentId) continue; + const reasons = Object.fromEntries( + Object.entries(parseObject(record.reasons)).flatMap(([reason, count]) => + EXTERNAL_ATTACHMENT_OMISSION_REASONS.has(reason) && + typeof count === "number" && + Number.isSafeInteger(count) && + count > 0 + ? [[reason, count]] + : [], + ), + ); + if (Object.keys(reasons).length === 0) continue; + byCommentId.set(commentId, { commentId, reasons }); + } + return [...byCommentId.values()].slice(-50); +} + +function mergeExternalAttachmentOmissions( + existing: Record, + incoming: Record, +) { + return readExternalAttachmentOmissions([ + ...readExternalAttachmentOmissions( + existing[EXTERNAL_ATTACHMENT_OMISSIONS_KEY], + ), + ...readExternalAttachmentOmissions( + incoming[EXTERNAL_ATTACHMENT_OMISSIONS_KEY], + ), + ]); +} + +function externalAttachmentOmissionNotice( + omission: ExternalAttachmentOmission, +) { + const entries = Object.entries(omission.reasons); + const omitted = entries.reduce((total, [, count]) => total + count, 0); + const reasons = entries + .map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`) + .join(", "); + return `Paperclip could not import every attachment from this exact external message: ${omitted} attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons}). Treat omitted attachments as unavailable; do not infer their contents or substitute an older workspace file.`; +} + +export function enrichWakeContextSnapshot(input: { + contextSnapshot: Record; + reason: string | null; + source: WakeupOptions["source"]; + triggerDetail: WakeupOptions["triggerDetail"] | null; + payload: Record | null; +}) { + const { contextSnapshot, reason, source, triggerDetail, payload } = input; + const issueIdFromPayload = + readNonEmptyString(payload?.["issueId"]) ?? + readNonEmptyString(payload?.["taskId"]); + const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]); + const taskKey = deriveTaskKey(contextSnapshot, payload); + const wakeCommentId = deriveCommentId(contextSnapshot, payload); + const wakeCommentIds = mergeWakeCommentIds( + contextSnapshot, + commentIdFromPayload, + ); + + if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) { + contextSnapshot.wakeReason = reason; + } + if (!readNonEmptyString(contextSnapshot["issueId"]) && issueIdFromPayload) { + contextSnapshot.issueId = issueIdFromPayload; + } + if (!readNonEmptyString(contextSnapshot["taskId"]) && issueIdFromPayload) { + contextSnapshot.taskId = issueIdFromPayload; + } + if (!readNonEmptyString(contextSnapshot["taskKey"]) && taskKey) { + contextSnapshot.taskKey = taskKey; + } + if ( + !readNonEmptyString(contextSnapshot["commentId"]) && + commentIdFromPayload + ) { + contextSnapshot.commentId = commentIdFromPayload; + } + if (wakeCommentIds.length > 0) { + const latestCommentId = wakeCommentIds[wakeCommentIds.length - 1]; + contextSnapshot[WAKE_COMMENT_IDS_KEY] = wakeCommentIds; + contextSnapshot.commentId = latestCommentId; + contextSnapshot.wakeCommentId = latestCommentId; + // Once comment ids are normalized into the snapshot, rebuild the structured + // wake payload from those ids later instead of carrying forward stale data. + delete contextSnapshot[PAPERCLIP_WAKE_PAYLOAD_KEY]; + } else if ( + !readNonEmptyString(contextSnapshot["wakeCommentId"]) && + wakeCommentId + ) { + contextSnapshot.wakeCommentId = wakeCommentId; + } + if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) { + contextSnapshot.wakeSource = source; + } + if ( + !readNonEmptyString(contextSnapshot["wakeTriggerDetail"]) && + triggerDetail + ) { + contextSnapshot.wakeTriggerDetail = triggerDetail; + } + normalizeInteractionContinuationWakeContext(contextSnapshot, payload); + + return { + contextSnapshot, + issueIdFromPayload, + commentIdFromPayload, + taskKey, + wakeCommentId, + }; +} + +const INTERACTION_CONTINUATION_CONTEXT_KEYS = [ + "interactionId", + "interactionKind", + "interactionStatus", + "continuationPolicy", + "checkboxSelection", + "itemVerdicts", + "newlyResolvedItemIds", +] as const; + +export function isInteractionResolutionWakePayload( + payload: Record | null | undefined, +) { + return readNonEmptyString(payload?.mutation) === "interaction"; +} + +export function clearInteractionContinuationWakeContext( + contextSnapshot: Record, +) { + for (const key of INTERACTION_CONTINUATION_CONTEXT_KEYS) { + delete contextSnapshot[key]; + } +} + +export function hasInteractionContinuationWakeContext( + contextSnapshot: Record, +) { + return INTERACTION_CONTINUATION_CONTEXT_KEYS.some((key) => + readNonEmptyString(contextSnapshot[key]), + ); +} + +function normalizeInteractionContinuationWakeContext( + contextSnapshot: Record, + payload: Record | null | undefined, +) { + if (isInteractionResolutionWakePayload(payload)) return; + clearInteractionContinuationWakeContext(contextSnapshot); +} + +type AcceptedPlanWakeRoutingDecision = { + otherActiveClaimIssueId: string; + otherActiveClaimIdentifier: string | null; + otherActiveClaimTitle: string; + forceFreshSession: boolean; + suppressAcceptedContinuation: boolean; +}; + +export async function resolveAcceptedPlanWakeRoutingDecision(args: { + db: Db; + companyId: string; + agentId: string; + issueId: string | null; + acceptedPlanContinuationWake: boolean; + contextSnapshot: Record; +}): Promise { + if (args.issueId === null) return null; + if (!args.acceptedPlanContinuationWake) return null; + + const activeClaims = await args.db + .select({ + sourceIssueId: issuePlanDecompositions.sourceIssueId, + identifier: issues.identifier, + title: issues.title, + }) + .from(issuePlanDecompositions) + .innerJoin(issues, eq(issues.id, issuePlanDecompositions.sourceIssueId)) + .where( + and( + eq(issuePlanDecompositions.companyId, args.companyId), + eq(issuePlanDecompositions.ownerAgentId, args.agentId), + eq(issuePlanDecompositions.status, "in_flight"), + ), + ) + .orderBy( + desc(issuePlanDecompositions.updatedAt), + asc(issuePlanDecompositions.createdAt), + ); + + if (activeClaims.length === 0) return null; + if (activeClaims.some((claim) => claim.sourceIssueId === args.issueId)) + return null; + + const otherActiveClaim = activeClaims[0]; + if (!otherActiveClaim) return null; + + const hasAcceptedContinuationWake = + readNonEmptyString(args.contextSnapshot.interactionKind) === + "request_confirmation" && + readNonEmptyString(args.contextSnapshot.interactionStatus) === "accepted"; + + return { + otherActiveClaimIssueId: otherActiveClaim.sourceIssueId, + otherActiveClaimIdentifier: otherActiveClaim.identifier ?? null, + otherActiveClaimTitle: otherActiveClaim.title, + forceFreshSession: true, + suppressAcceptedContinuation: hasAcceptedContinuationWake, + }; +} + +export function mergeCoalescedContextSnapshot( + existingRaw: unknown, + incoming: Record, + options: { preserveExistingInteractionContinuation?: boolean } = {}, +) { + const existing = parseObject(existingRaw); + const existingSource = readNonEmptyString(existing.source); + const incomingSource = readNonEmptyString(incoming.source); + const preservesExternalChatOrigin = + existingSource?.startsWith("chat:") === true && + readNonEmptyString(existing.issueId) !== null && + existing.issueId === incoming.issueId && + incomingSource === "native_status_decision" && + readNonEmptyString(incoming.statusDecisionSource) === + "native_status_decision"; + const merged: Record = { + ...existing, + ...incoming, + }; + // Only executeRun can mint this proof. Coalescence may retain an unchanged + // admitted proof, but must never accept a new marker from an incoming wake. + delete merged[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY]; + delete merged[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY]; + const mergedAttachmentOmissions = mergeExternalAttachmentOmissions( + existing, + incoming, + ); + if (mergedAttachmentOmissions.length > 0) { + merged[EXTERNAL_ATTACHMENT_OMISSIONS_KEY] = mergedAttachmentOmissions; + } else { + delete merged[EXTERNAL_ATTACHMENT_OMISSIONS_KEY]; + } + // A native status wake is control-flow metadata, not a new user-input + // provenance. When it coalesces into the live run, retain the verified chat + // source so the eventual terminal presentation can still prove its route. + // Fresh status-decision runs keep their native_status_decision source. + if (preservesExternalChatOrigin) { + merged.source = existingSource; + } + if ( + existing.forceFreshSession === true || + incoming.forceFreshSession === true + ) { + merged.forceFreshSession = true; + } + if (existing.refreshTools === true || incoming.refreshTools === true) { + merged.refreshTools = true; + } + const mergedCommentIds = mergeWakeCommentIds(existing, incoming); + if (mergedCommentIds.length > 0) { + const latestCommentId = mergedCommentIds[mergedCommentIds.length - 1]; + merged[WAKE_COMMENT_IDS_KEY] = mergedCommentIds; + merged.commentId = latestCommentId; + merged.wakeCommentId = latestCommentId; + // The merged context should carry canonical comment ids; the next wake will + // regenerate any structured payload from those ids. + delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY]; + } + const existingWake = parseObject(existing[PAPERCLIP_WAKE_PAYLOAD_KEY]); + const existingCommentIds = extractWakeCommentIds(existing); + const payloadCommentIds = Array.isArray(existingWake.commentIds) + ? existingWake.commentIds + : []; + const preservesAdmittedWake = + preservesExternalChatOrigin && + parseObject(existingWake.issue).id === existing.issueId && + CHAT_PROVIDERS.some( + (provider) => + existingWake.externalChatProvider === provider && + (existingSource === `chat:${provider}` || + existingSource === `chat:${provider}:recovery`), + ) && + existingCommentIds.length > 0 && + mergedCommentIds.length === existingCommentIds.length && + mergedCommentIds.every((id, index) => id === existingCommentIds[index]) && + payloadCommentIds.length === existingCommentIds.length && + payloadCommentIds.every((id, index) => id === existingCommentIds[index]) && + ((existing[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true && + existingWake.checkedOutByHarness === true) || + (existing[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] === true && + existingWake.externalChatExecutionBound === true)); + if (preservesAdmittedWake) { + merged[PAPERCLIP_WAKE_PAYLOAD_KEY] = existingWake; + merged.wakeReason = existing.wakeReason; + if (existing[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] === true) { + merged[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] = true; + } + } + if ( + !hasInteractionContinuationWakeContext(incoming) && + !( + options.preserveExistingInteractionContinuation === true && + hasInteractionContinuationWakeContext(existing) + ) + ) { + clearInteractionContinuationWakeContext(merged); + } + return merged; +} + +// This is a prompt optimization, not an authorization grant. Verify the durable +// chat binding rather than trusting an arbitrary caller's source/context marker. +export async function resolveExternalChatWakeProvider(input: { + db: Db; + companyId: string; + agentId?: string | null; + runId?: string | null; + issueId: string | null; + contextSnapshot: Record; +}): Promise { + if (input.contextSnapshot.source === "issue.interaction.respond") { + if (!input.runId || !input.agentId || !input.issueId) return null; + const answer = await resolveExternalChatQuestionResponse( + input.db, + { + companyId: input.companyId, + agentId: input.agentId, + issueId: input.issueId, + runId: input.runId, + }, + input.contextSnapshot, + "read", + ); + return answer?.provider ?? null; + } + const source = readNonEmptyString(input.contextSnapshot.source); + const provider = CHAT_PROVIDERS.find( + (candidate) => + source === `chat:${candidate}` || source === `chat:${candidate}:recovery`, + ); + const commentIds = extractWakeCommentIds(input.contextSnapshot); + if ( + !provider || + !input.agentId || + !input.issueId || + commentIds.length === 0 || + (input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] !== true && + input.contextSnapshot[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] !== + true) + ) { + return null; + } + + const links = await input.db + .select({ + commentId: chatMessageLinks.commentId, + conversationId: chatConversations.id, + }) + .from(chatMessageLinks) + .innerJoin( + chatConversations, + and( + eq(chatConversations.companyId, chatMessageLinks.companyId), + eq(chatConversations.id, chatMessageLinks.conversationId), + eq(chatConversations.endpointId, chatMessageLinks.endpointId), + ), + ) + .innerJoin( + chatEndpoints, + and( + eq(chatEndpoints.companyId, chatConversations.companyId), + eq(chatEndpoints.id, chatConversations.endpointId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, input.companyId), + eq(chatMessageLinks.direction, "inbound"), + inArray(chatMessageLinks.commentId, commentIds), + eq(chatConversations.issueId, input.issueId), + inArray(chatConversations.state, ["active", "waiting"]), + eq(chatEndpoints.provider, provider), + eq(chatEndpoints.assignedAgentId, input.agentId), + inArray(chatEndpoints.status, ["active", "verifying"]), + ), + ); + const linkedCommentIds = new Set(links.map((link) => link.commentId)); + return new Set(links.map((link) => link.conversationId)).size === 1 && + commentIds.every((id) => linkedCommentIds.has(id)) + ? provider + : null; +} + +/** Bind an already-claimed chat/reply turn without checking out or approving it. */ +export async function attestReviewedExternalChatRun(input: { + db: Db; + companyId: string; + agentId: string; + issueId: string; + runId: string; + contextSnapshot: Record; + /** Diagnostic-only value from the committed, authorized answer; never a wake field. */ + onQuestionResponseAttested?: (answeredAtMs: number) => void; +}): Promise { + const attempt = () => + input.db.transaction( + async ( + transaction, + ): Promise => { + const tx = transaction as unknown as Db; + const [issue] = await tx + .select() + .from(issues) + .where( + and( + eq(issues.id, input.issueId), + eq(issues.companyId, input.companyId), + ), + ) + .for("update", { noWait: true }) + .limit(1); + const [run] = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, input.runId), + eq(heartbeatRuns.companyId, input.companyId), + eq(heartbeatRuns.agentId, input.agentId), + ), + ) + .for("update", { noWait: true }) + .limit(1); + const [actor] = await tx + .select({ status: agents.status }) + .from(agents) + .where( + and( + eq(agents.id, input.agentId), + eq(agents.companyId, input.companyId), + ), + ) + .for("update", { noWait: true }) + .limit(1); + if ( + !issue || + !run || + !actor || + !( + issue.status === "in_review" || + (input.contextSnapshot.source === "issue.interaction.respond" && + issue.status === "in_progress") + ) || + issue.assigneeAgentId !== input.agentId || + issue.executionRunId !== input.runId || + run.status !== "running" || + (run.nativeIssueId !== null && run.nativeIssueId !== input.issueId) || + // This attestation precedes execution-start's transition to running. + // A prior run's error projection is invokable, unlike an operator + // pause, termination, or pending approval; all binding checks remain. + DIRECT_NON_INVOKABLE_STATUSES.has(actor.status) + ) + return false; + const admittedContext = parseObject(run.contextSnapshot); + const admittedIds = extractWakeCommentIds(admittedContext); + const suppliedIds = extractWakeCommentIds(input.contextSnapshot); + if ( + admittedContext.issueId !== input.issueId || + input.contextSnapshot.issueId !== input.issueId || + admittedContext.source !== input.contextSnapshot.source || + admittedIds.length === 0 || + admittedIds.length !== suppliedIds.length || + admittedIds.some((id, index) => id !== suppliedIds[index]) + ) + return false; + try { + const answer = + admittedContext.source === "issue.interaction.respond" + ? await resolveExternalChatQuestionResponse( + tx, + input, + admittedContext, + "nonblocking", + true, + ) + : null; + if (admittedContext.source === "issue.interaction.respond" && !answer) + return false; + // The marker is built here only after proving the real execution owner. + // The shared boundary then verifies current provider/resource/principal + // access for every admitted message; no lifecycle state is mutated. + await authorizeChatConversationForBoundRun( + tx, + input, + { + ...(answer?.authorizationContext ?? admittedContext), + [PAPERCLIP_HARNESS_CHECKOUT_KEY]: false, + [PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY]: true, + }, + "nonblocking", + ); + if (answer) + input.contextSnapshot[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY] = + answer.marker; + return answer ? { answeredAtMs: answer.answeredAtMs } : true; + } catch (error) { + if ( + error instanceof Error && + error.message === "paperclip_runner_chat_attachment_binding_denied" + ) { + // Inbound processing commits the message/link before dispatching its + // wake, but completes subscription and marks delivery processed after + // dispatch. Wait for that exact committed batch, never admit it early. + const links = await tx + .select({ + commentId: chatMessageLinks.commentId, + state: chatDeliveries.state, + }) + .from(chatMessageLinks) + .innerJoin( + chatDeliveries, + and( + eq(chatDeliveries.id, chatMessageLinks.deliveryId), + eq(chatDeliveries.companyId, chatMessageLinks.companyId), + eq(chatDeliveries.endpointId, chatMessageLinks.endpointId), + eq( + chatDeliveries.conversationId, + chatMessageLinks.conversationId, + ), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, input.companyId), + eq(chatMessageLinks.direction, "inbound"), + inArray(chatMessageLinks.commentId, admittedIds), + ), + ); + if ( + admittedIds.every((id) => + links.some((link) => link.commentId === id), + ) && + links.some((link) => link.state === "processing") && + links.every( + (link) => + link.state === "processing" || link.state === "processed", + ) + ) { + return "pending_delivery"; + } + } + if ( + error instanceof Error && + [ + "paperclip_runner_chat_attachment_binding_denied", + "paperclip_runner_chat_attachment_destination_denied", + "paperclip_runner_chat_attachment_principal_denied", + ].includes(error.message) + ) + return false; + throw error; + } + }, + ); + // Release every lock before retrying and re-prove the current execution + // owner, admitted batch, and policy. Routine control-plane contention is not + // evidence that the user lost access; neither is it permission to bypass it. + for (let attemptNumber = 0; attemptNumber < 51; attemptNumber += 1) { + try { + const result = await attempt(); + if (typeof result === "object") { + // The transaction (including COMMIT) must succeed before any attempt- + // local timing is accepted. A retry/rollback cannot publish this value. + input.onQuestionResponseAttested?.(result.answeredAtMs); + return true; + } + if (result !== "pending_delivery") return result; + } catch (error) { + if (!isExternalChatWaitAuthorizationContention(error)) throw error; + } + if (attemptNumber < 50) + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("reviewed_chat_execution_binding_not_ready"); +} + +export async function buildPaperclipWakePayload(input: { + db: Db; + companyId: string; + agentId?: string | null; + runId?: string | null; + contextSnapshot: Record; + continuationSummary?: { + key: string; + title: string | null; + body: string; + sourceTrust?: SourceTrustMetadata | null; + updatedAt: Date; + } | null; + issueSummary?: { + id: string; + identifier: string | null; + title: string; + description: string | null; + status: string; + priority: string; + workMode: string; + projectId?: string | null; + executionPolicy?: unknown; + } | null; + exposeLowTrustRaw?: boolean; + // Experimental: agents write user-interaction content in ASD-STE100 + // Simplified Technical English (rendered as a prompt directive downstream). + simplifiedEnglishInteractions?: boolean; +}) { + const executionStage = parseObject(input.contextSnapshot.executionStage); + const commentIds = extractWakeCommentIds(input.contextSnapshot); + const annotationCommentId = readNonEmptyString( + input.contextSnapshot.annotationCommentId, + ); + const issueId = readNonEmptyString(input.contextSnapshot.issueId); + const conversationMode = input.contextSnapshot.conversationMode === true; + const continuationSummary = conversationMode ? null : input.continuationSummary ?? null; + const agentMessage = parseObject( + input.contextSnapshot[PAPERCLIP_AGENT_MESSAGE_KEY], + ); + const agentMessageText = sanitizeAgentSessionMessageText(agentMessage.text); + const issueSummary = + input.issueSummary ?? + (issueId + ? await input.db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + description: issues.description, + status: issues.status, + priority: issues.priority, + workMode: issues.workMode, + }) + .from(issues) + .where( + and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)), + ) + .then((rows) => rows[0] ?? null) + : null); + if ( + commentIds.length === 0 && + Object.keys(executionStage).length === 0 && + !issueSummary && + !agentMessageText + ) + return null; + + const commentRows = + commentIds.length === 0 + ? [] + : await input.db + .select({ + id: issueComments.id, + issueId: issueComments.issueId, + body: issueComments.body, + authorType: issueComments.authorType, + authorAgentId: issueComments.authorAgentId, + authorUserId: issueComments.authorUserId, + presentation: issueComments.presentation, + metadata: issueComments.metadata, + deletedAt: issueComments.deletedAt, + deletedByType: issueComments.deletedByType, + deletedByAgentId: issueComments.deletedByAgentId, + deletedByUserId: issueComments.deletedByUserId, + deletedByRunId: issueComments.deletedByRunId, + sourceTrust: issueComments.sourceTrust, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + issueId ? eq(issueComments.issueId, issueId) : undefined, + inArray(issueComments.id, commentIds), + ), + ); + + const commentsById = new Map( + commentRows.map((comment) => [comment.id, comment]), + ); + const issueDescription = conversationMode ? null : issueSummary?.description ?? null; + const issueDescriptionTruncated = + issueDescription !== null && + issueDescription.length > MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS; + const inlineIssueDescription = issueDescriptionTruncated + ? issueDescription.slice(0, MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS) + : issueDescription; + const comments: Array> = []; + let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS; + let truncated = false; + let missingCommentCount = 0; + const safeContinuationSummary = + continuationSummary && !input.exposeLowTrustRaw + ? redactQuarantinedBodyForHigherTrust(continuationSummary) + : continuationSummary; + + for (const commentId of commentIds) { + const row = commentsById.get(commentId); + if (!row) { + truncated = true; + missingCommentCount += 1; + continue; + } + if (comments.length >= MAX_INLINE_WAKE_COMMENTS) { + truncated = true; + break; + } + + const deletedAt = row.deletedAt ?? null; + const safeRow = + deletedAt || input.exposeLowTrustRaw + ? row + : sanitizeQuarantinedCommentForHigherTrust(row); + const fullBody = deletedAt ? "" : safeRow.body; + const allowedBodyChars = Math.min( + MAX_INLINE_WAKE_COMMENT_BODY_CHARS, + remainingBodyChars, + ); + if (allowedBodyChars <= 0) { + truncated = true; + break; + } + + const body = + fullBody.length > allowedBodyChars + ? fullBody.slice(0, allowedBodyChars) + : fullBody; + const bodyTruncated = body.length < fullBody.length; + if (bodyTruncated) truncated = true; + remainingBodyChars -= body.length; + + comments.push({ + id: row.id, + issueId: row.issueId, + authorType: + row.authorType ?? + (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"), + body, + bodyTruncated, + presentation: deletedAt ? null : (safeRow.presentation ?? null), + metadata: deletedAt ? null : (safeRow.metadata ?? null), + deletedAt: deletedAt ? deletedAt.toISOString() : null, + deletedByType: deletedAt ? (row.deletedByType ?? null) : null, + deletedByAgentId: deletedAt ? (row.deletedByAgentId ?? null) : null, + deletedByUserId: deletedAt ? (row.deletedByUserId ?? null) : null, + deletedByRunId: deletedAt ? (row.deletedByRunId ?? null) : null, + sourceTrust: row.sourceTrust ?? null, + createdAt: row.createdAt.toISOString(), + author: row.authorAgentId + ? { type: "agent", id: row.authorAgentId } + : row.authorUserId + ? { type: "user", id: row.authorUserId } + : { type: "system", id: null }, + }); + } + + const attachmentCommentIds = comments.flatMap((comment) => + typeof comment.id === "string" && + comment.deletedAt === null && + (input.exposeLowTrustRaw || + !isLowTrustQuarantined(comment.sourceTrust as SourceTrustMetadata | null)) + ? [comment.id] + : [], + ); + const attachmentRows = + !issueId || attachmentCommentIds.length === 0 + ? [] + : await input.db + .select({ + id: issueAttachments.id, + issueCommentId: issueAttachments.issueCommentId, + filename: assets.originalFilename, + contentType: assets.contentType, + byteSize: assets.byteSize, + }) + .from(issueAttachments) + .innerJoin( + assets, + and( + eq(issueAttachments.assetId, assets.id), + eq(assets.companyId, input.companyId), + ), + ) + .where( + and( + eq(issueAttachments.companyId, input.companyId), + eq(issueAttachments.issueId, issueId), + inArray(issueAttachments.issueCommentId, attachmentCommentIds), + ), + ) + .orderBy(asc(issueAttachments.createdAt), asc(issueAttachments.id)) + .limit(MAX_INLINE_WAKE_ATTACHMENTS + 1); + if (attachmentRows.length > MAX_INLINE_WAKE_ATTACHMENTS) truncated = true; + const attachmentsByCommentId = new Map< + string, + Array<{ + id: string; + filename: string; + contentType: string; + byteSize: number; + contentPath: string; + }> + >(); + for (const attachment of attachmentRows.slice( + 0, + MAX_INLINE_WAKE_ATTACHMENTS, + )) { + if (!attachment.issueCommentId) continue; + const descriptors = + attachmentsByCommentId.get(attachment.issueCommentId) ?? []; + descriptors.push({ + id: attachment.id, + filename: attachment.filename?.trim() || "attachment", + contentType: attachment.contentType, + byteSize: attachment.byteSize, + contentPath: `/api/attachments/${attachment.id}/content`, + }); + attachmentsByCommentId.set(attachment.issueCommentId, descriptors); + } + for (const comment of comments) { + if (typeof comment.id !== "string") continue; + const attachments = attachmentsByCommentId.get(comment.id); + if (attachments?.length) comment.attachments = attachments; + } + + const annotationDeltas = + annotationCommentId && issueId + ? await input.db + .select({ + id: documentAnnotationComments.id, + issueId: documentAnnotationComments.issueId, + threadId: documentAnnotationComments.threadId, + body: documentAnnotationComments.body, + authorType: documentAnnotationComments.authorType, + authorAgentId: documentAnnotationComments.authorAgentId, + authorUserId: documentAnnotationComments.authorUserId, + createdAt: documentAnnotationComments.createdAt, + documentKey: documentAnnotationThreads.documentKey, + status: documentAnnotationThreads.status, + anchorState: documentAnnotationThreads.anchorState, + anchorConfidence: documentAnnotationThreads.anchorConfidence, + currentRevisionNumber: + documentAnnotationThreads.currentRevisionNumber, + selectedText: documentAnnotationThreads.selectedText, + prefixText: documentAnnotationThreads.prefixText, + suffixText: documentAnnotationThreads.suffixText, + }) + .from(documentAnnotationComments) + .innerJoin( + documentAnnotationThreads, + eq( + documentAnnotationComments.threadId, + documentAnnotationThreads.id, + ), + ) + .where( + and( + eq(documentAnnotationComments.companyId, input.companyId), + eq(documentAnnotationComments.issueId, issueId), + eq(documentAnnotationComments.id, annotationCommentId), + eq(documentAnnotationThreads.companyId, input.companyId), + eq(documentAnnotationThreads.issueId, issueId), + ), + ) + .then((rows) => + rows.map((row) => ({ + id: row.id, + issueId: row.issueId, + threadId: row.threadId, + documentKey: row.documentKey, + revisionNumber: row.currentRevisionNumber, + quote: row.selectedText, + prefix: row.prefixText, + suffix: row.suffixText, + threadStatus: row.status, + anchorState: row.anchorState, + anchorConfidence: row.anchorConfidence, + body: + row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS + ? row.body.slice(0, MAX_INLINE_WAKE_COMMENT_BODY_CHARS) + : row.body, + bodyTruncated: + row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS, + createdAt: row.createdAt.toISOString(), + author: row.authorAgentId + ? { type: "agent", id: row.authorAgentId } + : row.authorUserId + ? { type: "user", id: row.authorUserId } + : { type: row.authorType, id: null }, + })), + ) + : []; + const interactionId = readNonEmptyString(input.contextSnapshot.interactionId); + const interactionKind = readNonEmptyString( + input.contextSnapshot.interactionKind, + ); + const interactionStatus = readNonEmptyString( + input.contextSnapshot.interactionStatus, + ); + const interactionContinuationSource = readNonEmptyString( + input.contextSnapshot.source, + ); + const externalInteractionContinuation = + (interactionStatus === "answered" || interactionStatus === "accepted") && + (input.contextSnapshot.externalChatContinuation === true || + interactionContinuationSource === "external_chat.interaction.resolve"); + const checkboxSelection = parseObject( + input.contextSnapshot.checkboxSelection, + ); + // A resolved plan review is new user input, including in chat. Ordinary chat + // wakes must still exclude historical plan context across /new boundaries. + const resolvedPlanInteraction = interactionId && interactionKind === "request_confirmation" && + (interactionStatus === "accepted" || interactionStatus === "rejected"); + const planReviewContext = issueId && (!conversationMode || resolvedPlanInteraction) + ? await buildPlanReviewContext({ + db: input.db, + companyId: input.companyId, + issueId, + issueWorkMode: conversationMode ? null : issueSummary?.workMode ?? null, + includeForIssueComment: !conversationMode && commentIds.length > 0, + includeForAnnotationDelta: !conversationMode && annotationDeltas.length > 0, + interactionId, + }) + : null; + const documentReviewContext = issueId && !conversationMode + ? await buildDocumentReviewContext({ + db: input.db, + companyId: input.companyId, + issueId, + includeForIssueComment: commentIds.length > 0, + includeForAnnotationDelta: annotationDeltas.length > 0, + }) + : null; + const payloadTruncated = + truncated || + issueDescriptionTruncated || + planReviewContext?.truncated === true || + documentReviewContext?.truncated === true; + const recoveryActionId = readNonEmptyString( + input.contextSnapshot.recoveryActionId, + ); + const recoveryCause = readNonEmptyString(input.contextSnapshot.recoveryCause); + const recoveryAction = recoveryActionId + ? await input.db + .select() + .from(issueRecoveryActions) + .where( + and( + eq(issueRecoveryActions.id, recoveryActionId), + eq(issueRecoveryActions.companyId, input.companyId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + const recoveryEvidence = parseObject(recoveryAction?.evidence); + // A restored task resumes its deliverable, not the completed repair. A + // referenced settled/missing action must not fall back to a stale cause. + const recoveryStillActive = recoveryAction + ? ["active", "escalated"].includes(recoveryAction.status) + : !recoveryActionId && Boolean(recoveryCause); + const originalAssigneeId = + recoveryAction?.returnOwnerAgentId ?? + recoveryAction?.previousOwnerAgentId ?? + null; + const originalAssignee = originalAssigneeId + ? await input.db + .select({ id: agents.id, name: agents.name }) + .from(agents) + .where( + and( + eq(agents.id, originalAssigneeId), + eq(agents.companyId, input.companyId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + const externalChatProvider = await resolveExternalChatWakeProvider({ + db: input.db, + companyId: input.companyId, + agentId: input.agentId, + runId: input.runId, + issueId: issueSummary?.id === issueId ? issueId : null, + contextSnapshot: input.contextSnapshot, + }); + const attachmentOmissions = externalChatProvider + ? readExternalAttachmentOmissions( + input.contextSnapshot[EXTERNAL_ATTACHMENT_OMISSIONS_KEY], + ) + .filter((omission) => commentIds.includes(omission.commentId)) + .map((omission) => ({ + commentId: omission.commentId, + reasons: omission.reasons, + notice: externalAttachmentOmissionNotice(omission), + })) + : []; + const payload = { + reason: readNonEmptyString(input.contextSnapshot.wakeReason), + executionContinuation: input.contextSnapshot.executionContinuation ?? null, + chatCompletionUpdates: input.contextSnapshot.chatCompletionUpdates ?? null, + attachmentOmissions, + externalChatProvider, + recovery: + recoveryStillActive + ? { + cause: recoveryAction?.cause ?? recoveryCause, + failureSummary: readNonEmptyString(recoveryEvidence.failureSummary), + originalAssignee: originalAssignee + ? { id: originalAssignee.id, name: originalAssignee.name } + : originalAssigneeId + ? { id: originalAssigneeId, name: null } + : null, + attemptCount: recoveryAction?.attemptCount ?? null, + maxAttempts: recoveryAction?.maxAttempts ?? null, + nextAction: recoveryAction?.nextAction ?? null, + routingFallbackReason: readNonEmptyString( + recoveryEvidence.routingFallbackReason, + ), + } + : null, + issue: issueSummary + ? { + id: issueSummary.id, + identifier: issueSummary.identifier, + title: issueSummary.title, + description: inlineIssueDescription, + descriptionTruncated: issueDescriptionTruncated, + status: issueSummary.status, + priority: issueSummary.priority, + workMode: issueSummary.workMode, + } + : null, + agentMessage: agentMessageText + ? { + text: agentMessageText, + source: readNonEmptyString(agentMessage.source), + pluginKey: readNonEmptyString(agentMessage.pluginKey), + sessionId: readNonEmptyString(agentMessage.sessionId), + ...(Array.isArray(agentMessage.untrustedToolResults) + ? { + untrustedToolResults: agentMessage.untrustedToolResults + .slice(0, 8) + .map((value) => { + const result = parseObject(value); + return { + actionRequestId: + sanitizeAgentSessionMessageText( + result.actionRequestId, + ) ?? "", + toolName: + sanitizeAgentSessionMessageText(result.toolName) ?? "", + resultSummary: + sanitizeAgentSessionMessageText(result.resultSummary) ?? + "", + error: sanitizeAgentSessionMessageText(result.error), + declineReason: sanitizeAgentSessionMessageText( + result.declineReason, + ), + }; + }), + } + : {}), + } + : null, + childIssueSummaries: Array.isArray( + input.contextSnapshot.childIssueSummaries, + ) + ? input.contextSnapshot.childIssueSummaries + : [], + childIssueSummaryTruncated: + input.contextSnapshot.childIssueSummaryTruncated === true, + dispositionRepair: input.contextSnapshot.legacyDispositionEpisode ? { + attempt: input.contextSnapshot.dispositionRepairAttempt, + maxAttempts: input.contextSnapshot.dispositionRepairMaxAttempts, + sourceRunId: input.contextSnapshot.retryOfRunId, + instruction: input.contextSnapshot.dispositionRepairInstruction, + } : null, + livenessContinuation: + readNonEmptyString(input.contextSnapshot.livenessContinuationState) || + readNonEmptyString( + input.contextSnapshot.livenessContinuationInstruction, + ) || + readNonEmptyString( + input.contextSnapshot.livenessContinuationSourceRunId, + ) || + typeof input.contextSnapshot.livenessContinuationAttempt === "number" + ? { + attempt: input.contextSnapshot.livenessContinuationAttempt, + maxAttempts: input.contextSnapshot.livenessContinuationMaxAttempts, + sourceRunId: readNonEmptyString( + input.contextSnapshot.livenessContinuationSourceRunId, + ), + state: readNonEmptyString( + input.contextSnapshot.livenessContinuationState, + ), + reason: readNonEmptyString( + input.contextSnapshot.livenessContinuationReason, + ), + instruction: readNonEmptyString( + input.contextSnapshot.livenessContinuationInstruction, + ), + } + : null, + interactionKind, + interactionStatus, + interactionId, + sourceRunId: readNonEmptyString(input.contextSnapshot.sourceRunId), + externalChatQuestionResponse: externalChatProvider + ? (input.contextSnapshot[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY] ?? null) + : null, + externalInteractionContinuation, + checkboxSelection: + Object.keys(checkboxSelection).length > 0 ? checkboxSelection : null, + checkedOutByHarness: + input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, + externalChatExecutionBound: + input.contextSnapshot[PAPERCLIP_EXTERNAL_CHAT_EXECUTION_BOUND_KEY] === + true, + simplifiedEnglishInteractions: input.simplifiedEnglishInteractions === true, + dependencyBlockedInteraction: + input.contextSnapshot.dependencyBlockedInteraction === true, + treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true, + activeTreeHold: parseObject(input.contextSnapshot.activeTreeHold), + unresolvedBlockerIssueIds: Array.isArray( + input.contextSnapshot.unresolvedBlockerIssueIds, + ) + ? input.contextSnapshot.unresolvedBlockerIssueIds.filter( + (value): value is string => + typeof value === "string" && value.length > 0, + ) + : [], + unresolvedBlockerSummaries: Array.isArray( + input.contextSnapshot.unresolvedBlockerSummaries, + ) + ? input.contextSnapshot.unresolvedBlockerSummaries + : [], + executionStage: + Object.keys(executionStage).length > 0 ? executionStage : null, + taskWatchdog: (input.contextSnapshot.taskWatchdog ?? null) as unknown, + skillTest: (input.contextSnapshot.paperclipSkillTest ?? null) as unknown, + continuationSummary: safeContinuationSummary + ? { + key: safeContinuationSummary.key, + title: safeContinuationSummary.title, + body: + safeContinuationSummary.body.length > 4_000 + ? safeContinuationSummary.body.slice(0, 4_000) + : safeContinuationSummary.body, + bodyTruncated: safeContinuationSummary.body.length > 4_000, + sourceTrust: safeContinuationSummary.sourceTrust ?? null, + updatedAt: safeContinuationSummary.updatedAt.toISOString(), + } + : null, + commentIds, + latestCommentId: commentIds[commentIds.length - 1] ?? null, + comments, + annotationDeltas, + planReviewContext, + documentReviewContext, + commentWindow: { + requestedCount: commentIds.length, + includedCount: comments.length, + missingCount: missingCommentCount, + }, + truncated: payloadTruncated, + fallbackFetchNeeded: payloadTruncated || missingCommentCount > 0, + }; + return issueId + ? createRunSecretRedactionRegistry(input.db).redactForIssue( + input.companyId, + issueId, + payload, + ) + : payload; +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** Issue context and responsible-user resolution bound to one service database. */ +export function createHeartbeatRunPreparation(db: Db) { + function toAgentOrgRow( + agent: Pick< + typeof agents.$inferSelect, + "id" | "companyId" | "name" | "reportsTo" | "status" + >, + ): AgentOrgRow { + return { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + reportsTo: agent.reportsTo, + status: agent.status, + }; + } + + async function listCompanyAgentOrgRows( + companyId: string, + ): Promise { + return db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + reportsTo: agents.reportsTo, + status: agents.status, + }) + .from(agents) + .where(eq(agents.companyId, companyId)); + } + + function groupAgentOrgRowsByCompany(agentRows: AgentOrgRow[]) { + const byCompany = new Map(); + for (const agent of agentRows) { + const companyAgents = byCompany.get(agent.companyId); + if (companyAgents) { + companyAgents.push(agent); + } else { + byCompany.set(agent.companyId, [agent]); + } + } + return byCompany; + } + + async function getIssueExecutionContext(companyId: string, issueId: string) { + return db + .select({ + chatCommunicationGuidance: chatConversations.communicationGuidance, + chatAssignedAgentId: chatEndpoints.assignedAgentId, + // Select only the public command, never the rest of setup state. + chatSlackCommand: sql`case when ${chatEndpoints.status} in ('active', 'verifying') then ${chatEndpoints.setup}->>'command' end`, + externalConversationState: externalConversationStateSql(), + conversationAgentId: issues.conversationAgentId, + conversationUserId: issues.conversationUserId, + conversationState: issues.conversationState, + conversationSessionGeneration: issues.conversationSessionGeneration, + conversationBoundaryCommentId: issues.conversationBoundaryCommentId, + id: issues.id, + identifier: issues.identifier, + title: issues.title, + titleNeedsGeneration: issues.titleNeedsGeneration, + description: issues.description, + status: issues.status, + workMode: issues.workMode, + reviewPolicy: issues.reviewPolicy, + priority: issues.priority, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + assigneeAgentId: issues.assigneeAgentId, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionPolicy: issues.executionPolicy, + executionState: issues.executionState, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + parentId: issues.parentId, + createdByUserId: issues.createdByUserId, + responsibleUserId: issues.responsibleUserId, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, + originIdentityContextId: issues.originIdentityContextId, + continuationIdentityContextId: issues.continuationIdentityContextId, + updatedAt: issues.updatedAt, + }) + .from(issues) + .leftJoin(chatConversations, and( + eq(chatConversations.companyId, issues.companyId), + eq(chatConversations.issueId, issues.id), + )) + .leftJoin(chatEndpoints, and( + eq(chatEndpoints.companyId, chatConversations.companyId), + eq(chatEndpoints.id, chatConversations.endpointId), + eq(chatEndpoints.provider, "slack"), + )) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + } + + async function getPinnedSkillTestContext(companyId: string, issueId: string) { + const row = await db + .select({ + testRunId: companySkillTestRuns.id, + skillId: companySkillTestRuns.skillId, + inputId: companySkillTestRuns.inputId, + skillVersionId: companySkillTestRuns.skillVersionId, + outputDocumentKey: companySkillTestRuns.outputDocumentKey, + fileInventory: companySkillVersions.fileInventory, + revisionNumber: companySkillVersions.revisionNumber, + label: companySkillVersions.label, + }) + .from(companySkillTestRuns) + .innerJoin( + companySkillVersions, + and( + eq(companySkillVersions.id, companySkillTestRuns.skillVersionId), + eq(companySkillVersions.companyId, companySkillTestRuns.companyId), + ), + ) + .where( + and( + eq(companySkillTestRuns.companyId, companyId), + eq(companySkillTestRuns.issueId, issueId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!row) return null; + const fileInventory = Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) + return []; + const record = entry as unknown as Record; + const path = typeof record.path === "string" ? record.path : ""; + if (!path) return []; + return [ + { + path, + kind: typeof record.kind === "string" ? record.kind : "other", + content: typeof record.content === "string" ? record.content : "", + }, + ]; + }) + : []; + return { + testRunId: row.testRunId, + skillId: row.skillId, + inputId: row.inputId ?? null, + skillVersionId: row.skillVersionId, + revisionNumber: row.revisionNumber, + label: row.label ?? null, + outputDocumentKey: row.outputDocumentKey, + fileInventory, + }; + } + + async function getRoutineEnvForExecutionIssue( + companyId: string, + issueContext: { originKind: string | null; originId: string | null; originRunId: string | null } | null, + ) { + if ( + !issueContext || + issueContext.originKind !== "routine_execution" || + !issueContext.originId + ) { + return { routineId: null, env: null, responsibleUserId: null }; + } + + const routineRun = issueContext.originRunId + ? await db + .select({ + routineRevisionId: routineRuns.routineRevisionId, + responsibleUserId: routineRuns.responsibleUserId, + }) + .from(routineRuns) + .where( + and( + eq(routineRuns.id, issueContext.originRunId), + eq(routineRuns.companyId, companyId), + eq(routineRuns.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + if (routineRun?.routineRevisionId) { + const revision = await db + .select({ + snapshot: routineRevisions.snapshot, + responsibleUserId: routineRevisions.responsibleUserId, + }) + .from(routineRevisions) + .where( + and( + eq(routineRevisions.id, routineRun.routineRevisionId), + eq(routineRevisions.companyId, companyId), + eq(routineRevisions.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null); + const snapshot = revision?.snapshot as + RoutineRevisionSnapshotV1 | undefined; + if (snapshot?.version === 1) { + return { + routineId: issueContext.originId, + env: snapshot.routine.env ?? null, + responsibleUserId: + routineRun?.responsibleUserId ?? + revision?.responsibleUserId ?? + snapshot.routine.responsibleUserId ?? + null, + }; + } + } + + const routine = await db + .select({ + env: routines.env, + responsibleUserId: routines.responsibleUserId, + }) + .from(routines) + .where( + and( + eq(routines.id, issueContext.originId), + eq(routines.companyId, companyId), + ), + ) + .then((rows) => rows[0] ?? null); + return { + routineId: issueContext.originId, + env: routine?.env ?? null, + responsibleUserId: + routineRun?.responsibleUserId ?? routine?.responsibleUserId ?? null, + }; + } + + async function resolveCompanyDefaultResponsibleUserId(companyId: string) { + const company = await db + .select({ defaultResponsibleUserId: companies.defaultResponsibleUserId }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + const explicitDefault = readNonEmptyString( + company?.defaultResponsibleUserId, + ); + if (explicitDefault) return explicitDefault; + + const owner = await db + .select({ userId: companyMemberships.principalId }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .orderBy(asc(companyMemberships.createdAt), asc(companyMemberships.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + if (owner?.userId) return owner.userId; + + const firstUser = await db + .select({ userId: companyMemberships.principalId }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ) + .orderBy(asc(companyMemberships.createdAt), asc(companyMemberships.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + return firstUser?.userId ?? null; + } + + async function resolveParentIssueResponsibleUserId( + companyId: string, + parentId: string | null | undefined, + ) { + if (!parentId) return null; + const parent = await db + .select({ + responsibleUserId: issues.responsibleUserId, + createdByUserId: issues.createdByUserId, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.id, parentId))) + .then((rows) => rows[0] ?? null); + return parent?.responsibleUserId ?? null; + } + + function isManualUserRun(input: { + contextSnapshot: Record; + requestedByActorType?: "user" | "agent" | "system" | null; + source?: WakeupOptions["source"] | null; + triggerDetail?: WakeupOptions["triggerDetail"] | null; + }) { + if (input.requestedByActorType !== "user") return false; + const wakeReason = readNonEmptyString(input.contextSnapshot.wakeReason); + if (wakeReason && ISSUE_RESPONSIBLE_USER_WAKE_REASONS.has(wakeReason)) + return false; + return input.source === "on_demand" || input.triggerDetail === "manual"; + } + + async function resolveResponsibleUserIdForRunSeed(input: { + companyId: string; + contextSnapshot: Record; + issueContext: { id: string; responsibleUserId: string | null; parentId: string | null } | null; + routineEnvContext: Awaited< + ReturnType + >; + requestedByActorType?: "user" | "agent" | "system" | null; + requestedByActorId?: string | null; + source?: WakeupOptions["source"] | null; + triggerDetail?: WakeupOptions["triggerDetail"] | null; + existingRunResponsibleUserId?: string | null; + }) { + const contextResponsibleUserId = readNonEmptyString( + input.contextSnapshot.responsibleUserId, + ); + const requestedUserId = + input.requestedByActorType === "user" + ? readNonEmptyString(input.requestedByActorId) + : null; + const messageIds = Array.isArray(input.contextSnapshot.wakeCommentIds) + ? input.contextSnapshot.wakeCommentIds.filter( + (id): id is string => typeof id === "string", + ) + : readNonEmptyString(input.contextSnapshot.wakeCommentId) + ? [String(input.contextSnapshot.wakeCommentId)] + : []; + if ( + input.issueContext && + messageIds.length && + !input.contextSnapshot.retryOfRunId + ) { + const messages = await db + .select({ + id: issueComments.id, + authorUserId: issueComments.authorUserId, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + eq(issueComments.issueId, input.issueContext.id), + inArray(issueComments.id, messageIds), + ), + ); + for (const id of [...messageIds].reverse()) { + const author = messages.find( + (message) => message.id === id, + )?.authorUserId; + if (author) { + delete input.contextSnapshot.executionIdentityCause; + return author; + } + } + } + const retryOfRunId = readNonEmptyString(input.contextSnapshot.retryOfRunId); + if (retryOfRunId) { + const [origin] = await db + .select({ responsibleUserId: heartbeatRuns.responsibleUserId }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, input.companyId), + eq(heartbeatRuns.id, retryOfRunId), + ), + ); + if (origin?.responsibleUserId) return origin.responsibleUserId; + } + if (contextResponsibleUserId) return contextResponsibleUserId; + if (input.existingRunResponsibleUserId) + return input.existingRunResponsibleUserId; + if (input.routineEnvContext.responsibleUserId) + return input.routineEnvContext.responsibleUserId; + if (isManualUserRun(input) && requestedUserId) return requestedUserId; + if (input.issueContext?.responsibleUserId) + return input.issueContext.responsibleUserId; + const parentResponsibleUserId = await resolveParentIssueResponsibleUserId( + input.companyId, + input.issueContext?.parentId, + ); + if (parentResponsibleUserId) return parentResponsibleUserId; + if (!input.issueContext && requestedUserId) return requestedUserId; + input.contextSnapshot.executionIdentityCause = "company_default"; + return resolveCompanyDefaultResponsibleUserId(input.companyId); + } + + async function resolveResponsibleUserIdForRun(input: { + run: typeof heartbeatRuns.$inferSelect; + contextSnapshot: Record; + issueContext: Awaited> | null; + routineEnvContext: Awaited< + ReturnType + >; + }) { + const operatorIdentity = await explicitOperatorRunIdentity(db, input.run); + const responsibleUserId = operatorIdentity?.actorId ?? await resolveResponsibleUserIdForRunSeed({ + companyId: input.run.companyId, + contextSnapshot: input.contextSnapshot, + issueContext: input.issueContext, + routineEnvContext: input.routineEnvContext, + existingRunResponsibleUserId: input.run.responsibleUserId, + source: input.run.invocationSource as WakeupOptions["source"], + triggerDetail: input.run.triggerDetail as WakeupOptions["triggerDetail"], + }); + if (!responsibleUserId) { + throw new HttpError( + 422, + "Unable to resolve responsible user for heartbeat run dispatch", + { + code: "responsible_user_unresolved", + runId: input.run.id, + agentId: input.run.agentId, + companyId: input.run.companyId, + issueId: input.issueContext?.id ?? null, + invocationSource: input.run.invocationSource, + triggerDetail: input.run.triggerDetail, + wakeReason: readNonEmptyString(input.contextSnapshot.wakeReason), + }, + ); + } + return responsibleUserId; + } + + async function resolveResponsibleUserIdForRunContext( + run: typeof heartbeatRuns.$inferSelect, + contextSnapshot: Record, + ) { + const issueId = + readNonEmptyString(contextSnapshot.issueId) ?? + readNonEmptyString(contextSnapshot.taskId); + const issueContext = issueId + ? await getIssueExecutionContext(run.companyId, issueId) + : null; + return resolveResponsibleUserIdForRun({ + run, + contextSnapshot, + issueContext, + routineEnvContext: await getRoutineEnvForExecutionIssue( + run.companyId, + issueContext, + ), + }); + } + + return { + toAgentOrgRow, + listCompanyAgentOrgRows, + groupAgentOrgRowsByCompany, + getIssueExecutionContext, + getPinnedSkillTestContext, + getRoutineEnvForExecutionIssue, + resolveResponsibleUserIdForRunSeed, + resolveResponsibleUserIdForRun, + resolveResponsibleUserIdForRunContext, + }; +} diff --git a/server/src/services/heartbeat/run-state.test.ts b/server/src/services/heartbeat/run-state.test.ts new file mode 100644 index 00000000000..c9bb5e50011 --- /dev/null +++ b/server/src/services/heartbeat/run-state.test.ts @@ -0,0 +1,222 @@ +import { randomUUID } from "node:crypto"; +import type { Db } from "@paperclipai/db"; +import { + agents, + agentTaskSessions, + companies, + createDb, + heartbeatRuns, + issues, +} from "@paperclipai/db"; +import { eq, type SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "../../__tests__/helpers/embedded-postgres.js"; +import * as legacy from "../heartbeat.js"; +import * as extracted from "./run-state.js"; + +// SQL_ASCII needs a separate database cluster. Inspect its selected SQL here; +// the PostgreSQL tests below exercise the session queries and transactions. +function readingDatabase(serverEncoding: string, ...results: unknown[][]) { + const scopes: unknown[][] = []; + const columns: Record[] = []; + const execute = vi.fn(async () => [{ server_encoding: serverEncoding }]); + const select = vi.fn((projection: Record = {}) => { + columns.push(projection); + const rows = results.shift() ?? []; + const query = { + from: () => query, + where: (condition: SQL) => { + scopes.push(new PgDialect().sqlToQuery(condition).params); + return query; + }, + orderBy: () => query, + limit: () => query, + then: (resolve: (rows: unknown[]) => unknown) => Promise.resolve(rows).then(resolve), + }; + return query; + }); + return { db: { execute, select } as unknown as Db, execute, select, columns, scopes }; +} + +describe("heartbeat run state module", () => { + it("preserves the legacy public helper identities", () => { + expect(legacy.resolveNextSessionState).toBe(extracted.resolveNextSessionState); + expect(legacy.buildExplicitResumeSessionOverride).toBe(extracted.buildExplicitResumeSessionOverride); + expect(legacy.normalizeAdapterRunUsage).toBe(extracted.normalizeAdapterRunUsage); + expect(legacy.resolveLedgerScopeForRun).toBe(extracted.resolveLedgerScopeForRun); + expect(legacy.summarizeHeartbeatRunListResultJson).toBe(extracted.summarizeHeartbeatRunListResultJson); + }); + + it("constructs without queries and binds session lookups to each database", async () => { + const first = readingDatabase("UTF8", [{ sessionDisplayId: "first" }]); + const second = readingDatabase("UTF8", [{ sessionDisplayId: "second" }]); + const firstState = extracted.createHeartbeatRunState(first.db); + const secondState = extracted.createHeartbeatRunState(second.db); + expect(first.select).not.toHaveBeenCalled(); + expect(first.execute).not.toHaveBeenCalled(); + expect(second.select).not.toHaveBeenCalled(); + expect(second.execute).not.toHaveBeenCalled(); + const [one, two] = await Promise.all([ + firstState.getTaskSession("company-one", "agent-one", "codex_local", "task-one"), + secondState.getTaskSession("company-two", "agent-two", "claude_local", "task-two"), + ]); + expect(one?.sessionDisplayId).toBe("first"); + expect(two?.sessionDisplayId).toBe("second"); + expect(first.scopes[0]).toEqual(["company-one", "agent-one", "codex_local", "task-one"]); + expect(second.scopes[0]).toEqual(["company-two", "agent-two", "claude_local", "task-two"]); + }); + + it("keeps encoding caches local to each factory and preserves full-result reads", async () => { + const ascii = readingDatabase("SQL_ASCII"); + const utf8 = readingDatabase("UTF8"); + const asciiState = extracted.createHeartbeatRunState(ascii.db); + const utf8State = extracted.createHeartbeatRunState(utf8.db); + await Promise.all([asciiState.getRun("ascii-one"), asciiState.getRun("ascii-two"), utf8State.getRun("utf8")]); + const projectedSql = (column: unknown) => new PgDialect().sqlToQuery((column as SQL.Aliased).sql).sql; + expect(ascii.execute).toHaveBeenCalledTimes(1); + expect(utf8.execute).toHaveBeenCalledTimes(1); + expect(projectedSql(ascii.columns[0].resultJson)).toBe("NULL"); + expect(projectedSql(utf8.columns[0].resultJson)).toContain("pg_column_size"); + await asciiState.getRun("evidence", { includeExecutionEvidence: true }); + expect(projectedSql(ascii.columns[2].resultJson)).toContain("startupPreparationSettledAt"); + const raw = readingDatabase("SQL_ASCII"); + await extracted.createHeartbeatRunState(raw.db).getRun("raw", { unsafeFullResultJson: true }); + expect(raw.execute).not.toHaveBeenCalled(); + expect(raw.columns[0].resultJson).toBe(heartbeatRuns.resultJson); + }); +}); + +const postgresSupport = await getEmbeddedPostgresTestSupport(); +const describePostgres = postgresSupport.supported ? describe : describe.skip; +if (!postgresSupport.supported) { + console.warn(`Skipping run-state PostgreSQL tests: ${postgresSupport.reason ?? "unsupported host"}`); +} + +describePostgres("heartbeat run state database wiring", () => { + let database: Awaited>; + let db: Db; + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("heartbeat-run-state-"); + db = createDb(database.connectionString); + }, 20_000); + + afterAll(async () => { + await database?.cleanup(); + }); + + async function fixture() { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Run state", issuePrefix: `S${companyId.slice(0, 8)}` }); + const [agent] = await db.insert(agents).values({ + companyId, name: "Session agent", adapterType: "codex_local", + }).returning(); + const [issue] = await db.insert(issues).values({ companyId, title: "Session task", assigneeAgentId: agent.id }).returning(); + return { companyId, agent, issue, state: extracted.createHeartbeatRunState(db) }; + } + + it("resumes only the selected agent's run and task session", async () => { + const { companyId, agent, issue, state } = await fixture(); + const foreign = await fixture(); + const [run] = await db.insert(heartbeatRuns).values({ + companyId, agentId: agent.id, invocationSource: "assignment", status: "failed", + contextSnapshot: { issueId: issue.id }, sessionIdAfter: "our-session", + }).returning(); + const [foreignRun] = await db.insert(heartbeatRuns).values({ + companyId: foreign.companyId, agentId: foreign.agent.id, invocationSource: "assignment", + contextSnapshot: { issueId: foreign.issue.id }, sessionIdAfter: "foreign-session", + }).returning(); + await state.upsertTaskSession({ + companyId, agentId: agent.id, adapterType: agent.adapterType, taskKey: issue.id, + sessionParamsJson: { sessionId: "our-session", cwd: "/saved-workspace" }, + sessionDisplayId: "our-session", lastRunId: run.id, lastError: null, + }); + expect(await state.resolveExplicitResumeSessionOverride(agent, { resumeFromRunId: foreignRun.id }, issue.id)).toBeNull(); + expect(await state.resolveExplicitResumeSessionOverride(agent, { resumeFromRunId: run.id }, issue.id)).toMatchObject({ + resumeFromRunId: run.id, issueId: issue.id, taskKey: issue.id, + sessionDisplayId: "our-session", sessionParams: { sessionId: "our-session", cwd: "/saved-workspace" }, + }); + expect(await state.resolveSessionBeforeForWakeup(agent, issue.id)).toBe("our-session"); + expect(await state.getRuntimeStateWithSessions(agent.id)).toMatchObject({ + companyId, sessionDisplayId: "our-session", sessionParamsJson: { sessionId: "our-session" }, + }); + expect((await state.listRuns(companyId)).map((row) => row.id)).toEqual([run.id]); + }); + + it("subtracts prior cumulative usage while excluding the current run", async () => { + const { companyId, agent, state } = await fixture(); + await db.insert(heartbeatRuns).values({ + companyId, agentId: agent.id, invocationSource: "assignment", sessionIdAfter: "cumulative", + createdAt: new Date("2026-01-01T00:00:00Z"), + usageJson: { rawInputTokens: 800, rawCachedInputTokens: 100, rawOutputTokens: 80 }, + }); + const [current] = await db.insert(heartbeatRuns).values({ + companyId, agentId: agent.id, invocationSource: "assignment", sessionIdAfter: "cumulative", + createdAt: new Date("2026-01-02T00:00:00Z"), + usageJson: { rawInputTokens: 1000, rawCachedInputTokens: 125, rawOutputTokens: 100 }, + }).returning(); + const rawUsage = { inputTokens: 1000, cachedInputTokens: 125, outputTokens: 100 }; + const input = { agentId: agent.id, runId: current.id, sessionId: "cumulative", rawUsage }; + expect(await state.resolveNormalizedUsageForSession({ ...input, usageBasis: "session_cumulative" })).toEqual({ + normalizedUsage: { inputTokens: 200, cachedInputTokens: 25, outputTokens: 20 }, + previousRawUsage: { inputTokens: 800, cachedInputTokens: 100, outputTokens: 80 }, + derivedFromSessionTotals: true, + }); + expect(await state.resolveNormalizedUsageForSession({ ...input, usageBasis: "per_run" })).toEqual({ + normalizedUsage: rawUsage, previousRawUsage: null, derivedFromSessionTotals: false, + }); + }); + + it("uses the selected session's usage and summary for compaction", async () => { + const { companyId, agent, issue, state } = await fixture(); + const [run] = await db.insert(heartbeatRuns).values({ + companyId, agentId: agent.id, invocationSource: "assignment", sessionIdAfter: "compact", + usageJson: { rawInputTokens: 90, rawCachedInputTokens: 20, rawInputIncludesCached: false }, + resultJson: { summary: "Continue our task", providerPrivateData: "omit this" }, + }).returning(); + const foreign = await fixture(); + await db.insert(heartbeatRuns).values({ + companyId: foreign.companyId, agentId: foreign.agent.id, invocationSource: "assignment", + sessionIdAfter: "compact", resultJson: { summary: "Foreign task" }, + }); + const decision = await state.evaluateSessionCompaction({ + agent: { ...agent, runtimeConfig: { heartbeat: { sessionCompaction: { maxRawInputTokens: 100 } } } }, + sessionId: "compact", issueId: issue.id, + }); + expect(decision).toMatchObject({ rotate: true, reason: "session raw input reached 110 tokens (threshold 100)", previousRunId: run.id }); + expect(decision.handoffMarkdown).toContain("Continue our task"); + expect(decision.handoffMarkdown).not.toContain("Foreign task"); + expect(decision.handoffMarkdown).not.toContain("omit this"); + }); + + it.each(["superseded", "cancelled"] as const)("fences %s conversation session writes and clears", async (scenario) => { + const { companyId, agent, issue, state } = await fixture(); + await db.update(issues).set({ + conversationAgentId: agent.id, conversationUserId: "test-user", conversationState: "active", + conversationSessionGeneration: 2, + }).where(eq(issues.id, issue.id)); + const [run] = await db.insert(heartbeatRuns).values({ + companyId, agentId: agent.id, invocationSource: "assignment", status: "succeeded", + contextSnapshot: { conversationSessionGeneration: 2 }, + }).returning(); + const input = { + companyId, agentId: agent.id, adapterType: agent.adapterType, taskKey: issue.id, + sessionParamsJson: { sessionId: "retained" }, sessionDisplayId: "retained", lastRunId: run.id, lastError: null, + }; + const saved = await state.upsertTaskSession(input); + expect(saved?.sessionDisplayId).toBe("retained"); + if (scenario === "superseded") { + await db.update(issues).set({ conversationSessionGeneration: 3 }).where(eq(issues.id, issue.id)); + } else { + await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, run.id)); + } + expect(await state.upsertTaskSession({ ...input, sessionDisplayId: "stale" })).toBeNull(); + expect(await state.clearTaskSessions(companyId, agent.id, { taskKey: issue.id, expectedRunId: run.id })).toBe(0); + const [retained] = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.id, saved!.id)); + expect(retained.sessionDisplayId).toBe("retained"); + }); +}); diff --git a/server/src/services/heartbeat/run-state.ts b/server/src/services/heartbeat/run-state.ts new file mode 100644 index 00000000000..75961481cad --- /dev/null +++ b/server/src/services/heartbeat/run-state.ts @@ -0,0 +1,1923 @@ +import { deriveTaskKey } from "./run-preparation.js"; +import { retryIdempotentDatabaseOperation } from "../../database-retry.js"; +import { isConversation } from "../agent-conversations.js"; +import fs from "node:fs/promises"; +import { + and, + asc, + desc, + eq, + getTableColumns, + gt, + or, + sql, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + type BillingType, + type CostStatus, +} from "@paperclipai/shared"; +import { + agents, + agentConfigRevisions, + agentRuntimeState, + agentTaskSessions, + heartbeatRunEvents, + heartbeatRuns, + issues, +} from "@paperclipai/db"; +import { notFound } from "../../errors.js"; +import { logger } from "../../middleware/logger.js"; +import { getServerAdapter } from "../../adapters/index.js"; +import type { + AdapterExecutionResult, + AdapterSessionCodec, + UsageSummary, +} from "../../adapters/index.js"; +import { + parseObject, + asNumber, +} from "../../adapters/utils.js"; +import { + HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS, + HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS, + HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES, + summarizeRunErrorForModel, +} from "../heartbeat-run-summary.js"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type SessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; +import { isUuidLike } from "@paperclipai/shared"; +import { isUnsafeSessionWorkspaceCwd } from "../session-workspace-cwd.js"; + +export const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = + "execution_review_participant_recovery"; + +const heartbeatRunProcessGroupIdColumn = + heartbeatRuns.processGroupId ?? sql`NULL`.as("processGroupId"); + +const heartbeatRunListColumns = { + id: heartbeatRuns.id, + responsibleUserId: heartbeatRuns.responsibleUserId, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + scopeKind: heartbeatRuns.scopeKind, + issueId: heartbeatRuns.issueId, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + status: heartbeatRuns.status, + inputTokens: sql`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"), + cachedInputTokens: sql`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"), + outputTokens: sql`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"), + totalTokens: sql`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"), + costUsd: sql`coalesce( + (${heartbeatRuns.resultJson} ->> 'costUsd')::numeric, + (${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric, + (${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric + )`.as("costUsd"), + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + error: heartbeatRuns.error, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + exitCode: heartbeatRuns.exitCode, + signal: heartbeatRuns.signal, + usageJson: heartbeatRuns.usageJson, + sessionIdBefore: heartbeatRuns.sessionIdBefore, + sessionIdAfter: heartbeatRuns.sessionIdAfter, + logStore: heartbeatRuns.logStore, + logRef: heartbeatRuns.logRef, + logBytes: heartbeatRuns.logBytes, + logSha256: heartbeatRuns.logSha256, + logCompressed: heartbeatRuns.logCompressed, + stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), + stderrExcerpt: sql`NULL`.as("stderrExcerpt"), + errorCode: heartbeatRuns.errorCode, + externalRunId: heartbeatRuns.externalRunId, + processPid: heartbeatRuns.processPid, + processGroupId: heartbeatRunProcessGroupIdColumn, + processStartedAt: heartbeatRuns.processStartedAt, + lastOutputAt: heartbeatRuns.lastOutputAt, + lastOutputSeq: heartbeatRuns.lastOutputSeq, + lastOutputStream: heartbeatRuns.lastOutputStream, + lastOutputBytes: heartbeatRuns.lastOutputBytes, + retryOfRunId: heartbeatRuns.retryOfRunId, + processLossRetryCount: heartbeatRuns.processLossRetryCount, + scheduledRetryAt: heartbeatRuns.scheduledRetryAt, + scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt, + scheduledRetryReason: heartbeatRuns.scheduledRetryReason, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, + createdAt: heartbeatRuns.createdAt, + updatedAt: heartbeatRuns.updatedAt, +} as const; + +const heartbeatRunSummaryListColumns = { + ...heartbeatRunListColumns, + usageJson: sql | null>`NULL`.as("usageJson"), + sessionIdBefore: sql`NULL`.as("sessionIdBefore"), + sessionIdAfter: sql`NULL`.as("sessionIdAfter"), + logStore: sql`NULL`.as("logStore"), + logRef: sql`NULL`.as("logRef"), + logSha256: sql`NULL`.as("logSha256"), + externalRunId: sql`NULL`.as("externalRunId"), + processPid: sql`NULL`.as("processPid"), + processGroupId: sql`NULL`.as("processGroupId"), + resultJson: sql | null>`NULL`.as("resultJson"), +} as const; + +const heartbeatRunListContextColumns = { + contextIssueId: sql`coalesce( + ${heartbeatRuns.issueId}::text, + ${heartbeatRuns.contextSnapshot} ->> 'issueId' + )`.as("contextIssueId"), + contextTaskId: sql`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"), + contextTaskKey: sql`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"), + contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), + contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), + contextWakeReason: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"), + contextWakeSource: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"), + contextWakeTriggerDetail: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"), +} as const; + +const heartbeatRunListResultColumns = { + resultSummary: sql< + string | null + >`left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as( + "resultSummary", + ), + resultResult: sql< + string | null + >`left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as( + "resultResult", + ), + resultMessage: sql< + string | null + >`left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as( + "resultMessage", + ), + resultError: sql< + string | null + >`left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as( + "resultError", + ), + resultTotalCostUsd: sql< + string | null + >`${heartbeatRuns.resultJson} ->> 'total_cost_usd'`.as("resultTotalCostUsd"), + resultCostUsd: sql< + string | null + >`${heartbeatRuns.resultJson} ->> 'cost_usd'`.as("resultCostUsd"), + resultCostUsdCamel: sql< + string | null + >`${heartbeatRuns.resultJson} ->> 'costUsd'`.as("resultCostUsdCamel"), +} as const; + +// Reserve at most 9 KiB for diagnostics in the reduced result. An oversized +// multibyte field uses a conservative four-byte-per-character prefix, with a +// visible pointer to the full (adapter-bounded) run error and transcript. +const diagnosticRetrievalTitleBytes = 1024; + +const diagnosticRetrievalDetailsBytes = 8192; + +function boundedRunDiagnosticText(field: "title" | "details", maxBytes: number) { + const value = sql`${heartbeatRuns.resultJson} #>> ARRAY['terminalSessionFailure', ${field}]`; + return sql`case when octet_length(${value}) <= ${maxBytes} then ${value} + else left(${value}, ${Math.floor((maxBytes - 100) / 4)}) + || E'\\n[truncated for run retrieval; full text in run error/transcript]' end`; +} + +const heartbeatRunSafeResultJsonColumn = sql | null>` + case + when ${heartbeatRuns.resultJson} is null then null + when pg_column_size(${heartbeatRuns.resultJson}) <= ${HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES} + then ${heartbeatRuns.resultJson} + else jsonb_strip_nulls( + jsonb_build_object( + 'summary', left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'result', left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'message', left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'terminalSessionFailure', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'terminalSessionFailure') = 'object' + then jsonb_strip_nulls(jsonb_build_object( + 'category', left(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,category}', 32), + 'title', ${boundedRunDiagnosticText("title", diagnosticRetrievalTitleBytes)}, + 'details', ${boundedRunDiagnosticText("details", diagnosticRetrievalDetailsBytes)}, + 'retrievalTruncated', case when + octet_length(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,title}') > ${diagnosticRetrievalTitleBytes} + or octet_length(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,details}') > ${diagnosticRetrievalDetailsBytes} + then to_jsonb(true) end, + 'truncatedFields', case when ${heartbeatRuns.resultJson} #> '{terminalSessionFailure,truncatedFields}' + in ('["title"]'::jsonb, '["details"]'::jsonb, '["title","details"]'::jsonb) + then ${heartbeatRuns.resultJson} #> '{terminalSessionFailure,truncatedFields}' end + )) end, + 'instructionSave', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'instructionSave') = 'object' + then jsonb_strip_nulls(jsonb_build_object( + 'state', left(${heartbeatRuns.resultJson} #>> '{instructionSave,state}', 32), + 'contract', left(${heartbeatRuns.resultJson} #>> '{instructionSave,contract}', 32), + 'entryFile', left(${heartbeatRuns.resultJson} #>> '{instructionSave,entryFile}', 512), + 'errorCode', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorCode}', 128), + 'errorMessage', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorMessage}', 1024), + 'storageWarning', left(${heartbeatRuns.resultJson} #>> '{instructionSave,storageWarning}', 1024) + )) end, + 'workspaceRestoreRecovery', case when ${heartbeatRuns.resultJson} #>> '{workspaceRestoreRecovery,schema}' = 'paperclip.workspace-restore-recovery.v1' + then jsonb_build_object('schema', 'paperclip.workspace-restore-recovery.v1') end, + 'workspaceRestoreFailure', case when ${heartbeatRuns.resultJson} ->> 'workspaceRestoreFailure' + in ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed') + then ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure' end, + 'cancellation', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'cancellation') = 'object' + then jsonb_strip_nulls(jsonb_build_object( + 'source', case when ${heartbeatRuns.resultJson} #>> '{cancellation,source}' + in ('operator', 'queued_message', 'shutdown', 'provider', 'transport', 'control_plane', 'unknown') + then ${heartbeatRuns.resultJson} #> '{cancellation,source}' end, + 'expected', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{cancellation,expected}') = 'boolean' + then ${heartbeatRuns.resultJson} #> '{cancellation,expected}' end, + 'initiator', jsonb_strip_nulls(jsonb_build_object( + 'type', case when ${heartbeatRuns.resultJson} #>> '{cancellation,initiator,type}' in ('user', 'agent', 'system', 'provider') + then ${heartbeatRuns.resultJson} #> '{cancellation,initiator,type}' end, + 'id', left(${heartbeatRuns.resultJson} #>> '{cancellation,initiator,id}', 128) + )), + 'reason', left(${heartbeatRuns.resultJson} #>> '{cancellation,reason}', 512), + 'recordedAt', left(${heartbeatRuns.resultJson} #>> '{cancellation,recordedAt}', 64) + )) end, + 'acpToolInventoryComplete', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete') = 'boolean' + then ${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete' end, + 'acpPendingToolCount', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'acpPendingToolCount') = 'number' + and length(${heartbeatRuns.resultJson} ->> 'acpPendingToolCount') < 16 + then ${heartbeatRuns.resultJson} -> 'acpPendingToolCount' end, + 'errorFamily', left(${heartbeatRuns.resultJson} ->> 'errorFamily', 32), + 'finalResponseRecorded', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'finalResponseRecorded') = 'boolean' + then ${heartbeatRuns.resultJson} -> 'finalResponseRecorded' end, + 'executionBeforeRestore', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'executionBeforeRestore') = 'object' + then jsonb_strip_nulls(jsonb_build_object( + 'errorCode', left(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,errorCode}', 128), + 'exitCode', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{executionBeforeRestore,exitCode}') = 'number' + and length(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,exitCode}') < 16 + then ${heartbeatRuns.resultJson} #> '{executionBeforeRestore,exitCode}' end, + 'signal', left(${heartbeatRuns.resultJson} #>> '{executionBeforeRestore,signal}', 50), + 'timedOut', case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{executionBeforeRestore,timedOut}') = 'boolean' + then ${heartbeatRuns.resultJson} #> '{executionBeforeRestore,timedOut}' end + )) end, + 'stdoutTruncated', case + when length(${heartbeatRuns.resultJson} ->> 'stdout') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} + then to_jsonb(true) + else null + end, + 'stderrTruncated', case + when length(${heartbeatRuns.resultJson} ->> 'stderr') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} + then to_jsonb(true) + else null + end, + 'costUsd', coalesce( + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'total_cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'total_cost_usd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd' + ), + 'truncated', true, + 'truncationReason', 'oversized_result_json', + 'originalSizeBytes', pg_column_size(${heartbeatRuns.resultJson}) + ) + ) + end +`.as("resultJson"); + +// Execution admission needs retained server receipts even when presentation +// projection omits resultJson (SQL_ASCII or oversized provider output). Select +// only the evidence used by eligibility; never retrieve provider diagnostics. +const heartbeatRunExecutionEvidenceColumn = sql | null>` + case when ${heartbeatRuns.resultJson} is null then null else jsonb_build_object( + 'startupCancellation', ${heartbeatRuns.resultJson} -> 'startupCancellation', + 'startupPreparationSettledAt', ${heartbeatRuns.resultJson} -> 'startupPreparationSettledAt', + 'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason', + 'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource', + 'workspaceRestoreFailure', ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure', + 'workspaceRestoreRecovery', ${heartbeatRuns.resultJson} -> 'workspaceRestoreRecovery', + 'executionCancellation', ${heartbeatRuns.resultJson} -> 'executionCancellation', + 'nativeCancellation', ${heartbeatRuns.resultJson} -> 'nativeCancellation', + 'cancelledByActorType', ${heartbeatRuns.resultJson} -> 'cancelledByActorType', + 'cancelledByUserId', ${heartbeatRuns.resultJson} -> 'cancelledByUserId', + 'conversationContinuation', ${heartbeatRuns.resultJson} -> 'conversationContinuation', + 'cancellation', ${heartbeatRuns.resultJson} -> 'cancellation', + 'acpToolInventoryComplete', ${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete', + 'acpPendingToolCount', ${heartbeatRuns.resultJson} -> 'acpPendingToolCount' + ) end +`.as("resultJson"); + +const heartbeatRunSafeColumns = { + ...getTableColumns(heartbeatRuns), + processGroupId: heartbeatRunProcessGroupIdColumn, + resultJson: heartbeatRunSafeResultJsonColumn, +} as const; + +const heartbeatRunSqlAsciiSafeColumns = { + ...getTableColumns(heartbeatRuns), + processGroupId: heartbeatRunProcessGroupIdColumn, + error: sql`NULL`.as("error"), + resultJson: sql | null>`NULL`.as("resultJson"), + stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), + stderrExcerpt: sql`NULL`.as("stderrExcerpt"), +} as const; + +const heartbeatRunLogAccessColumns = { + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + scopeKind: heartbeatRuns.scopeKind, + issueId: heartbeatRuns.issueId, + logStore: heartbeatRuns.logStore, + logRef: heartbeatRuns.logRef, +} as const; + +const heartbeatRunIssueSummaryColumns = { + id: heartbeatRuns.id, + runtimeMode: heartbeatRuns.runtimeMode, + status: heartbeatRuns.status, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + contextCommentId: sql< + string | null + >`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), + contextWakeCommentId: sql< + string | null + >`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as( + "contextWakeCommentId", + ), + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + agentId: heartbeatRuns.agentId, + logBytes: heartbeatRuns.logBytes, + processStartedAt: heartbeatRuns.processStartedAt, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, + lastOutputAt: heartbeatRuns.lastOutputAt, + lastOutputSeq: heartbeatRuns.lastOutputSeq, + lastOutputStream: heartbeatRuns.lastOutputStream, + lastOutputBytes: heartbeatRuns.lastOutputBytes, + issueId: heartbeatRuns.issueId, +} as const; + +export type UsageTotals = { + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +}; + +type SessionCompactionDecision = { + rotate: boolean; + reason: string | null; + handoffMarkdown: string | null; + previousRunId: string | null; +}; + +export function summarizeHeartbeatRunContextSnapshot( + contextSnapshot: Record | null | undefined, +): Record | null { + const summary: Record = {}; + const allowedKeys = [ + "issueId", + "taskId", + "taskKey", + "commentId", + "wakeCommentId", + "wakeReason", + "wakeSource", + "wakeTriggerDetail", + ] as const; + + for (const key of allowedKeys) { + const value = readNonEmptyString(contextSnapshot?.[key]); + if (value) summary[key] = value; + } + + return Object.keys(summary).length > 0 ? summary : null; +} + +export function summarizeHeartbeatRunListResultJson(input: { + summary?: string | null; + result?: string | null; + message?: string | null; + error?: string | null; + totalCostUsd?: string | null; + costUsd?: string | null; + costUsdCamel?: string | null; +}): Record | null { + const summary: Record = {}; + for (const [key, value] of [ + ["summary", input.summary], + ["result", input.result], + ["message", input.message], + ["error", input.error], + ] as const) { + const normalized = readNonEmptyString(value); + if (normalized) summary[key] = normalized; + } + + for (const [key, value] of [ + ["total_cost_usd", input.totalCostUsd], + ["cost_usd", input.costUsd], + ["costUsd", input.costUsdCamel], + ] as const) { + const normalized = readNonEmptyString(value); + if (!normalized) continue; + const parsed = Number(normalized); + if (Number.isFinite(parsed)) summary[key] = parsed; + } + + return Object.keys(summary).length > 0 ? summary : null; +} + +export function normalizeLedgerBillingType(value: unknown): BillingType { + const raw = readNonEmptyString(value); + switch (raw) { + case "api": + case "metered_api": + return "metered_api"; + case "subscription": + case "subscription_included": + return "subscription_included"; + case "subscription_overage": + return "subscription_overage"; + case "credits": + return "credits"; + case "fixed": + return "fixed"; + default: + return "unknown"; + } +} + +export function resolveLedgerBiller(result: AdapterExecutionResult): string { + return ( + readNonEmptyString(result.biller) ?? + readNonEmptyString(result.provider) ?? + "unknown" + ); +} + +export function normalizeBilledCostCents( + costUsd: number | null | undefined, + billingType: BillingType, +): number { + if (billingType === "subscription_included") return 0; + if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return 0; + return Math.max(0, Number((costUsd * 100).toFixed(7))); +} + +export function resolveLedgerCostStatus(input: { + costUsd: number | null | undefined; + billingType?: BillingType; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +}): CostStatus { + if (input.billingType === "subscription_included") return "reported"; + // A paused turn can have neither a token receipt nor a cost receipt. Zero + // normalized counters do not establish that its billed cost was zero. + return typeof input.costUsd === "number" && + Number.isFinite(input.costUsd) && + input.costUsd >= 0 + ? "reported" + : "unpriced"; +} + +export function resolveCacheAdjustedCostUsd(input: { + costUsd?: number | null; + cacheAdjustedCostUsd?: number | null; +}) { + const explicit = input.cacheAdjustedCostUsd; + if ( + typeof explicit === "number" && + Number.isFinite(explicit) && + explicit >= 0 + ) { + return explicit; + } + const reported = input.costUsd; + if ( + typeof reported === "number" && + Number.isFinite(reported) && + reported >= 0 + ) { + return reported; + } + return null; +} + +export async function resolveLedgerScopeForRun( + db: Db, + companyId: string, + run: typeof heartbeatRuns.$inferSelect, +) { + const context = parseObject(run.contextSnapshot); + const contextIssueId = readNonEmptyString(context.issueId); + const contextProjectId = readNonEmptyString(context.projectId); + + if (!contextIssueId) { + return { + issueId: null, + projectId: contextProjectId, + billingCode: null, + }; + } + + const issue = await db + .select({ + id: issues.id, + projectId: issues.projectId, + billingCode: issues.billingCode, + }) + .from(issues) + .where(and(eq(issues.id, contextIssueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + + return { + issueId: issue?.id ?? null, + projectId: issue?.projectId ?? contextProjectId, + billingCode: issue?.billingCode ?? null, + }; +} + +type ResumeSessionRow = { + sessionParamsJson: Record | null; + sessionDisplayId: string | null; + lastRunId: string | null; +}; + +export function buildExplicitResumeSessionOverride(input: { + adapterType?: string | null; + resumeFromRunId: string; + resumeRunSessionIdBefore: string | null; + resumeRunSessionIdAfter: string | null; + resumeRunSessionParams?: Record | null; + taskSession: ResumeSessionRow | null; + sessionCodec: AdapterSessionCodec; +}) { + const resumeRunSessionIdAfter = truncateDisplayId( + input.resumeRunSessionIdAfter, + ); + const resumeRunSessionIdBefore = truncateDisplayId( + input.resumeRunSessionIdBefore, + ); + const desiredDisplayId = requiresCanonicalSessionIds(input.adapterType) + ? isCanonicalSessionIdForAdapter(input.adapterType, resumeRunSessionIdAfter) + ? resumeRunSessionIdAfter + : isCanonicalSessionIdForAdapter( + input.adapterType, + resumeRunSessionIdBefore, + ) + ? resumeRunSessionIdBefore + : null + : (resumeRunSessionIdAfter ?? resumeRunSessionIdBefore); + const runSessionParams = requiresCanonicalSessionIds(input.adapterType) + ? normalizeResumeParamsForAdapter( + input.adapterType, + input.sessionCodec.deserialize(input.resumeRunSessionParams ?? null), + ) + : null; + const runSessionDisplayId = truncateDisplayId( + readNonEmptyString(runSessionParams?.sessionId), + ); + const taskSessionParams = normalizeResumeParamsForAdapter( + input.adapterType, + input.sessionCodec.deserialize( + input.taskSession?.sessionParamsJson ?? null, + ), + ); + const taskSessionRawDisplayId = input.taskSession?.sessionDisplayId ?? null; + const taskSessionDisplayId = truncateDisplayId( + requiresCanonicalSessionIds(input.adapterType) + ? (readNonEmptyString(taskSessionParams?.sessionId) ?? + (isCanonicalSessionIdForAdapter( + input.adapterType, + taskSessionRawDisplayId, + ) + ? taskSessionRawDisplayId + : null)) + : (taskSessionRawDisplayId ?? + (input.sessionCodec.getDisplayId + ? input.sessionCodec.getDisplayId(taskSessionParams) + : null) ?? + readNonEmptyString(taskSessionParams?.sessionId)), + ); + const canReuseTaskSessionParams = + input.taskSession != null && + (!requiresCanonicalSessionIds(input.adapterType) || + taskSessionParams != null) && + (input.taskSession.lastRunId === input.resumeFromRunId || + (!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId)); + const sessionParams = canReuseTaskSessionParams + ? taskSessionParams + : runSessionParams + ? runSessionParams + : desiredDisplayId + ? { sessionId: desiredDisplayId } + : null; + const sessionDisplayId = canReuseTaskSessionParams + ? taskSessionDisplayId + : runSessionParams + ? runSessionDisplayId + : desiredDisplayId; + + if (!sessionDisplayId && !sessionParams) return null; + return { + sessionDisplayId, + sessionParams, + }; +} + +export function normalizeUsageTotals( + usage: UsageSummary | null | undefined, +): UsageTotals | null { + if (!usage) return null; + return { + inputTokens: Math.max(0, Math.floor(asNumber(usage.inputTokens, 0))), + cachedInputTokens: Math.max( + 0, + Math.floor(asNumber(usage.cachedInputTokens, 0)), + ), + outputTokens: Math.max(0, Math.floor(asNumber(usage.outputTokens, 0))), + }; +} + +function readRawUsageTotals(usageJson: unknown): UsageTotals | null { + const parsed = parseObject(usageJson); + if (Object.keys(parsed).length === 0) return null; + + const inputTokens = Math.max( + 0, + Math.floor( + asNumber(parsed.rawInputTokens, asNumber(parsed.inputTokens, 0)), + ), + ); + const cachedInputTokens = Math.max( + 0, + Math.floor( + asNumber( + parsed.rawCachedInputTokens, + asNumber(parsed.cachedInputTokens, 0), + ), + ), + ); + const outputTokens = Math.max( + 0, + Math.floor( + asNumber(parsed.rawOutputTokens, asNumber(parsed.outputTokens, 0)), + ), + ); + + if (inputTokens <= 0 && cachedInputTokens <= 0 && outputTokens <= 0) { + return null; + } + + return { + inputTokens, + cachedInputTokens, + outputTokens, + }; +} + +export function normalizeAdapterRunUsage( + current: UsageTotals | null, + previous: UsageTotals | null, + usageBasis?: "per_run" | "session_cumulative" | null, +): UsageTotals | null { + if (!current) return null; + if (!previous || usageBasis !== "session_cumulative") return { ...current }; + + const inputTokens = + current.inputTokens >= previous.inputTokens + ? current.inputTokens - previous.inputTokens + : current.inputTokens; + const cachedInputTokens = + current.cachedInputTokens >= previous.cachedInputTokens + ? current.cachedInputTokens - previous.cachedInputTokens + : current.cachedInputTokens; + const outputTokens = + current.outputTokens >= previous.outputTokens + ? current.outputTokens - previous.outputTokens + : current.outputTokens; + + return { + inputTokens: Math.max(0, inputTokens), + cachedInputTokens: Math.max(0, cachedInputTokens), + outputTokens: Math.max(0, outputTokens), + }; +} + +function formatCount(value: number | null | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) return "0"; + return value.toLocaleString("en-US"); +} + +export function parseSessionCompactionPolicy( + agent: typeof agents.$inferSelect, +): SessionCompactionPolicy { + return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig) + .policy; +} + +/** + * Synthetic task key for timer/heartbeat wakes that have no issue context. + * This allows timer wakes to participate in the `agentTaskSessions` system + * and benefit from robust session resume, instead of relying solely on the + * simpler `agentRuntimeState.sessionId` fallback. + */ +const HEARTBEAT_TASK_KEY = "__heartbeat__"; + +/** + * Extended task key derivation that falls back to a stable synthetic key + * for timer/heartbeat wakes. The synthetic key keeps the + * `agentTaskSessions` row addressable across heartbeats so the row can be + * cleared and re-keyed deterministically. Unscoped exploratory timer wakes + * still start fresh to avoid accumulating low-value inbox scans, while timer + * wakes scoped to a real issue reuse that issue's task session. + * + * The synthetic key is only used when: + * - No explicit task/issue key exists in the context + * - The wake source is "timer" (scheduled heartbeat) + */ +export function deriveTaskKeyWithHeartbeatFallback( + contextSnapshot: Record | null | undefined, + payload: Record | null | undefined, +) { + const explicit = deriveTaskKey(contextSnapshot, payload); + if (explicit) return explicit; + + const wakeSource = readNonEmptyString(contextSnapshot?.wakeSource); + if (wakeSource === "timer") return HEARTBEAT_TASK_KEY; + + return null; +} + +export function shouldResetTaskSessionForWake( + contextSnapshot: Record | null | undefined, +) { + if (contextSnapshot?.forceFreshSession === true) return true; + + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); + if ( + wakeReason === "issue_assigned" || + wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON || + wakeReason === "execution_approval_requested" || + // PF-4: unscoped timer wakes are exploratory ("any new work?") and should + // not accumulate low-value inbox scans. Issue-scoped timer wakes are + // continuation work, so reuse their task session to avoid paying the full + // session-start and re-orientation cost on every heartbeat. + (wakeReason === "heartbeat_timer" && !deriveTaskKey(contextSnapshot, null)) + ) { + return true; + } + return false; +} + +export function describeSessionResetReason( + contextSnapshot: Record | null | undefined, +) { + if (contextSnapshot?.forceFreshSession === true) + return "forceFreshSession was requested"; + + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); + if (wakeReason === "issue_assigned") return "wake reason is issue_assigned"; + if (wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON) { + return `wake reason is ${EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON}`; + } + if (wakeReason === "execution_approval_requested") + return "wake reason is execution_approval_requested"; + // PF-4: paired with shouldResetTaskSessionForWake — keep the reason wording + // explicit so run logs make session reuse/reset behavior legible. + if ( + wakeReason === "heartbeat_timer" && + !deriveTaskKey(contextSnapshot, null) + ) { + return "wake reason is heartbeat_timer (unscoped timer wake starts fresh)"; + } + return null; +} + +export function truncateDisplayId(value: string | null | undefined, max = 128) { + if (!value) return null; + return value.length > max ? value.slice(0, max) : value; +} + +const defaultSessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + const asObj = parseObject(raw); + if (Object.keys(asObj).length > 0) return asObj; + const sessionId = readNonEmptyString( + (raw as Record | null)?.sessionId, + ); + if (sessionId) return { sessionId }; + return null; + }, + serialize(params: Record | null) { + if (!params || Object.keys(params).length === 0) return null; + return params; + }, + getDisplayId(params: Record | null) { + return readNonEmptyString(params?.sessionId); + }, +}; + +export function getAdapterSessionCodec(adapterType: string) { + const adapter = getServerAdapter(adapterType); + return adapter.sessionCodec ?? defaultSessionCodec; +} + +export function normalizeSessionParams( + params: Record | null | undefined, +) { + if (!params) return null; + return Object.keys(params).length > 0 ? params : null; +} + +export type RunSessionOutcome = + "succeeded" | "interrupted" | "failed" | "cancelled" | "timed_out"; + +const HERMES_ADAPTER_TYPE = "hermes_local"; + +const HERMES_SESSION_ID_REGEX = + /^(?:\d{8}_\d{6}_[A-Za-z0-9_-]{4,}|[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})$/; + +export function requiresCanonicalSessionIds(adapterType: string | null | undefined) { + return adapterType === HERMES_ADAPTER_TYPE; +} + +export function isCanonicalSessionIdForAdapter( + adapterType: string | null | undefined, + sessionId: string | null | undefined, +) { + if (!sessionId) return false; + if (!requiresCanonicalSessionIds(adapterType)) return true; + return HERMES_SESSION_ID_REGEX.test(sessionId); +} + +export function normalizeResumeParamsForAdapter( + adapterType: string | null | undefined, + params: Record | null | undefined, +) { + const normalized = normalizeSessionParams(params); + if (!normalized) return null; + if (!requiresCanonicalSessionIds(adapterType)) return normalized; + const sessionId = readNonEmptyString(normalized.sessionId); + return isCanonicalSessionIdForAdapter(adapterType, sessionId) + ? normalized + : null; +} + +export function resolveNextSessionState(input: { + adapterType?: string | null; + codec: AdapterSessionCodec; + adapterResult: AdapterExecutionResult; + outcome: RunSessionOutcome; + previousParams: Record | null; + previousDisplayId: string | null; + previousLegacySessionId: string | null; +}) { + const { + adapterType, + codec, + adapterResult, + previousParams, + previousDisplayId, + previousLegacySessionId, + } = input; + + if (adapterResult.clearSession) { + return { + params: null as Record | null, + displayId: null as string | null, + legacySessionId: null as string | null, + }; + } + + if (!requiresCanonicalSessionIds(adapterType)) { + const explicitParams = adapterResult.sessionParams; + const hasExplicitParams = adapterResult.sessionParams !== undefined; + const hasExplicitSessionId = adapterResult.sessionId !== undefined; + const explicitSessionId = readNonEmptyString(adapterResult.sessionId); + const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined; + const explicitDisplayId = readNonEmptyString( + adapterResult.sessionDisplayId, + ); + const shouldUsePrevious = + !hasExplicitParams && !hasExplicitSessionId && !hasExplicitDisplay; + + const candidateParams = hasExplicitParams + ? explicitParams + : hasExplicitSessionId + ? explicitSessionId + ? { sessionId: explicitSessionId } + : null + : previousParams; + + const serialized = normalizeSessionParams( + codec.serialize(normalizeSessionParams(candidateParams) ?? null), + ); + const deserialized = normalizeSessionParams(codec.deserialize(serialized)); + + const displayId = truncateDisplayId( + explicitDisplayId ?? + (codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ?? + readNonEmptyString(deserialized?.sessionId) ?? + (shouldUsePrevious ? previousDisplayId : null) ?? + explicitSessionId ?? + (shouldUsePrevious ? previousLegacySessionId : null), + ); + + const legacySessionId = + explicitSessionId ?? + readNonEmptyString(deserialized?.sessionId) ?? + displayId ?? + (shouldUsePrevious ? previousLegacySessionId : null); + + return { + params: serialized, + displayId, + legacySessionId, + }; + } + + const previousSerializedParams = normalizeResumeParamsForAdapter( + adapterType, + codec.serialize( + normalizeResumeParamsForAdapter(adapterType, previousParams), + ), + ); + const validPreviousDisplayId = isCanonicalSessionIdForAdapter( + adapterType, + previousDisplayId, + ) + ? previousDisplayId + : null; + const validPreviousLegacySessionId = isCanonicalSessionIdForAdapter( + adapterType, + previousLegacySessionId, + ) + ? previousLegacySessionId + : null; + const previousState = () => { + const displayId = truncateDisplayId( + readNonEmptyString(previousSerializedParams?.sessionId) ?? + validPreviousDisplayId ?? + validPreviousLegacySessionId, + ); + return { + params: previousSerializedParams, + displayId, + legacySessionId: + readNonEmptyString(previousSerializedParams?.sessionId) ?? + displayId ?? + validPreviousLegacySessionId, + }; + }; + + if (input.outcome !== "succeeded") { + return previousState(); + } + + const explicitParams = adapterResult.sessionParams; + const hasExplicitParams = adapterResult.sessionParams !== undefined; + const explicitSessionId = readNonEmptyString(adapterResult.sessionId); + const validExplicitSessionId = isCanonicalSessionIdForAdapter( + adapterType, + explicitSessionId, + ) + ? explicitSessionId + : null; + const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId); + const validExplicitDisplayId = isCanonicalSessionIdForAdapter( + adapterType, + explicitDisplayId, + ) + ? explicitDisplayId + : null; + const explicitSerializedParams = hasExplicitParams + ? normalizeResumeParamsForAdapter( + adapterType, + codec.serialize(normalizeSessionParams(explicitParams) ?? null), + ) + : null; + const explicitCanonicalSessionId = + readNonEmptyString(explicitSerializedParams?.sessionId) ?? + validExplicitSessionId ?? + validExplicitDisplayId; + + if (!explicitCanonicalSessionId) { + return previousState(); + } + + const serialized = normalizeResumeParamsForAdapter( + adapterType, + codec.serialize({ sessionId: explicitCanonicalSessionId }), + ); + const displayId = truncateDisplayId( + readNonEmptyString(serialized?.sessionId) ?? + (codec.getDisplayId ? codec.getDisplayId(serialized) : null) ?? + explicitCanonicalSessionId, + ); + const legacySessionId = + readNonEmptyString(serialized?.sessionId) ?? explicitCanonicalSessionId; + + return { + params: serialized, + displayId, + legacySessionId, + }; +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** Run retrieval and session persistence bound to one service database. */ +export function createHeartbeatRunState(db: Db) { + let unsafeTextProjectionPromise: Promise | null = null; + + async function hasUnsafeTextProjectionDatabase() { + if (!unsafeTextProjectionPromise) { + unsafeTextProjectionPromise = db + .execute( + sql`select current_setting('server_encoding') as server_encoding`, + ) + .then((rows) => { + const first = Array.isArray(rows) ? rows[0] : null; + const serverEncoding = + typeof first === "object" && first !== null + ? (first as Record).server_encoding + : null; + return ( + typeof serverEncoding === "string" && + serverEncoding.toUpperCase() === "SQL_ASCII" + ); + }) + .catch((err) => { + logger.warn( + { err }, + "failed to inspect database server encoding; using conservative heartbeat result projection", + ); + return true; + }); + } + return unsafeTextProjectionPromise; + } + + async function getAgent(agentId: string) { + return db + .select() + .from(agents) + .where(eq(agents.id, agentId)) + .then((rows) => rows[0] ?? null); + } + + async function getRun( + runId: string, + opts?: { unsafeFullResultJson?: boolean; includeExecutionEvidence?: boolean }, + ) { + const safeForLegacyEncoding = + !opts?.unsafeFullResultJson && (await hasUnsafeTextProjectionDatabase()); + const columns = opts?.unsafeFullResultJson + ? getTableColumns(heartbeatRuns) + : safeForLegacyEncoding ? heartbeatRunSqlAsciiSafeColumns : heartbeatRunSafeColumns; + return db + .select(opts?.includeExecutionEvidence + ? { ...columns, resultJson: heartbeatRunExecutionEvidenceColumn } + : columns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + } + + async function getRunLogAccess(runId: string) { + return db + .select(heartbeatRunLogAccessColumns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + } + + async function getRuntimeState(agentId: string) { + return db + .select() + .from(agentRuntimeState) + .where(eq(agentRuntimeState.agentId, agentId)) + .then((rows) => rows[0] ?? null); + } + + async function getLatestAgentConfigRevision( + companyId: string, + agentId: string, + ) { + return db + .select({ + id: agentConfigRevisions.id, + changedKeys: agentConfigRevisions.changedKeys, + createdAt: agentConfigRevisions.createdAt, + }) + .from(agentConfigRevisions) + .where( + and( + eq(agentConfigRevisions.companyId, companyId), + eq(agentConfigRevisions.agentId, agentId), + ), + ) + .orderBy( + desc(agentConfigRevisions.createdAt), + desc(agentConfigRevisions.id), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + async function getTaskSession( + companyId: string, + agentId: string, + adapterType: string, + taskKey: string, + ) { + return db + .select() + .from(agentTaskSessions) + .where( + and( + eq(agentTaskSessions.companyId, companyId), + eq(agentTaskSessions.agentId, agentId), + eq(agentTaskSessions.adapterType, adapterType), + eq(agentTaskSessions.taskKey, taskKey), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function getLatestRunForSession( + agentId: string, + sessionId: string, + opts?: { excludeRunId?: string | null }, + ) { + const conditions = [ + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.sessionIdAfter, sessionId), + ]; + if (opts?.excludeRunId) { + conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`); + } + return db + .select({ + id: heartbeatRuns.id, + usageJson: heartbeatRuns.usageJson, + }) + .from(heartbeatRuns) + .where(and(...conditions)) + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + async function getOldestRunForSession(agentId: string, sessionId: string) { + return db + .select({ + id: heartbeatRuns.id, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.sessionIdAfter, sessionId), + ), + ) + .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + async function resolveNormalizedUsageForSession(input: { + agentId: string; + runId: string; + sessionId: string | null; + rawUsage: UsageTotals | null; + usageBasis?: "per_run" | "session_cumulative" | null; + }) { + const { agentId, runId, sessionId, rawUsage, usageBasis } = input; + // Adapters that declare per-run usage (e.g. the ACPX lane reports each + // turn's tokens, not session totals) must not be session-delta'd, or + // consecutive runs would be undercounted. + if (!sessionId || !rawUsage || usageBasis !== "session_cumulative") { + return { + normalizedUsage: rawUsage, + previousRawUsage: null as UsageTotals | null, + derivedFromSessionTotals: false, + }; + } + + const previousRun = await getLatestRunForSession(agentId, sessionId, { + excludeRunId: runId, + }); + const previousRawUsage = readRawUsageTotals(previousRun?.usageJson); + return { + normalizedUsage: normalizeAdapterRunUsage(rawUsage, previousRawUsage, usageBasis), + previousRawUsage, + derivedFromSessionTotals: previousRawUsage !== null, + }; + } + + async function evaluateSessionCompaction(input: { + agent: typeof agents.$inferSelect; + sessionId: string | null; + issueId: string | null; + continuationSummaryBody?: string | null; + }): Promise { + const { agent, sessionId, issueId } = input; + if (!sessionId) { + return { + rotate: false, + reason: null, + handoffMarkdown: null, + previousRunId: null, + }; + } + + const policy = parseSessionCompactionPolicy(agent); + if (!policy.enabled || !hasSessionCompactionThresholds(policy)) { + return { + rotate: false, + reason: null, + handoffMarkdown: null, + previousRunId: null, + }; + } + + const fetchLimit = Math.max( + policy.maxSessionRuns > 0 ? policy.maxSessionRuns + 1 : 0, + 4, + ); + const runs = await db + .select({ + id: heartbeatRuns.id, + createdAt: heartbeatRuns.createdAt, + usageJson: heartbeatRuns.usageJson, + error: heartbeatRuns.error, + terminalFailureCategory: sql`case + when jsonb_typeof(${heartbeatRuns.resultJson} -> 'terminalSessionFailure') = 'object' + then coalesce(left(${heartbeatRuns.resultJson} #>> '{terminalSessionFailure,category}', 32), 'unknown') end`, + ...heartbeatRunListResultColumns, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agent.id), + eq(heartbeatRuns.sessionIdAfter, sessionId), + ), + ) + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(fetchLimit); + + if (runs.length === 0) { + return { + rotate: false, + reason: null, + handoffMarkdown: null, + previousRunId: null, + }; + } + + const latestRun = runs[0] ?? null; + const oldestRun = + policy.maxSessionAgeHours > 0 + ? await getOldestRunForSession(agent.id, sessionId) + : (runs[runs.length - 1] ?? latestRun); + const latestRawUsage = readRawUsageTotals(latestRun?.usageJson); + // Historical Codex/Gemini raw input includes cache reads. Only add the + // separate cache counter when the writer explicitly saved exclusive input. + const latestRawInputTokens = (latestRawUsage?.inputTokens ?? 0) + + (latestRun?.usageJson?.rawInputIncludesCached === false ? latestRawUsage?.cachedInputTokens ?? 0 : 0); + const sessionAgeHours = + latestRun && oldestRun + ? Math.max( + 0, + (new Date(latestRun.createdAt).getTime() - + new Date(oldestRun.createdAt).getTime()) / + (1000 * 60 * 60), + ) + : 0; + + let reason: string | null = null; + if (policy.maxSessionRuns > 0 && runs.length > policy.maxSessionRuns) { + reason = `session exceeded ${policy.maxSessionRuns} runs`; + } else if ( + policy.maxRawInputTokens > 0 && + latestRawUsage && + latestRawInputTokens >= policy.maxRawInputTokens + ) { + reason = + `session raw input reached ${formatCount(latestRawInputTokens)} tokens ` + + `(threshold ${formatCount(policy.maxRawInputTokens)})`; + } else if ( + policy.maxSessionAgeHours > 0 && + sessionAgeHours >= policy.maxSessionAgeHours + ) { + reason = `session age reached ${Math.floor(sessionAgeHours)} hours`; + } + + if (!reason || !latestRun) { + return { + rotate: false, + reason: null, + handoffMarkdown: null, + previousRunId: latestRun?.id ?? null, + }; + } + + const latestSummary = summarizeHeartbeatRunListResultJson({ + summary: latestRun?.resultSummary, + result: latestRun?.resultResult, + message: latestRun?.resultMessage, + error: latestRun?.resultError, + totalCostUsd: latestRun?.resultTotalCostUsd, + costUsd: latestRun?.resultCostUsd, + costUsdCamel: latestRun?.resultCostUsdCamel, + }); + const latestTextSummary = + readNonEmptyString(latestSummary?.summary) ?? + readNonEmptyString(latestSummary?.result) ?? + readNonEmptyString(latestSummary?.message) ?? + readNonEmptyString(summarizeRunErrorForModel(latestRun.error, latestRun.terminalFailureCategory)); + + const handoffMarkdown = [ + "Paperclip session handoff:", + `- Previous session: ${sessionId}`, + issueId ? `- Issue: ${issueId}` : "", + `- Rotation reason: ${reason}`, + latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "", + input.continuationSummaryBody + ? `- Issue continuation summary: ${input.continuationSummaryBody.slice(0, 1_500)}` + : "", + "Continue from the current task state. Rebuild only the minimum context you need.", + ] + .filter(Boolean) + .join("\n"); + + return { + rotate: true, + reason, + handoffMarkdown, + previousRunId: latestRun.id, + }; + } + + async function resolveSessionBeforeForWakeup( + agent: typeof agents.$inferSelect, + taskKey: string | null, + ) { + if (taskKey) { + const codec = getAdapterSessionCodec(agent.adapterType); + const existingTaskSession = await getTaskSession( + agent.companyId, + agent.id, + agent.adapterType, + taskKey, + ); + const parsedParams = normalizeSessionParams( + codec.deserialize(existingTaskSession?.sessionParamsJson ?? null), + ); + return truncateDisplayId( + existingTaskSession?.sessionDisplayId ?? + (codec.getDisplayId ? codec.getDisplayId(parsedParams) : null) ?? + readNonEmptyString(parsedParams?.sessionId), + ); + } + + const runtimeForRun = await getRuntimeState(agent.id); + return runtimeForRun?.sessionId ?? null; + } + + async function hasResolvableSessionWorkspaceCwd( + sessionParams: Record | null | undefined, + ) { + const cwd = readNonEmptyString(sessionParams?.cwd); + if (!cwd || isUnsafeSessionWorkspaceCwd(cwd)) return false; + return fs + .stat(cwd) + .then((stats) => stats.isDirectory()) + .catch(() => false); + } + + async function hasResolvablePriorSessionWorkspaceForWake(input: { + agent: typeof agents.$inferSelect; + contextSnapshot: Record; + taskKey: string | null; + explicitResumeSession: Awaited< + ReturnType + > | null; + }) { + if ( + await hasResolvableSessionWorkspaceCwd( + input.explicitResumeSession?.sessionParams, + ) + ) + return true; + if (shouldResetTaskSessionForWake(input.contextSnapshot)) return false; + if (!input.taskKey) return false; + + const codec = getAdapterSessionCodec(input.agent.adapterType); + const taskSession = await getTaskSession( + input.agent.companyId, + input.agent.id, + input.agent.adapterType, + input.taskKey, + ); + const taskSessionParams = normalizeResumeParamsForAdapter( + input.agent.adapterType, + codec.deserialize(taskSession?.sessionParamsJson ?? null), + ); + return hasResolvableSessionWorkspaceCwd(taskSessionParams); + } + + async function resolveExplicitResumeSessionOverride( + agent: typeof agents.$inferSelect, + payload: Record | null, + taskKey: string | null, + ) { + const resumeFromRunId = readNonEmptyString(payload?.resumeFromRunId); + if (!resumeFromRunId) return null; + + const resumeRun = await db + .select({ + id: heartbeatRuns.id, + contextSnapshot: heartbeatRuns.contextSnapshot, + resultJson: heartbeatRuns.resultJson, + sessionIdBefore: heartbeatRuns.sessionIdBefore, + sessionIdAfter: heartbeatRuns.sessionIdAfter, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, resumeFromRunId), + eq(heartbeatRuns.companyId, agent.companyId), + eq(heartbeatRuns.agentId, agent.id), + ), + ) + .then((rows) => rows[0] ?? null); + if (!resumeRun) return null; + + const resumeContext = parseObject(resumeRun.contextSnapshot); + const resumeTaskKey = deriveTaskKey(resumeContext, null) ?? taskKey; + const resumeTaskSession = resumeTaskKey + ? await getTaskSession( + agent.companyId, + agent.id, + agent.adapterType, + resumeTaskKey, + ) + : null; + const sessionCodec = getAdapterSessionCodec(agent.adapterType); + const resumeRunResult = parseObject(resumeRun.resultJson); + const resumeRunSessionId = requiresCanonicalSessionIds(agent.adapterType) + ? (readNonEmptyString(resumeRunResult.sessionId) ?? + readNonEmptyString(resumeRunResult.session_id)) + : null; + const sessionOverride = buildExplicitResumeSessionOverride({ + adapterType: agent.adapterType, + resumeFromRunId, + resumeRunSessionIdBefore: resumeRun.sessionIdBefore, + resumeRunSessionIdAfter: resumeRun.sessionIdAfter, + resumeRunSessionParams: resumeRunSessionId + ? { sessionId: resumeRunSessionId } + : null, + taskSession: resumeTaskSession, + sessionCodec, + }); + if (!sessionOverride) return null; + + return { + resumeFromRunId, + taskKey: resumeTaskKey, + issueId: readNonEmptyString(resumeContext.issueId), + taskId: + readNonEmptyString(resumeContext.taskId) ?? + readNonEmptyString(resumeContext.issueId), + sessionDisplayId: sessionOverride.sessionDisplayId, + sessionParams: sessionOverride.sessionParams, + }; + } + + async function upsertTaskSession(input: { + companyId: string; + agentId: string; + adapterType: string; + taskKey: string; + sessionParamsJson: Record | null; + sessionDisplayId: string | null; + lastRunId: string | null; + lastError: string | null; + }) { + return db.transaction(async (tx) => { + const [issue] = await tx.select().from(issues).where(and(sql`${issues.id}::text = ${input.taskKey}`, eq(issues.companyId, input.companyId))).for("update"); + if (isConversation(issue)) { + const [run] = input.lastRunId ? await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.lastRunId)) : []; + if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return null; + } + const existing = await tx.select().from(agentTaskSessions).where(and(eq(agentTaskSessions.companyId, input.companyId), eq(agentTaskSessions.agentId, input.agentId), eq(agentTaskSessions.adapterType, input.adapterType), eq(agentTaskSessions.taskKey, input.taskKey))).then((rows) => rows[0] ?? null); + if (existing) { + return tx + .update(agentTaskSessions) + .set({ + sessionParamsJson: input.sessionParamsJson, + sessionDisplayId: input.sessionDisplayId, + lastRunId: input.lastRunId, + lastError: input.lastError, + updatedAt: new Date(), + }) + .where(eq(agentTaskSessions.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? null); + } + + return tx + .insert(agentTaskSessions) + .values({ + companyId: input.companyId, + agentId: input.agentId, + adapterType: input.adapterType, + taskKey: input.taskKey, + sessionParamsJson: input.sessionParamsJson, + sessionDisplayId: input.sessionDisplayId, + lastRunId: input.lastRunId, + lastError: input.lastError, + }) + .returning() + .then((rows) => rows[0] ?? null); + }); + } + + async function clearTaskSessions( + companyId: string, + agentId: string, + opts?: { + taskKey?: string | null; + adapterType?: string | null; + expectedRunId?: string; + includeIssueAliases?: boolean; + }, + ) { + const conditions = [ + eq(agentTaskSessions.companyId, companyId), + eq(agentTaskSessions.agentId, agentId), + ]; + if (opts?.taskKey) { + const exactTaskKey = eq(agentTaskSessions.taskKey, opts.taskKey); + if (opts.includeIssueAliases) { + const selectedIssue = isUuidLike(opts.taskKey) + ? eq(issues.id, opts.taskKey) + : eq(issues.identifier, opts.taskKey.toUpperCase()); + // Operator task resets accept the UUID sent by run detail and the + // identifier used by some saved sessions. Resolve only from the current + // same-company issue row, in this DELETE's snapshot; arbitrary custom + // keys retain exact-match behavior and run/model context grants no alias. + conditions.push( + or( + exactTaskKey, + sql`exists ( + select 1 from ${issues} + where ${issues.companyId} = ${companyId} + and ${selectedIssue} + and (${agentTaskSessions.taskKey} = ${issues.id}::text + or ${agentTaskSessions.taskKey} = ${issues.identifier}) + )`, + )!, + ); + } else { + conditions.push(exactTaskKey); + } + } + if (opts?.adapterType) { + conditions.push(eq(agentTaskSessions.adapterType, opts.adapterType)); + } + + return db.transaction(async (tx) => { + if (opts?.taskKey && opts.expectedRunId) { + const [issue] = await tx.select().from(issues).where(sql`${issues.id}::text = ${opts.taskKey}`).for("update"); + if (isConversation(issue)) { + const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, opts.expectedRunId)); + if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return 0; + } + } + return tx.delete(agentTaskSessions).where(and(...conditions)).returning().then((rows) => rows.length); + }); + } + + async function ensureRuntimeState(agent: typeof agents.$inferSelect) { + const existing = await getRuntimeState(agent.id); + if (existing) return existing; + + const inserted = await db + .insert(agentRuntimeState) + .values({ + agentId: agent.id, + companyId: agent.companyId, + adapterType: agent.adapterType, + stateJson: {}, + }) + .onConflictDoNothing({ + target: agentRuntimeState.agentId, + }) + .returning() + .then((rows) => rows[0] ?? null); + if (inserted) return inserted; + + const ensured = await getRuntimeState(agent.id); + if (!ensured) { + throw new Error(`Failed to ensure runtime state for agent ${agent.id}`); + } + return ensured; + } + + return { + getAgent, + resolveSessionBeforeForWakeup, + getRun, + ensureRuntimeState, + getTaskSession, + getLatestAgentConfigRevision, + evaluateSessionCompaction, + upsertTaskSession, + resolveNormalizedUsageForSession, + clearTaskSessions, + resolveExplicitResumeSessionOverride, + hasResolvablePriorSessionWorkspaceForWake, + getRunLogAccess, + + listRuns: async ( + companyId: string, + agentId?: string, + limit?: number, + options: { summary?: boolean } = {}, + ) => { + const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase(); + const summary = options.summary === true; + const rows = await retryIdempotentDatabaseOperation(async () => { + const query = db + .select( + summary + ? { + ...heartbeatRunSummaryListColumns, + ...heartbeatRunListContextColumns, + } + : safeForLegacyEncoding + ? { + ...heartbeatRunListColumns, + error: sql`NULL`.as("error"), + ...heartbeatRunListContextColumns, + } + : { + ...heartbeatRunListColumns, + ...heartbeatRunListContextColumns, + ...heartbeatRunListResultColumns, + }, + ) + .from(heartbeatRuns) + .where( + agentId + ? and( + eq(heartbeatRuns.companyId, companyId), + eq(heartbeatRuns.agentId, agentId), + ) + : eq(heartbeatRuns.companyId, companyId), + ) + .orderBy(desc(heartbeatRuns.createdAt)); + + return limit ? await query.limit(limit) : await query; + }); + return rows.map((row) => { + const { + contextIssueId, + contextTaskId, + contextTaskKey, + contextCommentId, + contextWakeCommentId, + contextWakeReason, + contextWakeSource, + contextWakeTriggerDetail, + resultSummary, + resultResult, + resultMessage, + resultError, + resultTotalCostUsd, + resultCostUsd, + resultCostUsdCamel, + ...rest + } = row as typeof row & { + resultSummary?: string | null; + resultResult?: string | null; + resultMessage?: string | null; + resultError?: string | null; + resultTotalCostUsd?: string | null; + resultCostUsd?: string | null; + resultCostUsdCamel?: string | null; + }; + + return { + ...rest, + contextSnapshot: summarizeHeartbeatRunContextSnapshot({ + issueId: contextIssueId, + taskId: contextTaskId, + taskKey: contextTaskKey, + commentId: contextCommentId, + wakeCommentId: contextWakeCommentId, + wakeReason: contextWakeReason, + wakeSource: contextWakeSource, + wakeTriggerDetail: contextWakeTriggerDetail, + }), + resultJson: + safeForLegacyEncoding || summary + ? null + : summarizeHeartbeatRunListResultJson({ + summary: resultSummary, + result: resultResult, + message: resultMessage, + error: resultError, + totalCostUsd: resultTotalCostUsd, + costUsd: resultCostUsd, + costUsdCamel: resultCostUsdCamel, + }), + }; + }); + }, + + getRuntimeStateWithSessions: async (agentId: string) => { + const state = await getRuntimeState(agentId); + const agent = await getAgent(agentId); + if (!agent) return null; + const ensured = state ?? (await ensureRuntimeState(agent)); + const latestTaskSession = await db + .select() + .from(agentTaskSessions) + .where( + and( + eq(agentTaskSessions.companyId, agent.companyId), + eq(agentTaskSessions.agentId, agent.id), + ), + ) + .orderBy(desc(agentTaskSessions.updatedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + return { + ...ensured, + sessionDisplayId: + latestTaskSession?.sessionDisplayId ?? ensured.sessionId, + sessionParamsJson: latestTaskSession?.sessionParamsJson ?? null, + }; + }, + + listTaskSessions: async (agentId: string) => { + const agent = await getAgent(agentId); + if (!agent) throw notFound("Agent not found"); + + return db + .select() + .from(agentTaskSessions) + .where( + and( + eq(agentTaskSessions.companyId, agent.companyId), + eq(agentTaskSessions.agentId, agentId), + ), + ) + .orderBy( + desc(agentTaskSessions.updatedAt), + desc(agentTaskSessions.createdAt), + ); + }, + + resetRuntimeSession: async ( + agentId: string, + opts?: { taskKey?: string | null }, + ) => { + const agent = await getAgent(agentId); + if (!agent) throw notFound("Agent not found"); + await ensureRuntimeState(agent); + const taskKey = readNonEmptyString(opts?.taskKey); + const clearedTaskSessions = await clearTaskSessions( + agent.companyId, + agent.id, + taskKey + ? { + taskKey, + adapterType: agent.adapterType, + includeIssueAliases: true, + } + : undefined, + ); + const runtimePatch: Partial = { + sessionId: null, + lastError: null, + updatedAt: new Date(), + }; + if (!taskKey) { + runtimePatch.stateJson = {}; + } + + const updated = await db + .update(agentRuntimeState) + .set(runtimePatch) + .where(eq(agentRuntimeState.agentId, agentId)) + .returning() + .then((rows) => rows[0] ?? null); + + if (!updated) return null; + return { + ...updated, + sessionDisplayId: null, + sessionParamsJson: null, + clearedTaskSessions, + }; + }, + + listEvents: (runId: string, afterSeq = 0, limit = 200) => + db + .select() + .from(heartbeatRunEvents) + .where( + and( + eq(heartbeatRunEvents.runId, runId), + gt(heartbeatRunEvents.seq, afterSeq), + ), + ) + .orderBy(asc(heartbeatRunEvents.seq)) + .limit(Math.max(1, Math.min(limit, 1000))), + + getRetryExhaustedReason: async (runId: string) => { + const row = await db + .select({ + message: heartbeatRunEvents.message, + }) + .from(heartbeatRunEvents) + .where( + and( + eq(heartbeatRunEvents.runId, runId), + eq(heartbeatRunEvents.eventType, "lifecycle"), + sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`, + ), + ) + .orderBy(desc(heartbeatRunEvents.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + return row?.message ?? null; + }, + + getRunIssueSummary: async (runId: string) => { + const [run] = await db + .select(heartbeatRunIssueSummaryColumns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .limit(1); + return run ?? null; + }, + + getActiveRunForAgent: async (agentId: string) => { + const [run] = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.status, "running"), + ), + ) + .orderBy(desc(heartbeatRuns.startedAt)) + .limit(1); + return run ?? null; + }, + + getActiveRunIssueSummaryForAgent: async (agentId: string) => { + const [run] = await db + .select(heartbeatRunIssueSummaryColumns) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.status, "running"), + ), + ) + .orderBy(desc(heartbeatRuns.startedAt)) + .limit(1); + return run ?? null; + }, + }; +} diff --git a/server/src/services/heartbeat/workspaces.ts b/server/src/services/heartbeat/workspaces.ts index 5893ca1e7c6..69b879f3c28 100644 --- a/server/src/services/heartbeat/workspaces.ts +++ b/server/src/services/heartbeat/workspaces.ts @@ -1,3 +1,5 @@ +import { readConnectionFailure, type GitConnectionFailure } from "@paperclipai/adapter-utils/connection-failure"; +import { classifyGitCloneFailure, GitConnectionFailureError, readGitConnectionFailure } from "../git-connection-failure.js"; import { managedAiSessionFingerprintConfig } from "../ai-connection-runtime.js"; import { PROJECT_REPOSITORIES_DIR, @@ -549,22 +551,30 @@ async function materializeManagedProjectWorkspace( : null; const cloneTmpDir = await fs.mkdtemp(`${cwd}.clone-`); try { - await execFile( - "git", - [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", "--", input.localSource ?? input.repoUrl, cloneTmpDir], - { - env: { - // Spread order matters: the sanitizer strips PAPERCLIP_*, which would remove the - // credential-helper token env if it came first. GIT_TERMINAL_PROMPT=0 fails a - // credential-less private clone immediately instead of hanging on a prompt until - // the clone timeout. - ...sanitizeRuntimeServiceBaseEnv(process.env), - GIT_TERMINAL_PROMPT: "0", - ...(auth?.env ?? {}), + try { + await execFile( + "git", + [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", "--", input.localSource ?? input.repoUrl, cloneTmpDir], + { + env: { + // Spread order matters: the sanitizer strips PAPERCLIP_*, which would remove the + // credential-helper token env if it came first. GIT_TERMINAL_PROMPT=0 fails a + // credential-less private clone immediately instead of hanging on a prompt until + // the clone timeout. + ...sanitizeRuntimeServiceBaseEnv(process.env), + GIT_TERMINAL_PROMPT: "0", + ...(auth?.env ?? {}), + }, + timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, }, - timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, - }, - ); + ); + } catch (error) { + const connectionFailure = !input.localSource ? classifyGitCloneFailure(input.repoUrl, error) : null; + if (connectionFailure) throw new GitConnectionFailureError( + error instanceof Error ? error.message : "Git clone failed", connectionFailure, + ); + throw error; + } if (input.localSource) { const snapshot = await readGitWorkspaceSnapshot(input.localSource, false); if (!snapshot) throw new Error("Configured repository folder is not a Git checkout"); @@ -595,6 +605,8 @@ async function materializeManagedProjectWorkspace( // Preserve the closed failure code without copying subprocess output or // credentials into the durable run. Setup recovery needs the actual cause. if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message); + const connectionFailure = readGitConnectionFailure(error); + if (connectionFailure) throw new GitConnectionFailureError(message, connectionFailure); throw new Error(message); } @@ -1060,8 +1072,10 @@ export async function assertGitWorktreeBaseWorkspaceReady(input: { reason: string, message: string, extra: Record = {}, + connectionFailure?: GitConnectionFailure, ) => { throw new WorkspaceValidationFailure(message, { + ...(connectionFailure ? { connectionFailure } : {}), workspaceValidation: { reason, issueId: input.issue!.id, @@ -1098,10 +1112,15 @@ export async function assertGitWorktreeBaseWorkspaceReady(input: { const materializationFailures = input.anchor?.materializationFailures ?? []; if (input.anchor?.baseCwdFallback && materializationFailures.length > 0) { const failureDetail = `: ${materializationFailures[0].error.replace(/\s+/g, " ")}`; + const connections = materializationFailures.map((failure) => readConnectionFailure(failure.connectionFailure)); + // Mixed connection/local failures must retain their application-error report. + const connectionFailure = connections.every((failure) => failure?.provider === "git") + ? connections[0] as GitConnectionFailure : undefined; fail( "git_worktree_base_materialization_failed", `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the project workspace checkout could not be prepared${failureDetail}. Repair the project workspace repository URL, clone access, or configured local cwd, then retry.`, { baseCwdFallback: true, materializationFailures }, + connectionFailure, ); } @@ -1410,6 +1429,7 @@ export type WorkspaceMaterializationFailure = { projectWorkspaceId: string | null; repoUrl: string | null; error: string; + connectionFailure?: GitConnectionFailure; }; export type ResolvedWorkspaceForRun = { @@ -3526,12 +3546,14 @@ export function createHeartbeatWorkspaceResolver(db: Db) { error instanceof Error ? error.message : String(error), ); const workspaceRepoUrl = readNonEmptyString(workspace.repoUrl); + const connectionFailure = readGitConnectionFailure(error); materializationFailures.push({ projectWorkspaceId: workspace.id, repoUrl: workspaceRepoUrl ? scrubGitCredentialText(workspaceRepoUrl) : null, error: scrubbedError, + ...(connectionFailure ? { connectionFailure } : {}), }); if (preferredWorkspace?.id === workspace.id) { preferredWorkspaceWarning = scrubbedError; diff --git a/server/src/services/hot-restart.test.ts b/server/src/services/hot-restart.test.ts index 7b53fc84055..41c6a375fb2 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 30bf71e40f0..1d8e7adfb0f 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/idle-database-backup.ts b/server/src/services/idle-database-backup.ts new file mode 100644 index 00000000000..271750d118c --- /dev/null +++ b/server/src/services/idle-database-backup.ts @@ -0,0 +1,52 @@ +import { constants, createReadStream } from "node:fs"; +import { mkdir, open, unlink } from "node:fs/promises"; +import path from "node:path"; +import { Writable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createGunzip } from "node:zlib"; + +// The marker survives process replacement on persistent instance storage. +// It contains no credentials or tenant data, and never substitutes for a dump. +export const idleBackupWakeMarker = (directory: string) => path.join(directory, ".idle-backup-wake-required"); + +async function syncDirectory(directory: string): Promise { + const handle = await open(directory, "r"); + try { await handle.sync(); } finally { await handle.close(); } +} + +export async function markIdleBackupWakeRequired(directory: string): Promise { + await mkdir(directory, { recursive: true }); + const marker = await open(idleBackupWakeMarker(directory), constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600); + try { + await marker.writeFile("Take a fresh database backup after this process restarts.\n"); + await marker.sync(); + } finally { await marker.close(); } + await syncDirectory(directory); +} + +/** Read the entire archive to verify the gzip trailer, then persist its bytes + * and directory entry before allowing compute to stop. The backup runner + * retains its in-flight receipt until this function settles. */ +export async function verifyIdleDatabaseBackup(backupFile: string): Promise { + const handle = await open(backupFile, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size === 0) throw new Error("Idle backup archive is empty or invalid"); + let bytes = 0; + await pipeline( + createReadStream(backupFile, { fd: handle.fd, autoClose: false }), + createGunzip(), + new Writable({ write(chunk, _encoding, done) { bytes += chunk.length; done(); } }), + ); + if (bytes === 0) throw new Error("Idle backup archive contains no SQL"); + await handle.sync(); + } finally { await handle.close(); } + await syncDirectory(path.dirname(backupFile)); +} + +export async function clearIdleBackupWakeRequired(directory: string): Promise { + await unlink(idleBackupWakeMarker(directory)).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + await syncDirectory(directory); +} diff --git a/server/src/services/idle-local-work.ts b/server/src/services/idle-local-work.ts index 1095c298f80..2d54ad90309 100644 --- a/server/src/services/idle-local-work.ts +++ b/server/src/services/idle-local-work.ts @@ -33,9 +33,11 @@ export async function inspectIdleSpool(directory: string): Promise { +export async function readIdleLocalWork(options: { backupCheckpoint?: boolean } = {}): Promise { if (!startupComplete || !ingressTracked) return "unknown"; - if (scheduledBackups || idleWorkSnapshot().active !== 0) return "present"; + // This exemption belongs only to a scan which will finish a new backup + // under the same admission hold before it can return an empty report. + if ((scheduledBackups && !options.backupCheckpoint) || idleWorkSnapshot().active !== 0) return "present"; const directories = new Set([...spoolDirectories, idleAccountingSpoolPath(), idleOrphanSpoolPath()]); const results = await Promise.all([...directories].map(inspectIdleSpool)); if (results.includes("unknown")) return "unknown"; diff --git a/server/src/services/idle-sleep-safety.ts b/server/src/services/idle-sleep-safety.ts index 574b57c11f9..a8c7a80e13b 100644 --- a/server/src/services/idle-sleep-safety.ts +++ b/server/src/services/idle-sleep-safety.ts @@ -17,6 +17,11 @@ export interface IdleSleepSafety { backgroundWork: "none" | "present" | "unknown"; } +export type InspectIdlePlugins = (hold: { ownerId: string; expiresAt: number }) => Promise<{ + backgroundWork: "none" | "present" | "unknown"; + pluginIds: string[]; +}>; + // These are deliberately conservative. Completed agent runs and workspace // operations are history, not reasons to keep an otherwise idle instance up. // Other background features remain awake until their work and inbound events @@ -91,29 +96,54 @@ export async function readIdleSleepSafety( now: () => number = Date.now, ownerId?: string, inspectLocalWork: () => Promise = readIdleLocalWork, + inspectPlugins?: InspectIdlePlugins, + prepareBackup?: () => Promise, ): Promise { const unknown: IdleSleepSafety = { version: 1, backgroundWork: "unknown" }; const before = getDrainStatus(); const localBefore = idleWorkSnapshot(); if (!sameQuietHold(before, before, now())) return unknown; try { + // Reject known local blockers before querying (and potentially waking) + // the tenant database. Keep the final inspection below as a second fence. + const localFirst = await inspectLocalWork(); + if (localFirst !== "none") return { version: 1, backgroundWork: localFirst }; + let quietPlugins: string[] = []; + if (inspectPlugins) { + if (!ownerId || ownerId !== before.ownerId || before.expiresAt === null || localBefore.active !== 0) return unknown; + const pluginWork = await inspectPlugins({ ownerId, expiresAt: before.expiresAt.getTime() }); + if (pluginWork.backgroundWork !== "none") return { version: 1, backgroundWork: pluginWork.backgroundWork }; + quietPlugins = pluginWork.pluginIds; + } const blocked = await db.transaction(async (tx) => { await tx.execute(sql`SET LOCAL statement_timeout = '5s'`); const checks = WORK_CHECKS.map((query) => sql`EXISTS (${sql.raw(query)})`); - // Version labels and manifest declarations do not prove arbitrary worker - // code has no background activity. No plugin approvals are supported. - checks.push(sql`EXISTS (SELECT 1 FROM plugins WHERE status <> 'disabled')`); + // Only the current worker's owned runtime drain can exempt a plugin. + // Missing workers, old SDKs, and newly installed plugins still block. + checks.push(quietPlugins.length === 0 + ? sql`EXISTS (SELECT 1 FROM plugins WHERE status <> 'disabled')` + : sql`EXISTS (SELECT 1 FROM plugins WHERE status <> 'disabled' AND id NOT IN (${sql.join(quietPlugins.map((id) => sql`${id}::uuid`), sql`, `)}))`); const rows = await tx.execute<{ blocked: boolean }>(sql`SELECT ${sql.join(checks, sql` OR `)} AS blocked`); return rows.length === 1 && typeof rows[0]?.blocked === "boolean" ? rows[0].blocked : undefined; }, { isolationLevel: "repeatable read", accessMode: "read only" }); if (blocked === undefined || !sameQuietHold(before, getDrainStatus(), now())) return unknown; if (blocked) return { version: 1, backgroundWork: "present" }; if (!ownerId || ownerId !== before.ownerId || before.expiresAt === null || localBefore.active !== 0) return unknown; + let expectedGeneration = localBefore.generation; + if (prepareBackup) { + if (!sameQuietHold(before, getDrainStatus(), now()) || + idleWorkSnapshot().generation !== expectedGeneration || idleWorkSnapshot().active !== 0) return unknown; + // The runner owns exactly one tracked-work receipt through dump, + // archive verification and fsync. Its start/finish are the only work + // allowed during this scan. Any concurrent work invalidates the dump. + if (!(await prepareBackup())) return unknown; + expectedGeneration += 2; + } const local = await inspectLocalWork(); const after = getDrainStatus(); const localAfter = idleWorkSnapshot(); if (!sameQuietHold(before, after, now()) || localAfter.active !== 0 || - localBefore.generation !== localAfter.generation) return unknown; + expectedGeneration !== localAfter.generation) return unknown; return { version: 1, backgroundWork: local }; } catch { diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 859dc5fe1e3..ed38f91fb9b 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -401,7 +401,8 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol const reviewPreset = parsed.data.reviewPreset; const authorizationPolicy = parsed.data.authorizationPolicy; - if (stages.length === 0 && !monitor && !reviewPreset && !authorizationPolicy) return null; + if (stages.length === 0 && !monitor && !reviewPreset && !authorizationPolicy + && parsed.data.maxReviewRounds == null) return null; return { mode: parsed.data.mode ?? "normal", diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 3b4c78bc075..d28d6a4ec07 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/issues.ts b/server/src/services/issues.ts index f8c862ad91b..a4ef06d713d 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -25,6 +25,7 @@ import { isNull, like, lt, + lte, ne, notExists, notInArray, @@ -7951,35 +7952,64 @@ export function issueService(db: Db) { }, dbOrTx: any = db, ) { - const now = new Date(); - const [row] = await dbOrTx - .insert(issueInboxArchives) - .values({ - companyId, - issueId, - userId, - archivedByActorType: attribution?.archivedByActorType ?? "user", - archivedByAgentId: attribution?.archivedByAgentId ?? null, - archivedByRunId: attribution?.archivedByRunId ?? null, - archivedAt, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [ - issueInboxArchives.companyId, - issueInboxArchives.issueId, - issueInboxArchives.userId, - ], - set: { - archivedAt, + const runArchive = async (tx: typeof dbOrTx) => { + // Completion locks the issue before archiving. Take the FK's parent lock + // first too, or an insert can hold the archive key while waiting on that + // issue and deadlock with completion's archive UPSERT. SHARE also holds + // companyId stable; different users can still archive concurrently. + const [issue] = await tx + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.id, issueId))) + .for("share"); + if (!issue) throw notFound("Issue not found"); + + const now = new Date(); + const [row] = await tx + .insert(issueInboxArchives) + .values({ + companyId, + issueId, + userId, archivedByActorType: attribution?.archivedByActorType ?? "user", archivedByAgentId: attribution?.archivedByAgentId ?? null, archivedByRunId: attribution?.archivedByRunId ?? null, + archivedAt, updatedAt: now, - }, - }) - .returning(); - return row; + }) + .onConflictDoUpdate({ + target: [ + issueInboxArchives.companyId, + issueInboxArchives.issueId, + issueInboxArchives.userId, + ], + set: { + archivedAt, + archivedByActorType: attribution?.archivedByActorType ?? "user", + archivedByAgentId: attribution?.archivedByAgentId ?? null, + archivedByRunId: attribution?.archivedByRunId ?? null, + updatedAt: now, + }, + // A request that waited behind completion must not replace the newer + // archive with its earlier request time and resurface the done task. + setWhere: lte(issueInboxArchives.archivedAt, archivedAt), + }) + .returning(); + if (row) return row; + // ON CONFLICT holds this row lock even when setWhere skips the update. + // Return the newer state, including its matching actor attribution. + const [existing] = await tx + .select() + .from(issueInboxArchives) + .where(and( + eq(issueInboxArchives.companyId, companyId), + eq(issueInboxArchives.issueId, issueId), + eq(issueInboxArchives.userId, userId), + )); + if (!existing) throw new Error("Inbox archive conflict row missing"); + return existing; + }; + return dbOrTx === db ? db.transaction(runArchive) : runArchive(dbOrTx); } const service = { diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 410cc02d0ee..6bfc0d36365 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -1,13 +1,24 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { startLocalBrowserLogin } from "./local-ai-browser-login.js"; +import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js"; + +vi.mock("../vendor/paperclip-runner/index.js", () => ({ + resolveCodexCommand: vi.fn(), +})); const initialPath = process.env.PATH; +const initialPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; +const initialArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; let root: string | undefined; afterEach(async () => { + Object.defineProperty(process, "platform", initialPlatform); + Object.defineProperty(process, "arch", initialArchitecture); process.env.PATH = initialPath; + vi.unstubAllEnvs(); + vi.clearAllMocks(); if (root) await rm(root, { recursive: true, force: true }); root = undefined; }); @@ -17,13 +28,85 @@ async function fakeCli(name: string, source: string) { const bin = path.join(root, "bin"); await mkdir(bin, { recursive: true }); await writeFile(path.join(bin, name), `#!/bin/sh\n${source}\n`, { mode: 0o700 }); + if (name === "codex") vi.mocked(resolveCodexCommand).mockReturnValue(path.join(bin, name)); process.env.PATH = `${bin}${path.delimiter}${initialPath}`; const home = path.join(root, "credential-home"); await mkdir(home, { mode: 0o700 }); - return home; + return realpath(home); +} + +function targetPlatform(platform: string, architecture: string) { + Object.defineProperty(process, "platform", { ...initialPlatform, value: platform }); + Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture }); } describe.skipIf(process.platform === "win32")("local browser subscription login", () => { + it.each([false, true])("uses the selected Codex command with its credential home and ambient HOME/cwd (legacy Linux ARM64: %s)", async (legacy) => { + if (legacy) targetPlatform("linux", "arm64"); + const home = await fakeCli("codex", [ + '[ "$1 $2" = "login --device-auth" ] || exit 11', + '[ "$HOME" = "$CLAUDE_CONFIG_DIR" ] && [ "$HOME" != "$CODEX_HOME" ] && [ "$CODEX_HOME" = "$PAPERCLIP_TEST_LOGIN_CODEX_HOME" ] || exit 12', + '[ "$PWD" = "$PAPERCLIP_TEST_LOGIN_CWD" ] || exit 13', + '[ -z "$OPENAI_API_KEY$CODEX_API_KEY$ANTHROPIC_API_KEY$ANTHROPIC_AUTH_TOKEN$CLAUDE_CODE_OAUTH_TOKEN" ] || exit 14', + 'printf "1. Open this link in your browser and sign in to your account\\nhttps://auth.openai.com/codex/device\\n2. Enter this one-time code (expires in 15 minutes)\\nABCD-EFGHJ\\n"', + ].join("\n")); + if (legacy) vi.mocked(resolveCodexCommand).mockImplementation(() => { throw new Error("native artifact is unavailable"); }); + else process.env.PATH = "/usr/bin:/bin"; + const ambientHome = path.join(root!, "unrelated-host-home"); + await mkdir(ambientHome, { mode: 0o700 }); + for (const key of ["HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR"]) vi.stubEnv(key, ambientHome); + vi.stubEnv("PAPERCLIP_TEST_LOGIN_CODEX_HOME", home); + vi.stubEnv("PAPERCLIP_TEST_LOGIN_CWD", process.cwd()); + for (const key of ["OPENAI_API_KEY", "CODEX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"]) + vi.stubEnv(key, "unrelated-host-value"); + const login = startLocalBrowserLogin("openai", home); + await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 }); + if (legacy) expect(resolveCodexCommand).not.toHaveBeenCalled(); + else expect(resolveCodexCommand).toHaveBeenCalledOnce(); + expect(login.code).toBe("ABCD-EFGHJ"); + }); + + it.each([["linux", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => { + targetPlatform(platform, architecture); + const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"'); + const failure = new Error("private-installation-path-or-provider-output"); + vi.mocked(resolveCodexCommand).mockImplementation(() => { throw failure; }); + const login = startLocalBrowserLogin("openai", home); + await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 }); + expect(login.error).toMatch(/requires an installed Codex CLI.*Install Codex/); + expect(JSON.stringify(login)).not.toContain(failure.message); + await expect(readFile(path.join(home, "unreviewed-cli"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(login.authorizationUrl).toBeUndefined(); + }); + + it("reports a missing Linux ARM64 Codex CLI without revealing the host PATH", async () => { + targetPlatform("linux", "arm64"); + const home = await fakeCli("codex", "exit 0"); + process.env.PATH = "/usr/bin:/bin"; + const login = startLocalBrowserLogin("openai", home); + await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 }); + expect(login.error).toMatch(/requires the Codex CLI.*Install Codex.*Paperclip's PATH/); + expect(JSON.stringify(login)).not.toContain(process.env.PATH); + expect(resolveCodexCommand).not.toHaveBeenCalled(); + }); + + it("reports a missing terminal prerequisite without exposing spawn details", async () => { + const home = await fakeCli("codex", "exit 0"); + process.env.PATH = path.join(root!, "no-python"); + const login = startLocalBrowserLogin("openai", home); + await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 }); + expect(login.error).toMatch(/requires Python 3.*Install Python 3/); + expect(JSON.stringify(login)).not.toContain(process.env.PATH); + }); + + it("cancels before launching the resolved Codex command", async () => { + const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/cancelled-cli"'); + const login = startLocalBrowserLogin("openai", home); + login.abort(); + await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5000 }); + await expect(readFile(path.join(home, "cancelled-cli"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("surfaces a Codex device link and code from a local PTY without a user shell command", async () => { const home = await fakeCli("codex", [ 'printf "1. Open this link in your browser and sign in to your account\\n"', diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index ad3ee6dfe7a..ad176b8618f 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -4,11 +4,14 @@ import { constants } from "node:fs"; import path from "node:path"; import { runSetupTokenLogin } from "@paperclipai/adapter-claude-local/server"; import { runDeviceLogin } from "@paperclipai/adapter-codex-local/server"; +import { ensureCommandResolvable, resolveCommandForLogs } from "@paperclipai/adapter-utils/server-utils"; +import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js"; export type LocalBrowserLoginState = { authorizationUrl?: string; code?: string; outcome?: "success" | "failure"; + error?: string; submitCode?: (code: string) => void; abort: () => void; }; @@ -47,11 +50,31 @@ sys.exit(os.waitstatus_to_exitcode(status)) export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: string): LocalBrowserLoginState { const controller = new AbortController(); const state: LocalBrowserLoginState = { abort: () => controller.abort() }; - const executable = provider === "anthropic" ? "claude" : "codex"; const args = provider === "anthropic" ? ["setup-token"] : ["login", "--device-auth"]; let child: ChildProcessWithoutNullStreams | null = null; const driver = { - start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { + async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { + let executable = "claude"; + if (provider === "openai") { + // Linux ARM64 retains its supported legacy CLI path; it is not a + // qualified native runner target. Other hosts prefer the installed + // dependency, then an existing CLI when the dependency is absent. + const legacyPlatform = process.platform === "linux" && process.arch === "arm64"; + try { + if (legacyPlatform) { + await ensureCommandResolvable("codex", home, process.env); + executable = path.resolve(home, await resolveCommandForLogs("codex", home, process.env)); + } else { + executable = resolveCodexCommand(); + } + } catch { + state.error = legacyPlatform + ? "Codex browser sign-in requires the Codex CLI on this platform. Install Codex on this execution host and ensure codex is on Paperclip's PATH, then start sign-in again." + : "Codex browser sign-in requires an installed Codex CLI. Install Codex on this execution host or restore Paperclip's runtime dependencies, then start sign-in again."; + throw new Error("Browser login runtime unavailable"); + } + } + if (controller.signal.aborted) throw new Error("Local sign-in cancelled before launch"); const env = { ...process.env, ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", CLAUDE_CODE_OAUTH_TOKEN: "", OPENAI_API_KEY: "", CODEX_API_KEY: "", @@ -64,7 +87,10 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s child.stdout.on("data", (bytes: Buffer) => onData(bytes.toString("utf8"))); // Provider output may contain secrets. Never log or retain stderr. child.stderr.resume(); - child.once("error", reject); + child.once("error", () => { + state.error = "Browser sign-in requires Python 3 and a working local terminal. Install Python 3 on this execution host, then start sign-in again."; + reject(new Error("Browser login terminal unavailable")); + }); child.once("close", (exitCode) => resolve({ exitCode })); }); }, diff --git a/server/src/services/local-ai-login.ts b/server/src/services/local-ai-login.ts index 3492d47896a..71482abc4cb 100644 --- a/server/src/services/local-ai-login.ts +++ b/server/src/services/local-ai-login.ts @@ -171,7 +171,7 @@ export function localAiLoginService(db: Db) { ...(!login && intent.provider !== "xai" ? { error: "The server restarted during sign-in. Start sign-in again." } : login?.outcome === "failure" - ? { error: "Sign-in ended before the account was connected. Start sign-in again." } + ? { error: login.error ?? "Sign-in ended before the account was connected. Start sign-in again." } : {}) }; } } diff --git a/server/src/services/mcp-connection-failure.ts b/server/src/services/mcp-connection-failure.ts new file mode 100644 index 00000000000..b3cbb816e0b --- /dev/null +++ b/server/src/services/mcp-connection-failure.ts @@ -0,0 +1,63 @@ +import type { ConnectionFailure } from "@paperclipai/adapter-utils/connection-failure"; +import { HttpError } from "../errors.js"; +import { classifyRemoteConnectionError, hasUnclassifiedRemoteConnectionAttempts, readRemoteConnectionFailure } from "./remote-connection-failure.js"; + +type McpConnectionFailure = Extract; +type Reason = McpConnectionFailure["reason"]; + +// These are local provenance receipts, never properties supplied by a provider, +// request body, or persisted error. They affect Sentry reporting only. +const failures = new WeakMap(); + +function remember(error: Error, reason: Reason): void { + failures.set(error, { schemaVersion: 1, provider: "mcp_http", operation: "discover_tools", reason }); +} + +/** Call only around the outbound MCP request or its response-body read. */ +export async function withMcpConnectionFailure(request: () => Promise): Promise { + try { + return await request(); + } catch (error) { + if (error instanceof Error) { + if (hasUnclassifiedRemoteConnectionAttempts(error)) failures.delete(error); + const reason = readRemoteConnectionFailure(error) ?? classifyRemoteConnectionError(error); + if (reason) remember(error, reason); + } + throw error; + } +} + +/** Preserve the existing HTTP failure; classify only the actual remote status. */ +export function mcpDiscoveryHttpFailure(response: Response, message: string): HttpError { + return markMcpHttpResponseFailure(response, new HttpError(502, message, { status: response.status })); +} + +/** Only an actual MCP response can attach this evidence to a protocol wrapper. */ +export function markMcpHttpResponseFailure(response: Response, error: T): T { + const status = response.status; + const reason: Reason | null = status === 401 || status === 403 ? "authentication_failed" + : status === 404 ? "endpoint_not_found" + : status === 429 ? "rate_limited" + : status >= 500 && status <= 599 ? "remote_unavailable" : null; + if (reason) remember(error, reason); + return error; +} + +/** Rewrapping a known error must not classify unrelated persistence failures. */ +export function retainMcpConnectionFailure(source: unknown, target: T): T { + if (hasUnclassifiedRemoteConnectionAttempts(source)) { + failures.delete(target); + return target; + } + const failure = source instanceof Error ? failures.get(source) : undefined; + if (failure) failures.set(target, failure); + else { + const reason = readRemoteConnectionFailure(source); + if (reason) remember(target, reason); + } + return target; +} + +export function isExpectedMcpConnectionFailure(error: unknown): boolean { + return error instanceof HttpError && error.status === 502 && !hasUnclassifiedRemoteConnectionAttempts(error) && failures.has(error); +} diff --git a/server/src/services/mcp-http.ts b/server/src/services/mcp-http.ts index 445d26b93ea..80db7f9c17e 100644 --- a/server/src/services/mcp-http.ts +++ b/server/src/services/mcp-http.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { markMcpHttpResponseFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "./mcp-connection-failure.js"; // Helpers for talking to remote MCP servers over the Streamable HTTP transport. // // The MCP Streamable HTTP spec requires the client to advertise that it accepts @@ -72,18 +73,18 @@ export async function initializeMcpHttpSession(input: { }), }); if (!initializeResponse.ok) { - throw new McpHttpInitializationError( + throw markMcpHttpResponseFailure(initializeResponse, new McpHttpInitializationError( `Remote MCP initialization returned HTTP ${initializeResponse.status}`, "initialize", initializeResponse.status, initializeResponse, - ); + )); } let payload: unknown; try { - payload = await readMcpHttpResponse(initializeResponse, `${input.requestId}-initialize`); - } catch { - throw new McpHttpInitializationError("Remote MCP initialization returned an invalid response", "initialize", null); + payload = await withMcpConnectionFailure(() => readMcpHttpResponse(initializeResponse, `${input.requestId}-initialize`)); + } catch (error) { + throw retainMcpConnectionFailure(error, new McpHttpInitializationError("Remote MCP initialization returned an invalid response", "initialize", null)); } const result = payload && typeof payload === "object" && "result" in payload ? (payload as { result?: unknown }).result @@ -109,11 +110,11 @@ export async function initializeMcpHttpSession(input: { }), }); if (!initializedResponse.ok) { - throw new McpHttpInitializationError( + throw markMcpHttpResponseFailure(initializedResponse, new McpHttpInitializationError( `Remote MCP initialized notification returned HTTP ${initializedResponse.status}`, "initialized_notification", initializedResponse.status, - ); + )); } return sessionHeaders; } diff --git a/server/src/services/native-runtime/acpx-qualification.test.ts b/server/src/services/native-runtime/acpx-qualification.test.ts index b46dfc72271..70eaf38c40b 100644 --- a/server/src/services/native-runtime/acpx-qualification.test.ts +++ b/server/src/services/native-runtime/acpx-qualification.test.ts @@ -8,25 +8,23 @@ 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(["pi"])("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" })); - expect(() => resolvePaperclipRunnerProviderProfile({ ...config, env: authorize([{ agent, model: config.model }]), [ACPX_QUALIFICATION_ENV]: JSON.stringify([{ agent, model: config.model }]) })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" })); - vi.stubEnv(ACPX_QUALIFICATION_ENV, JSON.stringify([{ agent, model: config.model }])); - expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject({ acpxAgent: agent, model: config.model }); - expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config })).toMatchObject({ acpxAgent: agent, model: config.model }); - 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("keeps Pi execution closed and admits only the exact operator pair", () => { - const provider = { kind: "acpx", agent: "pi", model: "exact-model" } as Extract; - expect(resolveAcpxQualification(provider, {})).toBeUndefined(); - expect(resolveAcpxQualification(provider, authorize([{ agent: "pi", model: "exact-model" }]))).toBe("pi"); - for (const entries of [[{ agent: "copilot", model: "exact-model" }], [{ agent: "pi", model: "other-model" }]]) { - expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("exact model"); + + 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.each([[], {}, [{ agent: "cursor", model: "" }], [{ agent: "cursor", model: " exact-model" }], [{ agent: "cursor", model: "exact-model", allowAll: true }], [{ agent: "claude", model: "exact-model" }], [{ agent: "cursor", model: "exact-model" }, { agent: "cursor", model: "exact-model" }], @@ -55,7 +53,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" }); @@ -80,3 +78,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/acpx-qualification.ts b/server/src/services/native-runtime/acpx-qualification.ts index 30e3794a9cd..1f67a284262 100644 --- a/server/src/services/native-runtime/acpx-qualification.ts +++ b/server/src/services/native-runtime/acpx-qualification.ts @@ -12,7 +12,7 @@ export function resolveAcpxQualification( if (provider.kind !== "acpx" || !["copilot", "pi"].includes(provider.agent)) return undefined; // The server release declaration admits qualified Copilot into the frozen // SDK's explicit native transport slot. No operator environment is required. - // Pending Pi continues through the exact host-only qualification check below. + // Pi retains the mainline transport and qualification behavior below. if (provider.agent === "copilot" && PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "copilot" && profile.qualified)) { if (!provider.model || provider.model !== provider.model.trim() || ["auto", "default"].includes(provider.model.toLowerCase())) { throw new Error("GitHub Copilot requires an explicit available model ID"); 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 4ee46edf7a6..fc1d3ef5256 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 7167b70c49d..60596365767 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 bb9d7d9abd2..eb6831d6530 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 668c2ff0515..ff8dc443883 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 9df5a0f51fe..8abc557e836 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 da55c577909..280acc18360 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 f0245291f41..a9711329ff3 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 2e1b71f6287..d97214db558 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 d986baf2c70..19d97de8c25 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 1fabdfc7dd8..8b9f7f502f6 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 907f8e3ba73..566dda44a59 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(); } }); @@ -8733,6 +9429,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) => { @@ -8771,24 +9472,7 @@ describe("native process ownership", () => { }, ); - it.each(["pi"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => { - const piExecution = { - ...execution, - binding: { ...execution.binding, runId: "run-acpx-pi-rejected" }, - provider: { kind: "acpx", agent, model: "pi-model" }, - session: { ...execution.session, driverKind: "acpx_runtime" }, - } as unknown as NativeExecutionInputV1; - state.createBackend.mockClear(); - await expect( - executePaperclipNativeSession({ - db: leaseDb(piExecution), - execution: piExecution, - runnerInstanceId: "runner", - }), - ).rejects.toThrow("exact host qualification authorization"); - expect(state.createBackend).not.toHaveBeenCalled(); - }); }); describe("runnerd provider runtime wiring", () => { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index e2d5b3accd1..f6adb07931a 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, shouldUseBundledAcpxImageAssets } 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,9 @@ 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", "copilot"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate + if (!(inventory === "providers" ? ["pi", "cursor", "copilot"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate || Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key)) - || candidate.qualification !== (provider === "cursor" || (inventory === "providers" && provider === "copilot") ? "qualified" : "pending") || candidate.path !== expectedPath + || candidate.qualification !== (["pi", "cursor"].includes(provider) || inventory === "providers" && provider === "copilot" ? "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) @@ -11265,8 +11380,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 useBundledAcpxImageAssets = shouldUseBundledAcpxImageAssets({ requiresRemoteProviderPack, configuredProviderPackRoot, @@ -11301,7 +11424,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() || (useBundledAcpxImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) + ? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledAcpxImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) : resolvePaperclipRunnerBinary(); const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; @@ -11680,7 +11803,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"], @@ -12933,9 +13056,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 574e0dda15e..41b99c90bb7 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 129ffdcf23a..9acb7e6a091 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 8be9aae5650..1545f170a66 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 { @@ -30,6 +31,7 @@ export const DEFAULT_ACPX_RUNNER_MODELS = { codex: null, cursor: null, copilot: null, + pi: null, } as const; export type QualifiedPaperclipRunnerAcpxAgent = @@ -128,6 +130,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 { @@ -377,10 +380,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 { @@ -490,10 +496,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" && acpxAgent !== "copilot") { + if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "copilot" && acpxAgent !== "pi") { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_agent_unavailable", "Paperclip Runner ACPX requires a qualified agent profile.", @@ -571,6 +577,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 00000000000..e13406b4b73 --- /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 3296f525a53..11eb8d808aa 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 2e77bb7e41c..3a31bb6b990 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/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index af6721f3d4b..2d3f961d530 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -20,6 +20,7 @@ import { fork, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { beginIdleTrackedWork, readTaskDrain, trackIdleWork } from "./task-admission.js"; import { EventEmitter } from "node:events"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; @@ -676,6 +677,8 @@ interface ExecuteLogRoute { * with exponential backoff automatically when `autoRestart` is enabled. */ export interface PluginWorkerHandle { + prepareIdleSleep?(hold: { ownerId: string; expiresAt: number }): Promise<"none" | "present" | "unknown">; + releaseIdleSleep?(): void; /** The plugin ID this worker serves. */ readonly pluginId: string; @@ -793,6 +796,10 @@ export interface WorkerDiagnostics { * for starting/stopping all workers and routing RPC calls. */ export interface PluginWorkerManager { + inspectIdleSleep?(hold: { ownerId: string; expiresAt: number }): Promise<{ + backgroundWork: "none" | "present" | "unknown"; pluginIds: string[]; + }>; + releaseIdleSleep?(): void; /** * Register and start a worker for a plugin. * @@ -888,6 +895,30 @@ export function createPluginWorkerHandle( // Pending RPC requests awaiting a response const pendingRequests = new Map(); + // Unlike RPC promises, these survive timeouts. A late response is the + // completion receipt. A worker crash cannot manufacture one. + const unsettledCalls = new Map void>(); + let activeHostHandlers = 0; + let idleHold: { ownerId: string; expiresAt: number; ready: boolean } | null = null; + + function releaseIdleSleep(): void { + const prior = idleHold; + idleHold = null; + if (prior && childProcess?.stdin?.writable) { + try { + sendMessage({ jsonrpc: JSONRPC_VERSION, method: "releaseIdleSleep", params: { ownerId: prior.ownerId } }); + } catch { /* Worker-side expiry still bounds a lost release. */ } + } + } + + function idleSleepHeld(): boolean { + if (!idleHold) return false; + const drain = readTaskDrain(new Date()); + if (drain?.ownerId !== idleHold.ownerId || drain.expiresAt?.getTime() !== idleHold.expiresAt) { + releaseIdleSleep(); + } + return idleHold !== null; + } let nextRequestId = 1; const activeInvocations = new Map(); // Host-owned execute routes, keyed by the host-issued invocation id. Only an @@ -1020,6 +1051,8 @@ export function createPluginWorkerHandle( function setStatus(newStatus: WorkerStatus): void { const prev = status; if (prev === newStatus) return; + const done = beginIdleTrackedWork(); + done(); status = newStatus; log.debug({ from: prev, to: newStatus }, "worker status change"); emitter.emit("status", { pluginId, status: newStatus, previousStatus: prev }); @@ -1093,6 +1126,8 @@ export function createPluginWorkerHandle( return; } + unsettledCalls.get(id)?.(); + unsettledCalls.delete(id); const pending = pendingRequests.get(id); if (!pending) { log.warn({ id }, "received response for unknown request id"); @@ -2695,6 +2730,23 @@ export function createPluginWorkerHandle( * Handle a JSON-RPC request from the worker (worker→host call). */ async function handleWorkerRequest(request: JsonRpcRequest): Promise { + const done = beginIdleTrackedWork(); + activeHostHandlers++; + try { + // A queued notification may already be running in the worker while its + // prepare request is in flight. Let its writes finish until that worker + // has positively acknowledged that all accepted handlers have drained. + if (idleSleepHeld() && idleHold?.ready) { + try { + sendMessage(createErrorResponse(request.id, PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, "Plugin is held for idle sleep")); + } catch { /* Worker may have exited after sending its request. */ } + return; + } + await dispatchWorkerRequest(request); + } finally { activeHostHandlers--; done(); } + } + + async function dispatchWorkerRequest(request: JsonRpcRequest): Promise { const method = request.method as WorkerToHostMethodName; const handler = options.hostHandlers[method] as | ((params: unknown, context?: WorkerHostCallContext) => Promise) @@ -3072,6 +3124,12 @@ export function createPluginWorkerHandle( backoffTimer = setTimeout(async () => { backoffTimer = null; nextRestartAt = null; + // A temporary idle hold must not consume the crash-recovery attempt. + // Keep the bounded backoff until admission reopens. + if (readTaskDrain(new Date())?.ownerId) { + scheduleRestart(); + return; + } try { await startInternal(); } catch (err) { @@ -3293,6 +3351,11 @@ export function createPluginWorkerHandle( executeLogSink?: ExecuteLogSink, ): Promise { const rpcPromise = new Promise((resolve, reject) => { + const idleControl = method === "prepareIdleSleep" || method === "releaseIdleSleep"; + if (!idleControl && method !== "shutdown" && idleSleepHeld()) { + reject(new Error("Plugin is held for idle sleep")); + return; + } if (!childProcess?.stdin?.writable) { reject( new Error( @@ -3303,6 +3366,7 @@ export function createPluginWorkerHandle( } const id = nextRequestId++; + if (!idleControl) unsettledCalls.set(id, beginIdleTrackedWork()); const timeout = resolveRpcCallTimeoutMs(timeoutMs, rpcTimeoutMs); const invocationScope = deriveInvocationScope(method, params); const invocation = invocationScope ? registerInvocation(invocationScope) : null; @@ -3368,6 +3432,8 @@ export function createPluginWorkerHandle( } catch (err) { clearTimeout(timer); pendingRequests.delete(id); + unsettledCalls.get(id)?.(); + unsettledCalls.delete(id); clearInvocation(invocation); clearExecuteRoute(invocation?.id); reject( @@ -3394,6 +3460,38 @@ export function createPluginWorkerHandle( // ----------------------------------------------------------------------- const handle: PluginWorkerHandle = { + releaseIdleSleep, + async prepareIdleSleep(hold) { + const drain = readTaskDrain(new Date()); + if (drain?.ownerId !== hold.ownerId || drain.expiresAt?.getTime() !== hold.expiresAt || + hold.expiresAt <= Date.now()) return "unknown"; + // Clear a previous hold even when this idle worker received no ordinary + // call between two controller attempts. + idleSleepHeld(); + if (status !== "running" || totalCrashes > 0) return "unknown"; + if (!supportedMethods.includes("prepareIdleSleep")) return "present"; + if (unsettledCalls.size || activeHostHandlers || loginPtyRoutesByHostRouteId.size || + liveDuplexRoutes.size || openingDuplexRoutes.size || terminalDuplexRoutes.size) return "present"; + if (idleHold && (idleHold.ownerId !== hold.ownerId || idleHold.expiresAt !== hold.expiresAt)) return "unknown"; + const candidate = idleHold ??= { ...hold, ready: false }; + try { + const result = await callInternal("prepareIdleSleep", hold, Math.min(5_000, hold.expiresAt - Date.now())); + if (!idleSleepHeld() || idleHold !== candidate || status !== "running" || unsettledCalls.size || activeHostHandlers || + result.ownerId !== hold.ownerId || result.expiresAt !== hold.expiresAt) { + if (idleHold === candidate) releaseIdleSleep(); + return "unknown"; + } + if (result.backgroundWork === "none") { + candidate.ready = true; + return "none"; + } + releaseIdleSleep(); + return result.backgroundWork === "present" ? "present" : "unknown"; + } catch { + if (idleHold === candidate) releaseIdleSleep(); + return "unknown"; + } + }, get pluginId() { return pluginId; }, @@ -3459,6 +3557,7 @@ export function createPluginWorkerHandle( notify(method: string, params: unknown) { if (status !== "running") return; + if (idleSleepHeld()) throw new Error("Plugin is held for idle sleep"); const invocationScope = deriveInvocationScope(method, params); // Notifications have no response to settle on, so the invocation scope // is GC'd by TTL. Call-path invocations are registered without a TTL and @@ -3637,6 +3736,20 @@ export function createPluginWorkerManager( ); return { + async inspectIdleSleep(hold) { + if (startupLocks.size) return { backgroundWork: "unknown", pluginIds: [] }; + const snapshot = [...workers.entries()]; + const results = await Promise.all(snapshot.map(async ([, worker]) => + worker.prepareIdleSleep ? worker.prepareIdleSleep(hold) : "unknown")); + if (startupLocks.size || snapshot.length !== workers.size || snapshot.some(([id, worker]) => workers.get(id) !== worker || worker.status !== "running")) { + return { backgroundWork: "unknown", pluginIds: [] }; + } + const backgroundWork = results.includes("present") ? "present" : results.includes("unknown") ? "unknown" : "none"; + return { backgroundWork, pluginIds: backgroundWork === "none" ? snapshot.map(([id]) => id) : [] }; + }, + releaseIdleSleep() { + for (const worker of workers.values()) worker.releaseIdleSleep?.(); + }, async startWorker( pluginId: string, options: WorkerStartOptions, @@ -3690,7 +3803,7 @@ export function createPluginWorkerManager( log.info({ pluginId }, "starting plugin worker"); // Set the lock before awaiting start() to prevent concurrent spawns - const startPromise = handle.start().then(() => handle).finally(() => { + const startPromise = trackIdleWork(handle.start()).then(() => handle).finally(() => { startupLocks.delete(pluginId); }); startupLocks.set(pluginId, startPromise); @@ -3706,7 +3819,7 @@ export function createPluginWorkerManager( } log.info({ pluginId }, "stopping plugin worker"); - await handle.stop(); + await trackIdleWork(handle.stop()); workers.delete(pluginId); }, diff --git a/server/src/services/public-mcp/contracts.ts b/server/src/services/public-mcp/contracts.ts index 1288399c5da..ead3d812619 100644 --- a/server/src/services/public-mcp/contracts.ts +++ b/server/src/services/public-mcp/contracts.ts @@ -39,4 +39,3 @@ export type Capability = { name: string; description: string; schema: z.ZodObject; write?: boolean; configure?: boolean; destructive?: boolean; ephemeral?: boolean; run: (p: McpPrincipal, args: Record, api: ApiDispatch, origin: string, transfers?: PublicMcpTransfers) => Promise>; }; - diff --git a/server/src/services/public-mcp/events.ts b/server/src/services/public-mcp/events.ts index d2cb45d4f70..39c1ce07043 100644 --- a/server/src/services/public-mcp/events.ts +++ b/server/src/services/public-mcp/events.ts @@ -212,7 +212,12 @@ export function createPublicMcpEvents(db: Db, oauth: PublicMcpOAuth, api: ApiDis async function enqueue(s: Subscription) { if (s.bindingId) { if (!options.enableDotRunner) return; - const [b] = await db.select().from(dotAgentBindings).where(and(eq(dotAgentBindings.id, s.bindingId), eq(dotAgentBindings.companyId, s.companyId), isNull(dotAgentBindings.revokedAt))); + const [binding] = await db.select().from(dotAgentBindings).where(and(eq(dotAgentBindings.id, s.bindingId), eq(dotAgentBindings.companyId, s.companyId), isNull(dotAgentBindings.revokedAt))); + if (!binding) return; + // SQL idempotency makes this safe across restarts, replicas and callback + // renewals. Reload before filtering delivery against the newly issued test. + await dotRunnerBroker(db).challenge(binding.companyId, binding.agentId, { automatic: true, bindingId: binding.id }); + const [b] = await db.select().from(dotAgentBindings).where(and(eq(dotAgentBindings.id, binding.id), isNull(dotAgentBindings.revokedAt))); if (!b) return; // Materialize only references to newly recorded task input. This does not steer // an external provider or create execution authority; the current run reads its history. diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index f5a3dc6d994..ec878f35a41 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -126,7 +126,8 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: { const refresh = grant.scopes.includes("offline_access") ? secret("pcmcp_rt_") : null; await tx.insert(mcpOauthTokens).values([ { grantId: grant.id, tokenHash: hashMcpSecret(access), kind: "access", expiresAt: new Date(Date.now() + accessLifetime) }, - ...(refresh ? [{ grantId: grant.id, tokenHash: hashMcpSecret(refresh), kind: "refresh" as const, expiresAt: new Date(Date.now() + refreshLifetime) }] : []), + ...(refresh ? [{ grantId: grant.id, tokenHash: hashMcpSecret(refresh), kind: "refresh" as const, + expiresAt: agentConnection && grant.purpose === "agent" ? null : new Date(Date.now() + refreshLifetime) }] : []), ]); return { access_token: access, token_type: "Bearer", expires_in: accessLifetime / 1000, @@ -440,7 +441,7 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: { if (!access.user || !membership || membership.membershipRole === "viewer" || !company || company.status === "archived") throw invalidGrant(); return { company: { id: company.id, name: company.name }, agent: { id: agent.id, name: agent.name }, permissions: "Start and accept work as this agent, coordinate permitted tasks and people, read assigned skills, and use assigned app tools. Reading assigned task attachment contents sends those contents to OpenAI and requires the agent’s separate attachment setting. Workspace files and sandboxed commands require the agent’s separate workspace setting. No board account or other-company access.", - accessDuration: "Ongoing until revoked. Reconnect after 30 days without refreshing the connection.", + accessDuration: "Ongoing until revoked. This connection does not expire from inactivity.", pairingExpiresAt: binding.pairingExpiresAt!.toISOString() }; }); }, @@ -515,7 +516,9 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: { }); return null; } - if (token.expiresAt <= new Date()) return null; + // Only agent connections support refresh tokens without an inactivity limit. + // Access tokens still expire, refresh still rotates, and replay still revokes. + if (token.expiresAt ? token.expiresAt <= new Date() : !agentConnection || grant.purpose !== "agent") return null; await actorForGrant(grant, tx as unknown as Db); if (input.scope !== undefined && input.scope !== grant.scopes.join(" ")) { throw new McpOAuthError("invalid_scope", "Refresh cannot change the consented scopes; reconnect instead."); diff --git a/server/src/services/remote-connection-failure.ts b/server/src/services/remote-connection-failure.ts new file mode 100644 index 00000000000..25b1e0269b3 --- /dev/null +++ b/server/src/services/remote-connection-failure.ts @@ -0,0 +1,80 @@ +/** Server-local provenance of a failure at a remote connection boundary. */ +export type RemoteConnectionFailureReason = "connection_refused" | "dns_failure" | "network_unreachable" + | "connection_reset" | "connection_timeout" | "tls_failure"; +type Reason = RemoteConnectionFailureReason; +const failures = new WeakMap(); +const failedAttempts = new WeakMap(); + +function attemptFailureReason(reasons: readonly (Reason | null)[]): Reason | null { + return reasons.length > 0 && reasons.every((reason) => reason !== null) ? reasons[reasons.length - 1]! : null; +} + +/** Keep every address's classification without changing the final thrown value. */ +export function recordRemoteConnectionAttempts(error: T, causes: readonly unknown[]): T { + if (error instanceof Error) { + const reasons = causes.map((cause) => readRemoteConnectionFailure(cause) ?? classifyRemoteConnectionError(cause)); + failedAttempts.set(error, Object.freeze(reasons)); + } + return error; +} + +export function hasUnclassifiedRemoteConnectionAttempts(error: unknown): boolean { + const attempts = error instanceof Error ? failedAttempts.get(error) : undefined; + return attempts !== undefined && attemptFailureReason(attempts) === null; +} + +export function markRemoteConnectionFailure(error: T, reason: Reason): T { + failures.set(error, reason); + return error; +} + +export function readRemoteConnectionFailure(error: unknown): Reason | null { + if (!(error instanceof Error)) return null; + const attempts = failedAttempts.get(error); + // An explicit unknown attempt overrides even a known final owned deadline. + return attempts ? attemptFailureReason(attempts) : failures.get(error) ?? null; +} + +const transportReasons: Readonly> = { + ECONNREFUSED: "connection_refused", + ENOTFOUND: "dns_failure", + ENODATA: "dns_failure", + EAI_AGAIN: "dns_failure", + EHOSTUNREACH: "network_unreachable", + ENETUNREACH: "network_unreachable", + ECONNRESET: "connection_reset", + EPIPE: "connection_reset", + UND_ERR_SOCKET: "connection_reset", + ETIMEDOUT: "connection_timeout", + UND_ERR_CONNECT_TIMEOUT: "connection_timeout", + UND_ERR_HEADERS_TIMEOUT: "connection_timeout", + UND_ERR_BODY_TIMEOUT: "connection_timeout", + CERT_HAS_EXPIRED: "tls_failure", + CERT_NOT_YET_VALID: "tls_failure", + DEPTH_ZERO_SELF_SIGNED_CERT: "tls_failure", + SELF_SIGNED_CERT_IN_CHAIN: "tls_failure", + UNABLE_TO_VERIFY_LEAF_SIGNATURE: "tls_failure", + UNABLE_TO_GET_ISSUER_CERT_LOCALLY: "tls_failure", + ERR_TLS_CERT_ALTNAME_INVALID: "tls_failure", +}; + +export function classifyRemoteConnectionError(value: unknown, depth = 0): Reason | null { + if (!value || typeof value !== "object" || Array.isArray(value) || depth > 2) return null; + const attempts = value instanceof Error ? failedAttempts.get(value) : undefined; + // Do not fall back to the last error's code/cause after mixed attempts. + if (attempts) return attemptFailureReason(attempts); + const error = value as Record; + if (error.name === "AbortError") return null; + const reason = typeof error.code === "string" && Object.hasOwn(transportReasons, error.code) + ? transportReasons[error.code]! + : null; + if (error.code !== undefined && !reason) return null; + if (error.errors !== undefined) { + if (!Array.isArray(error.errors) || error.errors.length === 0 || error.errors.length > 8) return null; + const reasons = error.errors.map((child) => classifyRemoteConnectionError(child, depth + 1)); + const first = reasons[0]; + return first && reasons.every((child) => child === first) && (!reason || reason === first) ? first : null; + } + if (reason) return reason; + return error.cause !== undefined ? classifyRemoteConnectionError(error.cause, depth + 1) : null; +} diff --git a/server/src/services/remote-http-endpoint-guard.ts b/server/src/services/remote-http-endpoint-guard.ts index 0c5c0b3ce34..a120718b1ea 100644 --- a/server/src/services/remote-http-endpoint-guard.ts +++ b/server/src/services/remote-http-endpoint-guard.ts @@ -1,5 +1,6 @@ import { lookup as dnsLookup } from "node:dns/promises"; import { isIP } from "node:net"; +import { classifyRemoteConnectionError, markRemoteConnectionFailure, readRemoteConnectionFailure } from "./remote-connection-failure.js"; const DEFAULT_DNS_TIMEOUT_MS = 5_000; @@ -83,11 +84,14 @@ export async function resolveApprovedRemoteHttpAddresses( options.lookup ?? defaultLookup, options.dnsTimeoutMs ?? DEFAULT_DNS_TIMEOUT_MS, ); - } catch { - throw error("Remote MCP connection hostname could not be resolved", "remote_http_dns_failed"); + } catch (cause) { + const failure = error("Remote MCP connection hostname could not be resolved", "remote_http_dns_failed"); + const reason = readRemoteConnectionFailure(cause) ?? classifyRemoteConnectionError(cause); + if (reason === "dns_failure" || reason === "connection_timeout") markRemoteConnectionFailure(failure, reason); + throw failure; } if (results.length === 0) { - throw error("Remote MCP connection hostname did not resolve", "remote_http_dns_failed"); + throw markRemoteConnectionFailure(error("Remote MCP connection hostname did not resolve", "remote_http_dns_failed"), "dns_failure"); } if (results.some((result) => isAlwaysDeniedLinkLocalIp(result.address) @@ -123,7 +127,7 @@ async function lookupWithTimeout(hostname: string, lookup: RemoteHttpEndpointLoo lookup(hostname), new Promise((_, reject) => { timeout = setTimeout(() => { - reject(new Error(`DNS lookup timed out for ${hostname}`)); + reject(markRemoteConnectionFailure(new Error(`DNS lookup timed out for ${hostname}`), "connection_timeout")); }, timeoutMs); timeout.unref?.(); }), diff --git a/server/src/services/remote-http-fetch.ts b/server/src/services/remote-http-fetch.ts index c6efa351344..b02d8769fa3 100644 --- a/server/src/services/remote-http-fetch.ts +++ b/server/src/services/remote-http-fetch.ts @@ -4,6 +4,7 @@ import { connect as netConnect, isIP, type Socket } from "node:net"; import { Readable } from "node:stream"; import { connect as tlsConnect, type TLSSocket } from "node:tls"; import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; +import { classifyRemoteConnectionError, markRemoteConnectionFailure, recordRemoteConnectionAttempts } from "./remote-connection-failure.js"; import { isAlwaysDeniedLinkLocalIp, @@ -104,10 +105,12 @@ export async function guardedRemoteHttpFetch( return await platformFetch(endpoint.toString(), { ...init, redirect: "manual" }); } catch (error) { if (isDnsResolutionError(error)) { - throw options.error( + const failure = options.error( "Remote MCP connection hostname could not be resolved", "remote_http_dns_failed", ); + if (classifyRemoteConnectionError(error) === "dns_failure") markRemoteConnectionFailure(failure, "dns_failure"); + throw failure; } throw error; } @@ -188,17 +191,21 @@ async function dialApprovedAddress(input: { options: GuardedRemoteHttpFetchOptions; }): Promise { let lastReason: unknown; + const failedAttempts: unknown[] = []; for (const address of input.approved) { input.signal?.throwIfAborted?.(); try { return await openVerifiedSocket({ ...input, address }); } catch (error) { - if (!(error instanceof UnreachableAddressError)) throw error; + if (!(error instanceof UnreachableAddressError)) { + throw recordRemoteConnectionAttempts(error, [...failedAttempts, error]); + } lastReason = error.reason; + failedAttempts.push(error.reason); } } - throw lastReason - ?? input.options.error("Remote MCP endpoint could not be reached", "remote_http_connect_failed"); + throw recordRemoteConnectionAttempts(lastReason + ?? input.options.error("Remote MCP endpoint could not be reached", "remote_http_connect_failed"), failedAttempts); } /** @@ -318,7 +325,7 @@ async function sendRequest(input: { const headersTimer = setTimeout(() => { // Destroying the request tears the socket down too, so a silent peer costs // neither a pending handler nor a leaked descriptor. - req.destroy(error("Remote MCP endpoint did not respond in time", "remote_http_response_timeout")); + req.destroy(markRemoteConnectionFailure(error("Remote MCP endpoint did not respond in time", "remote_http_response_timeout"), "connection_timeout")); }, responseTimeoutMs); headersTimer.unref?.(); req.on("response", () => clearTimeout(headersTimer)); @@ -330,7 +337,7 @@ async function sendRequest(input: { // Body idle deadline, mirroring undici's `bodyTimeout`: a stalled stream is // destroyed so `response.text()` rejects instead of hanging the caller. message.setTimeout(responseTimeoutMs, () => { - message.destroy(error("Remote MCP endpoint stalled mid-response", "remote_http_response_timeout")); + message.destroy(markRemoteConnectionFailure(error("Remote MCP endpoint stalled mid-response", "remote_http_response_timeout"), "connection_timeout")); }); // A finished response must not leave an armed socket timer behind, or a later // reader of the same socket inherits a deadline it never asked for. @@ -395,7 +402,7 @@ function once( ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - settle(new Error(`Timed out trying to ${input.what} the remote MCP endpoint`)); + settle(markRemoteConnectionFailure(new Error(`Timed out trying to ${input.what} the remote MCP endpoint`), "connection_timeout")); }, input.timeoutMs); timer.unref?.(); diff --git a/server/src/services/run-failure-diagnostics.ts b/server/src/services/run-failure-diagnostics.ts index 605f0b8afef..71188371e37 100644 --- a/server/src/services/run-failure-diagnostics.ts +++ b/server/src/services/run-failure-diagnostics.ts @@ -203,6 +203,9 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport if (diagnostic.step) execution.workspaceRestoreStep = diagnostic.step; if (diagnostic.httpStatus !== undefined) execution.workspaceRestoreHttpStatus = diagnostic.httpStatus; if (diagnostic.exitCode !== undefined) execution.workspaceRestoreExitCode = diagnostic.exitCode; + if (diagnostic.transferStep) execution.workspaceRestoreTransferStep = diagnostic.transferStep; + if (diagnostic.transferFailureKind) execution.workspaceRestoreTransferFailureKind = diagnostic.transferFailureKind; + if (diagnostic.rpcCode !== undefined) execution.workspaceRestoreRpcCode = diagnostic.rpcCode; if (diagnostic.gitCommand) execution.workspaceRestoreGitCommand = diagnostic.gitCommand; if (diagnostic.gitFailureKind) execution.workspaceRestoreGitFailureKind = diagnostic.gitFailureKind; } diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index 33f6fbe33e8..a059bf523d0 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -11,6 +11,8 @@ import { } from "./run-failure-diagnostics.js"; import { logger } from "../middleware/logger.js"; import { isUnexpectedRunCancellation } from "./run-cancellation.js"; +import { readConnectionFailure, type ConnectionFailure } from "@paperclipai/adapter-utils/connection-failure"; +import { isAiConnectionConfigurationReason } from "./ai-connection-configuration-failure.js"; type HeartbeatRun = typeof heartbeatRuns.$inferSelect; @@ -46,6 +48,22 @@ function asRecord(value: unknown): Record | null { : null; } +/** An owned AI selection rejection before dispatch, not its broad catch wrapper. */ +function isAiConnectionSelectionBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean { + // Resumed native input may already have provider effects, even during preparation. + if (run.runtimeMode === "native" && asRecord(run.runnerProfileJson)?.nativeExecutionInput !== undefined) return false; + if ( + run.status !== "failed" || run.errorCode !== "configuration_incomplete" || + run.executionStage !== "preparing" || options.phase !== "setup" || + run.exitCode != null || run.signal != null + ) return false; + const configuration = asRecord(run.resultJson?.configurationIncomplete); + const recovery = asRecord(run.resultJson?.executionRecovery); + return configuration?.reason === "ai_connection_unavailable" && + isAiConnectionConfigurationReason(configuration.selectionFailure) && + recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false; +} + /** A known owner action before dispatch, not a secret-provider or runtime failure. */ function isMissingSecretConfigurationBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean { if ( @@ -103,6 +121,49 @@ function isLocalPathWorkspaceConfigurationBlocker(run: HeartbeatRun, options: Ru recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false; } +/** Only the managed-clone producer can attach this bounded pre-dispatch evidence. */ +function isGitConnectionFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean { + const connection = readConnectionFailure(run.resultJson?.connectionFailure); + if (connection?.provider !== "git" || run.status !== "failed" || run.executionStage !== "preparing" || + options.phase !== "setup" || run.exitCode != null || run.signal != null) return false; + const recovery = asRecord(run.resultJson?.executionRecovery); + if (recovery?.kind !== "bootstrap" || recovery.providerWorkStarted !== false) return false; + const validation = asRecord(run.resultJson?.workspaceValidation); + if (run.errorCode === "setup_failed") return !validation; + if (run.errorCode !== "workspace_validation_failed" || validation?.reason !== "git_worktree_base_materialization_failed") return false; + const failures = validation.materializationFailures; + return Array.isArray(failures) && failures.length > 0 && failures.every((failure) => { + const marker = readConnectionFailure(asRecord(failure)?.connectionFailure); + return marker?.provider === "git"; + }) && readConnectionFailure(asRecord(failures[0])?.connectionFailure)?.reason === connection.reason; +} + +const hermesTransportReasons = new Set([ + "connection_refused", "dns_failure", "network_unreachable", "connection_reset", "connection_timeout", "tls_failure", +]); + +/** An adapter result remains failed; this only excludes its proven external cause. */ +function isHermesConnectionFailure(run: HeartbeatRun, options: RunFailureReportOptions, adapterType: string | undefined): boolean { + if (adapterType !== "hermes_gateway" || run.status !== "failed" || run.exitCode !== 1 || run.signal != null || options.phase === "setup") return false; + const connection = readConnectionFailure(run.resultJson?.connectionFailure); + if (connection?.provider !== "hermes_gateway") return false; + const code = run.errorCode; + if (connection.operation === "configuration") { + const codes: Record["reason"], string> = { + endpoint_missing: "hermes_gateway_api_base_url_missing", + endpoint_invalid: "hermes_gateway_api_base_url_invalid", + insecure_transport: "hermes_gateway_plain_http_remote_denied", + credentials_missing: "hermes_gateway_api_key_missing", + }; + return code === codes[connection.reason]; + } + if (hermesTransportReasons.has(connection.reason)) return code === "hermes_gateway_connect_failed" || code === "hermes_gateway_protocol_error"; + return (connection.reason === "authentication_failed" && code === "hermes_gateway_auth_failed") || + (connection.reason === "endpoint_not_found" && code === "hermes_gateway_runs_unsupported") || + (connection.reason === "rate_limited" && code === "hermes_gateway_rate_limited") || + (connection.reason === "remote_unavailable" && code === "hermes_gateway_upstream_error"); +} + /** * Report a terminal run failure to Sentry. Returns at once for any status * other than failures and unexpected started cancellations. Never throws — a Sentry failure or a @@ -118,7 +179,9 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR if (!isRunFailureStatus(run.status)) return Promise.resolve(); if (run.status === "cancelled" && !isUnexpectedRunCancellation(run)) return Promise.resolve(); if (isMissingSecretConfigurationBlocker(run, options)) return Promise.resolve(); + if (isAiConnectionSelectionBlocker(run, options)) return Promise.resolve(); if (isLocalPathWorkspaceConfigurationBlocker(run, options)) return Promise.resolve(); + if (isGitConnectionFailure(run, options)) return Promise.resolve(); const runStatus = run.status; const report = captureTerminalRunFailure(db, run, runStatus, options); pendingRunFailureReports.add(report); @@ -147,6 +210,8 @@ async function captureTerminalRunFailure( .where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId))) .then((rows) => rows[0] ?? null); + if (isHermesConnectionFailure(run, options, agent?.adapterType)) return; + const taskId = readTaskId(run); if (!taskId) { logger.warn({ runId: run.id }, "run failure report has no task id, skipping Sentry report"); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 2dc15f67b9f..def04ecabcd 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -10,6 +10,7 @@ import { composioAppAccounts, composioAppSetupResult } from "./composio-app-setu import { honchoManagedArguments } from "./honcho-connection.js"; import { defaultConnectionAgentInstructions } from "@paperclipai/shared"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; +import { mcpDiscoveryHttpFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "./mcp-connection-failure.js"; import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { BROWSER_USE_TOOLS } from "@paperclipai/shared"; import { browserUseClient, isBrowserUseConnection } from "./browser-use-client.js"; @@ -2886,6 +2887,7 @@ function healthFailureHttpStatus(failure: { if (failure.code === "oauth_challenge") return 422; if (failure.code === "oauth_refresh_missing") return 422; if (failure.code === "oauth_reauthorization_required") return 422; + if (failure.code === "oauth_insufficient_scope") return 422; if (failure.code === "slack_mcp_access_disabled") return 422; if (failure.code === "user_authorization_required") return 422; if (failure.code === "composio_broker_retired") return 422; @@ -7142,13 +7144,26 @@ export function toolAccessService( // PAP-17098 closed for the OAuth endpoints. let listRequestId = "paperclip-catalog-refresh"; let sessionHeaders = headers; - const sendRemote = (init: RequestInit) => requestRemoteHttpEndpoint(new URL(endpoint), init); + const sendRemote = (init: RequestInit) => withMcpConnectionFailure(() => requestRemoteHttpEndpoint(new URL(endpoint), init)); const sendToolsList = (requestHeaders: Record, cursor?: string) => { sessionHeaders = requestHeaders; return sendRemote({ method: "POST", headers: mcpHttpRequestHeaders(requestHeaders), body: JSON.stringify({ jsonrpc: "2.0", id: listRequestId, method: "tools/list", params: cursor ? { cursor } : {} }) }); }; - let usedInitializedSession = connection.config.mcpSessionRequired === true; + const connectionConfig = asRecord(connection.config); + const sourceTemplateKey = typeof connectionConfig?.sourceTemplateKey === "string" + ? connectionConfig.sourceTemplateKey + : null; + const connectionMethodKey = typeof connectionConfig?.connectionMethodKey === "string" + ? connectionConfig.connectionMethodKey + : null; + const curatedMethodRequiresMcpSession = Boolean( + sourceTemplateKey && connectionMethodKey && + getConnectableAppDefinition(sourceTemplateKey)?.methods.some((method) => + method.key === connectionMethodKey && method.defaults?.mcpSessionRequired === true + ), + ); + let usedInitializedSession = connectionConfig?.mcpSessionRequired === true || curatedMethodRequiresMcpSession; let response: Response; if (usedInitializedSession) { try { @@ -7193,7 +7208,7 @@ export function toolAccessService( } if ( usedInitializedSession && - connection.config.mcpSessionRequired !== true + connectionConfig?.mcpSessionRequired !== true ) { const nextConfig = { ...connection.config, mcpSessionRequired: true }; await db @@ -7330,14 +7345,12 @@ export function toolAccessService( oauthSupported: Boolean(endpoints), }); } - throw new HttpError(502, `Remote app returned HTTP ${response.status}`, { - status: response.status, - }); + throw mcpDiscoveryHttpFailure(response, `Remote app returned HTTP ${response.status}`); } const descriptors: McpToolDescriptor[] = []; const seenCursors = new Set(); for (let page = 0; ; page += 1) { - const payload = await readMcpHttpResponse(response, listRequestId); + const payload = await withMcpConnectionFailure(() => readMcpHttpResponse(response, listRequestId)); const record = asRecord(payload); if (record.error) throw new HttpError(502, "Remote MCP tool discovery failed", { code: "mcp_catalog_error" }); const result = asRecord(record.result); @@ -7349,7 +7362,7 @@ export function toolAccessService( seenCursors.add(cursor); listRequestId = `paperclip-catalog-refresh-${page + 1}`; response = await sendToolsList(sessionHeaders, cursor); - if (!response.ok) throw new HttpError(502, "Remote MCP catalog page could not be read", { status: response.status }); + if (!response.ok) throw mcpDiscoveryHttpFailure(response, "Remote MCP catalog page could not be read"); } if (!isRailwayConnection(connection)) return descriptors; if (descriptors.some((tool) => normalizeRailwayToolName(tool.name).startsWith(RAILWAY_TOOL_PREFIX))) { @@ -7700,13 +7713,13 @@ export function toolAccessService( actor, details: { status: failure.status, transport: connection.transport }, }); - throw new HttpError(healthFailureHttpStatus(failure), failure.message, { + throw retainMcpConnectionFailure(error, new HttpError(healthFailureHttpStatus(failure), failure.message, { code: failure.code, connection: toConnection(updated), runtimeSlot, setupUrl: connectionSetupUrl(connection), reconnectUrl: connectionReconnectUrl(connection), - }); + })); } } @@ -7753,11 +7766,11 @@ export function toolAccessService( details: { status: failure.status }, actor, }); - throw new HttpError(healthFailureHttpStatus(failure), failure.message, { + throw retainMcpConnectionFailure(error, new HttpError(healthFailureHttpStatus(failure), failure.message, { code: failure.code, setupUrl: connectionSetupUrl(connection), reconnectUrl: connectionReconnectUrl(connection), - }); + })); } const existingRows = await db @@ -9955,8 +9968,15 @@ export function toolAccessService( ); const host = new URL(input.redirectUri).host; + const clientNameHost = host + .replace(/[^A-Za-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); const requestedMetadata = { - client_name: `Paperclip (${host})`, + // Some DCR servers restrict client_name to alphanumeric characters, + // hyphens and spaces. Keep the deployment host recognizable without + // sending punctuation such as dots, colons or parentheses. + client_name: `Paperclip ${clientNameHost}`, redirect_uris: [input.redirectUri], grant_types: [ "authorization_code", @@ -10428,7 +10448,14 @@ export function toolAccessService( source: "preconfigured" as const, }; } - const metadataDocumentUrl = input.endpoints + const method = input.galleryEntry + ? connectionMethodForConnection(input.galleryEntry, input.connection) + : null; + const dcrRequired = method?.oauthClientRegistration === "dcr"; + // A reviewed provider compatibility choice may require DCR even when the + // server also advertises CIMD. Do not resolve/adopt a metadata URL for that + // method; the default for every other method remains CIMD-first. + const metadataDocumentUrl = !dcrRequired && input.endpoints .clientIdMetadataDocumentSupported ? await resolveOAuthClientIdMetadataDocumentUrl( input.redirectUri, @@ -10487,6 +10514,12 @@ export function toolAccessService( }, ); } + if (dcrRequired && !input.endpoints.registrationUrl) { + throw unprocessable( + "This provider requires dynamic client registration, but its authorization server did not advertise a registration endpoint.", + { code: "oauth_dcr_not_supported" }, + ); + } const key = `${input.connection.id}:${input.redirectUri}`; return singleFlight(oauthRegistrationFlights, key, async () => { @@ -10528,8 +10561,9 @@ export function toolAccessService( source: storedOAuthClientRegistrationSource(bound), }; } - // 3. Client ID Metadata Documents: no registration call at all, so prefer - // them over DCR when the authorization server advertises support. + // 3. By default, Client ID Metadata Documents need no registration call, + // so prefer them over DCR when the authorization server advertises + // support. Curated DCR opt-ins leave metadataDocumentUrl null above. if (metadataDocumentUrl) { const adopted = await adoptClientIdMetadataDocument({ connection: latest, @@ -12773,6 +12807,9 @@ export function toolAccessService( sourceTemplateKey: galleryEntry.slug, connectionMethodKey: method?.key, methodConfig: normalizedMethodConfig?.values ?? {}, + ...(method?.defaults?.mcpSessionRequired === true + ? { mcpSessionRequired: true } + : {}), // Grant-backed setup keeps the full discovered catalog selectable; // the wizard projects the app's action defaults into policies at // finish time instead of using catalog quarantine as access state. diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 3e56275d205..86d79cd2f9c 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -91,6 +91,7 @@ import type { UpdateToolMcpGateway, } from "@paperclipai/shared"; import { + getConnectableAppDefinition, isGitHubConnectorProfileId, isGoogleWorkspaceConnectorProfileId, type GitHubConnectorProfileId, @@ -470,6 +471,21 @@ function asRecord(value: unknown): Record | null { return value as Record; } +function connectionRequiresMcpSession(connection: { config: unknown }): boolean { + const config = asRecord(connection.config); + if (config?.mcpSessionRequired === true) return true; + const sourceTemplateKey = typeof config?.sourceTemplateKey === "string" + ? config.sourceTemplateKey + : null; + const connectionMethodKey = typeof config?.connectionMethodKey === "string" + ? config.connectionMethodKey + : null; + if (!sourceTemplateKey || !connectionMethodKey) return false; + return getConnectableAppDefinition(sourceTemplateKey)?.methods.some((method) => + method.key === connectionMethodKey && method.defaults?.mcpSessionRequired === true + ) ?? false; +} + async function mapWithConcurrency( items: readonly T[], concurrency: number, @@ -5972,6 +5988,27 @@ export function createToolGatewayService( // letting the tighter default cut a legitimately slow tool short. responseTimeoutMs: ms, }); + const requireExplicitRetryAfterOAuthRefresh = async (refreshedResponse: Response): Promise => { + // The provider rejected this dispatch, but automatic replay is unsafe: + // a server can apply a request before returning 401. A caller retry + // starts a fresh credential-scoped MCP session and dispatches once. + if (mcpSession) forgetMcpHttpSession(mcpSession); + await refreshedResponse.body?.cancel().catch(() => undefined); + execution.response = { + httpStatus: refreshedResponse.status, + contentType: refreshedResponse.headers.get("content-type"), + bodySizeBytes: 0, + upstreamRequestId: + refreshedResponse.headers.get("x-request-id") ?? + refreshedResponse.headers.get("traceparent"), + }; + throw new ToolGatewayHttpError( + 409, + "The provider rejected the saved OAuth token, which has now been refreshed. Retry the action explicitly; the original call was not replayed.", + "oauth_refreshed_retry_required", + { connectionId: connection.id, catalogEntryId: entry.id, execution }, + ); + }; if (isRailwayEndpoint(connection.config.url) && normalizeRailwayToolName(entry.toolName).startsWith(RAILWAY_TOOL_PREFIX)) { if (!isRailwayConnection(connection) || connection.config.railwayApiStatus !== "available") { throw new ToolGatewayHttpError(422, "Railway API access is not verified. Refresh actions or reconnect this Railway connection.", "railway_api_not_verified"); @@ -6002,7 +6039,7 @@ export function createToolGatewayService( }; } let requestHeaders = headers; - if (connection.config.mcpSessionRequired === true) { + if (connectionRequiresMcpSession(connection)) { const scope = `${connection.id}:grant:${grant.id}:actor:${session.agentId}:${endpoint}`; requestHeaders = await getMcpHttpSession({ scope, @@ -6066,24 +6103,7 @@ export function createToolGatewayService( }); headers = builtHeaders.headers; headerSummary = builtHeaders.summary; - response = await dispatchRemote(endpoint, { - ...requestInit, - headers: mcpHttpRequestHeaders(headers), - }); - if (response.status === 401) { - await db - .update(connectionGrants) - .set({ - status: "needs_reauthorization", - updatedAt: new Date(options.now?.() ?? Date.now()), - }) - .where( - and( - eq(connectionGrants.id, grant.id), - eq(connectionGrants.companyId, connection.companyId), - ), - ); - } + await requireExplicitRetryAfterOAuthRefresh(response); } if ( response.status === 401 && @@ -6106,10 +6126,7 @@ export function createToolGatewayService( }); headers = builtHeaders.headers; headerSummary = builtHeaders.summary; - response = await dispatchRemote(endpoint, { - ...requestInit, - headers: mcpHttpRequestHeaders(headers), - }); + await requireExplicitRetryAfterOAuthRefresh(response); } if ( response.status === 401 && @@ -6146,10 +6163,7 @@ export function createToolGatewayService( }); headers = builtHeaders.headers; headerSummary = builtHeaders.summary; - response = await dispatchRemote(endpoint, { - ...requestInit, - headers: mcpHttpRequestHeaders(headers), - }); + await requireExplicitRetryAfterOAuthRefresh(response); } const sessionExpired = response.status === 404 && new Headers(requestHeaders).has("mcp-session-id"); if (sessionExpired) { diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index bfab10f1e21..a7c81b2b038 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 = @@ -133,6 +135,8 @@ export const QUALIFIED_ACPX_PROFILES = runner.QUALIFIED_ACPX_PROFILES; export const QUALIFIED_ACPX_VERSION = runner.QUALIFIED_ACPX_VERSION; export const CURSOR_DISTRIBUTION_PINS = runner.CURSOR_DISTRIBUTION_PINS; export const resolveSourceCodexHome = runner.resolveSourceCodexHome; +export const resolveCodexCommand = runner.resolveCodexCommand; +export const resolvePinnedCodexCommand = runner.resolvePinnedCodexCommand; export const validatePrpEvent = runner.validatePrpEvent; export const validatePrpStructuredRunResult = runner.validatePrpStructuredRunResult; @@ -143,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 6610c978031..1d0d8f4151c 100644 --- a/server/src/vendor/paperclip-runner/live/index.ts +++ b/server/src/vendor/paperclip-runner/live/index.ts @@ -11,3 +11,4 @@ export const validateCopilotMetadata = runner.validateCopilotMetadata; export const probeAcpxClaudeInstallation = runner.probeAcpxClaudeInstallation; export const probeAcpxGrokInstallation = runner.probeAcpxGrokInstallation; export const probeAcpxCursorInstallation = runner.probeAcpxCursorInstallation; +export const probeAcpxPiInstallation = runner.probeAcpxPiInstallation; diff --git a/tests/acpx-qualification-models.ts b/tests/acpx-qualification-models.ts index 44452c47e85..ef4a978009c 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 de7fb5cbde7..649758e26e7 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 de11726d126..cbd94d7bdfc 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/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index cf575235bef..f920bb3eab9 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -20,6 +20,15 @@ import { GITHUB_PRIVATE_KEY_PASTE_FIXTURE, } from "./chat-adapters-ui.shared"; +async function findConnectorBySearch(page: Page, query: string, slug: string) { + const search = page.getByRole("searchbox", { name: "Search connectors" }); + await expect(search).toBeVisible(); + await search.fill(query); + const connector = page.locator(`[role="listitem"][data-app-slug="${slug}"]`); + await expect(connector).toBeVisible({ timeout: 30_000 }); + return connector; +} + async function exerciseGitHubReviewSetup(page: Page, mock: ChatMock, seed: Seed, provider: ProviderCase) { await expect(page.getByRole("heading", { name: "Choose agent", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Choose an agent", exact: true }).click(); @@ -289,10 +298,7 @@ test.describe.serial("native chat adapter UI", () => { await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible( { timeout: 30_000 }, ); - const connector = page.locator( - '[role="listitem"][data-app-slug="github"]', - ); - await expect(connector).toBeVisible(); + const connector = await findConnectorBySearch(page, "GitHub", "github"); // Without the cloud connector GitHub's default method is a token, so the // card's verb is "Add key" rather than "Connect". await connector.getByRole("button", { name: "Add key GitHub" }).click(); @@ -367,17 +373,15 @@ test.describe.serial("native chat adapter UI", () => { await expect( page.getByRole("heading", { name: "Connectors" }), ).toBeVisible({ timeout: 30_000 }); - const connector = page.locator( - `[role="listitem"][data-app-slug="${provider.slug}"]`, - ); - await expect(connector).toBeVisible({ timeout: 30_000 }); + let connector = await findConnectorBySearch(page, provider.name, provider.slug); if (provider.provider === "github") { - const tools = page.locator('[role="listitem"][data-app-slug="github"]'); + const tools = await findConnectorBySearch(page, "GitHub", "github"); await tools.getByRole("button", { name: "Add key GitHub", exact: true }).click(); await page.getByRole("button", { name: "Change", exact: true }).click(); await expect(page.getByRole("heading", { name: "Connect GitHub as" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Choose how to connect" })).toHaveCount(0); await page.goto(`/${seed.prefix}/apps`); + connector = await findConnectorBySearch(page, provider.name, provider.slug); } await connector .getByRole("button", { name: `Connect ${provider.provider === "github" ? "GitHub Code Review Bot" : provider.name}` }) @@ -980,10 +984,7 @@ test.describe("iMessage Photon setup and management", () => { theme, ); await page.goto(`/${seed.prefix}/apps`); - const card = page.locator( - '[role="listitem"][data-app-slug="imessage-photon"]', - ); - await expect(card).toBeVisible(); + const card = await findConnectorBySearch(page, "iMessage Photon", "imessage-photon"); await card .getByRole("button", { name: "Connect iMessage Photon" }) .click(); diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 0276144826e..2f86eb1b644 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, @@ -141,6 +161,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 @@ -316,6 +344,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 @@ -366,3 +858,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 ef63f4bdd90..1d7ed8fe742 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -111,6 +111,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 @@ -254,6 +273,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 @@ -1275,6 +1295,30 @@ contract tests separately verify that the advertised tool carries the documentat for fresh and resumed native executions. This tests the current documentation placement; it is not a statistical comparison with the former prompt arrangement. +The separate explicit-only `question-resume` suite uses the same neutral two-question +request on native Codex and ACPX Claude. It qualifies the user journey through +either the provider's built-in question or Paperclip's semantic question tool. +It does not replace or regrade `question-tool-documentation`. + +The oracle classifies each wait from its exact task/agent/run identity. Provider +questions require the server-generated request key and retain the paused run. +Semantic questions require an applied `request_human_input` receipt and one new +response wake bound to that interaction and source run. Only a verified provider +choice may carry the adapter's optional Other field. A second substantive question +is still rejected; the subsequent reference question must be text-only. Both real +board answers must remain bound to unchanged forms, and one saved document must +include both answers. Existing lifecycle, ownership, premature-output, terminal +state and no-extra-task checks apply. Three checkpoints are required even when +both answers resume one run; missing native inputs or unexplained runs fail. + +Select `question-resume.runner-acpx-claude.local.question-answer-resume` for the +bounded Claude qualification. The suite is excluded from `--all`; it allows one +attempt, one to three actual runs, a ten-minute cell deadline and verified +1,000-cent company/agent hard stops. The historical failure where the optional +Other field stopped the driver before any answer remains a FAIL. A new behavior +pass does not establish semantic-tool selection, documentation placement, default +hiring behavior or general reliability. + Continuation screenshots wait for the correct task heading and fully revealed conversation before capture. A loading screen or wrong task fails capture. Browser-only regressions exercise delayed rendering without provider calls: @@ -1330,7 +1374,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 @@ -1539,6 +1602,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 @@ -1629,6 +2004,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 2bec73c7782..a5c17bb800a 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 c82eddfd94e..4897dee5ff4 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 1dc20adca32..9aaba327558 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -156,10 +156,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 63, 12, 6, 8, 4, 2, 4, 2, 4, 30, 3, 16, 16, 2, 6, 8, 46, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116, + 4, 8, 10, 6, 63, 12, 6, 4, 2, 4, 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(708); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(708); + expect(validateRunnerCatalog()).toHaveLength(726); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(726); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); @@ -470,7 +470,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 7c23159052b..d9f6a837a0b 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,16 +11,13 @@ 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 { copilotProviderDeathTasks } from "./copilot-active-stop-tasks.js"; -import { copilotProtectionTasks } from "./copilot-protection-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"; import { blockerTasks, blockerProfile } from "./blocker-cases.js"; import { accountingTasks } from "./accounting-cases.js"; -import { continuationTasks } from "./continuation-cases.js"; +import { continuationTasks, questionResumeTask } from "./continuation-cases.js"; import { contextIntegrityTasks, paperclipDocumentTask } from "./context-integrity-cases.js"; import { productionDefaultHireProfile, stockHarnessSourceDigest, stockHarnessSkillSources } from "./stock-harness.js"; import { lifecycleLiveTasks, lifecycleLiveDefinitionDigest } from "./lifecycle-live-cases.js"; @@ -170,6 +173,8 @@ function legacyProfile(input: { }; } +const PI_QUALIFICATION_THINKING_LEVEL = "low" as const; + function nativeProfile(input: { id: string; label: string; @@ -221,6 +226,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 @@ -354,7 +360,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" }, }), ]; @@ -1099,6 +1105,58 @@ 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: "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.", @@ -1135,13 +1193,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ environments: [localEnvironment], tasks: blockerTasks, expectedMatrixSize: 6, definitionMetadata: { version: 1, instructions: "production-coordination-skill", grading: "saved-human-decision-ownership-and-resume", scheduling: "explicit-only" }, }, - { - id: "cursor-native", label: "Cursor native interactions", manualOnly: true, - description: "Native question continuation, revision-bound plan decisions and restrictive permission denial with independent process and file evidence.", - groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "cursor"), - environments: runnerEnvironments, tasks: cursorNativeTasks, expectedMatrixSize: 8, - definitionMetadata: { version: 2, qualification: "pending", scheduling: "explicit-only", profileVersion: QUALIFIED_ACPX_PROFILES.cursor.agentProfileVersion, modeAdmission: "native-config-ack", artifactExport: "pending-private-home", remoteEvidence: "owned-lease-sealed-observer" }, - }, + { id: "native-active-stop", label: "Stop an unanswered native permission", manualOnly: true, description: "Stop while one exact Cursor or Copilot native permission remains unanswered; require cancelled provider settlement, caller-owned acknowledgement, stale-answer refusal and independent retirement/no effects.", @@ -1179,26 +1231,14 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ }], 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 and Copilot.", - groups: ["native", "warm"], - profiles: extendedHarnessProfiles.filter(profile => ["cursor", "copilot"].includes(profile.qualificationCandidate ?? "")).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: 4, - definitionMetadata: { version: 3, savedTokenLifecycle: "fenced-session-and-owned-agent-files", leaseEvidence: "daytona-only", 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", @@ -1279,6 +1319,16 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ excludedExecutionIds: ["neutral", "challenge"].map(variant => `lifecycle-baseline.runner-codex.local.lifecycle-repair-${variant}`), definitionMetadata: { version: 5, clarificationGrading: "wrapped-information-fields-list", narrativeDigest: lifecycleLiveDefinitionDigest, grading: "durable-state-and-attributed-narrative", scheduling: "explicit-only" }, }, + { + id: "question-resume", label: "Native question and answer continuation", manualOnly: true, + description: "Two real UI answers, exact provider-pause or semantic-wake identity, and one saved document.", + groups: ["native", "local"], environments: [localEnvironment], + profiles: runnerProfiles.filter(p => ["runner-codex", "runner-acpx-claude"].includes(p.id)).map(productionStoryProfile), + tasks: [questionResumeTask], expectedMatrixSize: 2, + definitionMetadata: { version: 1, grading: "bound-question-path-and-two-durable-answers", instructions: "unchanged-production", + scheduling: "explicit-only", automaticRetryPolicy: "single_attempt", companyAndAgentBudgetCents: 1_000, + providerRuns: "1-3; each semantic answer starts one exactly bound wake; provider answers retain their paused run" }, + }, { id: "continuation", label: "Task continuation", description: "Human direction, approval boundaries, untrusted evidence, and completed actions across turns.", @@ -1580,6 +1630,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, @@ -1618,7 +1669,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, @@ -1678,6 +1730,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, @@ -1685,6 +1739,7 @@ export function validateRunnerCatalog(): MatrixExecution[] { ...accountingTasks, ...lifecycleLiveTasks, ...continuationTasks, + questionResumeTask, ...everydayTasks, ...taskTitleTasks, ...runnerTasks, diff --git a/tests/runner-e2e/cleanup-verification.test.ts b/tests/runner-e2e/cleanup-verification.test.ts index 54d9a127c03..fdca9d33df0 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/continuation-cases.ts b/tests/runner-e2e/continuation-cases.ts index 65810ad4fb7..781d44e394c 100644 --- a/tests/runner-e2e/continuation-cases.ts +++ b/tests/runner-e2e/continuation-cases.ts @@ -10,9 +10,9 @@ export const CONTINUATION_CASES = [ "question-tool-documentation", "provider-question-bridge", ] as const; -export type ContinuationCase = (typeof CONTINUATION_CASES)[number]; +export type ContinuationCase = (typeof CONTINUATION_CASES)[number] | "question-answer-resume"; export function continuationScenario(id: string, nonce: string) { - if (!CONTINUATION_CASES.includes(id as ContinuationCase)) + if (id !== "question-answer-resume" && !CONTINUATION_CASES.includes(id as (typeof CONTINUATION_CASES)[number])) throw new Error(`Unknown continuation case: ${id}`); const marker = `AMBER${nonce.replace(/[^a-z0-9]/gi, "")}`; const old = `COBALT${nonce.replace(/[^a-z0-9]/gi, "")}`; @@ -21,9 +21,11 @@ export function continuationScenario(id: string, nonce: string) { const childTitle = `Follow-up ${marker}`; const document = "Save the final note as a task document. No other deliverables or child tasks are needed."; + const questionPrompt = "Help me write a one-sentence welcome note for a club meetup. First let me choose Morning or Afternoon using clickable choices. After I choose, ask me for a reference to include using an open text field. Ask only one question at a time and wait for my answers. Then save the note as a task document, including the selected time and my reference exactly as supplied, and finish. Do not create any other tasks or deliverables."; const prompts: Record = { + "question-answer-resume": questionPrompt, "provider-question-bridge": `Use your built-in AskUserQuestion tool (not Paperclip's request_human_input) to ask which reference to include, with two choices: ${marker} and ${old}. Wait for my real answer, then save a one-sentence welcome note including only my selected reference as a task document and finish. No other tasks or deliverables are needed.`, - "question-tool-documentation": `Help me write a one-sentence welcome note for a club meetup. First let me choose Morning or Afternoon using clickable choices. After I choose, ask me for a reference to include using an open text field. Ask only one question at a time and wait for my answers. Then save the note as a task document, including the selected time and my reference exactly as supplied, and finish. Do not create any other tasks or deliverables.`, + "question-tool-documentation": questionPrompt, "answer-updates-scope": `I need a one-sentence welcome note containing ${old}. Before writing it, ask me one open-ended structured question about any changes I want. Then apply my answer and finish. ${document}`, "clarification-not-approval": `I need a one-sentence welcome note. First ask me one open-ended structured question for the word to include. After my answer, propose your approach and wait for my explicit approval before writing the note. ${document}`, "revision-preserves-approval": `Propose an approach for a one-sentence welcome note containing ${old}, and save that approach as the plan document. Wait for my explicit approval before writing the note. ${document}`, @@ -85,3 +87,12 @@ export function continuationScreenshotFile( ? "final-state.png" : `question-continuation-${phase}.png`; } + +/** Separate behavior contract; leave the semantic documentation case unchanged. */ +export const questionResumeTask: RunnerTaskFixture = { + ...continuationTasks.find(t => t.id === "question-tool-documentation")!, + id: "question-answer-resume", label: "Two answered questions across native paths", + expectedRunCount: 3, minimumExpectedRunCount: 1, + buildTitle: nonce => `Question resume ${nonce}`, + buildPrompt: nonce => continuationScenario("question-answer-resume", nonce).prompt, +}; diff --git a/tests/runner-e2e/continuation-flow.ts b/tests/runner-e2e/continuation-flow.ts index 6381d03b5c4..8c0ad9ea222 100644 --- a/tests/runner-e2e/continuation-flow.ts +++ b/tests/runner-e2e/continuation-flow.ts @@ -1,3 +1,4 @@ +import { validResumeQuestionForm } from "./question-resume-scoring.js"; import { gradeLifecycleBaseline, type LifecycleCheckpoint } from "./lifecycle-baseline.js"; import { lifecycleLiveCase, lifecycleLiveContinuation, gradeLifecycleNarrative } from "./lifecycle-live-cases.js"; import { prepareLegacyContinuationSkill, prepareContinuationBudget } from "./continuation-fixtures.js"; @@ -106,7 +107,7 @@ export async function runContinuationFlow(input: { return ready; }, reject: (state) => - state.runs.length > 12 + state.runs.length > (scenario.id === "question-answer-resume" ? 3 : 12) ? "Bounded continuation run count exceeded" : state.runs.some((r) => ["failed", "timed_out", "cancelled"].includes(r.status), @@ -185,7 +186,9 @@ export async function runContinuationFlow(input: { ); expect(questions, "one real question must be shown").toHaveLength(1); const set = chatQuestionPresentation(questions[0].payload); - if (scenario.id === "provider-question-bridge") { + if (scenario.id === "question-answer-resume") { + expect(validResumeQuestionForm(checkpoints.at(-1)!, questions[0], !!choice), "a verified native question path and the requested form").toBe(true); + } else if (scenario.id === "provider-question-bridge") { expect(isSingleClaudeQuestion(set.questions), "one choice question with only the optional provider Other field").toBe(true); } else expect(set.questions, "ask only the requested next question").toHaveLength(1); const before = new Set(runs.map((r) => r.id)); @@ -267,7 +270,7 @@ export async function runContinuationFlow(input: { await input.restart(); await open(); } - if (scenario.id === "question-tool-documentation") { + if (["question-tool-documentation", "question-answer-resume"].includes(scenario.id)) { await answer("Afternoon"); await snapshot("answered"); assertWaiting(); diff --git a/tests/runner-e2e/continuation-scoring.ts b/tests/runner-e2e/continuation-scoring.ts index ada89b09167..42e79f8ed0b 100644 --- a/tests/runner-e2e/continuation-scoring.ts +++ b/tests/runner-e2e/continuation-scoring.ts @@ -1,3 +1,4 @@ +import { gradeQuestionResume } from "./question-resume-scoring.js"; import { createHash } from "node:crypto"; import { gradeQuestionDocumentation } from "./question-documentation-scoring.js"; import type { ContinuationCase } from "./continuation-cases.js"; @@ -49,7 +50,7 @@ export function gradeContinuation(input: { const before = input.checkpoints.filter((c) => c.phase !== "final"); check( "recorded-continuation", - before.length > 0 && !!final && final.runs.length >= (input.id === "provider-question-bridge" ? 1 : 2), + before.length > 0 && !!final && final.runs.length >= (["provider-question-bridge", "question-answer-resume"].includes(input.id) ? 1 : 2), "Initial and final turns must both be recorded.", ); for (const c of before) { @@ -135,5 +136,6 @@ export function gradeContinuation(input: { "A real provider-native card must be answered and resume the same run to completion."); } if (input.id === "question-tool-documentation") checks.push(...gradeQuestionDocumentation(input.checkpoints, input.marker)); + if (input.id === "question-answer-resume") checks.push(...gradeQuestionResume(input.checkpoints, input.marker)); return checks; } diff --git a/tests/runner-e2e/copilot-protection-tasks.ts b/tests/runner-e2e/copilot-protection-tasks.ts index 6662e884c92..808a79eb789 100644 --- a/tests/runner-e2e/copilot-protection-tasks.ts +++ b/tests/runner-e2e/copilot-protection-tasks.ts @@ -12,7 +12,7 @@ export const copilotProtectionTasks: readonly RunnerTaskFixture[] = copilotProte "Run the supplied exact command once using native bash with mode async and detach false.", "After starting it, attempt to finish immediately without calling read_bash or another waiting tool. The attached-process runtime must settle the command before ending the turn.", "Do not modify fixture code, manufacture its result, or start another command.", - "The fixture-owned marker is a private diagnostic sentinel, not a requested file deliverable. Do not publish or attach it.", + "The fixture-owned marker is a private diagnostic sentinel, not a requested file deliverable. Do not publish it. Do not attach it.", `Call paperclip_finish with reportedWorkDisposition done, summary COPILOT-PROTECTION-${c.id}-${nonce}, current completion contract revision, satisfied objective criterion, no remaining work, evidence [], verification []. After acceptance reply with that exact summary.`, ].join("\n"), buildMatchers: () => [], // Flow grades public durable origin and independent OS/filesystem observations. diff --git a/tests/runner-e2e/cursor-native-cases.ts b/tests/runner-e2e/cursor-native-cases.ts index 9ca4add5d03..f792fd1c8ae 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 27c845f18ae..6af5af33688 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 6a76ed9145e..346e6c3a7d8 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 6ffde6bfae7..47e930a0675 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -51,17 +51,19 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-runner/test/fixtures/copilot-profile-v24-identity.json", "packages/paperclip-runner/test/fixtures/copilot-profile-v25-identity.json", "packages/paperclip-runner/test/fixtures/copilot-profile-v26-identity.json", - "packages/paperclip-runner/test/fixtures/copilot-profile-v33-identity.json", + "packages/paperclip-runner/test/fixtures/copilot-profile-v34-identity.json", "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 428ef2ddd84..2ffd970e755 100644 --- a/tests/runner-e2e/daytona-image.test.ts +++ b/tests/runner-e2e/daytona-image.test.ts @@ -111,6 +111,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", @@ -234,11 +242,13 @@ describe("runner E2E Daytona image contract", () => { "packages/paperclip-runner/test/fixtures/copilot-profile-v24-identity.json", "packages/paperclip-runner/test/fixtures/copilot-profile-v25-identity.json", "packages/paperclip-runner/test/fixtures/copilot-profile-v26-identity.json", - "packages/paperclip-runner/test/fixtures/copilot-profile-v33-identity.json", + "packages/paperclip-runner/test/fixtures/copilot-profile-v34-identity.json", "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); @@ -268,6 +278,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, @@ -313,7 +325,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"] }); @@ -350,6 +366,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 00000000000..0f6b5f3c834 --- /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 73abc204be3..a6aa2f85d9a 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 1f8a82cc7e8..6d2a152fd37 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 00000000000..752408372cf --- /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 00000000000..9f5707adddb --- /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 956a1fc5ab4..1202a4c57f4 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,11 +117,20 @@ export function buildRunnerE2EProcessEnvironment( const candidates = new Map(); for (const execution of executions) { const agent = execution.profile.qualificationCandidate; - if (!agent || agent === "cursor") continue; - const admittedSuite = execution.suite.id === "extended-harnesses" || (agent === "copilot" && ["rich-acp-warm-continuity", "copilot-protection", "native-active-stop", "copilot-provider-death"].includes(execution.suite.id)); + if (!agent) continue; + const admittedSuite = execution.suite.id === "extended-harnesses" + || 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") + || (["copilot-protection", "copilot-provider-death"].includes(execution.suite.id) && 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); @@ -168,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 00000000000..4020bafe8c4 --- /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 00000000000..97dc50a49f2 --- /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 00000000000..0a6b8e7679c --- /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 00000000000..b23742f692a --- /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 00000000000..c8c9ee07e17 --- /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 0ad54f51ab1..f2ee17b90ad 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 06fa730accd..3ffaba95f34 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 81b928b8f9b..1b7b0d22ffb 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 98475efb6a7..b3290af7ab2 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 cc931d7064b..7456f2e0195 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,13 +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 allCells = runnerMatrix.filter(e => e.suite === suite); - expect(allCells).toHaveLength(4); - const cells = allCells.filter(e => e.profile.qualificationCandidate === "cursor"); - 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"]); + const cells = runnerMatrix.filter(e => e.suite === suite); + 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: 20, 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/pi-bootstrap-permission.test.ts b/tests/runner-e2e/pi-bootstrap-permission.test.ts new file mode 100644 index 00000000000..7413da01b33 --- /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 00000000000..ea7054bfc03 --- /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 00000000000..24b17e1a740 --- /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 00000000000..b28f3b3091e --- /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 00000000000..5425bd26271 --- /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 00000000000..3944871670c --- /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 00000000000..dcfb126de7e --- /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 00000000000..f121f06af4b --- /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 00000000000..0eda6cd77a4 --- /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 00000000000..0dbd7c0283f --- /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 00000000000..03de12ad23f --- /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 00000000000..a58ac8c5430 --- /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 00000000000..836763e132a --- /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 00000000000..58874cd106f --- /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 00000000000..7ff3d95ec83 --- /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 00000000000..a6b452b69a6 --- /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 00000000000..0f1be0a5314 --- /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 00000000000..487e60f15af --- /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 00000000000..1189e0863ae --- /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 00000000000..6eeb401e64e --- /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 00000000000..f9a69039543 --- /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 00000000000..34a34739d3d --- /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 00000000000..56537a8b53f --- /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 00000000000..281c78cbc47 --- /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 00000000000..4c059c2cf55 --- /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 8fd8441e84f..195c81536e4 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 8bb7781f566..e9d5430a15e 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" - && ["copilot_protection", "native_active_stop", "cursor_native", "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 f88fd34b748..f53b9d4340f 100644 --- a/tests/runner-e2e/process-tree-owner.test.ts +++ b/tests/runner-e2e/process-tree-owner.test.ts @@ -33,6 +33,18 @@ it.skipIf(process.platform === "win32")("keeps surviving peer identities when a await f.owner.observe(); await f.owner.observe(); f.replace([row(100, process.pid, 100), row(200, 100, 200)]); + await f.owner.signal("SIGKILL"); + expect(f.signals).toEqual([[200, "SIGKILL"], [100, "SIGKILL"]]); + } 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(); } @@ -48,6 +60,26 @@ it.skipIf(process.platform === "win32")("keeps surviving peers after the launche f.replace([row(200, 1, 200), row(999, 1, 999)]); await f.owner.signal("SIGTERM"); expect(f.signals).toEqual([[200, "SIGTERM"]]); + } 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(); } }); diff --git a/tests/runner-e2e/process-tree-owner.ts b/tests/runner-e2e/process-tree-owner.ts index 4e1f7a76b56..86b44902bcc 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); @@ -55,6 +90,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; @@ -68,6 +111,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"); @@ -126,6 +196,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 1ab4ebcddc9..7a21642d9cd 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/question-resume-scoring.ts b/tests/runner-e2e/question-resume-scoring.ts new file mode 100644 index 00000000000..4f494bdae04 --- /dev/null +++ b/tests/runner-e2e/question-resume-scoring.ts @@ -0,0 +1,131 @@ +import { isDeepStrictEqual } from "node:util"; +import type { ContinuationCheckpoint } from "./continuation-scoring.js"; +import { isSingleClaudeQuestion } from "./runtime-question-readiness.js"; + +type Row = Record; +export type QuestionPath = "provider" | "semantic"; +const nonempty = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0; +const questions = (c?: ContinuationCheckpoint): Row[] => + (c?.interactions ?? []).filter((i: any) => i.kind === "ask_user_questions") as Row[]; +export const resumeQuestionFields = (i?: Row): Row[] => + Array.isArray(i?.payload?.questionSet?.questions) ? i.payload.questionSet.questions : []; + +/** Classify by server-owned identities and the applied semantic receipt. A + * missing receipt is unknown, never inferred from form shape or run count. */ +export function questionPath(checkpoint: ContinuationCheckpoint | undefined, i?: Row): QuestionPath | null { + if (!checkpoint || !i || !nonempty(i.id) || !nonempty(i.sourceRunId) || !nonempty(i.idempotencyKey)) return null; + const matches = checkpoint.runs.filter(r => r.id === i.sourceRunId) as Row[]; + const run = matches.length === 1 ? matches[0] : undefined; + if (!run || run.runtimeMode !== "native" || !nonempty(run.companyId) || + run.companyId !== (checkpoint.issue as Row).companyId || + i.companyId !== run.companyId || i.issueId !== checkpoint.issue.id || run.issueId !== i.issueId || + !nonempty(checkpoint.issue.assigneeAgentId) || i.createdByAgentId !== checkpoint.issue.assigneeAgentId || + run.agentId !== checkpoint.issue.assigneeAgentId) return null; + const request = i.payload?.runtimeRequestId; + if (request !== undefined) { + return nonempty(request) && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(request) && + i.idempotencyKey === `paperclip-runner-question:${run.id}:${request}` && + i.continuationPolicy === "none" && run.status === "running" ? "provider" : null; + } + const receipt = run.resultJson?.semanticToolReceipts?.[i.idempotencyKey]; + const created = receipt?.result?.interaction; + return run.status === "succeeded" && i.continuationPolicy === "wake_assignee" && + receipt?.operationId === "request_human_input" && receipt.result?.disposition === "applied" && + created?.id === i.id && created?.sourceRunId === run.id && created?.issueId === i.issueId && + created?.companyId === i.companyId && created?.kind === "ask_user_questions" && + isDeepStrictEqual(created?.payload?.questionSet, i.payload?.questionSet) ? "semantic" : null; +} + +export function validResumeQuestionForm(checkpoint: ContinuationCheckpoint, i: Row, choice: boolean): boolean { + const path = questionPath(checkpoint, i); + const fields = resumeQuestionFields(i); + if (!path || fields.some(f => !nonempty(f.id)) || new Set(fields.map(f => f.id)).size !== fields.length) return false; + if (!choice) return fields.length === 1 && fields[0].answerMode === "text" && + !fields[0].options?.length && !fields[0].customAnswer; + return fields[0]?.answerMode === "single_select" && + (fields.length === 1 || (path === "provider" && isSingleClaudeQuestion(fields))); +} + +function sameQuestion(a: Row, b?: Row) { + return !!b && ["id", "kind", "sourceRunId", "issueId", "companyId", "createdByAgentId", "idempotencyKey", "continuationPolicy"] + .every(k => a[k] === b[k]) && isDeepStrictEqual(a.payload, b.payload); +} +function answeredByUser(i?: Row) { + return i?.status === "answered" && nonempty(i.resolvedByUserId) && !i.resolvedByAgentId; +} +function resumedRun(before: ContinuationCheckpoint | undefined, after: ContinuationCheckpoint | undefined, i?: Row) { + if (!before || !after || !i) return false; + const path = questionPath(before, i); + const prior = new Set(before.runs.map(r => r.id)); + const next = after.runs.filter(r => !prior.has(r.id)) as Row[]; + if (prior.size !== before.runs.length || new Set(after.runs.map(r => r.id)).size !== after.runs.length || + before.runs.some(r => !after.runs.some(n => n.id === r.id))) return false; + if (path === "provider") return next.length === 0 && + after.runs.some(r => r.id === i.sourceRunId && ["running", "succeeded"].includes(r.status)); + if (path !== "semantic" || next.length !== 1) return false; + const context = next[0].contextSnapshot; + return context?.issueId === i.issueId && context?.sourceRunId === i.sourceRunId && + context?.interactionId === i.id && context?.interactionKind === "ask_user_questions" && + context?.interactionStatus === "answered"; +} + +/** Behavioral qualification supports either valid native path. It does not + * replace the separate documentation-placement/semantic-tool qualification. */ +export function gradeQuestionResume(checkpoints: ContinuationCheckpoint[], marker: string) { + const initial = checkpoints.find(c => c.phase === "initial"); + const middle = checkpoints.find(c => c.phase === "answered"); + const final = checkpoints.find(c => c.phase === "final"); + const first = questions(initial)[0]; + const second = questions(middle).find(i => i.status === "pending"); + const choice = resumeQuestionFields(first)[0]; + const text = resumeQuestionFields(second)[0]; + const options: Row[] = choice?.options ?? []; + const afternoon = options.find(o => /^afternoon\b/i.test(String(o.label).trim())); + const checks = [{ + id: "question-resume.forms", + passed: !!initial && !!middle && questions(initial).length === 1 && first?.status === "pending" && + validResumeQuestionForm(initial, first, true) && options.length >= 2 && + options.every(o => nonempty(o.id)) && new Set(options.map(o => o.id)).size === options.length && + options.some(o => /^morning\b/i.test(String(o.label).trim())) && !!afternoon && + questions(middle).length === 2 && questions(middle).filter(i => i.status === "pending").length === 1 && + nonempty(second?.id) && second?.id !== first?.id && validResumeQuestionForm(middle, second, false), + detail: "One choice plus only a verified provider Other companion, then a distinct text-only question.", + }]; + for (const [before, after, i, field, answer] of [ + [initial, middle, first, choice, "choice"], [middle, final, second, text, "text"], + ] as const) { + const committed = questions(after).filter(q => q.id === i?.id); + const retained = questions(final).filter(q => q.id === i?.id); + const response: Row[] = committed[0]?.result?.answers ?? []; + const primary = response.filter(a => a.questionId === field?.id); + const optionalIds = resumeQuestionFields(i).slice(1).map(f => f.id); + const validAnswer = primary.length === 1 && (answer === "choice" + ? primary[0].optionIds?.length === 1 && primary[0].optionIds[0] === afternoon?.id && !primary[0].otherText + : !primary[0].optionIds?.length && primary[0].otherText === `Use ${marker}.`); + checks.push({ + id: `question-resume.${answer}.answer`, + passed: !!i && committed.length === 1 && retained.length === 1 && + sameQuestion(i, committed[0]) && sameQuestion(i, retained[0]) && answeredByUser(committed[0]) && + answeredByUser(retained[0]) && isDeepStrictEqual(committed[0].result, retained[0].result) && + validAnswer && response.every(a => a.questionId === field?.id || + (optionalIds.includes(a.questionId) && !a.optionIds?.length && !a.otherText)), + detail: "The real board answer must remain bound to the unchanged question, with no fabricated or substituted response.", + }, { + id: `question-resume.${answer}.path`, + passed: resumedRun(before, after, i), + detail: `Observed path: ${questionPath(before, i) ?? "unknown"}; provider answers resume the same run; semantic answers require one exactly bound response wake.`, + }); + } + checks.push({ + id: "question-resume.complete-evidence", + passed: checkpoints.length === 3 && !!initial && !!middle && !!final && initial.runs.length === 1 && + questions(final).length === 2 && final.runs.length >= 1 && final.runs.length <= 3 && + final.runs.every((r: Row) => r.companyId === (final.issue as Row).companyId && + r.issueId === final.issue.id && r.agentId === final.issue.assigneeAgentId && + typeof r.runnerProfileJson?.nativeExecutionInput?.task?.prompt === "string") && + final.documents.length === 1 && !!final.documents[0].latestRevisionId && + final.documents[0].body.includes(`Use ${marker}.`) && /\bafternoon\b/i.test(final.documents[0].body), + detail: "Retain all three checkpoints, every native input and exactly one saved document containing the choice and the complete submitted reference.", + }); + return checks.map(c => ({ ...c, passed: Boolean(c.passed) })); +} diff --git a/tests/runner-e2e/question-resume.test.ts b/tests/runner-e2e/question-resume.test.ts new file mode 100644 index 00000000000..525bce0c199 --- /dev/null +++ b/tests/runner-e2e/question-resume.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { gradeQuestionResume, questionPath, validResumeQuestionForm, type QuestionPath } from "./question-resume-scoring.js"; +import { gradeContinuation } from "./continuation-scoring.js"; +import { gradeLifecycleBaseline } from "./lifecycle-baseline.js"; +import { continuationScenario } from "./continuation-cases.js"; +import { runnerMatrix } from "./catalog.js"; + +type Row = Record; +function recording(paths: QuestionPath[] = ["provider", "semantic"]) { + const runs: Row[] = []; + const cards: Row[] = []; + const points: any[] = []; + const newRun = (previous?: Row) => ({ id: `run-${runs.length + 1}`, status: "running", runtimeMode: "native", + companyId: "company", issueId: "task", agentId: "agent", resultJson: { semanticToolReceipts: {} }, + runnerProfileJson: { nativeExecutionInput: { task: { prompt: "actual native task input" } } }, + contextSnapshot: previous ? { issueId: "task", sourceRunId: previous.sourceRunId, + interactionId: previous.id, interactionKind: "ask_user_questions", interactionStatus: "answered" } : {}, + }); + runs.push(newRun()); + const snap = (phase: string, documents: Row[] = []) => { + const active = runs.find(r => r.status === "running"); + points.push(structuredClone({ phase, issue: { id: "task", companyId: "company", assigneeAgentId: "agent", + status: phase === "final" ? "done" : active ? "in_progress" : "in_review" }, + lifecycle: { executionRunId: active?.id ?? null, scheduledRetry: null, activeRecoveryAction: null, monitorNextCheckAt: null }, + runs, interactions: cards, children: [], attachments: [], comments: [], documents })); + }; + for (let n = 0; n < 2; n++) { + const run = runs.at(-1)!; + const provider = paths[n] === "provider"; + const request = `request-${n}`; + const idempotencyKey = provider ? `paperclip-runner-question:${run.id}:${request}` : `question-${n}`; + const field = n === 0 ? { id: "field-1-question_0-hash", answerMode: "single_select", options: [ + { id: "am", label: "Morning" }, { id: "pm", label: "Afternoon" }, + ] } : { id: "reference", answerMode: "text" }; + const questions: Row[] = [field]; + if (provider && n === 0) questions.push({ id: "field-2-question_0_custom-hash", answerMode: "text", header: "Other", required: false }); + const card: Row = { id: `card-${n}`, kind: "ask_user_questions", status: "pending", + companyId: "company", issueId: "task", createdByAgentId: "agent", sourceRunId: run.id, idempotencyKey, + continuationPolicy: provider ? "none" : "wake_assignee", + payload: { ...(provider ? { runtimeRequestId: request } : {}), questionSet: { questions } } }; + cards.push(card); + if (!provider) { + run.status = "succeeded"; + run.resultJson.semanticToolReceipts[idempotencyKey] = { operationId: "request_human_input", + result: { disposition: "applied", interaction: structuredClone(card) } }; + } + snap(n === 0 ? "initial" : "answered"); + card.status = "answered"; card.resolvedByUserId = "local-board"; + card.result = { answers: [n === 0 ? { questionId: field.id, optionIds: ["pm"] } : + { questionId: field.id, optionIds: [], otherText: "Use AMBERnonce." }] }; + if (!provider) runs.push(newRun(card)); + } + runs.at(-1)!.status = "succeeded"; + snap("final", [{ key: "welcome-note", latestRevisionId: "revision", body: "Welcome to the afternoon meetup. Use AMBERnonce." }]); + return points; +} +const failed = (r: any[]) => gradeQuestionResume(r, "AMBERnonce").filter(c => !c.passed).map(c => c.id); +const question = (r: any[], n = 0) => r[n].interactions[n]; + +describe("native question/resume path qualification", () => { + it("keeps the new journey explicit-only and the original documentation cells intact", () => { + const cells = runnerMatrix.filter(c => c.suite.id === "question-resume"); + expect(cells.map(c => c.profile.id).sort()).toEqual(["runner-acpx-claude", "runner-codex"]); + for (const c of cells) { + expect(c.suite.manualOnly).toBe(true); + expect(c.task).toMatchObject({ expectedRunCount: 3, minimumExpectedRunCount: 1, automaticRetryPolicy: "single_attempt" }); + expect(c.task.buildPrompt("nonce")).toBe(continuationScenario("question-tool-documentation", "nonce").prompt); + } + expect(runnerMatrix.filter(c => c.suite.id === "continuation")).toHaveLength(23); + }); + it.each([ + ["provider", "provider"], ["provider", "semantic"], ["semantic", "provider"], ["semantic", "semantic"], + ] as QuestionPath[][])("accepts exactly bound %s then %s journeys", (a, b) => { + const r = recording([a,b]); + expect(failed(r)).toEqual([]); + expect(gradeContinuation({ ...continuationScenario("question-answer-resume", "nonce"), runtimeMode: "native", checkpoints: r }).filter(c => !c.passed)).toEqual([]); + expect(gradeLifecycleBaseline(r).filter(c => !c.passed)).toEqual([]); + }); + it.each([ + "Welcome to the afternoon meetup, AMBERnonce.", + "Welcome to the afternoon meetup. Use AMBERnonce", + ])("rejects a shortened supplied reference in the saved document: %s", body => { + const r = recording(); + r[2].documents[0].body = body; + expect(failed(r)).toContain("question-resume.complete-evidence"); + }); + it("does not admit an optional Other field on the semantic path", () => { + const r = recording(["semantic", "semantic"]); + const i = question(r); + i.payload.questionSet.questions.push({ id: "field-2-question_0_custom-hash", answerMode: "text", header: "Other", required: false }); + expect(validResumeQuestionForm(r[0], i, true)).toBe(false); + }); + it.each(["sourceRunId", "issueId", "companyId", "createdByAgentId", "idempotencyKey"])("rejects a provider card with mismatched %s", key => { + const r = recording(); question(r)[key] = "unrelated"; + expect(failed(r)).toContain("question-resume.choice.path"); + }); + it("rejects a consistent foreign-company card/run pair on this task", () => { + const r = recording(); question(r).companyId = "foreign"; r[0].runs[0].companyId = "foreign"; + expect(questionPath(r[0], question(r))).toBeNull(); + }); + it.each(["missing-request", "wrong-request", "terminal-run", "wrong-policy", "legacy-run"])("rejects invalid provider binding: %s", kind => { + const r = recording(); const i = question(r); + if (kind === "missing-request") delete i.payload.runtimeRequestId; + if (kind === "wrong-request") i.payload.runtimeRequestId = "unrelated"; + if (kind === "terminal-run") r[0].runs[0].status = "succeeded"; + if (kind === "wrong-policy") i.continuationPolicy = "wake_assignee"; + if (kind === "legacy-run") r[0].runs[0].runtimeMode = "legacy"; + expect(questionPath(r[0], i)).toBeNull(); + }); + it.each(["missing", "wrong-operation", "rejected", "wrong-interaction", "wrong-source", "wrong-form"])("rejects missing or mismatched semantic receipt: %s", kind => { + const r = recording(["semantic", "semantic"]); const i = question(r); + const receipt = r[0].runs[0].resultJson.semanticToolReceipts[i.idempotencyKey]; + if (kind === "missing") delete r[0].runs[0].resultJson.semanticToolReceipts[i.idempotencyKey]; + if (kind === "wrong-operation") receipt.operationId = "write_document"; + if (kind === "rejected") receipt.result.disposition = "rejected"; + if (kind === "wrong-interaction") receipt.result.interaction.id = "unrelated"; + if (kind === "wrong-source") receipt.result.interaction.sourceRunId = "unrelated"; + if (kind === "wrong-form") receipt.result.interaction.payload.questionSet.questions = []; + expect(questionPath(r[0], i)).toBeNull(); + }); + it.each(["required-other", "extra-question", "text-with-options", "two-early-cards"])("rejects the wrong form: %s", kind => { + const r = recording(); const fields = question(r).payload.questionSet.questions; + if (kind === "required-other") fields[1].required = true; + if (kind === "extra-question") fields.push({ id: "extra", answerMode: "text" }); + if (kind === "text-with-options") question(r,1).payload.questionSet.questions[0].options = [{ id: "other" }]; + if (kind === "two-early-cards") r[0].interactions.push(structuredClone(question(r,1))); + expect(failed(r)).toContain("question-resume.forms"); + }); + it.each(["agent-answer", "wrong-choice", "wrong-text", "duplicate-answer", "replaced-card", "changed-form", "changed-answer", "unanswered"])("rejects corrupt answer evidence: %s", kind => { + const r = recording(); const first = r[1].interactions[0]; const last = r[2].interactions[1]; + if (kind === "agent-answer") { first.resolvedByUserId = null; first.resolvedByAgentId = "agent"; } + if (kind === "wrong-choice") first.result.answers[0].optionIds = ["am"]; + if (kind === "wrong-text") last.result.answers[0].otherText = "wrong"; + if (kind === "duplicate-answer") first.result.answers.push(structuredClone(first.result.answers[0])); + if (kind === "replaced-card") last.id = "replacement"; + if (kind === "changed-form") last.payload.questionSet.questions[0].prompt = "Changed question"; + if (kind === "changed-answer") r[2].interactions[0].result.answers[0].optionIds = ["am"]; + if (kind === "unanswered") last.status = "pending"; + expect(failed(r).some(id => id.endsWith(".answer"))).toBe(true); + }); + it("rejects replacing a paused provider run with a fresh run", () => { + const r = recording(); r[1].runs.push({ ...structuredClone(r[1].runs[0]), id: "replacement" }); + expect(failed(r)).toContain("question-resume.choice.path"); + }); + it.each(["interactionId", "sourceRunId", "issueId", "interactionStatus"])("rejects the wrong semantic response wake %s", key => { + const r = recording(); r[2].runs[1].contextSnapshot[key] = "unrelated"; + expect(failed(r)).toContain("question-resume.text.path"); + }); + it.each(["lost-run", "duplicate-run", "extra-run", "missing-input", "missing-final", "missing-middle", "missing-output", "wrong-output"])("rejects incomplete continuation evidence: %s", kind => { + const r = recording(); + if (kind === "lost-run") r[2].runs.shift(); + if (kind === "duplicate-run") r[2].runs.push(structuredClone(r[2].runs[0])); + if (kind === "extra-run") r[2].runs.push({ ...structuredClone(r[2].runs[0]), id: "extra" }); + if (kind === "missing-input") delete r[2].runs[0].runnerProfileJson; + if (kind === "missing-output") r[2].documents = []; + if (kind === "wrong-output") r[2].documents[0].body = "Welcome AMBERnonce."; + if (kind === "missing-final") r.pop(); + if (kind === "missing-middle") r.splice(1,1); + expect(failed(r).length).toBeGreaterThan(0); + }); +}); diff --git a/tests/runner-e2e/remote-native-bootstrap.test.ts b/tests/runner-e2e/remote-native-bootstrap.test.ts index d6537a8d52d..41cfad852c1 100644 --- a/tests/runner-e2e/remote-native-bootstrap.test.ts +++ b/tests/runner-e2e/remote-native-bootstrap.test.ts @@ -12,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 = { @@ -67,6 +67,8 @@ it("keeps async completion immediate with a private sentinel and no delivery wai 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"); }); @@ -128,6 +130,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 67dfa8be9f1..f85eaee235d 100644 --- a/tests/runner-e2e/remote-native-bootstrap.ts +++ b/tests/runner-e2e/remote-native-bootstrap.ts @@ -178,6 +178,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 df0d25c8f81..0209318959f 100644 --- a/tests/runner-e2e/remote-native-fixtures.test.ts +++ b/tests/runner-e2e/remote-native-fixtures.test.ts @@ -8,8 +8,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")}`; @@ -53,6 +54,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" }); @@ -248,7 +262,7 @@ describe("remote native lease admission", () => { const f = await bindRemoteNativeFixture({ ...h.options, retainTerminalDiagnostics: retain }); await f.publishAction("action.txt", "task"); h.resolveTerminal({ ...structuredClone(h.current), incompleteReasons: ["untrusted provider content"], processes: { ...h.current.processes, live: [] }, files: {} } as any); - await expect(f.finish()).rejects.toThrow("incomplete_reason_shape"); + await expect(f.finish()).rejects.toThrow("incomplete_reasons_shape"); expect(retain).not.toHaveBeenCalled(); }); it("reports only closed terminal failure reasons without weakening retirement proof", async () => { @@ -267,6 +281,64 @@ describe("remote native lease admission", () => { expect(JSON.stringify(retain.mock.calls)).not.toContain("secret-provider-payload"); expect(remoteNativeFixtureDiagnostics(error)).toEqual([{ phase: "wait", code: "terminal_evidence_incomplete" }]); }); + 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, 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("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" }; h.current.targets["@cross-root"] = { absent: false, sha256: hash("outside sentinel"), parent: { dev: "1", ino: "outside" }, mutationCount: 0, complete: true }; @@ -355,7 +427,8 @@ describe("cell deadline and cleanup bounds", () => { }); describe("independent filesystem and process observations", () => { - it.runIf(process.platform === "linux")("retains observed create/delete events and detects same-path parent replacement", async () => { + // This real filesystem probe qualifies the Linux Daytona observer. + it.runIf(process.platform === "linux")("retains transient create/delete events and detects same-path parent replacement", async () => { const dir = await mkdtemp(join(tmpdir(), "remote-watch-")); const watcher = createRemoteTargetWatch(dir, "denied.txt"); try { await writeFile(join(dir, "denied.txt"), "forbidden"); @@ -403,7 +476,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 processExecutables = 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); @@ -428,11 +501,11 @@ 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) => processExecutables.get(path)?.path ?? (/^\/proc\/\d+\/exe$/u.test(path) ? proc.get(Number(path.split("/")[2]))?.argv[0] : path), statSync(path: string) { const identity = processExecutables.get(path); if (!identity) return missing(); return identity; }, - 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; } }); @@ -459,8 +532,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((_node: string, argv: string[]) => { const child = Object.assign(new EventEmitter(), { argv, stdin: { end: vi.fn() }, 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((_node: string, argv: string[]) => { const child = Object.assign(new EventEmitter(), { argv, stdin: { end: vi.fn() }, 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(), kill: vi.fn() }, Buffer, __filename: `${config.root}/observer.cjs`, setInterval(fn: () => void) { intervals.push(fn); return 1; }, clearInterval: vi.fn(), @@ -472,8 +546,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, processExecutables, 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, processExecutables, 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("'\\''", "'"); @@ -754,7 +876,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"); @@ -772,3 +894,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 037fb05f955..f965528d3f9 100644 --- a/tests/runner-e2e/remote-native-fixtures.ts +++ b/tests/runner-e2e/remote-native-fixtures.ts @@ -1,8 +1,11 @@ import { canonicalRemoteCopilotCommand, copilotDeathArguments, selectOwnedCopilotProcess } from "./copilot-provider-death.js"; 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; @@ -31,8 +34,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", "release-attached", "read", "inject-loss", "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", "terminal_evidence_incomplete"] as const; +const RPC_PHASES = ["runtime-ready", "install", "wait", "close", "arm", "publish", "snapshot", "attached", "release-attached", "read", "inject-loss", "provider-death", "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", "terminal_evidence_incomplete", "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 } @@ -50,15 +53,16 @@ export interface RemoteNativeAuthority { companyId: string; environmentId: strin export interface RemoteNativeBinding extends RemoteNativeAuthority { remoteCwd: string } export interface RemoteProcessIdentity { pid: number; ppid: number; startTicks: string; bootId: string } const TERMINAL_FAILURE_CODES = ["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"] as const; -const INCOMPLETE_REASONS = ["multiple_run_roots", "run_root_changed", "process_identity_reused", "workspace_watch_incomplete", "setup_file_changed", "workspace_event_bound", "workspace_directory_added", "retirement_snapshot_failed", "process_sample_failed", "control_bound", "observer_ttl"] as const; - +const INCOMPLETE_REASONS = ["multiple_run_roots", "run_root_changed", "process_identity_reused", "workspace_watch_incomplete", "setup_file_changed", "workspace_event_bound", "workspace_directory_added", "retirement_snapshot_failed", "process_sample_failed", "control_bound", "observer_ttl", "process_read_io", "process_stat_shape", "process_runtime_binding", "process_terminal_seal_failed", "unknown_watch_name", "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"] 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?: readonly string[]; + incompleteReasons?: IncompleteReason[]; workspace: Record; targets: Record; watcher: { complete: boolean; targetMutationCount: number; workspaceMutationCount: number }; @@ -72,6 +76,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 RemoteFixtureError { + constructor(readonly snapshot: RemoteNativeSnapshot, flags: string[] = [], codes: string[] = []) { + super(`remote_native_fixture:terminal_evidence_incomplete${flags.length ? ":" + flags.join(",") + ":" + codes.join(",") : ""}`, { phase: "wait", code: "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 { @@ -89,6 +113,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]) => { @@ -126,25 +161,27 @@ 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 selectCopilot=SELECT_COPILOT;const canonicalCopilot=CANONICAL_COPILOT;const copilotArgs=COPILOT_ARGS;let providerDeathDispatched=false; +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 selectCopilot=SELECT_COPILOT;const canonicalCopilot=CANONICAL_COPILOT;const copilotArgs=COPILOT_ARGS;let providerDeathDispatched=false; 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 incompleteReasons=new Set();const failureCodes=new Set();function incomplete(code){complete=false;failureCodes.add(code)}function markIncomplete(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; +const targets=new Map();let complete=true,sealed=false,root=null,attached=null,child=null,client=null,childTimer=null; +const incompleteReasons=new Set(),failureCodes=new Set();function incomplete(reason){complete=false;incompleteReasons.add(reason);failureCodes.add(reason)}function markIncomplete(reason){incomplete(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 deathCommand(pid){const command=fs.readFileSync('/proc/'+pid+'/cmdline').toString().split('\0').filter(Boolean);if(!/^\/proc\/self\/fd\/(?:3|7)$/.test(command[0]??''))return command;const executable=fs.realpathSync('/proc/'+pid+'/exe'),s=fs.statSync('/proc/'+pid+'/exe',{bigint:true}),fd='/proc/'+pid+'/fd/'+command[0].split('/').at(-1),d=fs.statSync(fd,{bigint:true});return canonicalCopilot(command,executable,{dev:String(s.dev),ino:String(s.ino)},{path:fs.realpathSync(fd),dev:String(d.dev),ino:String(d.ino)})} -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}; @@ -155,23 +192,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],incompleteReasons:[...incompleteReasons],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,failureCodes:[...failureCodes],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||(r.waitForFinishAttempt!==undefined&&typeof r.waitForFinishAttempt!=='boolean'))throw Error('attached_bounds'); attached={waitForFinishAttempt:r.waitForFinishAttempt===true,releasedAtMs:null,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'); @@ -202,12 +251,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(()=>{markIncomplete("observer_ttl");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("SELECT_COPILOT", () => selectOwnedCopilotProcess.toString()).replace("CANONICAL_COPILOT", () => canonicalRemoteCopilotCommand.toString()).replace("COPILOT_ARGS", () => JSON.stringify(copilotDeathArguments)).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("SELECT_COPILOT", () => selectOwnedCopilotProcess.toString()).replace("CANONICAL_COPILOT", () => canonicalRemoteCopilotCommand.toString()).replace("COPILOT_ARGS", () => JSON.stringify(copilotDeathArguments)).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(); @@ -224,6 +273,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; @@ -232,9 +301,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>; killOwnedCopilot(): Promise>; setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number; waitForFinishAttempt?: boolean }): Promise<{ command: string; commandSha256: string }>; releaseAttachedCommand(): Promise<{ releasedAtMs: number }>; @@ -262,7 +333,6 @@ function readSnapshot(value: unknown, binding: RemoteNativeBinding, names: strin const row = record(value), watcher = record(row.watcher), processes = record(row.processes), setup = record(row.setup); fail(JSON.stringify(row.binding) === JSON.stringify(binding), "receipt_binding"); fail(Number.isSafeInteger(row.observedAtMs) && (row.observedAtMs as number) > 0 && typeof row.observedMonotonicNs === "string" && /^\d+$/u.test(row.observedMonotonicNs) && typeof row.complete === "boolean", "receipt_shape"); - fail(row.incompleteReasons === undefined || (Array.isArray(row.incompleteReasons) && row.incompleteReasons.length <= INCOMPLETE_REASONS.length && row.incompleteReasons.every(reason => INCOMPLETE_REASONS.includes(reason as typeof INCOMPLETE_REASONS[number])) && new Set(row.incompleteReasons).size === row.incompleteReasons.length), "incomplete_reason_shape"); fail(Object.keys(record(row.workspace)).length <= 512 && Object.entries(record(row.workspace)).every(([path, hash]) => { try { relative(path); return hash === "directory" || sha(hash); } catch { return false; } }), "workspace_shape"); @@ -274,6 +344,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); @@ -449,7 +522,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, @@ -458,6 +533,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!); } @@ -509,6 +590,12 @@ 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; const allowed = new Set(TERMINAL_FAILURE_CODES); 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) : []; @@ -517,7 +604,7 @@ export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOption // Closed flags explain missing evidence without retaining SDK output, // provider text, file contents or opaque observer control credentials. 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, flags, codes); } const files = record(record(receipt.value).files); for (const name of names) { @@ -536,7 +623,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 cfe7bdec824..6094da4cf71 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -1,6 +1,13 @@ import { runNativeActiveStopFlow as runCopilotActiveStopFlow } from "./copilot-active-stop-flow.js"; -import { runCopilotProtectionFlow } from "./copilot-protection-flow.js"; import { nativeActiveStopTasks as copilotActiveStopTasks } from "./copilot-active-stop-tasks.js"; +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"; @@ -10,10 +17,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"; @@ -239,6 +243,7 @@ async function restartIsolatedPaperclipServer(input: { api: RunnerApi; requestId: string; deadlineAt: number; + preserveRunner?: RestartRunnerIdentity; }): Promise { const { controlDirectory, @@ -249,7 +254,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); @@ -587,7 +592,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", "copilot_protection", "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> = []; @@ -604,6 +609,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); @@ -880,6 +886,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); } @@ -906,7 +917,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" - && ["copilot_protection", "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, @@ -1016,6 +1027,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, @@ -1053,6 +1073,17 @@ 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 === "instruction_persistence") { const story = await runInstructionPersistenceFlow({ page, api, fixtures, execution, nonce, secrets, deadlineAt: startedAtMs + deadlineMs, @@ -2209,6 +2240,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 1c48ae8d916..222b3eaf343 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 121e0dc2102..ff69beff4f8 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 c9e28b288a1..3d9a42c0552 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 00000000000..6315f690549 --- /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 00000000000..54d6031571b --- /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 a4baf54c5d9..8cf57bcdc84 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 bbeddc734c1..bcd6ff59c74 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 57b3f37d142..1a5ecf34b44 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 d46d334e95c..9bfb983f060 100644 --- a/tests/runner-e2e/types.ts +++ b/tests/runner-e2e/types.ts @@ -34,6 +34,8 @@ export type RunnerTaskFlow = | "plan_approval_completion" | "warm_three_turn" | "instruction_persistence" + | "pi_native" + | "pi_controls" | "native_active_stop" | "copilot_protection" | "native_provider_loss" diff --git a/tests/runner-e2e/user-actions.ts b/tests/runner-e2e/user-actions.ts index ebe43eadb36..42965012c3f 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 de88292fcbe..7735119d246 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 c1173c16be5..9588de3bf6d 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/public/brands/adapters/openai-dot.README.md b/ui/public/brands/adapters/openai-dot.README.md new file mode 100644 index 00000000000..a4e283ba2f2 --- /dev/null +++ b/ui/public/brands/adapters/openai-dot.README.md @@ -0,0 +1,7 @@ +# Dot mark + +`openai-dot.svg` is the unmodified Dot ring mark linked by OpenAI's public [Introducing dots](https://openai.com/index/introducing-dots/) announcement. + +Source: https://images.ctfassets.net/kftzwdyauwt9/KKSjto3MtJguvsLQsM1Bp/1704a6937d22b1bb3cac38dc5f43f9df/dots-o.svg + +Retrieved 2026-10-08. Used to identify the Dot provider in Paperclip setup UI. Hermes uses the existing `hermesagent.svg` and `hermesagent-dark.svg` assets. diff --git a/ui/public/brands/adapters/openai-dot.svg b/ui/public/brands/adapters/openai-dot.svg new file mode 100644 index 00000000000..d3c9a81f234 --- /dev/null +++ b/ui/public/brands/adapters/openai-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/public/brands/apps/calendly.png b/ui/public/brands/apps/calendly.png new file mode 100644 index 00000000000..6d1f3edf51b Binary files /dev/null and b/ui/public/brands/apps/calendly.png differ diff --git a/ui/public/brands/apps/exa.svg b/ui/public/brands/apps/exa.svg new file mode 100644 index 00000000000..9f3159d327d --- /dev/null +++ b/ui/public/brands/apps/exa.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/public/brands/apps/firecrawl.svg b/ui/public/brands/apps/firecrawl.svg new file mode 100644 index 00000000000..bf31d1f070e --- /dev/null +++ b/ui/public/brands/apps/firecrawl.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ui/public/brands/apps/gauge.png b/ui/public/brands/apps/gauge.png new file mode 100644 index 00000000000..6e27551c5e4 Binary files /dev/null and b/ui/public/brands/apps/gauge.png differ diff --git a/ui/public/brands/apps/gsc-wizard.png b/ui/public/brands/apps/gsc-wizard.png new file mode 100644 index 00000000000..37c53bd81e2 Binary files /dev/null and b/ui/public/brands/apps/gsc-wizard.png differ diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index c981650dc9b..d8290fc25db 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -140,6 +140,12 @@ "catalogVisible": true, "localAsset": "/brands/apps/executor.png" }, + { + "slug": "gauge", + "provider": "Gauge", + "catalogVisible": true, + "localAsset": "/brands/apps/gauge.png" + }, { "slug": "github", "provider": "GitHub", @@ -260,7 +266,7 @@ { "slug": "make", "provider": "Make", - "catalogVisible": false, + "catalogVisible": true, "localAsset": "/brands/apps/make.svg", "darkAsset": "/brands/apps/make-dark.svg" }, @@ -591,6 +597,49 @@ "provider": "Responses API", "catalogVisible": true, "localAsset": "/brands/apps/api-key-generic.svg" + }, + { + "slug": "calendly", + "provider": "Calendly", + "catalogVisible": true, + "localAsset": "/brands/apps/calendly.png" + }, + { + "slug": "exa", + "provider": "Exa", + "catalogVisible": true, + "localAsset": "/brands/apps/exa.svg" + }, + { + "slug": "firecrawl", + "provider": "Firecrawl", + "catalogVisible": true, + "localAsset": "/brands/apps/firecrawl.svg" + }, + { + "slug": "gsc-wizard", + "provider": "GSC Wizard", + "catalogVisible": true, + "localAsset": "/brands/apps/gsc-wizard.png" + }, + { + "slug": "parallel-search", + "provider": "Parallel Search", + "catalogVisible": true, + "localAsset": "/brands/apps/parallel.svg", + "darkAsset": "/brands/apps/parallel-dark.svg" + }, + { + "slug": "tavily", + "provider": "Tavily", + "catalogVisible": true, + "localAsset": "/brands/apps/tavily.png" + }, + { + "slug": "windsor-ai", + "provider": "Windsor.ai", + "catalogVisible": true, + "localAsset": "/brands/apps/windsor-ai.png" } ] } diff --git a/ui/public/brands/apps/parallel-dark.svg b/ui/public/brands/apps/parallel-dark.svg new file mode 100644 index 00000000000..0c143cae6d6 --- /dev/null +++ b/ui/public/brands/apps/parallel-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/public/brands/apps/parallel.svg b/ui/public/brands/apps/parallel.svg new file mode 100644 index 00000000000..2f91ae241ef --- /dev/null +++ b/ui/public/brands/apps/parallel.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/public/brands/apps/tavily.png b/ui/public/brands/apps/tavily.png new file mode 100644 index 00000000000..3c98a108c2c Binary files /dev/null and b/ui/public/brands/apps/tavily.png differ diff --git a/ui/public/brands/apps/windsor-ai.png b/ui/public/brands/apps/windsor-ai.png new file mode 100644 index 00000000000..2b7f8829780 Binary files /dev/null and b/ui/public/brands/apps/windsor-ai.png differ diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 9fd1ae461c3..87550fb39c8 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 Copilot and keeps Pi visibly disabled", async () => { + it("offers qualified Claude, Cursor, Pi and Copilot", async () => { const html = await renderRunner({ provider: "acpx", acpxAgent: "claude", @@ -105,19 +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(1); - expect(html).toContain('Cursor'); - expect(html).not.toContain('Cursor — qualification pending'); - expect(html).toContain('GitHub Copilot'); - expect(html).not.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", disabled: false }, + ])); 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 c0e7b6f985d..6147d5f89ca 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 e4c73548264..df5fdfd9f59 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 ad1f8ef6c52..d7ebfbb2cb7 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/api/agents.ts b/ui/src/api/agents.ts index 4685c27592f..c6b2d3c2828 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -110,6 +110,8 @@ function agentPath(id: string, companyId?: string, suffix = "") { } export const agentsApi = { + setAvatar: (companyId: string, agentId: string, imageBase64: string | null) => + api.put<{ agentId: string; appearance: import("@paperclipai/shared").AgentAppearance; avatarUrl: string }>(`/companies/${companyId}/agents/${agentId}/avatar`, { imageBase64 }), getIdentity: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/identity")), adoptAiConnection: (agentId: string, interactionId: string, connectionId: string, companyId: string) => diff --git a/ui/src/api/dotInvitations.ts b/ui/src/api/dotInvitations.ts new file mode 100644 index 00000000000..5237b704013 --- /dev/null +++ b/ui/src/api/dotInvitations.ts @@ -0,0 +1,11 @@ +import { api } from "./client"; + +import type { DotInvitation, DotConnection, DotPairing } from "@paperclipai/shared"; +export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "@paperclipai/shared"; +const path = (companyId: string, agentId: string) => `/companies/${companyId}/agents/${agentId}/dot-binding`; +export const dotInvitationsApi = { + create: (companyId: string) => api.post(`/companies/${companyId}/dot-invitations`, {}), + connection: (companyId: string, agentId: string, signal?: AbortSignal) => api.get(path(companyId, agentId), { signal }), + pair: (companyId: string, agentId: string, replaceBindingId?: string) => api.post(path(companyId, agentId), { replaceBindingId }), + retry: (companyId: string, agentId: string, bindingId: string) => api.post(path(companyId, agentId) + "/event-test", { bindingId }), +}; diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index c794e82ac7f..43cb010466d 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -87,6 +87,8 @@ export type ToolPoliciesResponse = { policies: ToolPolicy[] }; export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] }; export type ToolGalleryResponse = { apps: AppDefinition[]; + /** Server-authoritative OAuth callback URI, omitted when this deployment has no supported browser origin. */ + oauthCallbackUrl?: string; capabilities: ToolConnectionCreateCapabilities; credentialSources: { vercelConnect: { diff --git a/ui/src/components/AdapterMark.tsx b/ui/src/components/AdapterMark.tsx index 8d293cbbacc..cc700dc541f 100644 --- a/ui/src/components/AdapterMark.tsx +++ b/ui/src/components/AdapterMark.tsx @@ -2,6 +2,7 @@ import { getAdapterDisplay } from "@/adapters/adapter-display-registry"; import { cn } from "@/lib/utils"; const brandMarks: Record = { + openai_dot: { src: "/brands/adapters/openai-dot.svg" }, claude_local: { src: "/brands/claude-color.svg" }, codex_local: { src: "/brands/codex-color.svg" }, gemini_local: { src: "/brands/adapters/gemini-color.svg" }, diff --git a/ui/src/components/AgentAvatar.test.tsx b/ui/src/components/AgentAvatar.test.tsx new file mode 100644 index 00000000000..c4d5f9ac731 --- /dev/null +++ b/ui/src/components/AgentAvatar.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vitest"; +import { appearanceForPalette } from "@paperclipai/shared"; +import { AgentAvatar } from "./AgentAvatar"; +import { AgentCharacter } from "./AgentCharacter"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +it("shows the uploaded image in small and animated-character surfaces, then returns to the palette on reset", async () => { + const base = appearanceForPalette("deep-tide"); + const appearance = { ...base, customAvatarAssetId: "22222222-2222-4222-8222-222222222222" }; + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(async () => root.render(<>)); + for (const image of container.querySelectorAll("img")) { + expect(image.getAttribute("src")).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content"); + expect(image.hasAttribute("srcset")).toBe(false); + expect(image.parentElement?.classList.contains("invisible")).toBe(false); + } + expect(container.querySelectorAll("img")).toHaveLength(2); + expect(container.querySelector("canvas")).toBeNull(); + await act(async () => root.render()); + expect(container.querySelector("img")?.getAttribute("src")).toContain("/api/agent-avatars/cap-v1/deep-tide/"); + } finally { await act(async () => root.unmount()); } +}); diff --git a/ui/src/components/AgentAvatar.tsx b/ui/src/components/AgentAvatar.tsx index 8ab2d166243..0866c2bf490 100644 --- a/ui/src/components/AgentAvatar.tsx +++ b/ui/src/components/AgentAvatar.tsx @@ -27,7 +27,7 @@ export function AgentAvatar({ agent, appearance, size = 24, name, label, pose = {failedUrl === src ? {deriveInitials(name ?? agent?.name ?? "Agent")} : - setFailedUrl(src)} />} ); diff --git a/ui/src/components/AgentCharacter.tsx b/ui/src/components/AgentCharacter.tsx index d85bd2f650a..7b267333220 100644 --- a/ui/src/components/AgentCharacter.tsx +++ b/ui/src/components/AgentCharacter.tsx @@ -20,7 +20,7 @@ export function AgentCharacter({ agent, appearance, size = 256, state = "idle", const slotId = useRef(Symbol("agent-character")); const owner = useSyncExternalStore(characterSlot.subscribe, characterSlot.getSnapshot, () => null); const [visible, setVisible] = useState(false), [reduced, setReduced] = useState(true), [failed, setFailed] = useState(false), [ready, setReady] = useState(false); - const active = visible && !reduced && !failed && motion === "auto" && state !== "rest"; + const active = !identity.customAvatarAssetId && visible && !reduced && !failed && motion === "auto" && state !== "rest"; useEffect(() => { if (typeof matchMedia !== "function" || typeof IntersectionObserver !== "function") return; const media = matchMedia("(prefers-reduced-motion: reduce)"); @@ -57,7 +57,7 @@ export function AgentCharacter({ agent, appearance, size = 256, state = "idle", }, [identity, muted, state, ready]); return - + ; } diff --git a/ui/src/components/AgentSetupPrompt.tsx b/ui/src/components/AgentSetupPrompt.tsx index df025d7eb3a..f130a91dcbe 100644 --- a/ui/src/components/AgentSetupPrompt.tsx +++ b/ui/src/components/AgentSetupPrompt.tsx @@ -22,15 +22,17 @@ export interface AgentSetupPromptProps { /** Carry over the clipboard result when generating a prompt already copied it. */ initialCopyStatus?: "idle" | "copied" | "failed"; onCopied?: () => void; + /** Brand a handoff for one recipient; omit for the general agent logo stack. */ + agent?: { name: string; src: string; className?: string }; } -const agents = [ +const defaultAgents = [ { name: "Codex", src: "/brands/codex-color.svg", className: "" }, { name: "ChatGPT", src: "/brands/apps/openai.svg", className: "dark:invert" }, { name: "Claude", src: "/brands/claude-color.svg", className: "" }, ]; -function AgentLogos({ ref, hidden }: { ref: Ref; hidden: boolean }) { +function AgentLogos({ ref, hidden, agents }: { ref: Ref; hidden: boolean; agents: NonNullable[] }) { return ( - +
-
+
{status === "failed" ? (