feat: integrate Pi 1.0 into the experimental Runner (#14921)

## Thinking Path

> - Paperclip manages AI agents and their work.
> - The experimental Runner owns provider processes and durable
sessions.
> - Pi needs working task execution and human controls.
> - The five-PR stack must preserve changes already on master.
> - Each layer now carries the complete integrated source for a safe
sequential fallback.
> - This PR belongs to native GitHub stack #15602, ending at #14956.

## Linked Issues or Issue Description

Refs #14436, #14631, #14743 and #14956.

Ship Pi 1.0 through the experimental Paperclip Runner. The five PRs are
#14921, #14922, #14923, #14924 and #14956. The user authorized the
complete merge after checks pass. Existing `pi_local` execution is
unchanged. Accounting and wider provider/platform qualification remain
deferred.

## What Changed

- Recover missing final replies after workspace finalization changes
owners, using accepted-turn evidence without rerunning work or granting
external-chat publication.
- Preserve the admitted Pi instruction root across warm runs, while
retaining changed-root rejection.
- Give Pi a bounded 15-second default shutdown grace so stop, drain
acknowledgement and durable suspension can complete. Explicit deadlines
and other providers retain their existing behavior.
- Integrate the Pi 1.0 runtime and master contracts.
- Use Pi profile 22. Preserve explicit caller-selected models and exact
native thinking levels. Keep Pi's wrapper, helper, extension and
question/control behavior unchanged from the qualified profile-19
runtime.
- Preserve master's Dot lifecycle and consent fields, configured task
environment, status guards and current Codex/Claude dependency versions.
Cursor stays qualified. Copilot stays pending; profile 17 binds the
changed shared protocol validation sources.
- Exclude general AWS IAM credentials from Pi static/custom provider
bindings and selected task projections; preserve the provider-scoped
Bedrock bearer key. Profile 21 is retained as historical provenance.
Rust and cloud install probes use the current declaration.
- Patch bundled brace-expansion 5.0.9 to the exact official 5.0.12
payload. Pin the patch and complete runtime closures. Include the patch
in normal installed setup tooling. Keep the upstream Pi shrinkwrap as
provenance and permit only this exact security correction.
- Include current attestation files in the Docker build context. Keep
the repository lockfile unchanged from master. CI and private image
builds resolve manifest changes before their frozen installation.

## Verification

- Full local `pnpm -r typecheck` passes, including Runner Rust, server
and UI. Focused integration checks pass: 194 Runner
admission/environment tests, 63 profile/credential tests with one
expected skip, 152 Dot/UI configuration tests, and Pi transcript/notice
tests.
- Full local `pnpm build` passes on the final source.
- Fresh final-source checks pass: all 698 Rust workspace tests (32
binaries), 156 credential/profile/controller tests with one expected
skip, Runner TypeScript typecheck, and 20 package/setup/sandbox tests.
- The profile-21 Pi materializer passes on the native host with the
official pinned Node 24.21.0 and its npm. It verifies all 150 locked
packages, the patched dependency and the exact closure. Setup/package
bundle tests and UI token gates pass.
- The old hashes were reproduced for all three supported targets before
calculating the patched graph. New closure hashes are darwin-arm64
`282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf`,
darwin-x64
`64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e`, and
linux-x64
`713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410`.
Independent native platform execution is separate from graph identity
reproduction.
- Historical cloud qualification remains unchanged: all seven core cases
pass on shipping source `10dc43c9ec65d88c2f782d62afb296d09494f215`,
harness `1a4408a48cfb5a1f094a311141c257c92cd7a893`, image
`sha256:5b3a775b383591bda1b0c1889e509acc70ce7f37c53f09733c81d59037f02280`,
and accepted Sonnet 4.6/low fixture. All 215 canonical files and all
seven cleanup checks pass independent verification. These are profile-19
results and are not relabeled as fresh profile-22 runs.
- Current Pi digest:
`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`.
[The readiness
plan](https://github.com/paperclipai/paperclip/blob/codex/pi-production-readiness/doc/plans/2026-10-02-pi-production-readiness.md)
preserves campaign and failed-attempt provenance.
- Merge only after every PR's current-head CI and fresh review pass.
Linux CI covers the full suites, build and browser tests. The local
embedded Postgres API-authority suite cannot start on this macOS/Node 26
host, so Linux CI must confirm that suite.

### Fresh profile-22 core qualification — 2026-10-08

All seven accepted core cases pass canonically on Pi profile 22, with
`openrouter/anthropic/claude-sonnet-4.6` and native-confirmed low
thinking. This model is a fixture; production accepts the caller's
explicit Pi provider/model.

Runtime/install source: `3241a992f2a7703e59e97ed0fd3e5d6405de4401`.
Frozen accepted harness: `1a4408a48cfb5a1f094a311141c257c92cd7a893`.
Immutable cloud image:
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:506f22db7edd78f37c0c40bec1cc084af1850455026dbf467194bfbb8fcef141`.
Pi digest:
`sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9`.

[Hosted Linux image and clean-install
verification](https://github.com/paperclipai/paperclip/actions/runs/37868328023)
passes, including all 20 source-bound archives, normal CLI/Pi setup,
companion import and the production pack reader. This exact installation
source includes the latest master integration and the corrected Pi warm
instruction-root fence. Full local typecheck/build and current-head
hosted CI verify the final stack. All 13 focused real-root regressions
pass. The full local executor suite passed 662 tests; 15 database tests
could not start the Mac embedded PostgreSQL service. Hosted Linux CI
passes the full required verification and E2E checks. These fresh
results keep their own source identity; profile-19 results remain
historical.

| Core path | Canonical campaign | Retained archive SHA-256 |
| --- | --- | --- |
| File edit, validation, download and Done |
`pi-core22-replyfix-0-1791511228` | 23 files;
`a473e8603a3dd4737863291f8d3d1e392391f0b16d433c3e0e0e9d8baf7a97b0` |
| Pending question and controller restart |
`pi-core22-replyfix-1-1791511376` | 33 files;
`6b829c4eb74e1f32a89c692a4ae7130dbfc1c6d3cf13915effe2103d9e242c8e` |
| Three-turn session/process/workspace continuity |
`pi-core22-replyfix-2-1791511587` | 23 files;
`7a87021f8f9a3fdd3c58bb4467f8d82c635e3ea4795d6e75f144d9aa14818df8` |
| Four typed questions and browser reconnects |
`pi-core22-replyfix-3-1791511881` | 42 files;
`9e31755252be1f4f9cb0626c984c142d4d1ae5f5bee3a7af08444db8d12c280a` |
| Plan approval and completion | `pi-core22-replyfix-4-1791512031` | 22
files;
`a0383ce1aab38e7b5a25ce0e9dd3bebea5c037ebd96ae6b29dae19015da2ae2c` |
| Same-turn steering and permission denial |
`pi-core22-replyfix-5-1791512261` | 39 files;
`c929b8c7070f0b66aedc17e65ca46e6beab1e363926ac9f7e2a75fb250f05949` |
| Stop during pending permission | `pi-core22-replyfix-6-1791512390` |
33 files;
`7f0a58ae0f4d5bfc76149435f4e322537089c5bd16e7ffe9b5ad71f10a621a07` |

All 215 canonical files (28714587 bytes) are independently
hash-verified. All seven cleanup grades pass, with no owned runtime
process or temporary root after each case. Automatic retries are zero.
The owned cloud host stopped normally after retention. The prior
profile-22 warm attempt remains failed and separately retained: archive
SHA-256
`1e54eba5ec72b50cee1534b23d1d1d4f21a090006b8a64501ba70db972abfde5`. Its
original canonical classification is preserved. Diagnosis reproduced a
product bug comparing an agent-files root against an unset
checkpoint-only field. The fix stores the admitted physical root
separately from the adopted per-run collection capability. The real-root
regression fails before the fix and passes afterward, including
rejection of a changed physical root. Fixture, grader, model and all
seven accepted case IDs are unchanged; this fresh campaign tests
final-reply publication after file registration first. The intermediate
restart attempt also remains failed and retained: archive SHA-256
`5dcaefdf1d17cf4cd54fd4cf810f45e736667392339b8ce7caf08bb4e225277f`. Its
original canonical classification is preserved. Pi resumed, wrote the
verified answer and completed its task; exact runner suspension was
proven, but idle stop consumed about 5.2s and left under 3s for the
drain acknowledgement. The Pi-only default shutdown grace is now 15s,
preserving a full 5s drain round trip and a finite suspension reserve.
Explicit caller deadlines, other provider defaults, literal drain
receipts and exact suspension identity checks remain unchanged. The
timing regression fails before this correction and passes afterward; all
18 focused settlement tests and Runner typecheck pass. The final-source
file attempt is also preserved as failed (`candidate_failure`), archive
SHA-256
`db6767b6773ea618997927ac77bdb005a5ac81492c7b9c0ffbc900449f829bc9`.
Native edit, validation, exact downloadable artifact and Done/succeeded
all passed, and the exact final reply was durably recorded. A workspace
recovery owner completed before the live heartbeat reached presentation,
leaving that reply absent from task chat. Recovery now materializes only
a completed final reply from the accepted turn of an ordinary internal
Done task, preserving issue/run/contract binding, suppression,
external-chat authorization and same-run deduplication. The database
regression covers the generated file-preparation receipt, suppression,
unapproved external continuation and replay. Server typecheck and all 49
response-selection tests pass; hosted Linux verifies the database
regression because embedded PostgreSQL cannot start on this Mac.

The delayed-final-answer database regression passes on [the final
root-source Linux server
shard](https://github.com/paperclipai/paperclip/actions/runs/37868262553/job/113628594152),
alongside 1,108 passing tests. The first root Runner shard had one
unchanged durable-resume test exceed its 5-second timeout; the identical
top-source shard and the isolated exact test passed. One rerun of that
failed job and its required aggregate passed without source or test
changes. The original failed job log and the single-rerun receipt remain
retained.

### October 9 merge verification

Current merge head: `5a8fe63512a7166aaef5cf50065a25008aa8b44b`. All
current-head checks pass, including `ci / verify` and `ci / e2e`;
exact-head Greptile review is 5/5 with no unresolved threads. Current
master conflicts are resolved. The user authorized the maintainer
override of the code-owner review gate after these checks. The seven
retained live core cases remain bound to source
`3241a992f2a7703e59e97ed0fd3e5d6405de4401` and its recorded cloud image.

## Risks

- The security correction changes the dependency closure and profile
identity. Old sessions must reopen on the new profile. Exact identities
and credential bindings fail closed.
- The runner remains experimental and requires explicit selection.
Legacy Pi Local is unchanged. Caller model IDs pass through; the E2E
model is a fixture.
- Accounting and the broad platform/provider matrix remain deferred.
This merge does not publish a release or deploy a service.

## Model Used

OpenAI GPT-6 through Codex assisted with reasoning, repository
inspection, editing and tool use. The exact serving ID and context
window are not exposed in this session. Final live qualification uses Pi
1.0.0 with `openrouter/anthropic/claude-sonnet-4.6` and native-confirmed
low thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <[email protected]>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-09 08:56:41 -05:00
1 parent 4fb1ab53a8
commit 57e977be72
517 files changed
+61737 -1440

No files matched your search

+17
View File
@@ -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
+5
View File
@@ -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
+2
View File
@@ -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
+13 -10
View File
@@ -8,19 +8,19 @@ on:
type: string
required: false
publish_eval_image:
description: "Publish an immutable Daytona qualification image on the EC2 fleet (no provider credentials)"
description: "Publish an immutable Daytona qualification image on hosted Linux (no provider credentials)"
type: boolean
default: false
verify_source:
description: "Run broad source checks on EC2"
description: "Run broad source checks on hosted Linux"
type: boolean
default: false
verify_public_install:
description: "Build and verify clean public npm installation on EC2 (no provider credentials)"
description: "Build and verify clean public npm installation on hosted Linux (no provider credentials)"
type: boolean
default: false
build_eval_viewer:
description: "Build the canonical eval report viewer on EC2"
description: "Build the canonical eval report viewer on hosted Linux"
type: boolean
default: false
pull_request:
@@ -98,9 +98,10 @@ jobs:
echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT"
manual_image:
name: Build and verify on EC2
name: Build and verify on Linux
needs: authorize_manual
runs-on: runs-on/fleet=paperclip-public-pr-x64/env=public-ci
# Keep maintainer authorization below; use a native hosted Linux builder.
runs-on: ubuntu-latest
timeout-minutes: 90
permissions:
contents: read
@@ -169,9 +170,9 @@ jobs:
docker buildx imagetools inspect "$immutable" >/dev/null
echo "$immutable" > remote-verification/image.txt
echo "Image: $immutable" >> "$GITHUB_STEP_SUMMARY"
- name: Verify repository on EC2
- name: Verify repository on hosted Linux
if: inputs.verify_source
# Leave time for artifact retention before the fleet's one-hour lifetime.
# Leave time for artifact retention within the job deadline.
timeout-minutes: 38
run: |
set -uo pipefail
@@ -193,9 +194,11 @@ jobs:
run: |
set -euo pipefail
test -f scripts/verify-grok-npm-install.mjs || { echo "Selected source does not provide the public-install verifier"; exit 1; }
pnpm install --frozen-lockfile --ignore-scripts > remote-verification/npm-setup.log 2>&1
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile > remote-verification/npm-setup.log 2>&1
pnpm install --frozen-lockfile --ignore-scripts >> remote-verification/npm-setup.log 2>&1
pnpm build > remote-verification/npm-build.log 2>&1
node scripts/verify-grok-npm-install.mjs > remote-verification/public-npm-install.log 2>&1
PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR="$PWD/remote-verification/qualification-packages" \
node scripts/verify-grok-npm-install.mjs > remote-verification/public-npm-install.log 2>&1
- name: Build canonical eval report viewer
if: always() && (inputs.verify_source || inputs.build_eval_viewer)
run: |
+3
View File
@@ -82,3 +82,6 @@ test-results/
# Retained local lifecycle behavior measurements
.lifecycle-baseline/
# Local native Runner test/session state
.paperclip-runner-prp/
+3 -1
View File
@@ -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
+58
View File
@@ -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" });
});
+35 -15
View File
@@ -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:[email protected]:${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:[email protected]:${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;
}
},
);
+77 -6
View File
@@ -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<string> {
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<string> {
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<void> {
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<void>((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<string> {
const url = new URL(serverUrl);
@@ -39,12 +93,29 @@ export async function setupCursorRuntime(): Promise<void> {
} 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 <provider>")
.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 <directory>")
.description("Import a verified Linux Pi companion into the installed server (no downloads)")
.requiredOption("--sha256 <digest>", "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<unknown> };
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 <provider>")
.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");
});
}
+3 -2
View File
@@ -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);
+73 -2
View File
@@ -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
@@ -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/[email protected]` 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/<platform>-<arch>` 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.
+987
View File
@@ -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/[email protected]`** with
`[email protected]`, 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 `[email protected]` and
`@earendil-works/[email protected]`. The candidate model is
`openrouter/deepseek/deepseek-v4-flash-0731`; only an explicitly bound OpenRouter
credential may reach this profile. `patches/[email protected]` 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__<server>__<tool>` 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/[email protected]` 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 `[email protected]` 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/<platform-arch>`, 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/<platform>-<arch>` 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.
+683 -42
View File
@@ -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.
@@ -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"
}
}
}
@@ -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."
}
}
@@ -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"
}
}
}
@@ -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
}
}
+16
View File
@@ -55,6 +55,18 @@ Historical Everyday cases and production prompts are preserved.
## Selecting a family
Pi's explicit-only `pi-controls` Product suite tests Stop while native permission
is unanswered and browser-originated same-turn steering, locally and on Daytona.
Its [fixture contract](../tests/runner-e2e/FIXTURES.md#pi-active-controls) separates
control acknowledgment, actual message consumption and owned process retirement.
The current Pi matrix has 26 explicit cells (13 local and 13 Daytona), including
these four controls, pending-native-question controller restart, and a Daytona-only
exact-Pi-child provider-death journey. Provider death must expire the original
unanswered card and reject stale replies without a replacement run; any durable
fallback is distinct from restoration. File editing
also requires native edit/validation events and a registered artifact download.
All remain unqualified until measured on the exact candidate runtime and harness.
Use **Runner Evals** for a runner protocol, adapter, transport, native session,
tool grant, or one-turn provider qualification question. The workflow checks
out an exact `paperclip-evals` revision, builds the Runner and viewer, runs a
@@ -428,6 +440,10 @@ Copilot and Pi ACP profiles on local and Daytona. See the
The private Runner Evals campaign of the same name provides complementary
semantic protocol cases; catalog membership is not live qualification.
The explicit local [Copilot protection fixtures](../tests/runner-e2e/FIXTURES.md#copilot-native-protection)
exercise native permission denial with operator cancellation and bounded attached
command settlement. Their registration remains separate from paid qualification;
source/pack provenance and complete persisted evidence are required.
The explicit-only Product E2E `confirmation-replies` suite tests conversational
approval and rejection, persisted message provenance, approval before execution,
ambiguous proposals, and the existing card-click path with native Claude/Codex.
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -33,7 +33,7 @@ COPY cli/package.json ./cli/package.json
# The complete resolved lock (including transitive integrity hashes) is reviewed.
# Reject registry-time drift BEFORE installing packages or running lifecycle code.
# Refresh this digest together with source/provider dependency changes.
ARG PAPERCLIP_RUNNER_LOCK_SHA256=d3c4cffe7d127d99789cf354c5275246526f5396d2a86ce0d387352f68a2394b
ARG PAPERCLIP_RUNNER_LOCK_SHA256=440f0ccd8a383b32a576dc53b4191d73bb03a91ba0dabfa1c3d79c3039e01aa5
# pnpm 9 subtracts PNPM_WORKERS from available CPUs; it is not a worker count.
# Subtract all available CPUs to select its minimum (one tarball worker).
RUN export PNPM_WORKERS="$(node -p 'require("node:os").availableParallelism()')" \
+1 -1
View File
@@ -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",
@@ -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,
@@ -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); })
@@ -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);
@@ -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);
}
@@ -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 });
+1
View File
@@ -127,6 +127,7 @@ export {
normalizeLegacyRunnerProvider,
resolvePaperclipRunnerPermissionMode,
resolvePaperclipRunnerCursorMode,
resolvePaperclipRunnerPiThinkingLevel,
} from "./paperclip-runner-permissions.js";
export {
PAPERCLIP_RUNNER_INGRESS_PORT,
@@ -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();
});
});
@@ -246,5 +246,17 @@ export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([
{ value: "claude", label: "Claude", qualified: true, credentialEnvironment: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] },
{ value: "cursor", label: "Cursor", qualified: true, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] },
{ value: "copilot", label: "GitHub Copilot", qualified: false, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] },
{ value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] },
{ value: "pi", label: "Pi", qualified: true, credentialEnvironment: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] },
] as const);
/** Exact Pi native thinking levels; aliases that silently clamp are not configuration. */
export type PaperclipRunnerPiThinkingLevel = "off" | "low" | "high" | "max";
export function resolvePaperclipRunnerPiThinkingLevel(provider: unknown, agent: unknown, value: unknown): PaperclipRunnerPiThinkingLevel | undefined {
if (provider !== "acpx" || agent !== "pi") {
if (value !== undefined) throw new Error("piThinkingLevel is supported only for Pi");
return undefined;
}
if (value === undefined) return "low";
if (value === "off" || value === "low" || value === "high" || value === "max") return value;
throw new Error("Pi thinking level must be off, low, high, or max");
}
@@ -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<PreparedSandboxManagedRuntime> {
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
@@ -231,7 +231,7 @@ describe("buildPaperclipRunnerConfig", () => {
expect(config).not.toHaveProperty("acpxAgent");
});
it.each(["pi", "copilot"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => {
it.each(["copilot"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => {
expect(() => buildPaperclipRunnerConfig(makeValues({
adapterType: "paperclip_runner",
model: "explicit-provider-model",
@@ -349,3 +349,11 @@ describe("buildPaperclipRunnerConfig", () => {
expect(config).not.toHaveProperty("idleTimeoutMs");
});
});
it.each([undefined, "off", "low", "high", "max"] as const)("builds exact Pi thinking configuration %s", piThinkingLevel => {
const config = buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", model: "openrouter/deepseek/deepseek-v4-flash-0731", adapterSchemaValues: { provider: "acpx", acpxAgent: "pi", piThinkingLevel } }));
expect(config.piThinkingLevel).toBe(piThinkingLevel ?? "low");
});
it("rejects silently clamped Pi aliases during configuration", () => {
expect(() => buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", adapterSchemaValues: { provider: "acpx", acpxAgent: "pi", piThinkingLevel: "medium" } }))).toThrow();
});
@@ -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<string
}
const acpxAgent = selectedAcpxProfile?.value ?? "claude";
const cursorMode = resolvePaperclipRunnerCursorMode(provider, acpxAgent, schemaValues.acpxSessionMode);
const piThinkingLevel = resolvePaperclipRunnerPiThinkingLevel(provider, acpxAgent, schemaValues.piThinkingLevel);
const schemaModel = typeof schemaValues.model === "string"
? schemaValues.model.trim()
@@ -114,7 +116,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
const configuredModel = typeof config.model === "string"
? config.model.trim()
: "";
if (provider === "acpx" && acpxAgent === "cursor" && !configuredModel && !schemaModel) {
if (provider === "acpx" && ["cursor", "pi", "copilot"].includes(acpxAgent) && !configuredModel && !schemaModel) {
throw new Error(`${acpxAgent} requires an explicit provider model`);
}
const managedProfileId = typeof schemaValues.managedProfileId === "string"
@@ -192,6 +194,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
"opencodePermissionMode",
"acpxPermissionMode",
"acpxSessionMode",
"piThinkingLevel",
"managedProfileId",
"managedAgentsRetentionAcknowledged",
"maxSessionListCostUsd",
@@ -251,6 +254,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
? {
acpxAgent,
...(cursorMode === undefined ? {} : { acpxSessionMode: cursorMode }),
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
model: configuredModel || schemaModel || (acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
}
: {}),
+33
View File
@@ -93,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.
@@ -172,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
+7 -7
View File
@@ -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": 2,
"agentProfileVersion": 17,
"agentServerPackage": "@github/copilot",
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
"commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f",
"requiresProviderPolicy": true,
"qualificationStatus": "pending"
},
@@ -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"
}
@@ -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) => {
@@ -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();
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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 <agent>` and
`--expected-acpx-profile <JSON>`. 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
@@ -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","requiredClaimLine truncated
[{"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","requiredClaimLine truncated
+7 -1
View File
@@ -55,6 +55,12 @@
"styles.css",
"README.md"
],
"publishConfig": {
"executableFiles": [
"./dist/bin/paperclip-runnerd",
"./dist/bin/paperclip-runnerd.exe"
]
},
"sideEffects": false,
"scripts": {
"build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && pnpm run check:semantic-contracts && pnpm run check:runner-workflow-traceability && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check",
@@ -75,7 +81,7 @@
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run test:typescript && pnpm run test:rust",
"test:typescript": "pnpm run test:typescript:prep && vitest run",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/provider-pack-executable-shims.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs",
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
@@ -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"
@@ -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"
},
@@ -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",
@@ -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": [
{
@@ -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 } }
}
}
@@ -396,7 +396,7 @@ fn sanitize_question_set(mut value: Value) -> Result<Value, LocalRunnerError> {
};
// 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")
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pi_thinking_level: Option<PiThinkingLevel>,
permission_mode_pinned: bool,
#[serde(default)]
provider_policy: Option<AcpxProviderRuntimePolicy>,
@@ -202,7 +204,7 @@ fn registered_asset_suffix(context: &Value) -> Option<String> {
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<String, Value>,
provider: &crate::acpx_provider_state::AcpxProviderState,
live: &BTreeMap<String, String>,
durable_turn_id: &str,
) -> Vec<Value> {
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<AcpxLaunchProfile>,
}
// 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<PathBuf>, 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<CommandExecution, DurableRunnerError> {
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<CommandExecution, DurableRunnerError> {
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<CommandExecution, DurableRunnerError> {
if self
.state
.as_ref()
.is_some_and(|state| state.provider_exit_unconfirmed)
{
return Err(DurableRunnerError::invalid(
"ACPX provider lifetime cleanup is not yet proven",
));
}
if let Some(session) = self.session.as_mut() {
let identity = session.suspend("runner.suspend").map_err(|error| {
DurableRunnerError::invalid(format!("failed to suspend ACPX provider: {error}"))
@@ -2055,6 +2169,15 @@ impl CommandExecutor for AcpxCommandExecutor {
"turn.stop" => self.stop_turn_for_suspension(&command.command_type),
"request.resolve" => self.resolve_request(&command.payload),
"semantic_tool.result" => self.deliver_tool_result(&command.payload),
"session.snapshot"
if command
.payload
.get("includePendingRuntimeRequests")
.and_then(Value::as_bool)
== Some(true) =>
{
self.snapshot_live_requests()
}
"session.snapshot" => self.snapshot(),
"session.close" | "session.destroy" => self.close_session(&command.command_type),
"runner.suspend" => self.suspend(),
@@ -2379,6 +2502,9 @@ mod tests {
if agent == "cursor" {
value["mode"] = json!("agent");
}
if agent == "pi" {
value["piThinkingLevel"] = json!("low");
}
value
}
@@ -2388,6 +2514,84 @@ mod tests {
"origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/ask_question"}})
}
#[test]
fn live_request_snapshot_preserves_durable_turn_and_provider_callback_bindings() {
use crate::acpx_provider_state::AcpxProviderState;
use crate::acpx_sidecar_transport::AcpxSidecarEvent;
use crate::generated_acpx_sidecar_contract::GeneratedAcpxSidecarEventType;
let mut provider = AcpxProviderState::new("run-1").unwrap();
provider.begin_turn("provider-turn-1").unwrap();
let mut projection = context();
projection.turn_id = "durable-turn-1".into();
projection.provider_turn_id = Some("provider-turn-1".into());
let mut pending = BTreeMap::new();
for (sequence, event_type, payload) in [
(
1,
GeneratedAcpxSidecarEventType::RuntimeInputRequested,
json!({"requestId":"raw input / 1","questionSet":pending_input_request("unused")["input"]}),
),
(
2,
GeneratedAcpxSidecarEventType::RuntimePermissionRequested,
json!({"requestId":"permission-1","kind":"execute","title":"Run?","choices":[{"key":"decline","label":"Decline"}]}),
),
] {
let event = AcpxSidecarEvent {
sequence,
event_type,
run_id: Some("run-1".into()),
turn_id: Some("provider-turn-1".into()),
payload,
};
for event in provider.accept_event(&event).unwrap() {
for normalized in project_acpx_state_event(&projection, &event).unwrap() {
if normalized.event_type == "runtime_request.created" {
let request = normalized.payload["request"].clone();
pending.insert(request["requestId"].as_str().unwrap().to_owned(), request);
}
}
}
}
validate_pending_runtime_requests(&pending).unwrap();
let live = BTreeMap::from([
("raw input / 1".into(), "input".into()),
("permission-1".into(), "permission".into()),
]);
let requests =
attested_pending_runtime_requests(&pending, &provider, &live, "durable-turn-1");
assert_eq!(requests.len(), 2);
assert!(requests
.iter()
.all(|request| request["turnId"] == "durable-turn-1"));
assert!(requests
.iter()
.any(|request| request["requestId"] != "raw input / 1" && request["type"] == "input"));
assert!(
attested_pending_runtime_requests(&pending, &provider, &live, "provider-turn-1")
.is_empty()
);
assert!(attested_pending_runtime_requests(
&pending,
&provider,
&BTreeMap::new(),
"durable-turn-1"
)
.is_empty());
provider.complete_permission("permission-1").unwrap();
let input_id = requests
.iter()
.find(|request| request["type"] == "input")
.unwrap()["requestId"]
.as_str()
.unwrap();
provider.complete_input(input_id).unwrap();
assert!(
attested_pending_runtime_requests(&pending, &provider, &live, "durable-turn-1")
.is_empty()
);
}
#[test]
fn durable_runtime_ledger_retains_only_unsettled_requests_and_rejects_forged_types() {
let operations = Vec::new();
@@ -2549,6 +2753,7 @@ mod tests {
effective_model: descriptor.model.clone(),
permission_mode: Some(descriptor.permission_mode),
mode: descriptor.mode.clone(),
pi_thinking_level: descriptor.pi_thinking_level,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
};
let operations = Vec::new();
@@ -2689,6 +2894,31 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn turn_control_uses_the_explicit_live_provider_binding() {
let command = json!({"turnId":"durable-turn", "providerTurnId":"provider-turn"});
assert_eq!(
turn_control_provider_turn_id(&command).unwrap(),
"provider-turn"
);
let legacy = json!({"turnId":"provider-turn"});
assert_eq!(
turn_control_provider_turn_id(&legacy).unwrap(),
"provider-turn"
);
for malformed in [
Value::Null,
json!(false),
json!(1),
json!(""),
json!("bad\0id"),
] {
let command = json!({"turnId":"provider-turn", "providerTurnId":malformed});
assert!(turn_control_provider_turn_id(&command).is_err());
}
assert!(turn_control_provider_turn_id(&json!({"providerTurnId":"provider-turn"})).is_err());
}
#[test]
fn retained_events_exposes_terminal_suffix_without_restoring_provider() {
let directory = temporary_directory("retained-terminal-suffix");
@@ -2799,6 +3029,7 @@ mod tests {
effective_model: "gpt-5.6-sol".to_owned(),
permission_mode: Some(AcpxPermissionMode::ApproveReads),
mode: None,
pi_thinking_level: None,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
};
@@ -2950,6 +3181,41 @@ mod tests {
}
}
#[test]
fn pi_profile_matches_published_identity_and_rejects_prior_profiles() {
let published: Value = serde_json::from_str(include_str!(
"../../../../test-fixtures/pi-acp/profile-v22-identity.json"
))
.unwrap();
let mut value = descriptor("codex");
value["agent"] = json!("pi");
value["model"] = json!("openrouter/deepseek/deepseek-v4-flash-0731");
value["agentServerPackage"] = json!("pi-acp");
value["agentServerVersion"] = json!("0.0.33");
value["agentRuntimePackage"] = json!("@earendil-works/pi-coding-agent");
value["agentRuntimeVersion"] = json!("1.0.0");
value["piThinkingLevel"] = json!("low");
value["providerPolicy"] = json!({"readOnly": true});
value["commandDigest"] = published["commandDigest"].clone();
let current: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
current.validate(&context()).unwrap();
for prior in [
include_str!("../../../../test-fixtures/pi-acp/profile-v15-identity.json"),
include_str!("../../../../test-fixtures/pi-acp/profile-v16-identity.json"),
include_str!("../../../../test-fixtures/pi-acp/profile-v17-identity.json"),
include_str!("../../../../test-fixtures/pi-acp/profile-v18-identity.json"),
include_str!("../../../../test-fixtures/pi-acp/profile-v19-identity.json"),
include_str!("../../../../test-fixtures/pi-acp/profile-v20-identity.json"),
include_str!("../../../../test-fixtures/pi-acp/profile-v21-identity.json"),
] {
let prior: Value = serde_json::from_str(prior).unwrap();
value["commandDigest"] = prior["commandDigest"].clone();
let rejected: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
assert!(rejected.validate(&context()).is_err());
}
}
#[test]
fn rejects_pi_with_another_profile_identity_before_process_launch() {
let mut pi = descriptor("codex");
@@ -2997,6 +3263,135 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[cfg(target_os = "macos")]
mod darwin_named_transport_cleanup {
use super::*;
struct Fixture {
directory: PathBuf,
descriptor: AcpxProviderDescriptor,
profile: AcpxLaunchProfile,
}
impl Fixture {
fn new(label: &str) -> Self {
let directory = temporary_directory(label);
let command = directory.join("node");
let sidecar = directory.join("sidecar.cjs");
write_artifact(&command, b"qualified node", true);
write_artifact(&sidecar, b"qualified sidecar", false);
let args = vec![sidecar.to_string_lossy().into_owned()];
let profile = AcpxLaunchProfile {
authority_digest: format!("sha256:{}", "d".repeat(64)),
command: command.clone(),
args: args.clone(),
artifacts: vec![artifact(&command), artifact(&sidecar)],
};
let mut value = descriptor("codex");
value["sidecarCommand"] = json!(command);
value["sidecarArgs"] = json!(args);
Self {
directory,
descriptor: serde_json::from_value(value).unwrap(),
profile,
}
}
fn named_images(&self) -> usize {
fs::read_dir(&self.directory)
.unwrap()
.map(|entry| entry.unwrap())
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".paperclip-verified-executable-")
})
.count()
}
fn prove_transport_owns_image(&self) {
assert_eq!(self.named_images(), 0);
let transport = self
.descriptor
.verified_transport(Some(&self.profile))
.unwrap();
assert_eq!(
self.named_images(),
1,
"verification must own one named Node image"
);
drop(transport);
assert_eq!(
self.named_images(),
0,
"dropping unstarted transport must retire its image"
);
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
// File verification only: these tests never start a transport/process.
#[test]
fn second_artifact_failure_retires_named_executable() {
let fixture = Fixture::new("darwin-second-artifact-cleanup");
fixture.prove_transport_owns_image();
// The command remains valid and first in the profile; the sidecar
// fails only after the command's named snapshot has been admitted.
write_artifact(
&fixture.profile.artifacts[1].path,
b"tampered sidecar",
false,
);
let error = fixture
.descriptor
.verified_transport(Some(&fixture.profile))
.err()
.unwrap();
assert!(error
.to_string()
.contains("verified process artifact digest mismatch"));
assert_eq!(fixture.named_images(), 0);
assert_eq!(
fs::read(&fixture.profile.command).unwrap(),
b"qualified node"
);
}
#[test]
fn argv_construction_failure_retires_named_executable() {
let mut fixture = Fixture::new("darwin-argv-cleanup");
fixture.prove_transport_owns_image();
// Descriptor and profile agree, so this passes launch binding and
// artifact verification, then fails the absolute-argument mapping.
let unauthenticated = fixture
.directory
.join("unauthenticated.cjs")
.to_string_lossy()
.into_owned();
fixture.profile.args.push(unauthenticated.clone());
fixture.descriptor.sidecar_args.push(unauthenticated);
let error = fixture
.descriptor
.verified_transport(Some(&fixture.profile))
.err()
.unwrap();
assert!(error
.to_string()
.contains("does not authenticate an absolute argument"));
assert_eq!(fixture.named_images(), 0);
assert_eq!(
fs::read(&fixture.profile.command).unwrap(),
b"qualified node"
);
}
}
#[cfg(unix)]
#[test]
fn rejects_symlinked_launch_artifacts() {
@@ -3071,12 +3466,32 @@ mod tests {
args: Vec::new(),
artifacts: vec![artifact(&command)],
};
let mut descriptor_value = descriptor("codex");
let mut descriptor_value = descriptor("pi");
descriptor_value["sidecarCommand"] = json!(command);
descriptor_value["runtimeContext"] = json!({ "instructions": { "digest": "stable" }, "mcp": { "digest": "before" }, "aggregateDigest": "before" });
descriptor_value["sidecarArgs"] = json!([]);
descriptor_value["runtimeDirectory"] = json!(runtime);
descriptor_value["cwd"] = json!(workspace);
let prior_root = directory.join("old-registered-copy");
let current_root = directory.join("new-registered-copy");
fs::create_dir_all(&prior_root).unwrap();
fs::create_dir_all(&current_root).unwrap();
descriptor_value["instructions"] = json!(format!(
"Current AGENT_HOME: {}. Custom entry.",
prior_root.display()
));
descriptor_value["runtimeContext"] = json!({
"aggregateDigest": "a".repeat(64),
"prompt": {"revision": "pinned", "digest": "b".repeat(64)},
"instructions": {
"entryPath": "AGENTS.md",
"bundle": {"digest": "c".repeat(64), "rootPath": "/old-bundle"},
"workingCopy": {"kind": "agent_files", "entryPath": "AGENTS.md", "rootPath": prior_root},
},
"skills": [{"key": "skill-1", "bundle": {"digest": "d".repeat(64), "rootPath": "/old-skill"}}],
"mcp": {"assignmentSetId": "assignment-1", "digest": "e".repeat(64), "bindingId": "old-run-binding"},
"futurePolicy": {"companyId": "company-1"},
});
let original_descriptor: AcpxProviderDescriptor =
serde_json::from_value(descriptor_value.clone()).unwrap();
let identity = AcpxProviderSessionIdentity {
@@ -3091,6 +3506,7 @@ mod tests {
effective_model: original_descriptor.model.clone(),
permission_mode: Some(original_descriptor.permission_mode),
mode: original_descriptor.mode.clone(),
pi_thinking_level: original_descriptor.pi_thinking_level,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
};
let operations = Vec::new();
@@ -3193,11 +3609,57 @@ mod tests {
.contains("requires run.attach before commands from a new run"));
descriptor_value["runId"] = json!("run-2");
fs::remove_dir_all(&prior_root).unwrap();
descriptor_value["instructions"] = json!(format!(
"Current AGENT_HOME: {}. Fresh custom entry.",
current_root.display()
));
descriptor_value["runtimeContext"]["instructions"]["workingCopy"]["rootPath"] =
json!(current_root);
descriptor_value["runtimeContext"]["instructions"]["bundle"]["rootPath"] =
json!("/new-bundle");
descriptor_value["runtimeContext"]["skills"][0]["bundle"]["rootPath"] = json!("/new-skill");
descriptor_value["runtimeContext"]["mcp"]["bindingId"] = json!("new-run-binding");
attached
.attach_run(&json!({"provider": descriptor_value}))
.unwrap();
assert_eq!(attached.state.as_ref().unwrap().descriptor.run_id, "run-2");
assert!(!marker.exists());
let refreshed = &attached.state.as_ref().unwrap().descriptor;
assert_eq!(
refreshed.runtime_context,
descriptor_value["runtimeContext"]
);
assert_eq!(refreshed.instructions, descriptor_value["instructions"]);
let session_config = refreshed
.session_config(
attached.state.as_ref().unwrap().tool_set.clone(),
attached.state.as_ref().unwrap().identity.clone(),
Some(&launch_profile),
)
.unwrap();
assert_eq!(
session_config.runtime_context,
descriptor_value["runtimeContext"]
);
assert_eq!(
session_config.system_instructions,
descriptor_value["instructions"]
);
// Both persistence and the sidecar launch config receive the new grant.
let persisted: AcpxDurableState =
serde_json::from_slice(&fs::read(attached.state_path()).unwrap()).unwrap();
assert_eq!(
persisted.descriptor.runtime_context,
descriptor_value["runtimeContext"]
);
let mut same_run_mutation = descriptor_value.clone();
same_run_mutation["instructions"] = json!("A different grant in the same run");
assert!(attached
.attach_run(&json!({"provider": same_run_mutation}))
.unwrap_err()
.to_string()
.contains("changed runtime context outside a new authenticated run"));
// In-place warm handoff executes under the old authority. Only the
// authenticated next-authority boundary may admit the new descriptor;
@@ -3227,7 +3689,7 @@ mod tests {
json!("other-session");
assert!(original.attach_run(&wrong_session).is_err());
let mut changed_profile = warm_payload.clone();
changed_profile["provider"]["instructions"] = json!("different profile");
changed_profile["provider"]["cwd"] = json!("/different-workspace");
assert!(original.attach_run(&changed_profile).is_err());
let mut refreshed = warm_payload.clone();
refreshed["provider"]["runtimeContext"]["mcp"] = json!({ "digest": "after" });
@@ -3263,7 +3725,7 @@ mod tests {
#[cfg(unix)]
fn authenticated_run_grant_attachment(agent: &str) {
let directory = temporary_directory("cursor-cross-run-attach");
let directory = temporary_directory(&format!("{agent}-cross-run-attach"));
let runtime = directory.join("runtime");
let workspace = directory.join("workspace");
fs::create_dir_all(&runtime).unwrap();
@@ -3329,6 +3791,7 @@ mod tests {
effective_model: original_descriptor.model.clone(),
permission_mode: Some(original_descriptor.permission_mode),
mode: original_descriptor.mode.clone(),
pi_thinking_level: original_descriptor.pi_thinking_level,
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
};
let operations = Vec::new();
@@ -3622,6 +4085,7 @@ mod tests {
effective_model: descriptor.model.clone(),
permission_mode: Some(descriptor.permission_mode),
mode: descriptor.mode.clone(),
pi_thinking_level: descriptor.pi_thinking_level,
provider_lifetime_fence_candidates,
};
let operations = Vec::new();
@@ -3794,6 +4258,7 @@ mod tests {
effective_model: provider_descriptor.model.clone(),
permission_mode: Some(provider_descriptor.permission_mode),
mode: provider_descriptor.mode.clone(),
pi_thinking_level: provider_descriptor.pi_thinking_level,
provider_lifetime_fence_candidates,
});
state.provider_exit_unconfirmed = true;
@@ -11,7 +11,7 @@ pub(crate) enum RunAttachmentPolicy {
pub(crate) fn run_attachment_policy(agent: &str) -> RunAttachmentPolicy {
match agent {
"cursor" => RunAttachmentPolicy::AuthenticatedRunGrants,
"cursor" | "pi" => RunAttachmentPolicy::AuthenticatedRunGrants,
"claude" => RunAttachmentPolicy::AuthenticatedAssetPaths,
_ => RunAttachmentPolicy::ImmutableInstructions,
}
@@ -27,11 +27,15 @@ mod tests {
run_attachment_policy("cursor"),
RunAttachmentPolicy::AuthenticatedRunGrants
);
assert_eq!(
run_attachment_policy("pi"),
RunAttachmentPolicy::AuthenticatedRunGrants
);
assert_eq!(
run_attachment_policy("claude"),
RunAttachmentPolicy::AuthenticatedAssetPaths
);
for agent in ["codex", "grok", "pi", "copilot", "unknown"] {
for agent in ["codex", "grok", "copilot", "unknown"] {
assert_eq!(
run_attachment_policy(agent),
RunAttachmentPolicy::ImmutableInstructions
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pi_thinking_level: Option<PiThinkingLevel>,
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()
@@ -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<AcpxPermissionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pi_thinking_level: Option<PiThinkingLevel>,
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<String>,
pub pi_thinking_level: Option<PiThinkingLevel>,
pub permission_mode_pinned: bool,
pub provider_policy: Option<AcpxProviderRuntimePolicy>,
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<std::collections::BTreeMap<String, String>, 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<std::collections::BTreeMap<String, String>, 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::<PiThinkingLevel>(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::<PiThinkingLevel>(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());
}
}
@@ -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<AcpxSidecarEvent>,
@@ -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<Vec<String>, LocalRunnerError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Binding {
schema: String,
agent: String,
session_id: String,
names: Vec<String>,
}
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, LocalRunnerError> {
Self::start_with_environment_keys(config, &[])
@@ -108,7 +213,7 @@ impl AcpxSidecarTransport {
"PAPERCLIP_AI_PROVIDER_KEY",
],
"grok" => &["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"],
"pi" => &["OPENROUTER_API_KEY"],
"pi" => &[],
"cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
"copilot" => &["COPILOT_GITHUB_TOKEN"],
_ => {
@@ -163,8 +268,28 @@ impl AcpxSidecarTransport {
"CLAUDE_CODE_SUBAGENT_MODEL",
]);
}
let pi_keys = if agent == "pi" {
pi_credential_environment_keys(
std::env::var("PAPERCLIP_ACPX_CREDENTIAL_BINDING")
.ok()
.as_deref(),
)?
} else {
Vec::new()
};
keys.extend(pi_keys.iter().map(String::as_str));
keys.extend_from_slice(credential_keys);
Self::start_with_environment_keys(config, &keys)
// Pi owns a native distribution copy, including a pending refresh at
// suspension. Allow its bounded cleanup to settle before group KILL;
// the ordinary two-second grace can cut off deletion mid-tree.
config.validate()?;
let mut launch_config = config.clone();
if agent == "pi" {
launch_config.shutdown_grace = Duration::from_secs(30);
}
let mut transport = Self::start_with_environment_keys(&launch_config, &keys)?;
transport.session_open_timeout = session_open_timeout(agent, config.request_timeout);
Ok(transport)
}
fn start_with_environment_keys(
@@ -191,6 +316,7 @@ impl AcpxSidecarTransport {
Ok(Self {
process,
request_timeout: config.request_timeout,
session_open_timeout: config.request_timeout,
next_request_id: 1,
last_event_sequence: 0,
buffered_events: VecDeque::new(),
@@ -200,6 +326,14 @@ impl AcpxSidecarTransport {
})
}
fn command_timeout(&self, command: GeneratedAcpxSidecarCommand) -> Duration {
if command == GeneratedAcpxSidecarCommand::SessionOpen {
self.session_open_timeout
} else {
self.request_timeout
}
}
pub fn process_id(&self) -> u32 {
self.process.id()
}
@@ -295,7 +429,8 @@ impl AcpxSidecarTransport {
})?;
self.next_request_id = request_id + 1;
let deadline = Instant::now() + self.request_timeout;
let timeout = self.command_timeout(command);
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
@@ -795,6 +930,67 @@ fn response_error_classification(error: &ResponseError) -> &'static str {
#[cfg(test)]
mod tests {
#[test]
fn pi_credentials_require_a_bounded_binding_without_process_control_variables() {
assert!(pi_credential_environment_keys(None).unwrap().is_empty());
for name in [
"NODE_OPTIONS",
"PATH",
"HOME",
"LD_PRELOAD",
"DYLD_INSERT_LIBRARIES",
"PAPERCLIP_NATIVE_MCP_TOKEN",
"INVALID-NAME",
] {
let binding = json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":[name]});
assert!(pi_credential_environment_keys(Some(&binding.to_string())).is_err());
}
for binding in [
json!({"schema":"other", "agent":"pi", "sessionId":"session-1", "names":[]}),
json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"cursor", "sessionId":"session-1", "names":[]}),
json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":["MY_PI_KEY", "MY_PI_KEY"]}),
json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":[], "extra":true}),
] {
assert!(pi_credential_environment_keys(Some(&binding.to_string())).is_err());
}
assert!(pi_credential_environment_keys(Some(&"x".repeat(4_097))).is_err());
}
#[test]
fn pi_loader_and_shell_controls_match_the_controller_reservations() {
let names: Vec<String> = 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]
@@ -25,6 +25,26 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.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::<u64>())
.transpose()?;
let mut opened_identity: Option<Value> = None;
let lifetime_fence_candidates = args
.windows(2)
.find(|pair| pair[0] == "--lifetime-fence-ports")
.map(|pair| {
pair[1]
.split(',')
.map(str::parse::<u16>)
.collect::<Result<Vec<_>, _>>()
})
.transpose()?
.unwrap_or_else(|| vec![60001, 60002, 60003]);
let lifetime_fence_candidates: [u16; 3] = lifetime_fence_candidates
.try_into()
.map_err(|_| "lifetime fence requires three ports")?;
// Only the receiver-admission fixture writes this task-owned command journal.
let mut admission_journal = if matches!(mode, "admission-tool" | "admission-tool-oversized") {
let path = args
@@ -71,6 +91,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
&request,
mode,
profile_digest,
lifetime_fence_candidates,
&mut session_identity,
)
};
@@ -136,6 +157,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
&request,
mode,
profile_digest,
lifetime_fence_candidates,
&mut session_identity,
),
)?;
@@ -207,7 +229,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
"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::<Vec<_>>()),
"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::<Vec<_>>()),
}
}),
)?;
@@ -262,6 +284,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}
"bootstrap"
| "goals"
| "bootstrap-wrong-pi-thinking"
| "bootstrap-missing-pi-thinking"
| "bootstrap-alias-pi-thinking"
| "bootstrap-wrong-model"
| "bootstrap-wrong-run"
| "controls"
@@ -299,6 +324,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
| "resolutions-error-redaction"
| "resolutions-projected-id"
| "resolutions-wrong-ack"
| "resolutions-snapshot-wrong-session"
| "resolutions-snapshot-wrong-turn"
| "resolutions-snapshot-missing-callbacks"
| "suspend"
| "suspend-wrong-ack"
| "suspend-wrong-identity"
@@ -318,17 +346,25 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
)?;
next_sequence += 1;
}
write_json(
&mut stdout,
&bootstrap_success(
id,
command,
&request,
mode,
profile_digest,
&mut session_identity,
),
)?;
let mut response = bootstrap_success(
id,
command,
&request,
mode,
profile_digest,
lifetime_fence_candidates,
&mut session_identity,
);
if command == "session.open" {
opened_identity = response.pointer("/result/identity").cloned();
}
if command == "session.suspend" {
if let Some(delay_ms) = suspend_delay_ms {
std::thread::sleep(Duration::from_millis(delay_ms));
response["result"]["identity"] = opened_identity.clone().unwrap();
}
}
write_json(&mut stdout, &response)?;
let params = request.get("params").unwrap_or(&Value::Null);
let turn_id = params
.get("turnId")
@@ -698,15 +734,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
)?;
next_sequence += 1;
}
if command == "turn.start"
&& matches!(
mode,
"resolutions"
| "resolutions-error-redaction"
| "resolutions-projected-id"
| "resolutions-wrong-ack"
)
{
if command == "turn.start" && mode.starts_with("resolutions") {
for (event_type, payload) in [
(
"runtime.tool_called",
@@ -777,6 +805,7 @@ fn bootstrap_success(
request: &Value,
mode: &str,
profile_digest: &str,
lifetime_fence_candidates: [u16; 3],
session_identity: &mut Value,
) -> Value {
if command == "permission.resolve" {
@@ -827,7 +856,13 @@ fn bootstrap_success(
"effectiveModel": if mode == "bootstrap-wrong-model" { "wrong-model" } else { model },
"permissionMode": params.get("permissionMode"),
"mode": params.get("mode"),
"providerLifetimeFenceCandidates": [60001, 60002, 60003],
"piThinkingLevel": match mode {
"bootstrap-wrong-pi-thinking" => json!("high"),
"bootstrap-missing-pi-thinking" => Value::Null,
"bootstrap-alias-pi-thinking" => json!("medium"),
_ => params.get("piThinkingLevel").cloned().unwrap_or(Value::Null),
},
"providerLifetimeFenceCandidates": lifetime_fence_candidates,
},
"status": {},
"turnControls": {"steering": matches!(mode, "controls" | "controls-wrong-ack" | "controls-downgrade"), "queuedFollowUp":matches!(mode, "controls" | "controls-wrong-ack" | "controls-downgrade")},
@@ -850,6 +885,21 @@ fn bootstrap_success(
"turn.cancel" => {
json!({"cancelled":mode != "turns-wrong-cancel", "sessionClosed":matches!(mode, "turns-retired" | "turns-retired-terminal-first")})
}
"session.snapshot" => {
let mut identity = session_identity.clone();
if mode == "resolutions-snapshot-wrong-session" {
identity["acpxRecordId"] = json!("other-record");
}
json!({
"identity": identity,
"runId":"run-1",
"turnId":if mode == "resolutions-snapshot-wrong-turn" {"other-turn"} else {"turn-1"},
"pendingRuntimeRequests":if mode == "resolutions-snapshot-missing-callbacks" {Value::Null} else {
json!([{"requestId":if mode == "resolutions-projected-id" {PROJECTED_INPUT_PROVIDER_ID} else {"input-1"},
"type":"input","turnId":"turn-1"}])
}
})
}
"session.suspend" => {
let mut identity = session_identity.clone();
if mode == "suspend-missing-identity" {
@@ -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"));
@@ -27,9 +27,9 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
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<AcpxReleaseProfile> {
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<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
"sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -40,6 +40,8 @@ pub(crate) fn is_node_interpreter(path: &Path) -> bool {
pub struct VerifiedProcessArtifact {
display_path: PathBuf,
file: Arc<File>,
#[cfg(target_os = "macos")]
executable: Option<Arc<NamedExecutableSnapshot>>,
}
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<Self, LocalRunnerError> {
#[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<File, LocalRunnerError> {
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<File, LocalRunnerError> {
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<File>,
expected_sha256: String,
expected_len: u64,
}
#[cfg(target_os = "macos")]
impl NamedExecutableSnapshot {
fn create(
display_path: &Path,
source: &mut File,
expected_sha256: &str,
) -> Result<Self, LocalRunnerError> {
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<rustix::fd::OwnedFd>,
#[cfg(target_os = "macos")]
temporary_executables: Vec<TemporaryExecutable>,
#[cfg(target_os = "macos")]
named_executable: Option<Arc<NamedExecutableSnapshot>>,
}
#[cfg(target_os = "macos")]
@@ -641,6 +896,8 @@ pub struct SupervisedProcess {
finished: bool,
#[cfg(target_os = "macos")]
_temporary_executables: Vec<TemporaryExecutable>,
#[cfg(target_os = "macos")]
_named_executable: Option<Arc<NamedExecutableSnapshot>>,
}
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));
@@ -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();
}
}
@@ -9,6 +9,21 @@ use crate::process_supervisor::VerifiedProcessArtifact;
pub fn verify_launch_artifact(
artifact: &QualifiedLaunchArtifact,
label: &str,
) -> Result<VerifiedProcessArtifact, DurableRunnerError> {
verify_launch_artifact_with_role(artifact, label, false)
}
pub fn verify_executable_launch_artifact(
artifact: &QualifiedLaunchArtifact,
label: &str,
) -> Result<VerifiedProcessArtifact, DurableRunnerError> {
verify_launch_artifact_with_role(artifact, label, true)
}
fn verify_launch_artifact_with_role(
artifact: &QualifiedLaunchArtifact,
label: &str,
executable: bool,
) -> Result<VerifiedProcessArtifact, DurableRunnerError> {
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)]
@@ -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)
@@ -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();
}
@@ -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())
@@ -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<serde_json::Value> = 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"));
}
}
@@ -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"] {
@@ -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(
@@ -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");
}
@@ -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");
@@ -17,7 +17,7 @@ use sha2::{Digest, Sha256};
const CODEX_ACPX_DIGEST: &str =
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
const PI_ACPX_DIGEST: &str =
"sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f";
"sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9";
fn temporary_directory(label: &str) -> PathBuf {
let nonce = SystemTime::now()
@@ -231,10 +231,11 @@ fn pi_prepare_payload(directory: &Path, mode: &str) -> Value {
provider["agentServerPackage"] = json!("pi-acp");
provider["agentServerVersion"] = json!("0.0.33");
provider["agentRuntimePackage"] = json!("@earendil-works/pi-coding-agent");
provider["agentRuntimeVersion"] = json!("0.84.2");
provider["agentRuntimeVersion"] = json!("1.0.0");
provider["commandDigest"] = json!(PI_ACPX_DIGEST);
provider["sidecarArgs"][3] = json!(PI_ACPX_DIGEST);
provider["providerPolicy"] = json!({"readOnly":true});
provider["piThinkingLevel"] = json!("low");
payload
}
@@ -1371,6 +1372,162 @@ fn rejects_pi_without_an_explicit_model_before_starting_a_sidecar() {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn pi_thinking_change_cannot_attach_to_an_existing_provider_identity() {
let directory = temporary_directory("pi-thinking-attach");
let config = pi_acpx_config(&directory, "bootstrap");
let payload = pi_prepare_payload(&directory, "bootstrap");
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
executor
.execute(&command(1, "run.prepare", payload.clone()))
.unwrap();
let opened = executor
.execute(&command(2, "session.open", json!({})))
.unwrap();
assert_eq!(opened.result["piThinkingLevel"], json!("low"));
assert_eq!(
opened.events[0].2["providerDescriptor"]["piThinkingLevel"],
json!("low")
);
let state_path = directory.join("acpx-provider-state.json");
let prior = fs::read(&state_path).unwrap();
for (index, mode) in ["off", "high", "max"].into_iter().enumerate() {
let mut changed = payload.clone();
changed["provider"]["piThinkingLevel"] = json!(mode);
let error = executor
.execute(&command(3 + index as u64, "run.attach", changed))
.unwrap_err();
assert!(
error
.to_string()
.contains("requires the same settled ACPX provider profile and session"),
"unexpected thinking identity rejection: {error}"
);
assert_eq!(fs::read(&state_path).unwrap(), prior);
}
executor
.execute(&command(6, "run.attach", payload))
.unwrap();
executor.shutdown().unwrap();
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn stops_an_idle_pi_provider_before_suspension_without_waiting_for_its_close_rpc() {
let directory = temporary_directory("pi-idle-suspension");
let mut config = pi_acpx_config(&directory, "bootstrap");
config
.acpx_launch_profile
.as_mut()
.unwrap()
.args
.extend(["--suspend-delay-ms".to_owned(), "8000".to_owned()]);
let mut payload = pi_prepare_payload(&directory, "bootstrap");
payload["provider"]["sidecarArgs"] = json!(config.acpx_launch_profile.as_ref().unwrap().args);
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
executor
.execute(&command(1, "run.prepare", payload))
.unwrap();
executor
.execute(&command(2, "session.open", json!({})))
.unwrap();
let state_path = directory.join("acpx-provider-state.json");
let opened: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap();
let started = std::time::Instant::now();
let stopped = executor
.execute(&command(3, "turn.stop", json!({})))
.unwrap();
executor
.execute(&command(4, "runner.drain", json!({})))
.unwrap();
executor
.execute(&command(5, "runner.suspend", json!({})))
.unwrap();
let suspended: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap();
let elapsed = started.elapsed();
executor.shutdown().unwrap();
fs::remove_dir_all(directory).unwrap();
assert_eq!(stopped.result["status"], "stopped");
assert_eq!(stopped.result["providerExitConfirmed"], true);
assert!(
elapsed < Duration::from_secs(3),
"idle close took {elapsed:?}"
);
assert_eq!(suspended["lifecycle"], "suspended");
assert_eq!(suspended["providerExitUnconfirmed"], false);
assert_eq!(suspended["identity"], opened["identity"]);
assert_eq!(suspended["activeProviderTurnId"], Value::Null);
}
#[test]
fn idle_pi_stop_cannot_publish_a_reusable_identity_while_its_lifetime_fence_is_held() {
let directory = temporary_directory("pi-idle-held-lifetime");
// Linux can assign port 0 below the identity contract's dynamic-port range.
// Keep this fixture distinct from the fake sidecar's default fence ports.
let fence = (61_000..=u16::MAX)
.filter_map(|port| std::net::TcpListener::bind(("127.0.0.1", port)).ok())
.take(3)
.collect::<Vec<_>>();
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::<Vec<_>>()
.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 [
@@ -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"))
@@ -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());
}
@@ -0,0 +1,100 @@
import profiles from "../acpx-profiles.json" with { type: "json" };
import { createHash, timingSafeEqual } from "node:crypto";
import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { gunzipSync } from "node:zlib";
import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribution } from "./materialize-copilot-binary.mjs";
const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 384 * 1024 * 1024;
const PROFILE_DIGEST = profiles.profiles.copilot.commandDigest;
/** Build-time only; outputRoot is the exact runner-owned platform asset directory. */
export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) {
if (typeof outputRoot !== "string" || !isAbsolute(outputRoot) || resolve(outputRoot) !== outputRoot || outputRoot.includes("\0")) {
throw new Error("Copilot outputRoot must be a normalized absolute build directory");
}
const distribution = resolveCopilotDistribution(platform, architecture);
const packageBasename = distribution.packageName.slice("@github/".length);
const archiveUrl = `https://registry.npmjs.org/${distribution.packageName}/-/${packageBasename}-${COPILOT_VERSION}.tgz`;
const response = await fetchImpl(archiveUrl, { redirect: "error", credentials: "omit", signal: AbortSignal.timeout(120_000) });
if (!response.ok || !response.body) throw new Error(`Pinned Copilot archive download failed (${response.status})`);
const advertisedLength = response.headers.get("content-length");
if (advertisedLength !== null && (!/^\d+$/.test(advertisedLength) || Number(advertisedLength) > MAX_ARCHIVE_BYTES)) {
await response.body.cancel(); throw new Error("Pinned Copilot archive exceeds its download bound");
}
const chunks = []; let total = 0;
for await (const chunk of response.body) {
total += chunk.length;
if (total > MAX_ARCHIVE_BYTES) throw new Error("Pinned Copilot archive exceeds its download bound");
chunks.push(chunk);
}
const archive = Buffer.concat(chunks, total);
verifyCopilotArchiveIntegrity(archive, distribution.archiveIntegrity);
const entries = readCopilotArchiveEntries(gunzipSync(archive, { maxOutputLength: MAX_EXPANDED_BYTES }));
const metadata = JSON.parse(entries.get("package/package.json").toString("utf8"));
if (metadata.name !== distribution.packageName || metadata.version !== COPILOT_VERSION) throw new Error("Copilot archive package identity differs from its immutable profile");
const binary = entries.get("package/copilot");
if (binary.length !== distribution.size || createHash("sha256").update(binary).digest("hex") !== distribution.executableDigest) {
throw new Error("Copilot archive executable differs from its pinned distribution");
}
// Never extract archive paths. Only the two admitted files enter this private
// temporary package, and no package scripts or executables are invoked.
const temporary = await mkdtemp(join(tmpdir(), "paperclip-copilot-build-"));
try {
await writeFile(join(temporary, "package.json"), entries.get("package/package.json"), { flag: "wx", mode: 0o600 });
await writeFile(join(temporary, "copilot"), binary, { flag: "wx", mode: 0o700 });
await mkdir(outputRoot, { recursive: true, mode: 0o755 });
if (!(await lstat(outputRoot)).isDirectory() || await realpath(outputRoot) !== outputRoot) throw new Error("Copilot build directory must not redirect through links");
const materialized = materializePinnedCopilotBinary({ packageRoot: temporary, targetDirectory: outputRoot, platform, architecture });
return { version: COPILOT_VERSION, profileDigest: PROFILE_DIGEST, closureDigest: `sha256:${materialized.closureSha256}` };
} finally { await rm(temporary, { recursive: true, force: true }); }
}
export function verifyCopilotArchiveIntegrity(bytes, integrity) {
if (!/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) throw new Error("Copilot archive integrity pin is malformed");
const expected = Buffer.from(integrity.slice(7), "base64");
if (!timingSafeEqual(createHash("sha512").update(bytes).digest(), expected)) throw new Error("Copilot archive SHA-512 integrity mismatch");
}
/** Strict npm tar subset: no links, traversal, PAX overrides, duplicates or hidden trailers. */
export function readCopilotArchiveEntries(tar) {
if (!Buffer.isBuffer(tar) || tar.length < 1024 || tar.length > MAX_EXPANDED_BYTES || tar.length % 512 !== 0) throw new Error("Copilot tar archive has invalid framing");
const permitted = new Set(["package/copilot", "package/package.json", "package/LICENSE.md", "package/README.md"]);
const entries = new Map(); let offset = 0; let ended = false;
const field = (header, start, end) => {
const bytes = header.subarray(start, end); const nul = bytes.indexOf(0);
if (nul >= 0 && bytes.subarray(nul).some(byte => byte !== 0)) throw new Error("Copilot tar field contains hidden bytes");
return bytes.subarray(0, nul < 0 ? undefined : nul).toString("ascii");
};
const octal = (header, start, end) => {
const value = header.subarray(start, end).toString("ascii").replace(/\0/g, "").trim();
if (!/^[0-7]+$/.test(value)) throw new Error("Copilot tar numeric field is malformed");
const parsed = Number.parseInt(value, 8);
if (!Number.isSafeInteger(parsed)) throw new Error("Copilot tar numeric field exceeds its bound");
return parsed;
};
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512); offset += 512;
if (header.every(byte => byte === 0)) {
if (offset + 512 > tar.length || tar.subarray(offset).some(byte => byte !== 0)) throw new Error("Copilot tar archive has a hidden or truncated trailer");
ended = true; break;
}
const checksum = header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0);
if (checksum !== octal(header, 148, 156)) throw new Error("Copilot tar header checksum mismatch");
const name = field(header, 0, 100);
const size = octal(header, 124, 136);
if (!permitted.has(name) || entries.has(name) || field(header, 345, 500) !== "" || field(header, 157, 257) !== ""
|| header[156] !== 48 || field(header, 257, 263) !== "ustar" || header.subarray(263, 265).toString("ascii") !== "00") {
throw new Error("Copilot tar archive contains an unrecognized or unsafe entry");
}
if (size < 1 || size > (name === "package/copilot" ? MAX_EXPANDED_BYTES : 256 * 1024) || offset + size > tar.length) throw new Error("Copilot tar entry exceeds its bounded payload");
const paddedSize = Math.ceil(size / 512) * 512;
if (offset + paddedSize > tar.length || tar.subarray(offset + size, offset + paddedSize).some(byte => byte !== 0)) throw new Error("Copilot tar entry has invalid padding");
entries.set(name, tar.subarray(offset, offset + size));
offset += paddedSize;
}
if (!ended || entries.size !== permitted.size) throw new Error("Copilot tar archive is incomplete");
return entries;
}
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { test } from "node:test";
import { buildPinnedCopilotDistribution, readCopilotArchiveEntries, verifyCopilotArchiveIntegrity } from "./build-copilot-distribution.mjs";
function header(name, body, type = "0") {
const block = Buffer.alloc(512); block.write(name, 0, 100, "ascii");
block.write("0000755\0", 100); block.write(body.length.toString(8).padStart(11, "0") + "\0", 124);
block.fill(32, 148, 156); block.write(type, 156); block.write("ustar\0", 257); block.write("00", 263);
block.write(block.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, "0") + "\0 ", 148);
return Buffer.concat([block, body, Buffer.alloc((512 - body.length % 512) % 512)]);
}
function archive(replace = {}) {
const entries = ["package/copilot", "package/package.json", "package/LICENSE.md", "package/README.md"];
return Buffer.concat([...entries.map(name => header(name === "package/copilot" ? replace.name ?? name : name,
Buffer.from(name === "package/copilot" ? "binary" : "data"), name === "package/copilot" ? replace.type ?? "0" : "0")), Buffer.alloc(1024)]);
}
test("strict archive reader admits only complete pinned npm entry shape", () => {
const entries = readCopilotArchiveEntries(archive());
assert.equal(entries.get("package/copilot").toString(), "binary");
for (const invalid of [archive({ name: "../../copilot" }), archive({ type: "2" }), archive({ type: "x" }),
archive({ name: "package/package.json" }), archive().subarray(0, -512), Buffer.concat([archive(), Buffer.from("hidden")])]) {
assert.throws(() => readCopilotArchiveEntries(invalid));
}
const changed = archive(); changed[20] ^= 1;
assert.throws(() => readCopilotArchiveEntries(changed), /checksum/);
const padding = archive(); padding[512 + 6] = 1;
assert.throws(() => readCopilotArchiveEntries(padding), /padding/);
});
test("SHA-512 archive verification rejects corruption and malformed pins", () => {
const bytes = Buffer.from("pinned archive"); const pin = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
verifyCopilotArchiveIntegrity(bytes, pin);
assert.throws(() => verifyCopilotArchiveIntegrity(Buffer.from("tampered"), pin), /mismatch/);
assert.throws(() => verifyCopilotArchiveIntegrity(bytes, "sha512-invalid"), /malformed/);
});
test("builder uses only exact pinned registry URL and refuses a corrupt download before writes", async () => {
let called = false;
await assert.rejects(buildPinnedCopilotDistribution({ outputRoot: "/unused-copilot-build", platform: "linux", architecture: "x64" }, {
fetchImpl: async (url, options) => {
called = true;
assert.equal(url, "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.88.tgz");
assert.equal(options.redirect, "error"); assert.equal(options.credentials, "omit");
return new Response("corrupt");
},
}), /integrity mismatch/);
assert.equal(called, true);
await assert.rejects(buildPinnedCopilotDistribution({ outputRoot: "relative", platform: "linux", architecture: "x64" }), /normalized absolute/);
});
test("builder bounds download metadata before streaming", async () => {
await assert.rejects(buildPinnedCopilotDistribution({ outputRoot: "/unused-copilot-build", platform: "linux", architecture: "x64" }, {
fetchImpl: async () => new Response("small", { headers: { "content-length": String(129 * 1024 * 1024) } }),
}), /download bound/);
});
@@ -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/);
});
@@ -20,8 +20,8 @@ import { dirname, join, relative, resolve } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
import { writePortableCopilotShims, writePortableExecutableShim } from "./provider-pack-executable-shims.mjs";
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
import { writePortableExecutableShim, writePortableCopilotShims } from "./provider-pack-executable-shims.mjs";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const workspaceRoot = resolve(packageRoot, "../..");
@@ -167,7 +167,7 @@ try {
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest)
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity");
candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest,
closureDigest: metadata.closureDigest, qualification: provider === "cursor" ? "qualified" : "pending", path: assetPath,
closureDigest: metadata.closureDigest, qualification: (provider === "cursor" || provider === "pi") ? "qualified" : "pending", path: assetPath,
sha256: sha256Tree(join(temporaryRoot, assetPath)) };
}
@@ -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/[email protected]", "pi-acp.patch"],
["../../patches/[email protected]", "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;
@@ -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";
@@ -1,3 +1,4 @@
import { materializePiDistribution } from "./materialize-pi-distribution.mjs";
import profiles from "../acpx-profiles.json" with { type: "json" };
import { materializePinnedCursorDistribution } from "./materialize-cursor-distribution.mjs";
const CANDIDATES = new Set(["cursor", "copilot", "pi"]);
@@ -5,14 +6,14 @@ const CANDIDATES = new Set(["cursor", "copilot", "pi"]);
/** Only materialized providers enter the serialized manifest and its digest. */
export function providerPackManifestFields(providers, candidates) {
return {
...(providers.cursor ? { providers: { cursor: providers.cursor } } : {}),
...(candidates.length ? { candidateProviders: Object.fromEntries(candidates.map(provider => [provider, providers[provider]])) } : {}),
...(["pi", "cursor"].some(provider => providers[provider]) ? { providers: Object.fromEntries(["pi", "cursor"].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}),
...(["pi", ...candidates].some(provider => providers[provider]) ? { candidateProviders: Object.fromEntries([...new Set(["pi", ...candidates])].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}),
};
}
export function providerPackProviders(platform, architecture, candidates) {
const cursorSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`);
return [...new Set([...(cursorSupported ? ["cursor"] : []), ...candidates])];
const nativeSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`);
return [...new Set([...(nativeSupported ? ["pi", "cursor"] : []), ...candidates])];
}
export function parseProviderPackArguments(args) {
@@ -33,6 +34,11 @@ export function parseProviderPackArguments(args) {
/** Closed source-owned builder registry; provider branches add their exact pins. */
export async function materializeCandidateProviderPack({ provider, outputRoot }) {
if (!CANDIDATES.has(provider)) throw new Error("Unknown candidate provider");
if (provider === "pi") return materializePiDistribution({ outputRoot });
if (provider === "copilot") {
const { buildPinnedCopilotDistribution } = await import("./build-copilot-distribution.mjs");
return buildPinnedCopilotDistribution({ outputRoot });
}
if (provider === "cursor") {
const result = await materializePinnedCursorDistribution({ destination: outputRoot });
return { version: result.version,
@@ -1,47 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
import { createHash } from "node:crypto";
// Matches the canonical manifest hashing used by the builder and admission.
function digest(value) {
const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`
: JSON.stringify(value);
return createHash("sha256").update(canonical(value)).digest("hex");
}
test("provider manifest digest survives disk JSON on supported and unsupported targets", () => {
const cursor = { version: "pinned", profileDigest: "sha256:profile", closureDigest: "sha256:closure" };
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) {
const selected = providerPackProviders(platform, architecture, []);
const payload = { target: { platform, architecture }, ...providerPackManifestFields(selected.includes("cursor") ? { cursor } : {}, []) };
const diskPayload = JSON.parse(JSON.stringify(payload));
assert.deepEqual(diskPayload, payload);
assert.equal(digest(diskPayload), digest(payload));
assert.equal(diskPayload.providers?.cursor?.version, selected.includes("cursor") ? "pinned" : undefined);
}
const candidate = providerPackManifestFields({ cursor }, ["cursor"]);
assert.deepEqual(candidate.candidateProviders, { cursor });
assert.equal(digest(JSON.parse(JSON.stringify(candidate))), digest(candidate));
});
test("candidate selection is explicit", () => {
test("candidate options stay explicit and supported native assets are included by default", () => {
assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] });
assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] });
});
test("normal packs include Cursor on its three pinned targets without breaking other hosts", () => {
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) {
assert.deepEqual(providerPackProviders(platform, architecture, []), ["cursor"]);
assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), ["cursor"]);
assert.deepEqual(providerPackProviders(platform, architecture, []), ["pi", "cursor"]);
assert.deepEqual(providerPackProviders(platform, architecture, ["cursor", "pi", "copilot"]), ["pi", "cursor", "copilot"]);
}
assert.deepEqual(providerPackProviders("linux", "arm64", []), []);
assert.deepEqual(providerPackProviders("win32", "x64", []), []);
assert.deepEqual(providerPackProviders("linux", "arm64", ["cursor"]), ["cursor"]);
});
test("serialized manifests include every materialized qualified provider", () => {
const pi = { qualification: "qualified", profileDigest: "pi-digest" };
const cursor = { qualification: "qualified", profileDigest: "cursor-digest" };
const copilot = { qualification: "pending", profileDigest: "copilot-digest" };
assert.deepEqual(providerPackManifestFields({ pi }, []), { providers: { pi }, candidateProviders: { pi } });
assert.deepEqual(providerPackManifestFields({ pi, cursor, copilot }, ["copilot"]), {
providers: { pi, cursor }, candidateProviders: { pi, copilot },
});
assert.deepEqual(providerPackManifestFields({}, []), {});
});
test("candidate builder cannot admit unknown providers, options or duplicate assets", async () => {
for (const args of [["--candidate-providers=cursor,cursor"], ["--candidate-providers=other"], ["--executable=/tmp/x"], ["/one", "/two"]]) {
assert.throws(() => parseProviderPackArguments(args));
}
await assert.rejects(materializeCandidateProviderPack({ provider: "arbitrary", outputRoot: "/tmp/unused" }), /Unknown candidate/);
});
test("Copilot pack selection reaches only the pinned native archive builder", async t => {
const requests = [];
t.mock.method(globalThis, "fetch", async (url, options) => {
requests.push({ url, redirect: options.redirect, credentials: options.credentials });
return new Response("corrupt archive");
});
await assert.rejects(materializeCandidateProviderPack({ provider: "copilot", outputRoot: "/unused-copilot-build" }), /integrity mismatch/);
assert.equal(requests.length, 1);
assert.match(requests[0].url, /^https:\/\/registry\.npmjs\.org\/@github\/copilot-(darwin-(arm64|x64)|linux-x64)\/-\/copilot-.*-1\.0\.88\.tgz$/);
assert.deepEqual({ redirect: requests[0].redirect, credentials: requests[0].credentials }, { redirect: "error", credentials: "omit" });
});
test("Pi pack selection reaches its pinned builder and rejects unsafe output before downloading", async t => {
let requests = 0;
t.mock.method(globalThis, "fetch", async () => {
requests += 1;
throw new Error("Unexpected download for invalid Pi output");
});
await assert.rejects(materializeCandidateProviderPack({ provider: "pi", outputRoot: "relative-pi-output" }), /Pi distribution output must be absolute/);
assert.equal(requests, 0);
});
@@ -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 } : {}),
});
@@ -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(
'<div title={\'import("fixture")\'}>require("text") {import("executed")}</div>',
"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/);
});
@@ -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",
}]);
});
@@ -0,0 +1,86 @@
import { createHash } from "node:crypto";
import { gunzipSync } from "node:zlib";
export const COPILOT_APP_SHA256 = "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0";
export const COPILOT_INNER_DISTRIBUTIONS = Object.freeze({
"darwin-arm64": Object.freeze({ offset: 92954063, size: 55323492, sha256: "49f5caa0582945a04c5619f8ea81680b1c8c2765888290cf8acf903e2985315b", entries: 230 }),
"darwin-x64": Object.freeze({ offset: 95169991, size: 64572726, sha256: "a4c41f1f480a259db2e4da952d5d2e92d19ae54ae1d6af97143341b6c4918aea", entries: 231 }),
"linux-x64": Object.freeze({ offset: 108781435, size: 60742828, sha256: "c386269ee1bf44bac514da2bb0c6b2a47546631f02e294fc5a8c20d1b1b990ff", entries: 216 }),
});
export const COPILOT_MESSAGE_MAPPING_ANCHOR = 'case"assistant.message_start":return null;case"assistant.message_delta":return{sessionUpdate:"agent_message_chunk",content:{type:"text",text:e.data.deltaContent}};case"assistant.message":return null;';
export const COPILOT_MESSAGE_MAPPING_REPLACEMENT = 'case"assistant.message_start":return{sessionUpdate:"agent_message_chunk",messageId:e.data.messageId,content:{type:"text",text:""}};case"assistant.message_delta":return{sessionUpdate:"agent_message_chunk",messageId:e.data.messageId,content:{type:"text",text:e.data.deltaContent}};case"assistant.message":return null;';
export const COPILOT_REPLAY_MAPPING_ANCHOR = 'case"assistant.message":return t.data.content?{sessionUpdate:"agent_message_chunk",content:{type:"text",text:t.data.content}}:null;';
export const COPILOT_REPLAY_MAPPING_REPLACEMENT = 'case"assistant.message":return{sessionUpdate:"agent_message_chunk",messageId:t.data.messageId,content:{type:"text",text:t.data.content??""}};';
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
const MAX_TAR_BYTES = 256 * 1024 * 1024;
/** Only called after the complete native executable's independent pin matches. */
export function readPinnedCopilotInnerDistribution(binary, target) {
const pin = COPILOT_INNER_DISTRIBUTIONS[target];
if (!pin || !Buffer.isBuffer(binary) || binary.length < pin.offset + pin.size) throw new Error("Copilot inner distribution target or framing is invalid");
const archive = binary.subarray(pin.offset, pin.offset + pin.size);
if (hash(archive) !== pin.sha256) throw new Error("Copilot embedded archive digest mismatch");
const entries = readCopilotInnerTar(gunzipSync(archive, { maxOutputLength: MAX_TAR_BYTES }));
if (entries.size !== pin.entries) throw new Error("Copilot embedded archive entry count changed");
const metadata = JSON.parse(entries.get("package.json")?.bytes.toString("utf8") ?? "null");
if (metadata?.name !== "@github/copilot" || metadata.version !== "1.0.88"
|| !entries.has("index.js") || !entries.has(`prebuilds/${target}/runtime.node`)) throw new Error("Copilot inner distribution identity is invalid");
const app = entries.get("app.js");
if (!app) throw new Error("Copilot inner app is missing");
return new Map([...entries].map(([path, entry]) => [path, path === "app.js" ? { ...entry, bytes: patchPinnedCopilotMessageIdentity(app.bytes) } : entry]));
}
/** Keep native message identity on the already serialized standard ACP stream. */
export function patchPinnedCopilotMessageIdentity(bytes) {
if (!Buffer.isBuffer(bytes) || hash(bytes) !== COPILOT_APP_SHA256) throw new Error("Copilot message mapping source digest mismatch");
let source = bytes.toString("utf8");
for (const [anchor, replacement] of [[COPILOT_MESSAGE_MAPPING_ANCHOR, COPILOT_MESSAGE_MAPPING_REPLACEMENT], [COPILOT_REPLAY_MAPPING_ANCHOR, COPILOT_REPLAY_MAPPING_REPLACEMENT]]) {
if (source.indexOf(anchor) < 0 || source.indexOf(anchor) !== source.lastIndexOf(anchor)) throw new Error("Copilot message mapping patch anchor is not unique");
source = source.replace(anchor, replacement);
}
return Buffer.from(source);
}
/** Closed ustar subset: all pinned inner assets are regular files, with no links. */
export function readCopilotInnerTar(tar) {
if (!Buffer.isBuffer(tar) || tar.length < 1024 || tar.length > MAX_TAR_BYTES || tar.length % 512) throw new Error("Copilot inner tar framing is invalid");
const entries = new Map(); let offset = 0; let ended = false;
const field = (header, start, end) => {
const bytes = header.subarray(start, end); const nul = bytes.indexOf(0);
if (nul >= 0 && bytes.subarray(nul).some(byte => byte !== 0)) throw new Error("Copilot inner tar field contains hidden bytes");
const value = bytes.subarray(0, nul < 0 ? undefined : nul);
if (value.some(byte => byte < 32 || byte > 126)) throw new Error("Copilot inner tar field is not ASCII");
return value.toString("ascii");
};
const octal = (header, start, end) => {
const raw = header.subarray(start, end).toString("ascii");
if (!/^[0-7]+[\0 ]*$/.test(raw)) throw new Error("Copilot inner tar numeric field is invalid");
const value = raw.replace(/[\0 ]+$/, "");
const parsed = Number.parseInt(value, 8);
if (!Number.isSafeInteger(parsed)) throw new Error("Copilot inner tar numeric field exceeds its bound");
return parsed;
};
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512); offset += 512;
if (header.every(byte => byte === 0)) {
if (offset + 512 > tar.length || tar.subarray(offset).some(byte => byte !== 0)) throw new Error("Copilot inner tar trailer is invalid");
ended = true; break;
}
if (header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0) !== octal(header, 148, 156)) throw new Error("Copilot inner tar checksum mismatch");
const prefix = field(header, 345, 500); const leaf = field(header, 0, 100);
const name = prefix ? `${prefix}/${leaf}` : leaf;
if (!name.startsWith("package/") || name.length > 4096 || name.includes("\\") || name.split("/").some(part => !part || part === "." || part === "..")
|| header[156] !== 48 || field(header, 157, 257) || field(header, 257, 263) !== "ustar" || header.subarray(263, 265).toString("ascii") !== "00") throw new Error("Copilot inner tar entry is unsafe or unsupported");
const path = name.slice(8); const size = octal(header, 124, 136); const mode = octal(header, 100, 108);
if (entries.has(path) || entries.size >= 1000 || size > MAX_TAR_BYTES || mode & ~0o777 || mode & 0o022) throw new Error("Copilot inner tar entry metadata is invalid");
const padded = Math.ceil(size / 512) * 512;
if (offset + padded > tar.length || tar.subarray(offset + size, offset + padded).some(byte => byte !== 0)) throw new Error("Copilot inner tar entry padding is invalid");
entries.set(path, { bytes: tar.subarray(offset, offset + size), executable: Boolean(mode & 0o111) }); offset += padded;
}
if (!ended || !entries.size) throw new Error("Copilot inner tar is incomplete");
// Refuse a file that is also another entry's parent before any write begins.
for (const path of entries.keys()) for (let i = path.indexOf("/"); i >= 0; i = path.indexOf("/", i + 1)) {
if (entries.has(path.slice(0, i))) throw new Error("Copilot inner tar file conflicts with a directory");
}
return entries;
}
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { runInNewContext } from "node:vm";
import { COPILOT_MESSAGE_MAPPING_REPLACEMENT, COPILOT_REPLAY_MAPPING_REPLACEMENT, patchPinnedCopilotMessageIdentity, readCopilotInnerTar, readPinnedCopilotInnerDistribution } from "./copilot-inner-distribution.mjs";
function file(name, content = "data", { type = "0", mode = 0o644, prefix = "" } = {}) {
const bytes = Buffer.from(content); const h = Buffer.alloc(512);
h.write(name, 0, 100, "ascii"); h.write(mode.toString(8).padStart(7, "0") + "\0", 100);
h.write(bytes.length.toString(8).padStart(11, "0") + "\0", 124);
h.fill(32, 148, 156); h.write(type, 156); h.write("ustar\0", 257); h.write("00", 263); h.write(prefix, 345, 155);
h.write(h.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, "0") + "\0 ", 148);
return Buffer.concat([h, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)]);
}
const tar = (...files) => Buffer.concat([...files, Buffer.alloc(1024)]);
test("inner reader preserves every regular asset and ustar prefix without executing it", () => {
const entries = readCopilotInnerTar(tar(file("package/index.js", "throw new Error('never execute')", { mode: 0o755 }), file("asset", "", { prefix: "package/assets" })));
assert.equal(entries.size, 2);
assert.equal(entries.get("index.js").executable, true);
assert.equal(entries.get("assets/asset").bytes.length, 0);
});
test("inner reader rejects traversal, links, duplicate entries, writable files and file parents", () => {
for (const files of [[file("package/../escape")], [file("/package/index.js")], [file("package/a\\b")],
[file("package/link", "", { type: "2" })], [file("package/hardlink", "", { type: "1" })],
[file("package/a", "", { mode: 0o777 })], [file("package/a"), file("package/a")],
[file("package/a"), file("package/a/b")], [file("package/meta", "", { type: "x" })]]) {
assert.throws(() => readCopilotInnerTar(tar(...files)));
}
});
test("inner reader rejects corrupt checksums, truncated framing and hidden padding/trailer", () => {
const valid = tar(file("package/a"));
const checksum = Buffer.from(valid); checksum[0] ^= 1;
const padding = Buffer.from(valid); padding[517] = 1;
const trailer = Buffer.from(valid); trailer[trailer.length - 1] = 1;
for (const bytes of [checksum, padding, trailer, valid.subarray(0, -512), valid.subarray(0, -1)]) assert.throws(() => readCopilotInnerTar(bytes));
});
test("pinned archive and source refuse unknown upstream bytes before patching", () => {
assert.throws(() => patchPinnedCopilotMessageIdentity(Buffer.from(COPILOT_MESSAGE_MAPPING_REPLACEMENT)), /source digest/);
assert.throws(() => readPinnedCopilotInnerDistribution(Buffer.alloc(1), "darwin-arm64"), /framing/);
assert.throws(() => readPinnedCopilotInnerDistribution(Buffer.alloc(1), "linux-arm64"), /target/);
});
test("ordered mapping retains native IDs including an empty start and never echoes completion text", () => {
const map = runInNewContext(`e=>{switch(e.type){${COPILOT_MESSAGE_MAPPING_REPLACEMENT}default:return null}}`);
const events = [
{ type: "assistant.message_start", data: { messageId: "first" } },
{ type: "assistant.message_delta", data: { messageId: "first", deltaContent: "same" } },
{ type: "assistant.message", data: { messageId: "first", content: "same" } },
{ type: "tool.execution_complete", data: {} },
{ type: "assistant.message_start", data: { messageId: "second" } },
{ type: "assistant.message_delta", data: { messageId: "second", deltaContent: "sa" } },
{ type: "assistant.message_delta", data: { messageId: "second", deltaContent: "me" } },
{ type: "assistant.message", data: { messageId: "second", content: "same" } },
{ type: "assistant.message_start", data: { messageId: "empty-final" } },
{ type: "assistant.message", data: { messageId: "empty-final", content: "" } },
];
assert.deepEqual(JSON.parse(JSON.stringify(events.map(map).filter(Boolean))).map(e => [e.messageId, e.content.text]),
[["first", ""], ["first", "same"], ["second", ""], ["second", "sa"], ["second", "me"], ["empty-final", ""]]);
});
test("the patched replay branch emits full messages once, with ordered identities and empty finals", () => {
const live = runInNewContext(`e=>{switch(e.type){${COPILOT_MESSAGE_MAPPING_REPLACEMENT}default:return null}}`);
const replay = runInNewContext(`t=>{switch(t.type){${COPILOT_REPLAY_MAPPING_REPLACEMENT}default:return null}}`);
const history = [
{ type: "assistant.message", data: { messageId: "old-first", content: "same" } },
{ type: "assistant.message", data: { messageId: "old-second", content: "same" } },
{ type: "assistant.message", data: { messageId: "old-empty", content: "" } },
];
assert.deepEqual(JSON.parse(JSON.stringify(history.map(replay))).map(e => [e.messageId, e.content.text]),
[["old-first", "same"], ["old-second", "same"], ["old-empty", ""]]);
assert.deepEqual(history.map(live), [null, null, null]);
// Completion has opposite roles: historical content is replayed once, while
// the active mapper must not echo text already emitted as live deltas.
const final = history[0];
assert.equal(replay(final).content.text, "same");
assert.equal(live(final), null);
});
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { once } from "node:events";
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { withCopilotSmokeResources } from "./copilot-smoke-resources.mjs";
// Build verification only. No prompt, credential, model request, or runtime
// installation occurs; the provider runs offline with isolated state.
const pack = await realpath(resolve(process.argv[2] ?? "provider-pack"));
const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8"));
// Bind the audited pack explicitly, just as the verified sidecar launcher does.
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack;
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json");
const candidate = manifest.payload.candidateProviders?.copilot;
assert.equal(candidate?.version, "1.0.88");
assert.equal(candidate.qualification, "pending");
const { assertAcpxProfileEnvironment, verifyAcpxProfileInstallation } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js")));
const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js")));
const { COPILOT_ACP_CLIENT_CAPABILITIES } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/copilot-events.js")));
// This explicit value exercises profile admission only; no model is selected
// or used by initialize, and this is not a claim of model availability.
const profile = resolveQualifiedAcpxProfile("copilot", "gpt-4.1");
assert.throws(() => assertAcpxProfileEnvironment("copilot", {}), { code: "COPILOT_AUTH_REQUIRED" });
const installation = await verifyAcpxProfileInstallation(profile);
assert.equal(installation.commandDigest, candidate.profileDigest);
await withCopilotSmokeResources(installation, async ({ lease, root, fixture, fixtureRequests }) => {
let child;
let exited;
try {
const environment = { PATH: "/usr/bin:/bin", COPILOT_OFFLINE: "true", COPILOT_AUTO_UPDATE: "false", NO_COLOR: "1",
COPILOT_PROVIDER_BASE_URL: `http://127.0.0.1:${fixture.address().port}`, COPILOT_PROVIDER_TYPE: "openai",
COPILOT_PROVIDER_MODEL_ID: "gpt-4.1", COPILOT_MODEL: "gpt-4.1",
};
for (const name of ["HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "COPILOT_HOME", "COPILOT_CACHE_HOME"]) {
environment[name] = join(root, name.toLowerCase());
await mkdir(environment[name], { mode: 0o700 });
}
await writeFile(join(environment.COPILOT_HOME, "config.json"), JSON.stringify({ autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, ide: { autoConnect: false } }), { mode: 0o600 });
child = lease.spawn([], { cwd: root, env: environment, stdio: "pipe" });
exited = once(child, "exit");
const initialized = new Promise((done, reject) => {
let buffer = "";
let stderr = "";
let total = 0;
const timeout = setTimeout(() => reject(new Error(`Copilot ACP initialize timed out: ${stderr.replaceAll(root, "/fixture/workspace").replaceAll(pack, "/fixture/provider-pack").slice(0, 4000)}`)), 20_000);
timeout.unref();
child.once("error", reject);
child.once("exit", () => reject(new Error(`Copilot exited before initialize: ${stderr.replaceAll(root, "/fixture/workspace").replaceAll(pack, "/fixture/provider-pack").slice(0, 4000)}`)));
child.stdout.on("data", chunk => {
total += chunk.length;
if (total > 1024 * 1024) { reject(new Error("Copilot ACP output exceeded bound")); return; }
buffer += chunk.toString("utf8");
while (buffer.includes("\n")) {
const newline = buffer.indexOf("\n");
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
if (!line.trim()) continue;
let response;
try { response = JSON.parse(line); } catch { reject(new Error("Copilot emitted malformed ACP JSON")); return; }
if (response.id === 0 && response.method === undefined) {
clearTimeout(timeout);
if (response.error || !response.result) reject(new Error("Copilot rejected ACP initialize"));
else done(response.result);
}
}
});
child.stderr.on("data", chunk => { stderr = (stderr + chunk.toString("utf8")).slice(-16_384); });
});
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: 1, clientInfo: { name: "paperclip-pack-offline-smoke", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES } })}\n`);
const result = await initialized;
assert.equal(result.protocolVersion, 1);
assert.equal(result.agentInfo?.version, "1.0.88");
child.stdin.end();
const timer = setTimeout(() => child.kill("SIGTERM"), 1_000);
const [exitCode, signal] = await within(exited, 10_000);
clearTimeout(timer);
assert.equal(exitCode, 0, "Copilot did not exit cleanly after stdin EOF");
assert.equal(signal, null);
assert.equal(fixtureRequests.some(request => request.method !== "GET"), false, "Unexpected model inference request");
const safeInitialize = JSON.parse(JSON.stringify(result, (key, value) =>
key === "command" && typeof value === "string" && value.endsWith("/distribution/copilot") ? "/fixture/verified/copilot" : value));
const executable = await readFile(join(pack, candidate.path, "copilot"));
process.stdout.write(`${JSON.stringify({
schema: "paperclip.copilot-provider-pack-smoke/v1", sourceRevision: manifest.payload.runnerSourceRevision,
providerPackDigest: manifest.digest, platform: process.platform, architecture: process.arch,
candidate, executableSha256: `sha256:${createHash("sha256").update(executable).digest("hex")}`,
registryLaunch: "verifyAcpxProfileInstallation/openCommand/spawn", initializeRequestId: 0,
initialize: safeInitialize, missingCredentialPreflight: "COPILOT_AUTH_REQUIRED", cleanExit: true, inheritedCredentials: false, promptSent: false,
networkMode: "COPILOT_OFFLINE=true; loopback metadata-only provider", fixtureRequests, protocolFixtureModel: "gpt-4.1 (not a qualified GitHub model)", costUsd: 0,
qualification: "pending: no credential, entitlement, model execution or Daytona",
}, null, 2)}\n`);
} finally {
if (child && child.exitCode === null && child.signalCode === null) {
child.stdin.end();
child.kill("SIGTERM");
await within(exited, 5_000);
}
}
});
async function within(promise, timeoutMs) {
let timer;
try {
return await Promise.race([promise, new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("Copilot smoke cleanup was not confirmed")), timeoutMs);
})]);
} finally { clearTimeout(timer); }
}
@@ -0,0 +1,37 @@
import { once } from "node:events";
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Own every setup resource immediately, including failures before native launch. */
export async function withCopilotSmokeResources(installation, callback, dependencies = { mkdtemp, rm, createServer }) {
const lease = await installation.openCommand();
let root;
let fixture;
const fixtureRequests = [];
try {
root = await dependencies.mkdtemp(join(tmpdir(), "paperclip-copilot-pack-smoke-"));
fixture = dependencies.createServer((request, response) => {
fixtureRequests.push({ method: request.method, path: request.url?.split("?")[0] });
response.writeHead(request.method === "GET" ? 200 : 503, { "content-type": "application/json" });
response.end(JSON.stringify(request.method === "GET" ? { object: "list", data: [{ id: "gpt-4.1", object: "model", owned_by: "fixture" }] } : { error: "This initialize-only fixture cannot run inference" }));
});
fixture.listen(0, "127.0.0.1");
await once(fixture, "listening");
return await callback({ lease, root, fixture, fixtureRequests });
} finally {
try {
if (fixture) {
fixture.closeAllConnections();
await new Promise((resolve, reject) => fixture.close(error => {
if (error && error.code !== "ERR_SERVER_NOT_RUNNING") reject(error);
else resolve();
}));
}
} finally {
try { await lease.close(); }
finally { if (root) await dependencies.rm(root, { recursive: true, force: true }); }
}
}
}
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import test from "node:test";
import { withCopilotSmokeResources } from "./copilot-smoke-resources.mjs";
function resources(failure) {
const actions = [];
const fixture = new EventEmitter();
fixture.listen = () => queueMicrotask(() => fixture.emit(failure === "listen" ? "error" : "listening", new Error("listen_failed")));
fixture.closeAllConnections = () => actions.push("connections_closed");
fixture.close = callback => { actions.push("server_closed"); callback(failure === "listen" ? Object.assign(new Error("not running"), { code: "ERR_SERVER_NOT_RUNNING" }) : undefined); };
const installation = { openCommand: async () => ({ close: async () => { actions.push("lease_closed"); if (failure === "lease_close") throw new Error("close_failed"); } }) };
const dependencies = {
mkdtemp: async () => { if (failure === "workspace") throw new Error("workspace_failed"); actions.push("workspace_created"); return "/fixture/private"; },
rm: async (root, options) => { assert.equal(root, "/fixture/private"); assert.deepEqual(options, { recursive: true, force: true }); actions.push("workspace_removed"); },
createServer: () => { if (failure === "server_create") throw new Error("server_create_failed"); return fixture; },
};
return { actions, installation, dependencies };
}
for (const failure of ["workspace", "server_create", "listen"]) {
test(`${failure} setup failure releases all already acquired resources before callback`, async () => {
const r = resources(failure);
await assert.rejects(withCopilotSmokeResources(r.installation, () => assert.fail("provider must not launch"), r.dependencies), new RegExp(`${failure}_failed`));
assert.deepEqual(r.actions, failure === "workspace" ? ["lease_closed"]
: failure === "server_create" ? ["workspace_created", "lease_closed", "workspace_removed"]
: ["workspace_created", "connections_closed", "server_closed", "lease_closed", "workspace_removed"]);
});
}
test("provider callback failure still closes server, lease, and workspace", async () => {
const r = resources();
await assert.rejects(withCopilotSmokeResources(r.installation, async () => { throw new Error("native_cleanup_failed"); }, r.dependencies), /native_cleanup_failed/);
assert.deepEqual(r.actions, ["workspace_created", "connections_closed", "server_closed", "lease_closed", "workspace_removed"]);
});
test("lease close failure cannot bypass workspace removal", async () => {
const r = resources("lease_close");
await assert.rejects(withCopilotSmokeResources(r.installation, async () => "ok", r.dependencies), /close_failed/);
assert.equal(r.actions.at(-1), "workspace_removed");
});
test("successful callback result is returned after all resource cleanup", async () => {
const r = resources();
assert.equal(await withCopilotSmokeResources(r.installation, async ({ root, fixtureRequests }) => { assert.equal(root, "/fixture/private"); assert.deepEqual(fixtureRequests, []); return "ok"; }, r.dependencies), "ok");
assert.deepEqual(r.actions, ["workspace_created", "connections_closed", "server_closed", "lease_closed", "workspace_removed"]);
});
@@ -0,0 +1,130 @@
// Metadata only. Supply an explicit token; this closed protocol client never prompts.
// Usage: node discover-copilot-acp.mjs <verified-pack> <safe-output.json> [exact-model-id]
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const discoveryMethods = new Set(["initialize", "session/new", "session/set_model", "session/set_config_option", "session/close"]);
export function validateDiscoveryModel(model) {
assert.ok(model === undefined || (typeof model === "string" && model.trim() && model.trim().toLowerCase() !== "auto"), "model must be an exact model ID, not auto");
}
export async function withDiscoveryWorkspace(install, callback, filesystem = { mkdtemp, rm }) {
const lease = await install.openCommand();
let root;
try {
root = await filesystem.mkdtemp("/tmp/paperclip-copilot-auth-discovery-");
return await callback(lease, root);
} finally {
try { await lease.close(); }
finally { if (root) await filesystem.rm(root, { recursive: true, force: true }); }
}
}
export function createDiscoveryRpc(child, { deadlineMs = 25_000, maxBytes = 2_097_152 } = {}) {
const pending = new Map(), observedMethods = {};
let nextId = 0, buffer = "", bytes = 0, failure;
function fail(code) {
failure ??= code;
for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error(failure)); }
pending.clear();
}
function fatal(code) { fail(code); child.kill("SIGTERM"); }
child.once("close", () => fail("provider_exited"));
child.once("error", () => fail("provider_spawn_failure"));
child.stdin.on("error", () => fail("provider_stdin_failure"));
child.stderr.on("data", () => {});
child.stdout.on("data", chunk => {
bytes += chunk.length;
if (bytes > maxBytes) { fatal("provider_wire_limit"); return; }
buffer += chunk.toString();
while (buffer.includes("\n")) {
const end = buffer.indexOf("\n"), line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
if (!line.trim()) continue;
let message;
try { message = JSON.parse(line); } catch { fatal("provider_malformed_json"); return; }
if (!message || typeof message !== "object" || Array.isArray(message)) { fatal("provider_malformed_message"); return; }
if (message.method) {
observedMethods[message.method] = (observedMethods[message.method] ?? 0) + 1;
if (message.id !== undefined) child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Discovery does not support inbound methods" } })}\n`);
continue;
}
const item = pending.get(message.id);
if (item) { clearTimeout(item.timer); pending.delete(message.id); message.error ? item.reject(new Error(`provider_rpc_error:${message.error.code}`)) : item.resolve(message.result); }
}
});
return {
observedMethods,
request(method, params) {
assert.ok(discoveryMethods.has(method), "metadata discovery cannot send this method");
if (failure) return Promise.reject(new Error(failure));
const id = nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, deadlineMs);
pending.set(id, { resolve, reject, timer });
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
});
},
close() { fail("discovery_closed"); },
};
}
export async function discoverCopilot(packInput, output, model, token) {
validateDiscoveryModel(model);
assert.ok(typeof token === "string" && token.trim() && !token.includes("\0"), "explicit Copilot token required");
const pack = await realpath(packInput);
const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8"));
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack;
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json");
const { verifyAcpxProfileInstallation, assertAcpxProfileEnvironment } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js")));
const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js")));
const { COPILOT_ACP_CLIENT_CAPABILITIES } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/copilot-events.js")));
assertAcpxProfileEnvironment("copilot", { COPILOT_GITHUB_TOKEN: token });
const install = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("copilot", model ?? "discovery-unselected"));
return withDiscoveryWorkspace(install, async (lease, root) => {
const env = { PATH: "/usr/bin:/bin", COPILOT_GITHUB_TOKEN: token, COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", NO_COLOR: "1" };
for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "COPILOT_HOME", "COPILOT_CACHE_HOME"]) {
env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 });
}
await writeFile(join(env.COPILOT_HOME, "config.json"), JSON.stringify({ autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, ide: { autoConnect: false } }), { mode: 0o600 });
let child, exitPromise, rpc;
try {
child = lease.spawn([], { cwd: root, env, stdio: "pipe" });
exitPromise = new Promise(resolve => child.once("close", (code, signal) => resolve({ code, signal })));
rpc = createDiscoveryRpc(child);
const initialized = await rpc.request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-authenticated-model-discovery", version: "1" }, clientCapabilities: COPILOT_ACP_CLIENT_CAPABILITIES });
const opened = await rpc.request("session/new", { cwd: root, mcpServers: [] });
const safe = { schema: "paperclip.copilot-authenticated-discovery/v1", observedAt: new Date().toISOString(), harnessVersion: initialized.agentInfo?.version,
sourceRevision: manifest.payload.runnerSourceRevision, providerPackDigest: manifest.digest, profileDigest: install.commandDigest,
agentCapabilities: initialized.agentCapabilities, models: opened.models ?? null, configOptions: opened.configOptions ?? [], promptSent: false,
promptRequestsSent: 0, inferenceVerified: false, qualification: "pending; metadata discovery only", observedMethods: rpc.observedMethods };
if (model) {
assert.ok(opened.models?.availableModels?.some(value => value.modelId === model), "requested exact model must be advertised");
const selected = await rpc.request("session/set_model", { sessionId: opened.sessionId, modelId: model });
const configured = await rpc.request("session/set_config_option", { sessionId: opened.sessionId, configId: "model", value: model });
assert.equal(configured.configOptions?.find(option => option.id === "model")?.currentValue, model);
safe.modelSelection = { requestedModel: model, succeeded: true, response: selected, configEcho: configured };
}
try { await rpc.request("session/close", { sessionId: opened.sessionId }); safe.sessionClosed = true; }
catch { safe.sessionClosed = false; }
const text = `${JSON.stringify(safe, null, 2)}\n`;
assert.ok(!text.includes(token)); assert.ok(!text.includes(opened.sessionId)); assert.ok(!text.includes(root));
await writeFile(output, text, { mode: 0o600 });
return { output, sha256: createHash("sha256").update(text).digest("hex"), harnessVersion: safe.harnessVersion, modelCount: safe.models?.availableModels?.length ?? 0,
configOptionIds: safe.configOptions.map(value => value.id), modelIds: safe.models?.availableModels?.map(value => value.modelId) ?? [], promptSent: false };
} finally {
rpc?.close();
if (child && child.exitCode === null && child.signalCode === null) {
child.stdin.end();
const term = setTimeout(() => child.kill("SIGTERM"), 1_000), kill = setTimeout(() => child.kill("SIGKILL"), 5_000);
try { await exitPromise; } finally { clearTimeout(term); clearTimeout(kill); }
}
}
});
}
if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) {
console.log(JSON.stringify(await discoverCopilot(process.argv[2], process.argv[3], process.argv[4], process.env.COPILOT_GITHUB_TOKEN)));
}
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import test from "node:test";
import { createDiscoveryRpc, validateDiscoveryModel, withDiscoveryWorkspace } from "./discover-copilot-acp.mjs";
test("exact model evidence rejects auto before any provider operation", () => {
for (const model of ["auto", "AUTO", " auto ", ""]) assert.throws(() => validateDiscoveryModel(model), /exact model ID/);
validateDiscoveryModel("gpt-5.6-luna"); validateDiscoveryModel(undefined);
});
test("workspace failure always releases the acquired native command lease", async () => {
const closed = [], removed = [];
const install = { openCommand: async () => ({ close: async () => closed.push(true) }) };
await assert.rejects(withDiscoveryWorkspace(install, () => assert.fail(), { mkdtemp: async () => { throw new Error("mkdir_failed"); }, rm: async (...args) => removed.push(args) }), /mkdir_failed/);
assert.equal(closed.length, 1); assert.equal(removed.length, 0);
await assert.rejects(withDiscoveryWorkspace(install, async () => { throw new Error("config_write_failed"); }, { mkdtemp: async () => "/fixture/private", rm: async (...args) => removed.push(args) }), /config_write_failed/);
assert.equal(closed.length, 2); assert.deepEqual(removed, [["/fixture/private", { recursive: true, force: true }]]);
});
function childFixture() {
const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
child.written = []; child.stdin.write = value => child.written.push(JSON.parse(value)); child.killed = [];
child.kill = signal => child.killed.push(signal); return child;
}
test("provider exit rejects pending discovery immediately with its actual failure", async () => {
const child = childFixture(), rpc = createDiscoveryRpc(child);
const pending = rpc.request("initialize", {}); child.emit("close", 1, null);
await assert.rejects(pending, /provider_exited/);
await assert.rejects(rpc.request("session/new", {}), /provider_exited/);
});
test("malformed provider output rejects pending requests instead of waiting for timeout", async () => {
const child = childFixture(), rpc = createDiscoveryRpc(child);
const pending = rpc.request("initialize", {}); child.stdout.emit("data", Buffer.from("not-json\n"));
await assert.rejects(pending, /provider_malformed_json/); assert.deepEqual(child.killed, ["SIGTERM"]);
});
test("closed discovery protocol preserves numeric zero and rejects prompts", async () => {
const child = childFixture(), rpc = createDiscoveryRpc(child);
const pending = rpc.request("initialize", {}); assert.equal(child.written[0].id, 0);
child.stdout.emit("data", Buffer.from('{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1}}\n'));
assert.deepEqual(await pending, { protocolVersion: 1 });
assert.throws(() => rpc.request("session/prompt", {}), /cannot send/); rpc.close();
});
@@ -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/[email protected]", root))).digest("hex"));
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v17-identity.json"]]) {
const identity = await readJson(path);
assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion);
assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest);
const serialized = agent === "pi" ? canonicalJson(identity.declaration)
: JSON.stringify(Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))));
assert.equal(identity.commandDigest, `sha256:${createHash("sha256").update(serialized).digest("hex")}`);
assert.equal(identity.declaration.acpxPatchSha256, contract.acpxPatchSha256);
}
for (const distribution of Object.values(distributions.platforms)) assert.match(distribution.closureSha256, /^[a-f0-9]{64}$/);
const quote = JSON.stringify;
@@ -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 });
}
}
@@ -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;
}
@@ -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",
@@ -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({
@@ -0,0 +1,133 @@
import { createHash } from "node:crypto";
import { constants, closeSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { readPinnedCopilotInnerDistribution } from "./copilot-inner-distribution.mjs";
export const COPILOT_VERSION = "1.0.88";
// Extracted executable SHA-256 pins from npm archives after registry SHA-512 verification.
export const COPILOT_DISTRIBUTIONS = Object.freeze({
"darwin-arm64": Object.freeze({ packageName: "@github/copilot-darwin-arm64", executableDigest: "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", size: 152595280, archiveIntegrity: "sha512-gt52dl+ja+G88RjLuSec59fA8C9HNfdGxKwwqE7eAIJXZYSKwKvuMy4B79WlCtlpnH/QKbs5Z9VsU0BTxe3EAg==" }),
"darwin-x64": Object.freeze({ packageName: "@github/copilot-darwin-x64", executableDigest: "85eb919f6b9b9dd833ce5e326cbf974b3ee2d4a9ac525c59d4ec9c9ec085715b", size: 165041200, archiveIntegrity: "sha512-GO5KwVR5vD1vNRe0jDDXFdpOHpS780ytVSRklk0DT6bK6HIpWXqbfnOasP9OZpPClaAYO4CShkGMhHv6vKRxaA==" }),
"linux-x64": Object.freeze({ packageName: "@github/copilot-linux-x64", executableDigest: "0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748", size: 169544512, archiveIntegrity: "sha512-wNigl8rqixvtYoRtygNf1NQIXx77MWC6XyqoV7fcfvSwWI3GJCltnczaNLbC611K9Hex39KyiUR5ZmGhQ+LzLw==" }),
});
export function resolveCopilotDistribution(platform = process.platform, architecture = process.arch) {
const distribution = COPILOT_DISTRIBUTIONS[`${platform}-${architecture}`];
if (!distribution) throw new Error(`Copilot distribution is not pinned for ${platform}/${architecture}`);
return distribution;
}
/**
* Build-time verifier/materializer only. Runtime admission must still use the
* shared descriptor-pinned command lease; this path grants no execution authority.
* Never run npm-loader.js, package postinstall, or an ambient PATH executable.
*/
export function materializePinnedCopilotBinary(options = {}) {
const distribution = resolveCopilotDistribution(options.platform, options.architecture);
const require = createRequire(import.meta.url);
const packageRoot = realpathSync(options.packageRoot ?? resolve(require.resolve(`${distribution.packageName}/package.json`), ".."));
const manifest = readPinnedFile(join(packageRoot, "package.json"), 256 * 1024);
const metadata = JSON.parse(manifest.toString("utf8"));
if (metadata.name !== distribution.packageName || metadata.version !== COPILOT_VERSION) {
throw new Error(`Expected ${distribution.packageName}@${COPILOT_VERSION}`);
}
const source = join(packageRoot, "copilot");
const bytes = readPinnedFile(source, 384 * 1024 * 1024, true);
const sourceDigest = createHash("sha256").update(bytes).digest("hex");
if (sourceDigest !== distribution.executableDigest) throw new Error("Copilot executable digest does not match its pinned distribution");
const inner = readPinnedCopilotInnerDistribution(bytes, `${options.platform ?? process.platform}-${options.architecture ?? process.arch}`);
const output = options.targetDirectory === undefined ? packageRoot : realpathSync(options.targetDirectory);
const target = options.targetDirectory === undefined ? source : join(output, "copilot");
const entries = [{ path: "copilot", sha256: sourceDigest, size: bytes.length, executable: true }];
const files = options.targetDirectory === undefined ? [] : [{ path: "copilot", bytes, executable: true }];
// Preserve every embedded asset; only the hash-pinned app's message mapping
// changes. Never import or execute any archive entry during materialization.
for (const [path, entry] of inner) {
const relative = `distribution/${path}`;
files.push({ path: relative, ...entry });
entries.push({ path: relative, sha256: createHash("sha256").update(entry.bytes).digest("hex"), size: entry.bytes.length, executable: entry.executable });
}
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
const closureSha256 = createHash("sha256").update(JSON.stringify(entries)).digest("hex");
const manifestPath = join(output, ".paperclip-copilot-closure.json");
files.push({ path: ".paperclip-copilot-closure.json", bytes: Buffer.from(`${JSON.stringify({ schema: "paperclip.native_distribution_closure.v1", entries })}\n`), executable: false });
writeCopilotMaterialization(output, files);
return { packageName: distribution.packageName, version: COPILOT_VERSION, sourceDigest, target, manifestPath, closureSha256 };
}
/** Build-only writer for already verified entries; never grants launch authority.
* Roll back only entries this invocation exclusively created, even when a write
* failed partway through. Never recursively remove a caller's output directory.
*/
export function writeCopilotMaterialization(output, files) {
if (realpathSync(output) !== output || !lstatSync(output).isDirectory()) throw new Error("Copilot target must be an owned real directory");
const root = lstatSync(output, { bigint: true });
const created = [];
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
const assertRoot = () => {
const current = lstatSync(output, { bigint: true });
if (!current.isDirectory() || !same(root, current) || realpathSync(output) !== output) throw new Error("Copilot output identity changed");
};
try {
for (const entry of files) {
if (typeof entry.path !== "string" || entry.path.includes("\\") || entry.path.split("/").some(part => !part || part === "." || part === "..")) throw new Error("Unsafe Copilot output entry");
assertRoot();
const parts = entry.path.split("/");
for (let i = 1; i < parts.length; i++) {
const directory = join(output, ...parts.slice(0, i));
try {
mkdirSync(directory, { mode: 0o755 });
created.push({ path: directory, stat: lstatSync(directory, { bigint: true }), directory: true });
} catch (error) { if (error.code !== "EEXIST") throw error; }
if (!lstatSync(directory).isDirectory() || realpathSync(directory) !== directory) throw new Error("Copilot inner destination redirects through links");
}
const path = join(output, entry.path);
const mode = entry.executable ? 0o755 : 0o644;
const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, mode);
try {
created.push({ path, stat: fstatSync(fd, { bigint: true }), directory: false });
writeFileSync(fd, entry.bytes);
fchmodSync(fd, mode);
fsyncSync(fd);
} finally { closeSync(fd); }
}
} catch (error) {
const cleanupErrors = [];
for (const entry of created.reverse()) {
try {
assertRoot();
if (realpathSync(dirname(entry.path)) !== dirname(entry.path)) throw new Error("Copilot cleanup parent identity changed");
let current;
try { current = lstatSync(entry.path, { bigint: true }); } catch (missing) { if (missing.code === "ENOENT") continue; throw missing; }
if (!same(entry.stat, current) || (entry.directory ? !current.isDirectory() : !current.isFile())) throw new Error("Copilot cleanup entry identity changed");
if (entry.directory) rmdirSync(entry.path); else unlinkSync(entry.path);
} catch (cleanupError) { cleanupErrors.push(cleanupError); }
}
if (cleanupErrors.length) throw new AggregateError([error, ...cleanupErrors], "Copilot materialization failed and owned cleanup is incomplete");
throw error;
}
}
function readPinnedFile(path, maxBytes, executable = false) {
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const before = fstatSync(fd, { bigint: true });
if (!before.isFile() || before.size < 1n || before.size > BigInt(maxBytes)) throw new Error("Copilot distribution file is not a bounded regular file");
if (executable && ((before.mode & 0o111n) === 0n || (before.mode & 0o022n) !== 0n)) throw new Error("Copilot executable has unsafe permissions");
const bytes = readFileSync(fd);
const after = fstatSync(fd, { bigint: true });
const entry = lstatSync(path, { bigint: true });
if (!entry.isFile() || bytes.length !== Number(before.size) || before.dev !== entry.dev || before.ino !== entry.ino
|| before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) throw new Error("Copilot distribution changed during verification");
return bytes;
} finally { closeSync(fd); }
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
const args = process.argv.slice(2);
if (args.some((arg, index) => index % 2 === 0 && !["--package-root", "--target-directory", "--platform", "--architecture"].includes(arg)) || args.length % 2 !== 0) throw new Error("Usage: materialize-copilot-binary.mjs [--package-root PATH] [--target-directory PATH] [--platform darwin|linux] [--architecture arm64|x64]");
const argsMap = Object.fromEntries(Array.from({ length: args.length / 2 }, (_, index) => [args[index * 2], args[index * 2 + 1]]));
process.stdout.write(`${JSON.stringify(materializePinnedCopilotBinary({ packageRoot: argsMap["--package-root"], targetDirectory: argsMap["--target-directory"], platform: argsMap["--platform"], architecture: argsMap["--architecture"] }))}\n`);
}
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import * as fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "node:test";
import { COPILOT_DISTRIBUTIONS, materializePinnedCopilotBinary, resolveCopilotDistribution, writeCopilotMaterialization } from "./materialize-copilot-binary.mjs";
const directories = [];
afterEach(async () => { await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); });
async function fixture(version = "1.0.88") {
const root = await mkdtemp(join(tmpdir(), "paperclip-copilot-materializer-")); directories.push(root);
await writeFile(join(root, "package.json"), JSON.stringify({ name: "@github/copilot-linux-x64", version }));
await writeFile(join(root, "copilot"), "#!/bin/sh\nexit 98\n", { mode: 0o755 });
return fs.realpathSync(root);
}
test("pins every requested platform and rejects unqualified platforms", () => {
assert.deepEqual(Object.keys(COPILOT_DISTRIBUTIONS), ["darwin-arm64", "darwin-x64", "linux-x64"]);
for (const [key, value] of Object.entries(COPILOT_DISTRIBUTIONS)) {
assert.match(value.executableDigest, /^[a-f0-9]{64}$/);
assert.match(value.archiveIntegrity, /^sha512-/);
assert.equal(resolveCopilotDistribution(...key.split("-")), value);
}
assert.throws(() => resolveCopilotDistribution("linux", "arm64"), /not pinned/);
});
test("refuses a different version before opening or executing the binary", async () => {
const packageRoot = await fixture("1.0.89");
assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }), /Expected/);
});
test("refuses executable tampering even when package metadata claims the right version", async () => {
const packageRoot = await fixture();
assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }), /digest/);
});
test("refuses executable symlinks, directory entries and writable binaries", async () => {
const packageRoot = await fixture(); const binary = join(packageRoot, "copilot");
await chmod(binary, 0o777);
assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }), /permissions/);
await rm(binary); await symlink(join(packageRoot, "package.json"), binary);
assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }));
await rm(binary); await mkdir(binary);
assert.throws(() => materializePinnedCopilotBinary({ packageRoot, platform: "linux", architecture: "x64" }), /regular file/);
});
const outputFiles = () => [
{ path: "copilot", bytes: Buffer.from("verified executable"), executable: true },
{ path: "distribution/nested/first.js", bytes: Buffer.from("verified first asset"), executable: false },
{ path: "distribution/nested/last.js", bytes: Buffer.from("verified last asset"), executable: false },
{ path: ".paperclip-copilot-closure.json", bytes: Buffer.from("verified closure"), executable: false },
];
test("partial inner writes roll back only owned entries and permit a clean retry", async t => {
const root = await fixture(); const output = join(root, "output"); await mkdir(output);
await writeFile(join(output, "caller-file"), "preserve");
const originalWrite = fs.writeFileSync; let calls = 0;
const mocked = t.mock.method(fs.default, "writeFileSync", (fd, bytes, ...args) => {
if (++calls === 3) { originalWrite(fd, bytes.subarray(0, 3)); throw Object.assign(new Error("injected partial write"), { code: "ENOSPC" }); }
return originalWrite(fd, bytes, ...args);
});
syncBuiltinESMExports();
try { assert.throws(() => writeCopilotMaterialization(output, outputFiles()), /injected partial write/); }
finally { mocked.mock.restore(); syncBuiltinESMExports(); }
assert.deepEqual(await readdir(output), ["caller-file"]);
assert.equal(await readFile(join(output, "caller-file"), "utf8"), "preserve");
writeCopilotMaterialization(output, outputFiles());
for (const file of outputFiles()) assert.deepEqual(await readFile(join(output, file.path)), file.bytes);
});
test("failure preserves preexisting binary, directories, assets and closure", async () => {
for (const collision of outputFiles()) {
const root = await fixture(); const output = join(root, "output"); await mkdir(output);
await mkdir(join(output, "distribution/nested"), { recursive: true });
await writeFile(join(output, collision.path), "preexisting");
assert.throws(() => writeCopilotMaterialization(output, outputFiles()), /EEXIST/);
assert.equal(await readFile(join(output, collision.path), "utf8"), "preexisting");
const kept = (await readdir(join(output, "distribution/nested"))).sort();
assert.deepEqual(kept, collision.path.startsWith("distribution/") ? [collision.path.split("/").at(-1)] : []);
assert.equal(fs.statSync(join(output, "distribution")).isDirectory(), true);
}
});
test("rollback refuses a replaced owned entry and never removes foreign files", async t => {
const root = await fixture(); const output = join(root, "output"); await mkdir(output);
const originalWrite = fs.writeFileSync; let calls = 0;
const mocked = t.mock.method(fs.default, "writeFileSync", (fd, bytes, ...args) => {
if (++calls === 3) {
fs.renameSync(join(output, "copilot"), join(output, "caller-retained-copy"));
originalWrite(join(output, "copilot"), "foreign replacement", { flag: "wx" });
throw new Error("injected replacement race");
}
return originalWrite(fd, bytes, ...args);
});
syncBuiltinESMExports();
try { assert.throws(() => writeCopilotMaterialization(output, outputFiles()), error => error instanceof AggregateError && error.errors.some(e => /entry identity changed/.test(e.message))); }
finally { mocked.mock.restore(); syncBuiltinESMExports(); }
assert.equal(await readFile(join(output, "copilot"), "utf8"), "foreign replacement");
assert.equal(await readFile(join(output, "caller-retained-copy"), "utf8"), "verified executable");
});
test("owned cleanup never follows a preexisting destination symlink", async () => {
const root = await fixture(); const output = join(root, "output"); const outside = join(root, "outside");
await mkdir(output); await mkdir(outside); await symlink(outside, join(output, "distribution"));
assert.throws(() => writeCopilotMaterialization(output, outputFiles()), /redirects through links/);
assert.deepEqual(await readdir(outside), []);
assert.deepEqual(await readdir(output), ["distribution"]);
});
test("in-place failure leaves the pinned source executable untouched", async t => {
const root = await fixture(); const before = await readFile(join(root, "copilot"));
const originalWrite = fs.writeFileSync; let calls = 0;
const mocked = t.mock.method(fs.default, "writeFileSync", (fd, bytes, ...args) => {
if (++calls === 2) { originalWrite(fd, bytes.subarray(0, 2)); throw new Error("injected asset failure"); }
return originalWrite(fd, bytes, ...args);
});
syncBuiltinESMExports();
try { assert.throws(() => writeCopilotMaterialization(root, outputFiles().slice(1)), /injected asset failure/); }
finally { mocked.mock.restore(); syncBuiltinESMExports(); }
assert.deepEqual((await readdir(root)).sort(), ["copilot", "package.json"]);
assert.deepEqual(await readFile(join(root, "copilot")), before);
writeCopilotMaterialization(root, outputFiles().slice(1));
assert.deepEqual(await readFile(join(root, "copilot")), before);
});
@@ -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/[email protected]");
const securityPatchPath = join(workspaceRoot, "patches/[email protected]");
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; });
}
@@ -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/[email protected]", 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/);
});
Loaded 100 of 517 files, more files were not shown because too many files have changed in this diff. Show more