mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Carry integrated Pi prerequisites through public installation
This commit is contained in:
commit
2b0325c917
304 files changed
+11783
-2178
No files matched your search
@@ -36,6 +36,19 @@ packages/paperclip-runner/scripts/*-smoke.mjs
|
||||
# on master failed its drift check — .github/docker-context-checks.Dockerfile
|
||||
# now guards this in PR CI.
|
||||
!packages/paperclip-runner/generated/**
|
||||
# ACPX generation verifies these immutable release attestations as build inputs.
|
||||
# Keep the two declarations while excluding other development fixtures.
|
||||
!packages/paperclip-runner/test
|
||||
packages/paperclip-runner/test/**
|
||||
!packages/paperclip-runner/test/fixtures
|
||||
packages/paperclip-runner/test/fixtures/**
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json
|
||||
!packages/paperclip-runner/test-fixtures
|
||||
packages/paperclip-runner/test-fixtures/**
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp
|
||||
packages/paperclip-runner/test-fixtures/pi-acp/**
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v18-identity.json
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v19-identity.json
|
||||
!packages/paperclip-runner/docs/capability-contract.md
|
||||
# check:runner-workflow-traceability access()es every regression test the
|
||||
# stress-traceability spec names — those are src/**/*.test.ts files, so
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,19 +8,19 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
publish_eval_image:
|
||||
description: "Publish an immutable Daytona qualification image on the EC2 fleet (no provider credentials)"
|
||||
description: "Publish an immutable Daytona qualification image on hosted Linux (no provider credentials)"
|
||||
type: boolean
|
||||
default: false
|
||||
verify_source:
|
||||
description: "Run broad source checks on EC2"
|
||||
description: "Run broad source checks on hosted Linux"
|
||||
type: boolean
|
||||
default: false
|
||||
verify_public_install:
|
||||
description: "Build and verify clean public npm installation on EC2 (no provider credentials)"
|
||||
description: "Build and verify clean public npm installation on hosted Linux (no provider credentials)"
|
||||
type: boolean
|
||||
default: false
|
||||
build_eval_viewer:
|
||||
description: "Build the canonical eval report viewer on EC2"
|
||||
description: "Build the canonical eval report viewer on hosted Linux"
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
@@ -98,9 +98,10 @@ jobs:
|
||||
echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
manual_image:
|
||||
name: Build and verify on EC2
|
||||
name: Build and verify on Linux
|
||||
needs: authorize_manual
|
||||
runs-on: runs-on/fleet=paperclip-public-pr-x64/env=public-ci
|
||||
# Keep maintainer authorization below; use a native hosted Linux builder.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -169,9 +170,9 @@ jobs:
|
||||
docker buildx imagetools inspect "$immutable" >/dev/null
|
||||
echo "$immutable" > remote-verification/image.txt
|
||||
echo "Image: $immutable" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Verify repository on EC2
|
||||
- name: Verify repository on hosted Linux
|
||||
if: inputs.verify_source
|
||||
# Leave time for artifact retention before the fleet's one-hour lifetime.
|
||||
# Leave time for artifact retention within the job deadline.
|
||||
timeout-minutes: 38
|
||||
run: |
|
||||
set -uo pipefail
|
||||
@@ -195,7 +196,8 @@ jobs:
|
||||
test -f scripts/verify-grok-npm-install.mjs || { echo "Selected source does not provide the public-install verifier"; exit 1; }
|
||||
pnpm install --frozen-lockfile --ignore-scripts > remote-verification/npm-setup.log 2>&1
|
||||
pnpm build > remote-verification/npm-build.log 2>&1
|
||||
node scripts/verify-grok-npm-install.mjs > remote-verification/public-npm-install.log 2>&1
|
||||
PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR="$PWD/remote-verification/qualification-packages" \
|
||||
node scripts/verify-grok-npm-install.mjs > remote-verification/public-npm-install.log 2>&1
|
||||
- name: Build canonical eval report viewer
|
||||
if: always() && (inputs.verify_source || inputs.build_eval_viewer)
|
||||
run: |
|
||||
|
||||
@@ -82,3 +82,6 @@ test-results/
|
||||
|
||||
# Retained local lifecycle behavior measurements
|
||||
.lifecycle-baseline/
|
||||
|
||||
# Local native Runner test/session state
|
||||
.paperclip-runner-prp/
|
||||
@@ -26,9 +26,9 @@ it("rejects foreign package identity and a setup entrypoint outside that package
|
||||
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("provides an explicit Pi-only operator command and rejects other providers before resolution", async () => {
|
||||
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 explicit runtime setup");
|
||||
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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -33,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";
|
||||
@@ -265,7 +264,6 @@ registerSecretCommands(program);
|
||||
registerSkillsCommands(program);
|
||||
registerTeamCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerRuntimeCommands(program);
|
||||
registerEnvLabCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -127,6 +127,7 @@ export {
|
||||
normalizeLegacyRunnerProvider,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
resolvePaperclipRunnerCursorMode,
|
||||
resolvePaperclipRunnerPiThinkingLevel,
|
||||
} from "./paperclip-runner-permissions.js";
|
||||
export {
|
||||
PAPERCLIP_RUNNER_INGRESS_PORT,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
describe("Paperclip Runner permission defaults", () => {
|
||||
it("holds intermediate Pi admission and preserves upstream Cursor qualification", () => {
|
||||
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi"))
|
||||
.toMatchObject({ qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] });
|
||||
.toMatchObject({ qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] });
|
||||
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "copilot")?.qualified).toBe(false);
|
||||
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "cursor")?.qualified).toBe(true);
|
||||
});
|
||||
|
||||
@@ -246,5 +246,17 @@ export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([
|
||||
{ value: "claude", label: "Claude", qualified: true, credentialEnvironment: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ value: "cursor", label: "Cursor", qualified: true, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] },
|
||||
{ value: "copilot", label: "GitHub Copilot", qualified: false, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] },
|
||||
{ value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] },
|
||||
{ value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] },
|
||||
] as const);
|
||||
|
||||
/** Exact Pi native thinking levels; aliases that silently clamp are not configuration. */
|
||||
export type PaperclipRunnerPiThinkingLevel = "off" | "low" | "high" | "max";
|
||||
export function resolvePaperclipRunnerPiThinkingLevel(provider: unknown, agent: unknown, value: unknown): PaperclipRunnerPiThinkingLevel | undefined {
|
||||
if (provider !== "acpx" || agent !== "pi") {
|
||||
if (value !== undefined) throw new Error("piThinkingLevel is supported only for Pi");
|
||||
return undefined;
|
||||
}
|
||||
if (value === undefined) return "low";
|
||||
if (value === "off" || value === "low" || value === "high" || value === "max") return value;
|
||||
throw new Error("Pi thinking level must be off, low, high, or max");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -97,16 +98,13 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
const provider = isPaperclipRunnerProvider(providerCandidate)
|
||||
? providerCandidate
|
||||
: "codex";
|
||||
if (provider === "openai_dot") {
|
||||
return { provider, lifecycleMode: "per_turn", allowUnmeteredProvider: schemaValues.allowUnmeteredProvider === true, dotWorkspaceAccess: schemaValues.dotWorkspaceAccess === true, dotAttachmentAccess: schemaValues.dotAttachmentAccess === true,
|
||||
...(typeof schemaValues.dotBindingId === "string" ? { dotBindingId: schemaValues.dotBindingId } : {}) };
|
||||
}
|
||||
const selectedAcpxProfile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === schemaValues.acpxAgent);
|
||||
if (provider === "acpx" && selectedAcpxProfile && !selectedAcpxProfile.qualified) {
|
||||
throw new Error(`${selectedAcpxProfile.label} is not enabled for production`);
|
||||
}
|
||||
const acpxAgent = selectedAcpxProfile?.value ?? "claude";
|
||||
const cursorMode = resolvePaperclipRunnerCursorMode(provider, acpxAgent, schemaValues.acpxSessionMode);
|
||||
const piThinkingLevel = resolvePaperclipRunnerPiThinkingLevel(provider, acpxAgent, schemaValues.piThinkingLevel);
|
||||
|
||||
const schemaModel = typeof schemaValues.model === "string"
|
||||
? schemaValues.model.trim()
|
||||
@@ -114,7 +112,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<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 +190,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
"opencodePermissionMode",
|
||||
"acpxPermissionMode",
|
||||
"acpxSessionMode",
|
||||
"piThinkingLevel",
|
||||
"managedProfileId",
|
||||
"managedAgentsRetentionAcknowledged",
|
||||
"maxSessionListCostUsd",
|
||||
@@ -251,6 +250,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)),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -66,6 +66,17 @@ Qualification model selections live in test catalogs, separately from optional
|
||||
product defaults. The legacy resolved snapshot field `qualificationModel` contains
|
||||
the caller's selected model; its serialized name preserves recovery identities.
|
||||
Historical profile fixtures remain immutable evidence, not release declarations.
|
||||
|
||||
Pi credentials are selected from the explicit run environment and remain session-bound.
|
||||
The native Rust launcher forwards the controller-bound credential names, including
|
||||
custom provider references, without restricting Pi to an OpenRouter credential.
|
||||
Built-in providers can use their usual API-key environment variables. Custom Pi
|
||||
providers are supported through `PAPERCLIP_PI_PROVIDERS`: a JSON object containing
|
||||
the native `models.json` provider entries (without the outer `providers` key).
|
||||
The runner writes that configuration to the private Pi agent directory and binds
|
||||
its digest to recovery. API-key and header environment references are forwarded
|
||||
only from the explicit run environment; command-based credential resolution is
|
||||
unsupported. Select the provider/model ID explicitly in the agent configuration.
|
||||
The bundled ACPX package tests exercise unlisted model selection, rejection,
|
||||
exact acknowledgement, and replay on a loaded connection. Catalog membership
|
||||
and Cursor model-alias expansion do not determine the selected model.
|
||||
@@ -145,13 +156,26 @@ 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. Candidate configuration requires an explicit model and still
|
||||
requires exact operator-controlled qualification admission.
|
||||
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
|
||||
|
||||
@@ -14,30 +14,30 @@
|
||||
"requiresProviderPolicy": false
|
||||
},
|
||||
"pi": {
|
||||
"agentProfileVersion": 1,
|
||||
"agentProfileVersion": 20,
|
||||
"agentServerPackage": "pi-acp",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
|
||||
"agentRuntimeVersion": "0.84.2",
|
||||
"commandDigest": "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f",
|
||||
"agentRuntimeVersion": "1.0.0",
|
||||
"commandDigest": "sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e",
|
||||
"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": 16,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
|
||||
"commandDigest": "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c",
|
||||
"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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -196,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
|
||||
@@ -81,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",
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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, CursorMode,
|
||||
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,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_profile_matches_published_identity_and_rejects_prior_profiles() {
|
||||
let published: Value = serde_json::from_str(include_str!(
|
||||
"../../../../test-fixtures/pi-acp/profile-v20-identity.json"
|
||||
))
|
||||
.unwrap();
|
||||
let mut value = descriptor("codex");
|
||||
value["agent"] = json!("pi");
|
||||
value["model"] = json!("openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
value["agentServerPackage"] = json!("pi-acp");
|
||||
value["agentServerVersion"] = json!("0.0.33");
|
||||
value["agentRuntimePackage"] = json!("@earendil-works/pi-coding-agent");
|
||||
value["agentRuntimeVersion"] = json!("1.0.0");
|
||||
value["piThinkingLevel"] = json!("low");
|
||||
value["providerPolicy"] = json!({"readOnly": true});
|
||||
value["commandDigest"] = published["commandDigest"].clone();
|
||||
let current: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
|
||||
current.validate(&context()).unwrap();
|
||||
|
||||
for prior in [
|
||||
include_str!("../../../../test-fixtures/pi-acp/profile-v15-identity.json"),
|
||||
include_str!("../../../../test-fixtures/pi-acp/profile-v16-identity.json"),
|
||||
include_str!("../../../../test-fixtures/pi-acp/profile-v17-identity.json"),
|
||||
include_str!("../../../../test-fixtures/pi-acp/profile-v18-identity.json"),
|
||||
] {
|
||||
let prior: Value = serde_json::from_str(prior).unwrap();
|
||||
value["commandDigest"] = prior["commandDigest"].clone();
|
||||
let rejected: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(rejected.validate(&context()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pi_with_another_profile_identity_before_process_launch() {
|
||||
let mut pi = descriptor("codex");
|
||||
@@ -2997,6 +3260,135 @@ mod tests {
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod darwin_named_transport_cleanup {
|
||||
use super::*;
|
||||
|
||||
struct Fixture {
|
||||
directory: PathBuf,
|
||||
descriptor: AcpxProviderDescriptor,
|
||||
profile: AcpxLaunchProfile,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(label: &str) -> Self {
|
||||
let directory = temporary_directory(label);
|
||||
let command = directory.join("node");
|
||||
let sidecar = directory.join("sidecar.cjs");
|
||||
write_artifact(&command, b"qualified node", true);
|
||||
write_artifact(&sidecar, b"qualified sidecar", false);
|
||||
let args = vec![sidecar.to_string_lossy().into_owned()];
|
||||
let profile = AcpxLaunchProfile {
|
||||
authority_digest: format!("sha256:{}", "d".repeat(64)),
|
||||
command: command.clone(),
|
||||
args: args.clone(),
|
||||
artifacts: vec![artifact(&command), artifact(&sidecar)],
|
||||
};
|
||||
let mut value = descriptor("codex");
|
||||
value["sidecarCommand"] = json!(command);
|
||||
value["sidecarArgs"] = json!(args);
|
||||
Self {
|
||||
directory,
|
||||
descriptor: serde_json::from_value(value).unwrap(),
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
fn named_images(&self) -> usize {
|
||||
fs::read_dir(&self.directory)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".paperclip-verified-executable-")
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
fn prove_transport_owns_image(&self) {
|
||||
assert_eq!(self.named_images(), 0);
|
||||
let transport = self
|
||||
.descriptor
|
||||
.verified_transport(Some(&self.profile))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
self.named_images(),
|
||||
1,
|
||||
"verification must own one named Node image"
|
||||
);
|
||||
drop(transport);
|
||||
assert_eq!(
|
||||
self.named_images(),
|
||||
0,
|
||||
"dropping unstarted transport must retire its image"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.directory);
|
||||
}
|
||||
}
|
||||
|
||||
// File verification only: these tests never start a transport/process.
|
||||
#[test]
|
||||
fn second_artifact_failure_retires_named_executable() {
|
||||
let fixture = Fixture::new("darwin-second-artifact-cleanup");
|
||||
fixture.prove_transport_owns_image();
|
||||
// The command remains valid and first in the profile; the sidecar
|
||||
// fails only after the command's named snapshot has been admitted.
|
||||
write_artifact(
|
||||
&fixture.profile.artifacts[1].path,
|
||||
b"tampered sidecar",
|
||||
false,
|
||||
);
|
||||
let error = fixture
|
||||
.descriptor
|
||||
.verified_transport(Some(&fixture.profile))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("verified process artifact digest mismatch"));
|
||||
assert_eq!(fixture.named_images(), 0);
|
||||
assert_eq!(
|
||||
fs::read(&fixture.profile.command).unwrap(),
|
||||
b"qualified node"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argv_construction_failure_retires_named_executable() {
|
||||
let mut fixture = Fixture::new("darwin-argv-cleanup");
|
||||
fixture.prove_transport_owns_image();
|
||||
// Descriptor and profile agree, so this passes launch binding and
|
||||
// artifact verification, then fails the absolute-argument mapping.
|
||||
let unauthenticated = fixture
|
||||
.directory
|
||||
.join("unauthenticated.cjs")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
fixture.profile.args.push(unauthenticated.clone());
|
||||
fixture.descriptor.sidecar_args.push(unauthenticated);
|
||||
let error = fixture
|
||||
.descriptor
|
||||
.verified_transport(Some(&fixture.profile))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("does not authenticate an absolute argument"));
|
||||
assert_eq!(fixture.named_images(), 0);
|
||||
assert_eq!(
|
||||
fs::read(&fixture.profile.command).unwrap(),
|
||||
b"qualified node"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlinked_launch_artifacts() {
|
||||
@@ -3071,7 +3463,7 @@ mod tests {
|
||||
args: Vec::new(),
|
||||
artifacts: vec![artifact(&command)],
|
||||
};
|
||||
let mut descriptor_value = descriptor("codex");
|
||||
let mut descriptor_value = descriptor("pi");
|
||||
descriptor_value["sidecarCommand"] = json!(command);
|
||||
descriptor_value["runtimeContext"] = json!({ "instructions": { "digest": "stable" }, "mcp": { "digest": "before" }, "aggregateDigest": "before" });
|
||||
descriptor_value["sidecarArgs"] = json!([]);
|
||||
@@ -3111,6 +3503,7 @@ mod tests {
|
||||
effective_model: original_descriptor.model.clone(),
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
pi_thinking_level: original_descriptor.pi_thinking_level,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3263,7 +3656,7 @@ mod tests {
|
||||
.attach_run(&json!({"provider": same_run_mutation}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("same_run_grant_changed"));
|
||||
.contains("changed runtime context outside a new authenticated run"));
|
||||
|
||||
// In-place warm handoff executes under the old authority. Only the
|
||||
// authenticated next-authority boundary may admit the new descriptor;
|
||||
@@ -3329,7 +3722,7 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn authenticated_run_grant_attachment(agent: &str) {
|
||||
let directory = temporary_directory("cursor-cross-run-attach");
|
||||
let directory = temporary_directory(&format!("{agent}-cross-run-attach"));
|
||||
let runtime = directory.join("runtime");
|
||||
let workspace = directory.join("workspace");
|
||||
fs::create_dir_all(&runtime).unwrap();
|
||||
@@ -3395,6 +3788,7 @@ mod tests {
|
||||
effective_model: original_descriptor.model.clone(),
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
pi_thinking_level: original_descriptor.pi_thinking_level,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3688,6 +4082,7 @@ mod tests {
|
||||
effective_model: descriptor.model.clone(),
|
||||
permission_mode: Some(descriptor.permission_mode),
|
||||
mode: descriptor.mode.clone(),
|
||||
pi_thinking_level: descriptor.pi_thinking_level,
|
||||
provider_lifetime_fence_candidates,
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3860,6 +4255,7 @@ mod tests {
|
||||
effective_model: provider_descriptor.model.clone(),
|
||||
permission_mode: Some(provider_descriptor.permission_mode),
|
||||
mode: provider_descriptor.mode.clone(),
|
||||
pi_thinking_level: provider_descriptor.pi_thinking_level,
|
||||
provider_lifetime_fence_candidates,
|
||||
});
|
||||
state.provider_exit_unconfirmed = true;
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::acpx_provider_session::{
|
||||
AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity, CursorMode,
|
||||
PiThinkingLevel,
|
||||
};
|
||||
use crate::durable::{
|
||||
create_private_temporary_file, open_private_regular_file, verify_private_directory,
|
||||
@@ -50,6 +51,8 @@ struct PersistedAcpxProviderSessionIdentity {
|
||||
permission_mode: AcpxPermissionMode,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mode: Option<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()
|
||||
|
||||
@@ -44,6 +44,16 @@ pub enum CursorMode {
|
||||
Ask,
|
||||
}
|
||||
|
||||
/// Exact native Pi modes. No aliases, clamping, or implicit Rust default.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PiThinkingLevel {
|
||||
Off,
|
||||
Low,
|
||||
High,
|
||||
Max,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AcpxProviderSessionIdentity {
|
||||
@@ -60,6 +70,8 @@ pub struct AcpxProviderSessionIdentity {
|
||||
pub permission_mode: Option<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],
|
||||
}
|
||||
|
||||
@@ -81,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,
|
||||
@@ -96,6 +109,11 @@ impl AcpxProviderSessionConfig {
|
||||
LocalRunnerError::invalid("ACPX agent must name a known immutable profile")
|
||||
})?;
|
||||
validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?;
|
||||
if (self.agent == "pi") != self.pi_thinking_level.is_some() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX Pi thinking level must be explicit for Pi and absent for other agents",
|
||||
));
|
||||
}
|
||||
if let Some(mode) = self.mode.as_deref() {
|
||||
validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?;
|
||||
}
|
||||
@@ -169,6 +187,7 @@ impl AcpxProviderSessionConfig {
|
||||
|| expected_identity.effective_model != self.model
|
||||
|| expected_identity.permission_mode != Some(self.permission_mode)
|
||||
|| expected_identity.mode != self.mode
|
||||
|| expected_identity.pi_thinking_level != self.pi_thinking_level
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX expected identity conflicts with the requested session",
|
||||
@@ -323,6 +342,35 @@ impl AcpxProviderSession {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Read the same live sidecar before exposing its in-memory pending ledger.
|
||||
/// A durable file alone cannot authorize a request after process loss.
|
||||
pub fn verify_live_request_snapshot(
|
||||
&mut self,
|
||||
) -> Result<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
|
||||
}
|
||||
@@ -955,6 +1003,20 @@ impl AcpxProviderSession {
|
||||
self.terminate_transport()
|
||||
}
|
||||
|
||||
/// Retires an idle provider at the same owned-process boundary as an active
|
||||
/// turn. The caller must prove the inherited lifetime fence before making
|
||||
/// the persisted identity attachable; an RPC close cannot supply that proof.
|
||||
pub fn terminate_idle_for_suspension(&mut self) -> Result<(), LocalRunnerError> {
|
||||
self.ensure_open()?;
|
||||
if self.state.active_turn_id().is_some() || self.state.has_pending_requests() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX idle suspension requires a settled turn and no pending requests",
|
||||
));
|
||||
}
|
||||
self.closed = true;
|
||||
self.terminate_transport()
|
||||
}
|
||||
|
||||
fn terminate_transport(&mut self) -> Result<(), LocalRunnerError> {
|
||||
if self.transport_terminated {
|
||||
return Ok(());
|
||||
@@ -1251,6 +1313,9 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu
|
||||
"tools": &sidecar_tools,
|
||||
"expectedIdentity": config.expected_identity,
|
||||
});
|
||||
if let Some(level) = config.pi_thinking_level {
|
||||
params["piThinkingLevel"] = json!(level);
|
||||
}
|
||||
if let Some(mode) = config.mode.as_deref() {
|
||||
params["mode"] = json!(mode);
|
||||
}
|
||||
@@ -1364,6 +1429,7 @@ fn verify_open_response(
|
||||
|| identity.effective_model != config.model
|
||||
|| identity.permission_mode != Some(config.permission_mode)
|
||||
|| identity.mode != config.mode
|
||||
|| identity.pi_thinking_level != config.pi_thinking_level
|
||||
|| config
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
@@ -1403,6 +1469,45 @@ fn verify_suspend_response(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn live_snapshot_requests(
|
||||
snapshot: &Value,
|
||||
active_turn_id: Option<&str>,
|
||||
) -> Result<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
|
||||
@@ -1494,6 +1599,45 @@ fn with_cleanup_error(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn live_request_snapshot_requires_current_unique_typed_callbacks() {
|
||||
use super::*;
|
||||
let request = json!({"requestId":"input-1","type":"input","turnId":"turn-1"});
|
||||
let snapshot = json!({"pendingRuntimeRequests":[request.clone(),
|
||||
{"requestId":"permission-1","type":"permission","turnId":"turn-1"}]});
|
||||
let live = live_snapshot_requests(&snapshot, Some("turn-1")).unwrap();
|
||||
assert_eq!(live.get("input-1").map(String::as_str), Some("input"));
|
||||
assert_eq!(
|
||||
live.get("permission-1").map(String::as_str),
|
||||
Some("permission")
|
||||
);
|
||||
for invalid in [
|
||||
json!({}),
|
||||
json!({"pendingRuntimeRequests":null}),
|
||||
json!({"pendingRuntimeRequests":[request.clone(),request.clone()]}),
|
||||
json!({"pendingRuntimeRequests":[{"requestId":"","type":"input","turnId":"turn-1"}]}),
|
||||
json!({"pendingRuntimeRequests":[{"requestId":"input-1","type":"tool","turnId":"turn-1"}]}),
|
||||
json!({"pendingRuntimeRequests":[{"requestId":"input-1","type":"input","turnId":"turn-2"}]}),
|
||||
json!({"pendingRuntimeRequests":[{"requestId":"input-1","type":"input","turnId":"turn-1","extra":true}]}),
|
||||
] {
|
||||
assert!(
|
||||
live_snapshot_requests(&invalid, Some("turn-1")).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
assert!(live_snapshot_requests(&snapshot, None).is_err());
|
||||
assert!(live_snapshot_requests(
|
||||
&json!({"pendingRuntimeRequests":vec![request;1_025]}),
|
||||
Some("turn-1")
|
||||
)
|
||||
.is_err());
|
||||
assert!(
|
||||
live_snapshot_requests(&json!({"pendingRuntimeRequests":[]}), None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_controls_require_exact_live_pi_capability_fields() {
|
||||
use super::*;
|
||||
@@ -1610,6 +1754,7 @@ mod mode_tests {
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: Some("plan".to_owned()),
|
||||
pi_thinking_level: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }),
|
||||
system_instructions: String::new(),
|
||||
@@ -1636,6 +1781,7 @@ mod mode_tests {
|
||||
effective_model: "explicit-model".to_owned(),
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: Some("plan".to_owned()),
|
||||
pi_thinking_level: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
@@ -1644,6 +1790,11 @@ mod mode_tests {
|
||||
let mut config = config();
|
||||
for agent in ["claude", "codex", "pi", "grok", "cursor", "copilot"] {
|
||||
config.agent = agent.to_owned();
|
||||
config.pi_thinking_level = if agent == "pi" {
|
||||
Some(PiThinkingLevel::Low)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
config.model = "custom/model[context=272k,reasoning=medium]".to_owned();
|
||||
config.validate().unwrap();
|
||||
assert_eq!(
|
||||
@@ -1751,4 +1902,137 @@ mod mode_tests {
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
fn pi_config() -> AcpxProviderSessionConfig {
|
||||
let mut config = config();
|
||||
config.agent = "pi".to_owned();
|
||||
config.model = "openrouter/deepseek/deepseek-v4-flash-0731".to_owned();
|
||||
config.mode = None;
|
||||
config.pi_thinking_level = Some(PiThinkingLevel::Low);
|
||||
config
|
||||
}
|
||||
|
||||
fn pi_identity(config: &AcpxProviderSessionConfig) -> AcpxProviderSessionIdentity {
|
||||
let mut identity = identity();
|
||||
identity.requested_model = config.model.clone();
|
||||
identity.effective_model = config.model.clone();
|
||||
identity.mode = None;
|
||||
identity.pi_thinking_level = config.pi_thinking_level;
|
||||
identity
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_thinking_level_is_closed_explicit_and_pi_only() {
|
||||
for (name, level) in [
|
||||
("off", PiThinkingLevel::Off),
|
||||
("low", PiThinkingLevel::Low),
|
||||
("high", PiThinkingLevel::High),
|
||||
("max", PiThinkingLevel::Max),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<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,107 @@ pub struct AcpxSidecarTransport {
|
||||
poisoned: bool,
|
||||
}
|
||||
|
||||
// A fresh Pi process verifies and copies its native closure before ACP admission.
|
||||
// Reopen/recovery uses the same path. Ordinary sidecar requests retain their bound.
|
||||
fn session_open_timeout(agent: &str, ordinary: Duration) -> Duration {
|
||||
if agent == "pi" {
|
||||
Duration::from_secs(60)
|
||||
} else {
|
||||
ordinary
|
||||
}
|
||||
}
|
||||
|
||||
// Pi's provider catalog is caller-selected. The authenticated controller binds
|
||||
// credential names (including custom models.json references); a Rust provider
|
||||
// roster would silently drop credentials before the sidecar can validate them.
|
||||
fn pi_credential_environment_keys(binding: Option<&str>) -> Result<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"
|
||||
);
|
||||
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 +210,7 @@ impl AcpxSidecarTransport {
|
||||
"PAPERCLIP_AI_PROVIDER_KEY",
|
||||
],
|
||||
"grok" => &["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"],
|
||||
"pi" => &["OPENROUTER_API_KEY"],
|
||||
"pi" => &[],
|
||||
"cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
"copilot" => &["COPILOT_GITHUB_TOKEN"],
|
||||
_ => {
|
||||
@@ -163,8 +265,28 @@ impl AcpxSidecarTransport {
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
]);
|
||||
}
|
||||
let pi_keys = if agent == "pi" {
|
||||
pi_credential_environment_keys(
|
||||
std::env::var("PAPERCLIP_ACPX_CREDENTIAL_BINDING")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
keys.extend(pi_keys.iter().map(String::as_str));
|
||||
keys.extend_from_slice(credential_keys);
|
||||
Self::start_with_environment_keys(config, &keys)
|
||||
// Pi owns a native distribution copy, including a pending refresh at
|
||||
// suspension. Allow its bounded cleanup to settle before group KILL;
|
||||
// the ordinary two-second grace can cut off deletion mid-tree.
|
||||
config.validate()?;
|
||||
let mut launch_config = config.clone();
|
||||
if agent == "pi" {
|
||||
launch_config.shutdown_grace = Duration::from_secs(30);
|
||||
}
|
||||
let mut transport = Self::start_with_environment_keys(&launch_config, &keys)?;
|
||||
transport.session_open_timeout = session_open_timeout(agent, config.request_timeout);
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
fn start_with_environment_keys(
|
||||
@@ -191,6 +313,7 @@ impl AcpxSidecarTransport {
|
||||
Ok(Self {
|
||||
process,
|
||||
request_timeout: config.request_timeout,
|
||||
session_open_timeout: config.request_timeout,
|
||||
next_request_id: 1,
|
||||
last_event_sequence: 0,
|
||||
buffered_events: VecDeque::new(),
|
||||
@@ -200,6 +323,14 @@ impl AcpxSidecarTransport {
|
||||
})
|
||||
}
|
||||
|
||||
fn command_timeout(&self, command: GeneratedAcpxSidecarCommand) -> Duration {
|
||||
if command == GeneratedAcpxSidecarCommand::SessionOpen {
|
||||
self.session_open_timeout
|
||||
} else {
|
||||
self.request_timeout
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_id(&self) -> u32 {
|
||||
self.process.id()
|
||||
}
|
||||
@@ -295,7 +426,8 @@ impl AcpxSidecarTransport {
|
||||
})?;
|
||||
self.next_request_id = request_id + 1;
|
||||
|
||||
let deadline = Instant::now() + self.request_timeout;
|
||||
let timeout = self.command_timeout(command);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
@@ -795,6 +927,67 @@ fn response_error_classification(error: &ResponseError) -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn pi_credentials_require_a_bounded_binding_without_process_control_variables() {
|
||||
assert!(pi_credential_environment_keys(None).unwrap().is_empty());
|
||||
for name in [
|
||||
"NODE_OPTIONS",
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LD_PRELOAD",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"PAPERCLIP_NATIVE_MCP_TOKEN",
|
||||
"INVALID-NAME",
|
||||
] {
|
||||
let binding = json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":[name]});
|
||||
assert!(pi_credential_environment_keys(Some(&binding.to_string())).is_err());
|
||||
}
|
||||
for binding in [
|
||||
json!({"schema":"other", "agent":"pi", "sessionId":"session-1", "names":[]}),
|
||||
json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"cursor", "sessionId":"session-1", "names":[]}),
|
||||
json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":["MY_PI_KEY", "MY_PI_KEY"]}),
|
||||
json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":[], "extra":true}),
|
||||
] {
|
||||
assert!(pi_credential_environment_keys(Some(&binding.to_string())).is_err());
|
||||
}
|
||||
assert!(pi_credential_environment_keys(Some(&"x".repeat(4_097))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_loader_and_shell_controls_match_the_controller_reservations() {
|
||||
let names: Vec<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" {
|
||||
|
||||
@@ -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:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e",
|
||||
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:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c",
|
||||
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)]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+40
@@ -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(
|
||||
|
||||
+95
-3
@@ -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");
|
||||
|
||||
+158
-1
@@ -17,7 +17,7 @@ use sha2::{Digest, Sha256};
|
||||
const CODEX_ACPX_DIGEST: &str =
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
|
||||
const PI_ACPX_DIGEST: &str =
|
||||
"sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f";
|
||||
"sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e";
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
@@ -235,6 +235,7 @@ fn pi_prepare_payload(directory: &Path, mode: &str) -> Value {
|
||||
provider["commandDigest"] = json!(PI_ACPX_DIGEST);
|
||||
provider["sidecarArgs"][3] = json!(PI_ACPX_DIGEST);
|
||||
provider["providerPolicy"] = json!({"readOnly":true});
|
||||
provider["piThinkingLevel"] = json!("low");
|
||||
payload
|
||||
}
|
||||
|
||||
@@ -1371,6 +1372,162 @@ fn rejects_pi_without_an_explicit_model_before_starting_a_sidecar() {
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_thinking_change_cannot_attach_to_an_existing_provider_identity() {
|
||||
let directory = temporary_directory("pi-thinking-attach");
|
||||
let config = pi_acpx_config(&directory, "bootstrap");
|
||||
let payload = pi_prepare_payload(&directory, "bootstrap");
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor
|
||||
.execute(&command(1, "run.prepare", payload.clone()))
|
||||
.unwrap();
|
||||
let opened = executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
assert_eq!(opened.result["piThinkingLevel"], json!("low"));
|
||||
assert_eq!(
|
||||
opened.events[0].2["providerDescriptor"]["piThinkingLevel"],
|
||||
json!("low")
|
||||
);
|
||||
let state_path = directory.join("acpx-provider-state.json");
|
||||
let prior = fs::read(&state_path).unwrap();
|
||||
for (index, mode) in ["off", "high", "max"].into_iter().enumerate() {
|
||||
let mut changed = payload.clone();
|
||||
changed["provider"]["piThinkingLevel"] = json!(mode);
|
||||
let error = executor
|
||||
.execute(&command(3 + index as u64, "run.attach", changed))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("requires the same settled ACPX provider profile and session"),
|
||||
"unexpected thinking identity rejection: {error}"
|
||||
);
|
||||
assert_eq!(fs::read(&state_path).unwrap(), prior);
|
||||
}
|
||||
executor
|
||||
.execute(&command(6, "run.attach", payload))
|
||||
.unwrap();
|
||||
executor.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_an_idle_pi_provider_before_suspension_without_waiting_for_its_close_rpc() {
|
||||
let directory = temporary_directory("pi-idle-suspension");
|
||||
let mut config = pi_acpx_config(&directory, "bootstrap");
|
||||
config
|
||||
.acpx_launch_profile
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.args
|
||||
.extend(["--suspend-delay-ms".to_owned(), "8000".to_owned()]);
|
||||
let mut payload = pi_prepare_payload(&directory, "bootstrap");
|
||||
payload["provider"]["sidecarArgs"] = json!(config.acpx_launch_profile.as_ref().unwrap().args);
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor
|
||||
.execute(&command(1, "run.prepare", payload))
|
||||
.unwrap();
|
||||
executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
let state_path = directory.join("acpx-provider-state.json");
|
||||
let opened: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let stopped = executor
|
||||
.execute(&command(3, "turn.stop", json!({})))
|
||||
.unwrap();
|
||||
executor
|
||||
.execute(&command(4, "runner.drain", json!({})))
|
||||
.unwrap();
|
||||
executor
|
||||
.execute(&command(5, "runner.suspend", json!({})))
|
||||
.unwrap();
|
||||
let suspended: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap();
|
||||
let elapsed = started.elapsed();
|
||||
executor.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
|
||||
assert_eq!(stopped.result["status"], "stopped");
|
||||
assert_eq!(stopped.result["providerExitConfirmed"], true);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"idle close took {elapsed:?}"
|
||||
);
|
||||
assert_eq!(suspended["lifecycle"], "suspended");
|
||||
assert_eq!(suspended["providerExitUnconfirmed"], false);
|
||||
assert_eq!(suspended["identity"], opened["identity"]);
|
||||
assert_eq!(suspended["activeProviderTurnId"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_pi_stop_cannot_publish_a_reusable_identity_while_its_lifetime_fence_is_held() {
|
||||
let directory = temporary_directory("pi-idle-held-lifetime");
|
||||
// Linux can assign port 0 below the identity contract's dynamic-port range.
|
||||
// Keep this fixture distinct from the fake sidecar's default fence ports.
|
||||
let fence = (61_000..=u16::MAX)
|
||||
.filter_map(|port| std::net::TcpListener::bind(("127.0.0.1", port)).ok())
|
||||
.take(3)
|
||||
.collect::<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"))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -7,7 +8,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 384 * 1024 * 1024;
|
||||
const PROFILE_DIGEST = "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8";
|
||||
const PROFILE_DIGEST = profiles.profiles.copilot.commandDigest;
|
||||
|
||||
/** Build-time only; outputRoot is the exact runner-owned platform asset directory. */
|
||||
export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) {
|
||||
|
||||
@@ -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,7 +20,6 @@ import { dirname, join, relative, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
|
||||
import { writePortableCopilotShims, writePortableExecutableShim } from "./provider-pack-executable-shims.mjs";
|
||||
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -167,7 +166,7 @@ try {
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest)
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity");
|
||||
candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest,
|
||||
closureDigest: metadata.closureDigest, qualification: provider === "cursor" ? "qualified" : "pending", path: assetPath,
|
||||
closureDigest: metadata.closureDigest, qualification: (provider === "cursor" || provider === "pi") ? "qualified" : "pending", path: assetPath,
|
||||
sha256: sha256Tree(join(temporaryRoot, assetPath)) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,52 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// Matches the canonical manifest hashing used by the builder and admission.
|
||||
function digest(value) {
|
||||
const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
||||
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`
|
||||
: JSON.stringify(value);
|
||||
return createHash("sha256").update(canonical(value)).digest("hex");
|
||||
}
|
||||
|
||||
test("provider manifest digest survives disk JSON on supported and unsupported targets", () => {
|
||||
const cursor = { version: "pinned", profileDigest: "sha256:profile", closureDigest: "sha256:closure" };
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) {
|
||||
const selected = providerPackProviders(platform, architecture, []);
|
||||
const payload = { target: { platform, architecture }, ...providerPackManifestFields(selected.includes("cursor") ? { cursor } : {}, []) };
|
||||
const diskPayload = JSON.parse(JSON.stringify(payload));
|
||||
assert.deepEqual(diskPayload, payload);
|
||||
assert.equal(digest(diskPayload), digest(payload));
|
||||
assert.equal(diskPayload.providers?.cursor?.version, selected.includes("cursor") ? "pinned" : undefined);
|
||||
}
|
||||
const candidate = providerPackManifestFields({ cursor }, ["cursor"]);
|
||||
assert.deepEqual(candidate.candidateProviders, { cursor });
|
||||
assert.equal(digest(JSON.parse(JSON.stringify(candidate))), digest(candidate));
|
||||
});
|
||||
|
||||
test("candidate selection is explicit", () => {
|
||||
test("candidate options stay explicit and supported native assets are included by default", () => {
|
||||
assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] });
|
||||
assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] });
|
||||
assert.deepEqual(providerPackSelections([]), [{ provider: "pi", qualification: "qualified" }]);
|
||||
assert.deepEqual(providerPackSelections(["pi", "cursor", "copilot"]), [
|
||||
{ provider: "pi", qualification: "qualified" },
|
||||
{ provider: "cursor", qualification: "pending" },
|
||||
{ provider: "copilot", qualification: "pending" },
|
||||
]);
|
||||
assert.throws(() => providerPackSelections(["other"]), /Unknown/);
|
||||
assert.throws(() => providerPackSelections(["pi", "pi"]), /duplicate/);
|
||||
});
|
||||
test("normal packs include Cursor on its three pinned targets without breaking other hosts", () => {
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) {
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, []), ["cursor"]);
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), ["cursor"]);
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, []), ["pi", "cursor"]);
|
||||
assert.deepEqual(providerPackProviders(platform, architecture, ["cursor", "pi", "copilot"]), ["pi", "cursor", "copilot"]);
|
||||
}
|
||||
assert.deepEqual(providerPackProviders("linux", "arm64", []), []);
|
||||
assert.deepEqual(providerPackProviders("win32", "x64", []), []);
|
||||
assert.deepEqual(providerPackProviders("linux", "arm64", ["cursor"]), ["cursor"]);
|
||||
});
|
||||
test("serialized manifests include every materialized qualified provider", () => {
|
||||
const pi = { qualification: "qualified", profileDigest: "pi-digest" };
|
||||
const cursor = { qualification: "qualified", profileDigest: "cursor-digest" };
|
||||
const copilot = { qualification: "pending", profileDigest: "copilot-digest" };
|
||||
assert.deepEqual(providerPackManifestFields({ pi }, []), { providers: { pi }, candidateProviders: { pi } });
|
||||
assert.deepEqual(providerPackManifestFields({ pi, cursor, copilot }, ["copilot"]), {
|
||||
providers: { pi, cursor }, candidateProviders: { pi, copilot },
|
||||
});
|
||||
assert.deepEqual(providerPackManifestFields({}, []), {});
|
||||
});
|
||||
test("candidate builder cannot admit unknown providers, options or duplicate assets", async () => {
|
||||
for (const args of [["--candidate-providers=cursor,cursor"], ["--candidate-providers=other"], ["--executable=/tmp/x"], ["/one", "/two"]]) {
|
||||
assert.throws(() => parseProviderPackArguments(args));
|
||||
|
||||
@@ -39,6 +39,15 @@ const { commandDigest, ...attestation } = contract;
|
||||
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
|
||||
assert.equal(contract.acpxPatchSha256, createHash("sha256")
|
||||
.update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex"));
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v20-identity.json"], ["copilot", "test/fixtures/copilot-profile-v16-identity.json"]]) {
|
||||
const identity = await readJson(path);
|
||||
assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion);
|
||||
assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest);
|
||||
const serialized = agent === "pi" ? canonicalJson(identity.declaration)
|
||||
: JSON.stringify(Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))));
|
||||
assert.equal(identity.commandDigest, `sha256:${createHash("sha256").update(serialized).digest("hex")}`);
|
||||
assert.equal(identity.declaration.acpxPatchSha256, contract.acpxPatchSha256);
|
||||
}
|
||||
for (const distribution of Object.values(distributions.platforms)) assert.match(distribution.closureSha256, /^[a-f0-9]{64}$/);
|
||||
|
||||
const quote = JSON.stringify;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
|
||||
import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/pi-closure-pins.ts";
|
||||
import { PI_NODE_DISTRIBUTIONS, PI_NODE_VERSION } from "../src/drivers/acpx/pi-node-pins.ts";
|
||||
import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts";
|
||||
import acpxProfiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import { inventoryPiRuntimeFiles, verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts";
|
||||
|
||||
const run = promisify(execFile);
|
||||
@@ -19,8 +19,8 @@ const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch");
|
||||
const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]);
|
||||
export const PI_DISTRIBUTION_PINS = Object.freeze({
|
||||
wrapper: "0.0.33", runtime: "1.0.0", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, undici: "8.10.2", nodeBundledUndici: "7.29.1",
|
||||
wrapperSha256: "c41750802680543d72a5a43e21eaf91c31ca8cec833bbf5f27330614936810fe",
|
||||
helperSha256: "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da",
|
||||
wrapperSha256: "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36",
|
||||
helperSha256: "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d",
|
||||
});
|
||||
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np
|
||||
const binding = await verifyPiRuntimeManifest(finalRoot, manifest);
|
||||
return {
|
||||
version: PI_DISTRIBUTION_PINS.runtime,
|
||||
profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest,
|
||||
profileDigest: acpxProfiles.profiles.pi.commandDigest,
|
||||
closureDigest: `sha256:${nativeClosureSha256}`,
|
||||
outputRoot: output, runtimeRoot: finalRoot, manifestPath: join(output, "pi-distribution.json"), metadata, ...binding,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -9,7 +9,7 @@ import { build } from "esbuild";
|
||||
import { bundlePiProvisioner } from "./build-verified-provider-entrypoints.mjs";
|
||||
|
||||
test("public server tar layout carries a self-contained host provisioner and exact small inputs", async t => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-public-layout-"));
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-public-layout-")));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const pkg = join(root, "package"); const cli = join(pkg, "dist/vendor/paperclip-runner/cli");
|
||||
await mkdir(cli, { recursive: true });
|
||||
@@ -33,18 +33,21 @@ test("public server tar layout carries a self-contained host provisioner and exa
|
||||
execFileSync("tar", ["-xzf", archive, "-C", installed], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 });
|
||||
const installedPackage = join(installed, "package"); const entry = join(installedPackage, "dist/vendor/paperclip-runner/cli/provision-pi.cjs");
|
||||
const api = createRequire(import.meta.url)(entry);
|
||||
assert.equal((await api.provisionPackageRoot(entry)).root, installedPackage);
|
||||
const layout = await api.provisionPackageRoot(entry);
|
||||
assert.equal(layout.root, installedPackage);
|
||||
const installedRunner = join(installedPackage, "dist/vendor/paperclip-runner");
|
||||
assert.equal(layout.assetRoot, installedRunner);
|
||||
const network = { PATH: "/usr/bin:/bin", HTTPS_PROXY: "http://proxy.example:8080", HTTP_PROXY: "http://proxy.example:8080", NO_PROXY: "localhost", http_proxy: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9", no_proxy: "localhost", SSL_CERT_FILE: "/public/ca.pem", SSL_CERT_DIR: "/public/certs", NODE_EXTRA_CA_CERTS: "/public/extra-ca.pem" };
|
||||
assert.deepEqual(api.provisionEnvironment({ ...network, HOME: "/private/home", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", NODE_OPTIONS: "--require /foreign.js", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" }), { ...network, LANG: "C.UTF-8" });
|
||||
// Real, unmocked installation verifier rejects a corrupt cache before any
|
||||
// download/process. The positive full-closure proof uses real platform packs.
|
||||
await mkdir(join(installedPackage, "provider-assets/pi", `${process.platform}-${process.arch}`, "runtime"), { recursive: true });
|
||||
await mkdir(join(installedRunner, "provider-assets/pi", `${process.platform}-${process.arch}`, "runtime"), { recursive: true });
|
||||
const deny = join(root, "deny.cjs");
|
||||
await writeFile(deny, `process.nextTick(()=>{const assert=require('node:assert/strict');assert.equal(process.env.HTTP_PROXY,'http://127.0.0.1:9');assert.equal(process.env.HTTPS_PROXY,'http://127.0.0.1:9');assert.equal(process.env.NO_PROXY,'localhost');assert.equal(process.env.http_proxy,'http://127.0.0.1:9');assert.equal(process.env.https_proxy,'http://127.0.0.1:9');assert.equal(process.env.no_proxy,'localhost');assert.equal(process.env.NODE_EXTRA_CA_CERTS,'/dev/null');for(const key of ['OPENROUTER_API_KEY','NPM_TOKEN','HOME','NODE_OPTIONS','NODE_PATH','NODE_TLS_REJECT_UNAUTHORIZED'])assert.equal(process.env[key],undefined,key);});const fail=()=>{throw Error('UNEXPECTED_NETWORK_OR_CHILD')}; globalThis.fetch=fail; for(const m of ['node:net','node:tls','node:http','node:https']){const x=require(m);for(const k of ['connect','createConnection','request','get'])if(k in x)x[k]=fail;}const c=require('node:child_process');for(const k of ['spawn','execFile','exec'])c[k]=fail;`);
|
||||
const result = spawnSync(process.execPath, ["--require", deny, entry], { encoding: "utf8", env: { PATH: "/usr/bin:/bin", HTTP_PROXY: "http://127.0.0.1:9", HTTPS_PROXY: "http://127.0.0.1:9", NO_PROXY: "localhost", http_proxy: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9", no_proxy: "localhost", NODE_EXTRA_CA_CERTS: "/dev/null", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", HOME: "/private/home", NODE_OPTIONS: "", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" }, timeout: 10_000 });
|
||||
assert.ifError(result.error); assert.equal(result.status, 1); assert.match(result.stderr, /Pi setup failed/);
|
||||
assert.doesNotMatch(result.stderr, /UNEXPECTED_NETWORK_OR_CHILD|sensitive-canary/);
|
||||
assert.deepEqual(await readdir(join(installedPackage, "provider-assets/pi")), [`${process.platform}-${process.arch}`]);
|
||||
assert.deepEqual(await readdir(join(installedRunner, "provider-assets/pi")), [`${process.platform}-${process.arch}`]);
|
||||
});
|
||||
|
||||
test("explicit CLI setup passes only network settings to its real child and enables Node proxy handling", async t => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function provisionPackageRoot(entrypoint) {
|
||||
const expected = vendored ? "@paperclipai/server" : "@paperclipai/paperclip-runner";
|
||||
if (JSON.parse(bytes.toString()).name !== expected) throw new Error("Pi setup package identity does not match its layout");
|
||||
} finally { await handle.close(); }
|
||||
return { root, manifest, cli };
|
||||
return { root, manifest, cli, assetRoot: vendored ? resolve(cli, "..") : root };
|
||||
}
|
||||
async function verifiedInstallation(root, manifest) {
|
||||
const keys = ["PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"];
|
||||
@@ -53,8 +53,8 @@ export async function provisionPi(entrypoint, checkCancelled = () => {}) {
|
||||
checkCancelled();
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
if (!Object.hasOwn(PI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error(`Pi is not qualified for platform ${target}`);
|
||||
const { root, manifest, cli } = await provisionPackageRoot(entrypoint);
|
||||
const assets = join(root, "provider-assets"); const parent = join(assets, "pi");
|
||||
const { root, manifest, cli, assetRoot } = await provisionPackageRoot(entrypoint);
|
||||
const assets = join(assetRoot, "provider-assets"); const parent = join(assets, "pi");
|
||||
await containedDirectory(root, assets); await containedDirectory(root, parent);
|
||||
const lockPath = join(parent, `.setup-${target}.lock`);
|
||||
const lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, mkdtemp, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
||||
const source = path.join(packageRoot, "runner", "target", "release", executable);
|
||||
const destinationDirectory = path.join(packageRoot, "dist", "bin");
|
||||
const destination = path.join(destinationDirectory, executable);
|
||||
export async function stageRunnerBinary(source, destination) {
|
||||
const destinationDirectory = path.dirname(destination);
|
||||
await mkdir(destinationDirectory, { recursive: true });
|
||||
const stagingDirectory = await mkdtemp(path.join(destinationDirectory, ".runnerd-stage-"));
|
||||
const staged = path.join(stagingDirectory, path.basename(destination));
|
||||
try {
|
||||
await copyFile(source, staged, constants.COPYFILE_EXCL);
|
||||
if (process.platform !== "win32") await chmod(staged, 0o755);
|
||||
// Sign the new inode before publication. Replacing the pathname atomically
|
||||
// preserves a running old executable instead of truncating its live inode.
|
||||
if (process.platform === "darwin") {
|
||||
await execFileAsync("codesign", ["--force", "--sign", "-", staged], { timeout: 30_000 });
|
||||
await execFileAsync("codesign", ["--verify", "--strict", staged], { timeout: 30_000 });
|
||||
}
|
||||
await rename(staged, destination);
|
||||
} finally {
|
||||
// This fresh directory is the only cleanup target, never the destination.
|
||||
await rm(stagingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(destinationDirectory, { recursive: true });
|
||||
await copyFile(source, destination);
|
||||
if (process.platform !== "win32") await chmod(destination, 0o755);
|
||||
// Rust's linker emits an ad-hoc Mach-O signature. Copying that executable to
|
||||
// its package location preserves the bytes but can leave the kernel rejecting
|
||||
// the new inode with SIGKILL. Re-sign the staged inode so local packaged-runner
|
||||
// evals execute the same artifact that was just built.
|
||||
if (process.platform === "darwin") {
|
||||
await execFileAsync("codesign", ["--force", "--sign", "-", destination]);
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
||||
await stageRunnerBinary(
|
||||
path.join(packageRoot, "runner", "target", "release", executable),
|
||||
path.join(packageRoot, "dist", "bin", executable),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { stageRunnerBinary } from "./stage-runner-binary.mjs";
|
||||
|
||||
async function withOwnedChildren(root, action) {
|
||||
const owned = [];
|
||||
const start = (command, args) => {
|
||||
const child = spawn(command, args, { env: { PATH: "/usr/bin:/bin" }, stdio: "ignore" });
|
||||
// close follows both exit and spawn error. A failed second spawn must not
|
||||
// interrupt retirement of the first still-running executable.
|
||||
const closed = new Promise(resolve => child.once("close", resolve));
|
||||
const spawned = once(child, "spawn");
|
||||
void spawned.catch(() => {});
|
||||
owned.push({ child, closed });
|
||||
return { child, spawned, closed };
|
||||
};
|
||||
try { return await action(start); }
|
||||
finally {
|
||||
try {
|
||||
const results = await Promise.allSettled(owned.map(async ({ child, closed }) => {
|
||||
if (child.pid && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
await closed;
|
||||
}));
|
||||
const failures = results.filter(result => result.status === "rejected");
|
||||
if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Owned test child cleanup failed");
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
}
|
||||
}
|
||||
|
||||
test("atomically publishes while the previous executable is running", { skip: process.platform === "win32", timeout: 15_000 }, async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-test-"));
|
||||
const destination = path.join(root, "paperclip-runnerd");
|
||||
await withOwnedChildren(root, async start => {
|
||||
await stageRunnerBinary("/bin/sleep", destination);
|
||||
const previous = await stat(destination);
|
||||
const old = start(destination, ["30"]);
|
||||
await old.spawned;
|
||||
await stageRunnerBinary("/bin/sleep", destination);
|
||||
const published = await stat(destination);
|
||||
assert.notEqual(published.ino, previous.ino);
|
||||
assert.equal(published.mode & 0o777, 0o755);
|
||||
assert.equal(old.child.exitCode, null);
|
||||
assert.equal(old.child.signalCode, null);
|
||||
assert.deepEqual(await readdir(root), ["paperclip-runnerd"]);
|
||||
const next = start(destination, ["0"]);
|
||||
await next.spawned; await next.closed;
|
||||
assert.equal(next.child.exitCode, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("a second spawn error still retires the live old child and removes its fixture", { skip: process.platform === "win32", timeout: 15_000 }, async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-spawn-failure-"));
|
||||
let old;
|
||||
await assert.rejects(withOwnedChildren(root, async start => {
|
||||
old = start("/bin/sleep", ["30"]); await old.spawned;
|
||||
const failed = start(path.join(root, "missing-executable"), []);
|
||||
await failed.spawned;
|
||||
}), { code: "ENOENT" });
|
||||
assert.notEqual(old.child.signalCode, null);
|
||||
await assert.rejects(stat(root), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("failed preparation preserves the published bytes and mode", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-failure-"));
|
||||
const destination = path.join(root, "paperclip-runnerd");
|
||||
try {
|
||||
await writeFile(destination, "previous build", { mode: 0o755 });
|
||||
const before = await stat(destination);
|
||||
await assert.rejects(stageRunnerBinary(path.join(root, "absent-source"), destination), { code: "ENOENT" });
|
||||
assert.equal(await readFile(destination, "utf8"), "previous build");
|
||||
const after = await stat(destination);
|
||||
assert.equal(after.ino, before.ino);
|
||||
assert.equal(after.mode, before.mode);
|
||||
assert.deepEqual(await readdir(root), ["paperclip-runnerd"]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
|
||||
import type { NativeExecutionInput } from "../contracts/native-execution.js";
|
||||
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
|
||||
export interface CodexAcpxNativeSessionBackendOptions extends Omit<
|
||||
CodexAcpxDriverOptions,
|
||||
"model" | "permissionMode" | "mode" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
"model" | "permissionMode" | "mode" | "piThinkingLevel" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
> {}
|
||||
|
||||
export type AcpxNativeSessionBackendOptions =
|
||||
@@ -58,6 +59,7 @@ export function createAcpxNativeSessionBackend(
|
||||
throw new Error("ACPX candidate direct execution requires completed qualification; use the host-controlled runnerd evaluation path");
|
||||
}
|
||||
|
||||
resolvePiThinkingLevel(input.provider.agent, input.provider.piThinkingLevel);
|
||||
const constraints = nativeTaskConstraints(input);
|
||||
const systemInstructions = [
|
||||
nativeSystemInstructions(input),
|
||||
@@ -73,6 +75,7 @@ export function createAcpxNativeSessionBackend(
|
||||
model: input.provider.model,
|
||||
permissionMode: input.provider.permissionMode ?? "approve-reads",
|
||||
mode: input.provider.mode,
|
||||
piThinkingLevel: input.provider.piThinkingLevel,
|
||||
systemInstructions,
|
||||
runtimeContext: "runtimeContext" in input ? input.runtimeContext : null,
|
||||
providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" },
|
||||
|
||||
@@ -851,7 +851,7 @@ describe("HarnessDriverBackend", () => {
|
||||
expect(events.map((event) => event.eventType)).toEqual([
|
||||
"runtime_request.created",
|
||||
"runtime_request.expired",
|
||||
"turn.interrupted",
|
||||
"turn.failed",
|
||||
]);
|
||||
}
|
||||
await session.close({ reason: "fixture complete" });
|
||||
@@ -898,7 +898,7 @@ describe("HarnessDriverBackend", () => {
|
||||
await session.close({ reason: "fixture complete" });
|
||||
});
|
||||
|
||||
it("emits one non-replayable input expiration and terminal wait after provider loss", async () => {
|
||||
it("expires pending input without converting provider loss into a successful wait", async () => {
|
||||
const questionSet = {
|
||||
schema: "paperclip.question_set.v1" as const,
|
||||
questions: [{ id: "target", prompt: "Which target?", required: true, answerMode: "text" as const }],
|
||||
@@ -940,11 +940,14 @@ describe("HarnessDriverBackend", () => {
|
||||
},
|
||||
} });
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: {
|
||||
eventType: "turn.interrupted",
|
||||
eventType: "turn.failed",
|
||||
sourceSeq: 3,
|
||||
payload: { reason: "provider_process_lost" },
|
||||
} });
|
||||
await expect(iterator.next()).resolves.toMatchObject({ done: true });
|
||||
await expect(session.snapshot()).resolves.toMatchObject({ terminal: {
|
||||
turnTerminalState: "failed", runTerminalState: "failed",
|
||||
} });
|
||||
});
|
||||
|
||||
it("does not synthesize a fallback after the input was already resolved", async () => {
|
||||
|
||||
@@ -507,7 +507,7 @@ class HarnessNativeSession implements NativeSession {
|
||||
let sourceInstanceId: string | null = null;
|
||||
let lastSourceSequence = 0;
|
||||
let sawTerminal = false;
|
||||
let synthesizedDurableWait = false;
|
||||
let synthesizedProviderFailure = false;
|
||||
let streamFailure: unknown = null;
|
||||
const observedPendingInputs = new Map<string, Record<string, unknown>>();
|
||||
try {
|
||||
@@ -630,7 +630,7 @@ class HarnessNativeSession implements NativeSession {
|
||||
this.#rethrowProtocolIntegrity(error);
|
||||
return null;
|
||||
});
|
||||
let governedWaitTurnId: string | undefined;
|
||||
let providerLossTurnId: string | undefined;
|
||||
for (const request of observedPendingInputs.values()) {
|
||||
const sourceSeq =
|
||||
Math.max(lastSourceSequence, snapshot?.lastSourceSequence ?? 0) + 1;
|
||||
@@ -638,7 +638,7 @@ class HarnessNativeSession implements NativeSession {
|
||||
const requestId = String(request.requestId);
|
||||
const turnId =
|
||||
typeof request.turnId === "string" ? request.turnId : undefined;
|
||||
governedWaitTurnId ??= turnId;
|
||||
providerLossTurnId ??= turnId;
|
||||
const itemId =
|
||||
typeof request.itemId === "string" ? request.itemId : requestId;
|
||||
yield {
|
||||
@@ -670,16 +670,18 @@ class HarnessNativeSession implements NativeSession {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (governedWaitTurnId) {
|
||||
if (providerLossTurnId) {
|
||||
// Expiring input preserves its durable human fallback. The unexpected
|
||||
// transport loss remains a failed provider execution, never a yield.
|
||||
const sourceSeq =
|
||||
Math.max(lastSourceSequence, snapshot?.lastSourceSequence ?? 0) + 1;
|
||||
lastSourceSequence = sourceSeq;
|
||||
synthesizedDurableWait = true;
|
||||
synthesizedProviderFailure = true;
|
||||
sawTerminal = true;
|
||||
this.#terminal = {
|
||||
schema: "paperclip.prp.terminal.v1",
|
||||
turnTerminalState: "interrupted",
|
||||
runTerminalState: "cancelled",
|
||||
turnTerminalState: "failed",
|
||||
runTerminalState: "failed",
|
||||
reportedWorkDisposition: "yielded",
|
||||
};
|
||||
yield {
|
||||
@@ -690,16 +692,16 @@ class HarnessNativeSession implements NativeSession {
|
||||
sourceKind: "runner",
|
||||
runId: this.#input.identity.runId,
|
||||
normalizedSessionId: this.#input.identity.sessionId,
|
||||
turnId: governedWaitTurnId,
|
||||
eventType: "turn.interrupted",
|
||||
turnId: providerLossTurnId,
|
||||
eventType: "turn.failed",
|
||||
schemaVersion: 1,
|
||||
priority: 0,
|
||||
emittedAt: new Date().toISOString(),
|
||||
payload: { status: "interrupted", reason: "provider_process_lost" },
|
||||
payload: { status: "failed", reason: "provider_process_lost" },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (streamFailure && !synthesizedDurableWait) throw streamFailure;
|
||||
if (streamFailure && !synthesizedProviderFailure) throw streamFailure;
|
||||
}
|
||||
|
||||
async startTurn(input: Parameters<HarnessSession["startTurn"]>[0]) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { QUALIFIED_ACPX_PROFILES, resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -519,7 +519,15 @@ describe("native backend factory", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["pi", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => {
|
||||
it("keeps direct Pi execution held in the prerequisite layer", async () => {
|
||||
const input = acpxExecution();
|
||||
if (input.provider.kind !== "acpx") throw new Error("invalid fixture");
|
||||
const profile = resolveQualifiedAcpxProfile("pi", "custom/explicit-test-model");
|
||||
Object.assign(input.provider, { agent: "pi", model: profile.qualificationModel, piThinkingLevel: "low", profile });
|
||||
expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" })).toThrow(/candidate direct execution requires completed qualification/);
|
||||
});
|
||||
|
||||
it.each(["copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => {
|
||||
const input = acpxExecution();
|
||||
if (input.provider.kind !== "acpx") throw new Error("invalid fixture");
|
||||
const model = "explicit-fixture-model";
|
||||
@@ -530,13 +538,39 @@ describe("native backend factory", () => {
|
||||
})).toThrow("ACPX candidate direct execution requires completed qualification");
|
||||
});
|
||||
|
||||
it("constructs the qualified Cursor backend without candidate admission", async () => {
|
||||
it.each([
|
||||
["cursor", "../../test/fixtures/cursor-acp/profile-v7-identity.json"],
|
||||
["cursor", "../../test/fixtures/cursor-acp/profile-v10-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v14-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v13-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v14-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v18-identity.json"],
|
||||
["cursor", "../../test/fixtures/cursor-acp/profile-v9-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v7-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v9-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v10-identity.json"],
|
||||
["copilot", "../../test/fixtures/copilot-profile-v11-identity.json"],
|
||||
["pi", "../../test-fixtures/pi-acp/profile-v9-identity.json"],
|
||||
] as const)("rejects the exact historical %s identity before runtime startup", (agent, path) => {
|
||||
const historical = JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8"));
|
||||
const input = acpxExecution();
|
||||
if (input.provider.kind !== "acpx") throw new Error("invalid fixture");
|
||||
const model = "explicit-cursor-model";
|
||||
Object.assign(input.provider, { agent: "cursor", model, mode: "agent", profile: resolveQualifiedAcpxProfile("cursor", model) });
|
||||
const backend = createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime", acpxEnvironment: { CURSOR_API_KEY: "explicit-fixture" } });
|
||||
await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" });
|
||||
const current = resolveQualifiedAcpxProfile(agent, agent === "pi" ? "custom/explicit-test-model" : "explicit-fixture-model");
|
||||
Object.assign(input.provider, { agent, model: current.qualificationModel, profile: { ...current,
|
||||
agentProfileVersion: historical.declaration.agentProfileVersion, commandDigest: historical.commandDigest } });
|
||||
expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" }))
|
||||
.toThrow("does not match the qualified agentProfileVersion");
|
||||
input.provider.profile.agentProfileVersion = current.agentProfileVersion;
|
||||
expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" }))
|
||||
.toThrow("does not match the qualified commandDigest");
|
||||
});
|
||||
|
||||
it.each([1, 2] as const)("rejects a Pi version %s warm snapshot after the rich ACP upgrade", version => {
|
||||
const input = acpxExecution("pi");
|
||||
if (input.provider.kind !== "acpx") throw new Error("invalid fixture");
|
||||
input.provider.profile.agentProfileVersion = version;
|
||||
expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" }))
|
||||
.toThrow("does not match the qualified agentProfileVersion");
|
||||
});
|
||||
|
||||
it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { acpxProfileActivity } from "../drivers/acpx/profile-activity.js";
|
||||
import { appendSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "../drivers/semantic-tool-receipt.js";
|
||||
import { startRunnerToolBridge, type RunnerToolCall } from "../drivers/runner-tool-bridge.js";
|
||||
import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js";
|
||||
@@ -11,11 +12,52 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("live sidecar pending request snapshots", () => {
|
||||
it("attests current callback identities after the live status barrier without exposing forms", async () => {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf(' if (request.command === "session.snapshot") {');
|
||||
const end = source.indexOf(' if (request.command === "session.goal.get") {', start);
|
||||
expect(start).toBeGreaterThan(0);
|
||||
const inputs = new Map([['input-1', { turnId: 'turn-1', questionSet: { private: 'hidden form' } }]]);
|
||||
const permissions = new Map([['permission-1', { turnId: 'turn-1', normalized: { private: 'hidden permission' } }]]);
|
||||
const host = { identity: () => 'identity', binding: () => 'binding' };
|
||||
const readStatus = vi.fn(async () => {
|
||||
inputs.delete('input-1');
|
||||
inputs.set('input-2', { turnId: 'turn-1', questionSet: { private: 'new hidden form' } });
|
||||
return { live: true };
|
||||
});
|
||||
const run = new Function('requireHost', 'readSidecarHostStatusWithin', 'sanitizeRuntimeStatus', 'acpxProviderSessionIdentity', 'inputs', 'permissions', `
|
||||
const request={command:"session.snapshot"}, tools=new Map(), runId="run-1", turnId="turn-1", sequence=7;
|
||||
${stripTypeScriptTypes(`async function readSnapshot() { ${source.slice(start, end)} }`)}
|
||||
return readSnapshot;
|
||||
`)(() => host, readStatus, (value: unknown) => value, () => ({ kind: 'acpx' }), inputs, permissions);
|
||||
const snapshot = await run();
|
||||
expect(readStatus).toHaveBeenCalledExactlyOnceWith(host);
|
||||
expect(snapshot).toMatchObject({ runId: 'run-1', turnId: 'turn-1', pendingRuntimeRequests: [
|
||||
{ requestId: 'input-2', type: 'input', turnId: 'turn-1' },
|
||||
{ requestId: 'permission-1', type: 'permission', turnId: 'turn-1' },
|
||||
] });
|
||||
expect(JSON.stringify(snapshot)).not.toContain('hidden');
|
||||
});
|
||||
|
||||
it("rejects the snapshot when its provider status cannot be verified", async () => {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf(' if (request.command === "session.snapshot") {');
|
||||
const end = source.indexOf(' if (request.command === "session.goal.get") {', start);
|
||||
const run = new Function('requireHost', 'readSidecarHostStatusWithin', 'sanitizeRuntimeStatus', `
|
||||
const request={command:"session.snapshot"};
|
||||
${stripTypeScriptTypes(`async function readSnapshot() { ${source.slice(start, end)} }`)}
|
||||
return readSnapshot;
|
||||
`)(() => ({}), async () => { throw new Error('provider process lost'); }, (value: unknown) => value);
|
||||
await expect(run()).rejects.toThrow('provider process lost');
|
||||
});
|
||||
});
|
||||
|
||||
import { deliverAcpxResponse } from "../drivers/acpx/response-delivery.js";
|
||||
import { normalizeAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { ACPX_SIDECAR_PROTOCOL_VERSION, stringifyAcpxSidecarFrame } from "../drivers/acpx/sidecar-protocol.js";
|
||||
import { ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES, stringifyAcpxSidecarFrame, parseAcpxSidecarRequest, record, text } from "../drivers/acpx/sidecar-protocol.js";
|
||||
import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js";
|
||||
import { createPiMessageProjection } from "../drivers/acpx/pi-message-projection.js";
|
||||
import { createCopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js";
|
||||
@@ -53,10 +95,10 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
const end = source.indexOf(" const usage = persistedAcpxTurnUsage(", start);
|
||||
expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start);
|
||||
const emitted: unknown[] = [];
|
||||
const project = new Function("persistedCursorUsageNotice", "validateAcpxRichEvent", "emit", "usageBefore", "usageAfter", "agent", `
|
||||
const currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent};
|
||||
const project = new Function("acpxProfileActivity", "validateAcpxRichEvent", "emit", "usageBefore", "usageAfter", "agent", `
|
||||
const currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent}, activity=acpxProfileActivity(agent);
|
||||
${stripTypeScriptTypes(source.slice(start, end))}
|
||||
`).bind(null, persistedCursorUsageNotice, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args));
|
||||
`).bind(null, acpxProfileActivity, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args));
|
||||
const before = { promptMessageIds: [], requestTokenUsage: {} };
|
||||
const after = { lastRequestId: "request-1", promptMessageIds: ["prompt-1"], requestTokenUsage: {}, cursorPromptUsage: {
|
||||
request_id: "request-1", prompt_message_id: "prompt-1", receipt: {
|
||||
@@ -82,7 +124,7 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
const after = { lastRequestId: "request-1", requestTokenUsage: { "prompt-1": { input_tokens: 12, output_tokens: 3 } } };
|
||||
const project = new Function("readSidecarHostStatusWithin", "persistedCursorUsageNotice", "persistedAcpxTurnUsage", "acpxUsageEstimateNotice", "validateAcpxRichEvent", "emit", "diagnostic", `
|
||||
return (async () => {
|
||||
const activeHost={}, currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent:"cursor"};
|
||||
const activeHost={}, currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent:"cursor"}, activity={usageNotice:persistedCursorUsageNotice};
|
||||
const usageBefore={requestTokenUsage:{}}, sanitizeRuntimeEvent=value=>value, safeMessage=()=>"fixture error";
|
||||
${stripTypeScriptTypes(source.slice(start, end))}
|
||||
})();
|
||||
@@ -104,7 +146,7 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
it.each(["codex", "claude", "grok", "pi", "copilot", null])("preserves existing non-Cursor sidecar identity policy: %s", agent => {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf("function stableProviderIdentity(");
|
||||
const stable = new Function("createHash", "cursorToolIdentity", "openParams", `${stripTypeScriptTypes(source.slice(start, source.indexOf("\nfunction canonicalJson", start)))}; return stableProviderIdentity;`)(createHash, cursorToolIdentity, agent ? { agent } : null);
|
||||
const stable = new Function("createHash", "acpxProfileActivity", "openParams", "initializedAgent", `${stripTypeScriptTypes(source.slice(start, source.indexOf("\nfunction canonicalJson", start)))}; return stableProviderIdentity;`)(createHash, acpxProfileActivity, agent ? { agent } : null, null);
|
||||
for (const kind of ["tool", "message"]) {
|
||||
for (const raw of ["safe-tool", "tool/1", "native\u0080tool", "native\u0085tool", "native\u009ftool", "x".repeat(161)]) {
|
||||
expect(stable(raw, kind)).toBe(raw);
|
||||
@@ -118,7 +160,7 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
it.each(["tool-first", "permission-first"])("uses the same Cursor identity in actual sidecar tool and pending permission paths: %s", async order => {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const identityStart = source.indexOf("function stableProviderIdentity(");
|
||||
const stableIdentity = new Function("createHash", "cursorToolIdentity", "openParams", `${stripTypeScriptTypes(source.slice(identityStart, source.indexOf("\nfunction canonicalJson", identityStart)))}; return stableProviderIdentity;`)(createHash, cursorToolIdentity, { agent: "cursor" });
|
||||
const stableIdentity = new Function("createHash", "acpxProfileActivity", "openParams", "initializedAgent", `${stripTypeScriptTypes(source.slice(identityStart, source.indexOf("\nfunction canonicalJson", identityStart)))}; return stableProviderIdentity;`)(createHash, acpxProfileActivity, { agent: "cursor" }, "cursor");
|
||||
const boundStart = source.indexOf("function boundRuntimeEventForNormalization(");
|
||||
const bound = new Function("boundedOptionalText", "stableProviderIdentity", "safeAcpxLocations", "openParams", "safeOutput",
|
||||
`${stripTypeScriptTypes(source.slice(boundStart, source.indexOf("\nfunction sanitizeRuntimeEvent", boundStart)))}; return boundRuntimeEventForNormalization;`)(
|
||||
@@ -175,13 +217,13 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
const end = source.indexOf("\nfunction elicitationResponse(", start);
|
||||
const code = stripTypeScriptTypes(source.slice(start, end));
|
||||
const emitted: any[] = [], inputs = new Map();
|
||||
const invoke = new Function("cursorPlanToolIdentity", "requireAcpxResponseDelivery", "emit", "inputs", `
|
||||
const invoke = new Function("acpxProfileActivity", "requireAcpxResponseDelivery", "emit", "inputs", `
|
||||
const turnId="turn", openParams={agent:"cursor"}, initializedAgent="cursor", MAX_PENDING_INPUTS=16;
|
||||
let requestSequence=0;
|
||||
const stableRequestId=()=>"input-request";
|
||||
${code}
|
||||
return waitForExtensionInput;
|
||||
`)(cursorPlanToolIdentity, (context: any) => context.responseDelivery, (...args: any[]) => emitted.push(args), inputs);
|
||||
`)(acpxProfileActivity, (context: any) => context.responseDelivery, (...args: any[]) => emitted.push(args), inputs);
|
||||
const abort = new AbortController();
|
||||
const pending = invoke("turn", { method: "cursor/create_plan", details: { toolCallId: "tool with spaces" }, questionSet: { schema: "paperclip.question_set.v1", questions: [] }, cancel: () => ({ cancelled: true }) }, { requestId: 0, signal: abort.signal, responseDelivery: Promise.resolve() });
|
||||
expect(emitted).toEqual([["runtime.input_requested", expect.objectContaining({ toolCallId: "tool with spaces", origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "cursor/create_plan" } }), "turn"]]);
|
||||
@@ -190,18 +232,18 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
});
|
||||
it.each(["cursor", "copilot", "pi"])("binds native tool evidence to the active sidecar turn for %s", agent => {
|
||||
const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf(" const evidenceFactory =");
|
||||
const start = source.indexOf(" const activity = acpxProfileActivity(activeAgent);");
|
||||
const end = source.indexOf(" let usageBefore:", start);
|
||||
expect(start).toBeGreaterThan(0);
|
||||
const emitted: unknown[] = [];
|
||||
const create = new Function("createCopilotToolEvidence", "createCursorToolEvidence", "validateAcpxRichEvent", "emit", "agent", `
|
||||
const create = new Function("acpxProfileActivity", "validateAcpxRichEvent", "emit", "agent", `
|
||||
const activeHost = { identity: () => ({ backendSessionId: "session" }) };
|
||||
let host = activeHost, turnId = "turn", activeCopilotEvidence;
|
||||
const currentTurnId = "turn", openParams = { agent, workingDirectory: "/workspace" };
|
||||
const currentTurnId = "turn", activeAgent = agent, openParams = { agent, workingDirectory: "/workspace" };
|
||||
const diagnostic = () => {};
|
||||
${stripTypeScriptTypes(source.slice(start, end))}
|
||||
return { evidence: toolEvidence, retire: () => { turnId = null; } };
|
||||
`)(createCopilotToolEvidence, createCursorToolEvidence, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args), agent);
|
||||
`)(acpxProfileActivity, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args), agent);
|
||||
const tool = { type: "tool_call", tag: "tool_call", toolCallId: "tool", kind: "execute", status: "pending", rawInput: { command: "printf private-value" } };
|
||||
create.evidence?.tool(tool);
|
||||
expect(emitted).toHaveLength(agent === "pi" ? 0 : 1);
|
||||
@@ -432,8 +474,8 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
const wait = new Function("permissions", "openParams", "normalizeAcpxPermission", "emit",
|
||||
`let turnId = "turn-1", requestSequence = 0; const MAX_PENDING_INPUTS = 512;
|
||||
const stableRequestId = () => "request-1"; const requireAcpxResponseDelivery = c => c.responseDelivery;
|
||||
return async function(activeTurnId, request, context) { const agent = openParams.agent, toolEvidence = undefined; ${source.slice(start, end)}`)(
|
||||
permissions, { agent }, normalizeAcpxPermission, (_event: string, payload: { choices: Array<{ key: string }> }) => emitted.push(payload),
|
||||
return async function(activeTurnId, agent, request, context, toolEvidence) { ${source.slice(start, end)}`)(
|
||||
permissions, { agent }, normalizeAcpxPermission, (_event: string, payload: { choices: Array<{ key: string }>; origin: unknown }) => emitted.push(payload),
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const pending = wait("turn-1", agent, { sessionId: "session", inferredKind: "edit", raw: {
|
||||
@@ -1021,6 +1063,46 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["off", "low", "high", "max"])(
|
||||
"accepts Pi thinking level %s through the actual session.open dispatcher",
|
||||
async (piThinkingLevel) => {
|
||||
const sidecar = startSidecar();
|
||||
const model = "custom-provider/caller-selected-model";
|
||||
sidecar.write(initializeRequest(1, "pi", model));
|
||||
expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ ok: true });
|
||||
sidecar.write({
|
||||
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: 2, command: "session.open",
|
||||
params: { runtimeDirectory: "/unused", workingDirectory: "/unused", normalizedSessionId: "pi-open",
|
||||
agent: "pi", model, permissionMode: "deny-all", piThinkingLevel, systemInstructions: "Test instructions", tools: [] },
|
||||
});
|
||||
// The host's policy gate runs after parsing and before installation or
|
||||
// credentials. This proves the real process crosses the open boundary
|
||||
// without starting a provider or making a model request.
|
||||
expect(await sidecar.next(frame => frame.id === 2)).toMatchObject({
|
||||
ok: false, error: { message: "Pi admission requires an explicit task execution policy" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["pi", { piThinkingLevel: "medium" }, "Pi thinking level"],
|
||||
["codex", { piThinkingLevel: "low" }, "only supported by Pi"],
|
||||
["pi", { piThinkingLevel: "low", unknownOption: true }, "unsupported field"],
|
||||
])("rejects invalid %s session.open fields before launch", async (agent, fields, message) => {
|
||||
const sidecar = startSidecar();
|
||||
const model = "caller-selected-model";
|
||||
sidecar.write(initializeRequest(1, agent, model));
|
||||
expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ ok: true });
|
||||
sidecar.write({
|
||||
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: 2, command: "session.open",
|
||||
params: { runtimeDirectory: "/unused", workingDirectory: "/unused", normalizedSessionId: "invalid-open",
|
||||
agent, model, permissionMode: "deny-all", tools: [], ...fields },
|
||||
});
|
||||
expect(await sidecar.next(frame => frame.id === 2)).toMatchObject({
|
||||
ok: false, error: { message: expect.stringContaining(message) },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cursor", "explicit-cursor-model"],
|
||||
["copilot", "explicit-copilot-model"],
|
||||
@@ -1104,7 +1186,7 @@ class SidecarProcess {
|
||||
this.#child = spawn(
|
||||
fileURLToPath(new URL("../../node_modules/.bin/tsx", import.meta.url)),
|
||||
[fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url))],
|
||||
{ stdio: ["pipe", "pipe", "pipe"] },
|
||||
{ stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, LANG: "C.UTF-8" } },
|
||||
);
|
||||
let stdout = "";
|
||||
this.#child.stdout.setEncoding("utf8");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { parseProviderMode } from "../contracts/provider-mode.js";
|
||||
import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "../drivers/acpx/profile-activity.js";
|
||||
|
||||
import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createInterface } from "node:readline";
|
||||
import { deliverAcpxResponse, requireAcpxResponseDelivery } from "../drivers/acpx/response-delivery.js";
|
||||
@@ -294,6 +296,7 @@ async function dispatch(
|
||||
model: params.model,
|
||||
permissionMode: params.permissionMode,
|
||||
mode: params.mode,
|
||||
piThinkingLevel: params.piThinkingLevel,
|
||||
providerPolicy: params.providerPolicy,
|
||||
systemInstructions: params.systemInstructions,
|
||||
runtimeContext: params.runtimeContext,
|
||||
@@ -385,6 +388,7 @@ async function dispatch(
|
||||
emit: event => { validateAcpxRichEvent(event); emit("runtime.rich_event", { ...event }, currentTurnId); },
|
||||
unavailable: () => diagnostic(`${openParams!.agent}_evidence_unavailable`, "ACP tool evidence is incomplete; permission and terminal outcomes are unchanged."),
|
||||
});
|
||||
activeCopilotEvidence = toolEvidence && "captureSemanticReceipt" in toolEvidence ? toolEvidence as CopilotToolEvidence : undefined;
|
||||
let usageBefore: unknown;
|
||||
try {
|
||||
usageBefore = await readSidecarHostStatusWithin(activeHost);
|
||||
@@ -516,19 +520,22 @@ async function dispatch(
|
||||
}
|
||||
if (request.command === "session.snapshot") {
|
||||
const activeHost = requireHost();
|
||||
const status = sanitizeRuntimeStatus(await readSidecarHostStatusWithin(activeHost));
|
||||
return {
|
||||
identity: acpxProviderSessionIdentity(
|
||||
activeHost.identity(),
|
||||
activeHost.binding(),
|
||||
),
|
||||
status: sanitizeRuntimeStatus(
|
||||
await readSidecarHostStatusWithin(activeHost),
|
||||
),
|
||||
status,
|
||||
runId,
|
||||
turnId,
|
||||
sequence,
|
||||
pendingToolCount: tools.size,
|
||||
pendingInputCount: inputs.size,
|
||||
pendingRuntimeRequests: [
|
||||
...Array.from(inputs, ([requestId, pending]) => ({ requestId, type: "input", turnId: pending.turnId })),
|
||||
...Array.from(permissions, ([requestId, pending]) => ({ requestId, type: "permission", turnId: pending.turnId })),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (request.command === "session.goal.get") {
|
||||
@@ -728,6 +735,7 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
// This is the same roundtrip used by ordinary dynamic tools; emitting a
|
||||
// local semantic_result here would let an invalid review handoff appear
|
||||
// accepted before the server has checked it.
|
||||
const commitNormalizedInput = call.captureNormalizedInput?.(validation.result);
|
||||
const forwarded = emit(
|
||||
"runtime.tool_called",
|
||||
{
|
||||
@@ -752,6 +760,7 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
// A pipe write alone does not prove receiver admission. Only this
|
||||
// call's turn-bound tool.resolve success confirms runnerd accepted
|
||||
// the validated body; rejection, cancellation and timeout stay null.
|
||||
commitNormalizedInput?.();
|
||||
settle(result);
|
||||
},
|
||||
reject,
|
||||
@@ -1200,11 +1209,12 @@ function safeOutput(value: unknown): Record<string, unknown> {
|
||||
function parseOpenParams(
|
||||
value: Record<string, unknown>,
|
||||
): AcpxSidecarOpenParams {
|
||||
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
|
||||
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
|
||||
if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field");
|
||||
const agent = requireQualifiedAgent(value.agent);
|
||||
const model = requiredText(value.model, "model");
|
||||
if (value.cursorMode !== undefined && agent !== "cursor") throw new Error("cursorMode is supported only for Cursor");
|
||||
if (value.mode !== undefined && agent !== "cursor") throw new Error("mode is supported only for Cursor");
|
||||
const piThinkingLevel = resolvePiThinkingLevel(agent, value.piThinkingLevel);
|
||||
resolveQualifiedAcpxProfile(agent, model);
|
||||
if (
|
||||
value.providerSessionKey !== undefined &&
|
||||
@@ -1225,6 +1235,7 @@ function parseOpenParams(
|
||||
model,
|
||||
permissionMode: requiredPermissionMode(value.permissionMode),
|
||||
...(value.mode === undefined ? {} : { mode: parseProviderMode(value.mode) }),
|
||||
...(piThinkingLevel ? { piThinkingLevel } : {}),
|
||||
permissionModePinned: value.permissionModePinned === true,
|
||||
...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }),
|
||||
systemInstructions: boundedText(
|
||||
@@ -1295,6 +1306,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity {
|
||||
? {}
|
||||
: { permissionMode: requiredPermissionMode(input.permissionMode) }),
|
||||
...(input.mode === undefined ? {} : { mode: parseProviderMode(input.mode) }),
|
||||
...(input.piThinkingLevel === undefined ? {} : { piThinkingLevel: resolvePiThinkingLevel("pi", input.piThinkingLevel) }),
|
||||
providerLifetimeFenceCandidates: requiredFenceCandidates(
|
||||
input.providerLifetimeFenceCandidates,
|
||||
),
|
||||
@@ -1321,7 +1333,7 @@ function requiredFenceCandidates(
|
||||
|
||||
function requiredCursorMode(value: unknown): "agent" | "plan" | "ask" {
|
||||
if (value === "agent" || value === "plan" || value === "ask") return value;
|
||||
throw new Error("cursorMode must be agent, plan, or ask");
|
||||
throw new Error("mode must be agent, plan, or ask");
|
||||
}
|
||||
|
||||
function requiredPermissionMode(
|
||||
|
||||
@@ -35,7 +35,7 @@ function request(overrides: Record<string, unknown> = {}): unknown {
|
||||
maxAgentTurns: 1,
|
||||
maxEstimatedCostNanodollars: 100_000_000,
|
||||
},
|
||||
session: {},
|
||||
session: overrides.acpxAgent === "pi" ? { piThinkingLevel: "low" } : {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -68,13 +68,13 @@ function agentCoreProfile(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
describe("eval-session request contract", () => {
|
||||
it.each(["pi", "copilot"] as const)("admits %s only with the matching CLI diagnostic opt-in", (agent) => {
|
||||
const value = request({ provider: "acpx", acpxAgent: agent, model: "explicit-provider-model" });
|
||||
const value = request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "explicit-provider-model" });
|
||||
expect(() => parseEvalSessionRequest(value)).toThrow("--candidate-profile");
|
||||
expect(parseEvalSessionRequest(value, { candidateProfile: agent })).toMatchObject({ acpxAgent: agent, model: "explicit-provider-model" });
|
||||
expect(parseEvalSessionRequest(value, { candidateProfile: agent })).toMatchObject({ acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "explicit-provider-model" });
|
||||
expect(() => parseEvalSessionRequest(value, { candidateProfile: agent === "pi" ? "cursor" : "pi" })).toThrow("must match");
|
||||
expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, model: "" }), { candidateProfile: agent })).toThrow("request.model");
|
||||
expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "" }), { candidateProfile: agent })).toThrow("request.model");
|
||||
expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "codex", session: { acpxAgent: agent } }))).toThrow("session.acpxAgent must match");
|
||||
expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, candidateProfile: agent }))).toThrow("--candidate-profile");
|
||||
expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), candidateProfile: agent }))).toThrow("--candidate-profile");
|
||||
});
|
||||
|
||||
it("admits qualified Cursor without a diagnostic flag and preserves its explicit model", () => {
|
||||
@@ -106,7 +106,7 @@ describe("eval-session request contract", () => {
|
||||
const args = ["--request", requestPath, "--output", join(workspace, "output.json"), "--candidate-profile", agent];
|
||||
const serviceFactory = vi.fn(() => { throw new Error("provider must not start"); });
|
||||
try {
|
||||
await writeFile(requestPath, JSON.stringify(request({ provider: "acpx", acpxAgent: agent, model,
|
||||
await writeFile(requestPath, JSON.stringify(request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model,
|
||||
runnerd: { path: join(workspace, "missing-runnerd"), sha256: "a".repeat(64) },
|
||||
session: { workingDirectory: workspace },
|
||||
})));
|
||||
@@ -216,7 +216,7 @@ describe("eval-session request contract", () => {
|
||||
it("requires explicit Pi diagnosis and accepts both qualified remote provider profiles", () => {
|
||||
expect(() => parseEvalSessionRequest(request({
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
acpxAgent: "pi", piThinkingLevel: "low",
|
||||
}))).toThrow("--candidate-profile");
|
||||
expect(parseEvalSessionRequest(request({
|
||||
provider: "aws_agentcore",
|
||||
@@ -337,7 +337,7 @@ describe("eval-session usage", () => {
|
||||
|
||||
it.each(["pi", "cursor", "copilot"] as const)("keeps %s oracle results when the current turn explicitly has unavailable usage", (agent) => {
|
||||
const parsed = parseEvalSessionRequest(request({
|
||||
provider: "acpx", acpxAgent: agent, model: "exact-provider-model",
|
||||
provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "exact-provider-model",
|
||||
}), { candidateProfile: agent });
|
||||
const unavailable = {
|
||||
turnId: "turn-candidate", attemptId: "attempt-1", agent,
|
||||
@@ -442,7 +442,7 @@ describe("eval-session usage", () => {
|
||||
estimatedCostNanodollars: null, pricingVersion: null, ratesUsdPerMillionTokens: null,
|
||||
costCoverage: "unpriced",
|
||||
});
|
||||
const parsed = parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, model: "exact-provider-model[context=272k]" }), { candidateProfile: agent as "pi" | "cursor" | "copilot" });
|
||||
const parsed = parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "exact-provider-model[context=272k]" }), { candidateProfile: agent as "pi" | "cursor" | "copilot" });
|
||||
expect(() => boundedEvalSessionUsage(parsed, {
|
||||
turnId: "candidate-turn", status: "completed", assistantText: "Completed provider response", snapshot,
|
||||
})).toThrow("budget cost coverage is unavailable");
|
||||
@@ -474,7 +474,7 @@ describe("eval-session budget settlement", () => {
|
||||
providerModel: { id: model, provider: agent === "grok" ? "xai" : "github" }, status: "idle", activeTurnId: null,
|
||||
createdAt: "2026-09-28T19:00:00.000Z", updatedAt: "2026-09-28T19:00:01.000Z",
|
||||
authority: { companyId: "company-1", actorId: "actor-1", taskId: "task-1", runId: "run-1", sessionId: "session-1", scenarioId: "budget-test" },
|
||||
config: { provider: "acpx", acpxAgent: agent, driver: "acpx_runtime", seedState: state, workingDirectory: workspace, scenario: { id: "budget-test" }, capabilities: [], explicitClaims: [], turnTimeoutMs: 1000 },
|
||||
config: { provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), driver: "acpx_runtime", seedState: state, workingDirectory: workspace, scenario: { id: "budget-test" }, capabilities: [], explicitClaims: [], turnTimeoutMs: 1000 },
|
||||
mockState: state, process: null, networkEvidence: { realPaperclipRequests: 0, childPaperclipEnvironmentKeys: [] },
|
||||
transcript: [{ id: "assistant-1", role: "assistant", text: "Retained actual provider response", turnId: "turn-1", at: "2026-09-28T19:00:01.000Z" }],
|
||||
evidence: [], authorizationRecords: [], attempts: [], terminalTurns: [{ turnId: "turn-1", status: "completed" }],
|
||||
@@ -484,7 +484,7 @@ describe("eval-session budget settlement", () => {
|
||||
const sendMessage = vi.fn(async () => ({ turnId: "turn-1", status: "completed", assistantText: "Retained actual provider response", snapshot }));
|
||||
const completeAttempt = vi.fn(async () => undefined);
|
||||
const shutdown = vi.fn(async () => { snapshot.status = "closed"; });
|
||||
const input = request({ provider: "acpx", acpxAgent: agent, model,
|
||||
const input = request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model,
|
||||
runnerd: { path: binary, sha256: createHash("sha256").update("unused fake runner").digest("hex") },
|
||||
session: { workingDirectory: workspace }, limits: { turnTimeoutMs: 1000, maxAgentTurns: 2, maxEstimatedCostNanodollars: 100_000_000 },
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
|
||||
import type {
|
||||
CapabilityLiveSessionSnapshot,
|
||||
CreateCapabilityLiveSessionInput,
|
||||
@@ -69,6 +71,7 @@ export interface EvalSessionRequest {
|
||||
driver?: EvalSessionDriver;
|
||||
opencodeVersion?: string;
|
||||
acpxAgent?: QualifiedAcpxAgent;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
managedProfile?: EvalSessionManagedProfile;
|
||||
agentCoreProfile?: EvalSessionAgentCoreProfile;
|
||||
runnerd: { path: string; sha256: string };
|
||||
@@ -258,11 +261,12 @@ export function parseEvalSessionRequest(
|
||||
if (provider !== "acpx" && acpxAgent !== undefined) {
|
||||
throw new Error("eval-session acpxAgent requires provider acpx");
|
||||
}
|
||||
const piThinkingLevel = resolvePiThinkingLevel(provider === "acpx" ? String(acpxAgent) : "", input.piThinkingLevel);
|
||||
const candidate = acpxAgent === "pi" || acpxAgent === "cursor" || acpxAgent === "copilot";
|
||||
if (options.candidateProfile !== undefined && (provider !== "acpx" || acpxAgent !== options.candidateProfile || !candidate)) {
|
||||
throw new Error("--candidate-profile must match the request's registered candidate ACPX agent");
|
||||
}
|
||||
if (candidate && acpxAgent !== "cursor" && options.candidateProfile !== acpxAgent) {
|
||||
if (candidate && acpxAgent && ACPX_CAPABILITY_PROFILES[acpxAgent].qualification === "pending" && options.candidateProfile !== acpxAgent) {
|
||||
throw new Error("Candidate ACPX profiles require an explicit matching --candidate-profile diagnostic flag");
|
||||
}
|
||||
const managedProfileInput = input.managedProfile === null
|
||||
@@ -327,6 +331,7 @@ export function parseEvalSessionRequest(
|
||||
throw new Error("request.session.acpxAgent must match request.acpxAgent");
|
||||
}
|
||||
|
||||
if (session.piThinkingLevel !== piThinkingLevel) throw new Error("request.session.piThinkingLevel must match request.piThinkingLevel");
|
||||
return {
|
||||
schema: EVAL_SESSION_REQUEST_SCHEMA,
|
||||
attemptId: text(input.attemptId, "request.attemptId"),
|
||||
@@ -338,6 +343,7 @@ export function parseEvalSessionRequest(
|
||||
? { opencodeVersion: text(input.opencodeVersion, "request.opencodeVersion") }
|
||||
: {}),
|
||||
...(acpxAgent === undefined ? {} : { acpxAgent }),
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
...(managedProfile === undefined ? {} : { managedProfile }),
|
||||
...(agentCoreProfile === undefined ? {} : { agentCoreProfile }),
|
||||
runnerd: {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type EvalSessionUsage,
|
||||
} from "./eval-session-contract.js";
|
||||
import { evalProviderTransportOptions } from "./eval-provider-runtime.js";
|
||||
import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js";
|
||||
|
||||
interface EvalSessionCliOptions {
|
||||
requestPath: string;
|
||||
@@ -103,6 +104,7 @@ const EVAL_RUNTIME_INSTRUCTIONS = [
|
||||
"Use the provided Paperclip semantic tools to inspect and act on the assigned task.",
|
||||
"Treat the seeded control-plane state as authoritative and keep every action within the requested scope.",
|
||||
"The current user request defines the work for this turn. Seeded task descriptions, notes, and past interaction results are background context; they do not supersede that request or establish that a newly requested action has already been performed.",
|
||||
"For a bounded request, read only the context needed for that request, perform the requested action, and end the turn. A request to record a brief progress update does not require investigating unrelated history or documents.",
|
||||
"Task-state changes in this mock control plane use finish_task and block_task. Native paperclip_finish and paperclip_block report the provider run result but do not update the mock task. When asked to finish or block the assigned task, use its task-state semantic operation before reporting the run result.",
|
||||
"Do not finish or block the mock task unless the current request asks for that state change. Ending the provider turn after another requested action does not authorize additional task-state changes or completion comments.",
|
||||
"",
|
||||
@@ -224,8 +226,28 @@ function failureClass(error: unknown): {
|
||||
class: string;
|
||||
category: string;
|
||||
retryable: boolean;
|
||||
diagnostics: Record<string, never>;
|
||||
diagnostics: Record<string, unknown>;
|
||||
} {
|
||||
if (error instanceof NativeSessionCloseUnrecoverableError) {
|
||||
const settlement = error.settlement ?? {};
|
||||
const state = settlement.suspensionState !== null && typeof settlement.suspensionState === "object"
|
||||
? settlement.suspensionState as Record<string, unknown> : {};
|
||||
const closedValue = (value: unknown, allowed: string[]) =>
|
||||
typeof value === "string" && allowed.includes(value) ? value : null;
|
||||
const observedBoolean = (value: unknown) => typeof value === "boolean" ? value : null;
|
||||
return {
|
||||
class: "runner_infrastructure_failure",
|
||||
category: "runner_infrastructure",
|
||||
retryable: false,
|
||||
diagnostics: {
|
||||
runnerSuspended: observedBoolean(settlement.runnerSuspended),
|
||||
providerDrained: observedBoolean(settlement.providerDrained),
|
||||
suspensionCommandStatus: closedValue(state.commandStatus, ["pending", "completed", "failed", "rejected", "indeterminate"]),
|
||||
runnerLifecycle: closedValue(state.runnerLifecycle, ["ready", "suspended", "closed", "recoverable_failure"]),
|
||||
runnerIdentityMatches: observedBoolean(state.runnerIdentityMatches),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (error instanceof EvalSessionBudgetError && error.coverageUnknown) {
|
||||
return { class: "provider_budget_coverage_unknown", category: "provider_budget", retryable: false, diagnostics: {} };
|
||||
}
|
||||
@@ -407,7 +429,7 @@ export async function runEvalSessionCli(
|
||||
provider: requestedProvider,
|
||||
requestedModel: request.model,
|
||||
...(requestedProvider === "acpx"
|
||||
? { acpxAgent: request.acpxAgent ?? "codex" }
|
||||
? { acpxAgent: request.acpxAgent ?? "codex", ...(request.piThinkingLevel === undefined ? {} : { piThinkingLevel: request.piThinkingLevel }) }
|
||||
: { acpxAgent: undefined }),
|
||||
...(request.managedProfile === undefined
|
||||
? {}
|
||||
@@ -450,6 +472,7 @@ export async function runEvalSessionCli(
|
||||
driver: requestedDriver,
|
||||
providerVersion: requestedProviderVersion,
|
||||
providerSessionId: snapshot.providerSessionId,
|
||||
...(request.acpxAgent === "pi" ? { piThinkingLevel: snapshot.process?.piThinkingLevel ?? null } : {}),
|
||||
...(requestedProvider === "claude_managed"
|
||||
? {
|
||||
managedProfile: request.managedProfile,
|
||||
|
||||
@@ -456,6 +456,7 @@ export interface AcpxSessionIdentity {
|
||||
permissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
|
||||
/** Effective provider mode identifier, bound to the session identity. */
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
providerLifetimeFenceCandidates: readonly [number, number, number];
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,22 @@ describe("NativeExecutionInputV1", () => {
|
||||
expect(composeNativeSystemInstructions(parsed.runtimeContext, "Follow sibling.md")).toBe(
|
||||
`${PAPERCLIP_EXECUTION_PROMPT}\n\nFollow sibling.md\n\nRead-only instruction sibling root: /runtime/instructions`,
|
||||
);
|
||||
const agentFilesContext = {
|
||||
...parsed.runtimeContext,
|
||||
instructions: {
|
||||
...parsed.runtimeContext.instructions,
|
||||
workingCopy: { kind: "agent_files" as const, rootPath: "/runtime/current-agent-copy", entryPath: "AGENTS.md" },
|
||||
},
|
||||
};
|
||||
const agentFilesInstructions = composeNativeSystemInstructions(agentFilesContext, "Preserve my personal notes.");
|
||||
expect(agentFilesInstructions).toContain("AGENT_HOME) is /runtime/current-agent-copy.");
|
||||
expect(agentFilesInstructions).toContain("its absolute path may change between turns");
|
||||
expect(agentFilesInstructions).toContain("use the current $AGENT_HOME environment variable instead of an absolute agent-directory path from an earlier turn");
|
||||
expect(agentFilesInstructions).toContain("All supported files and subfolders there are restored across tasks and sessions");
|
||||
expect(agentFilesInstructions).toContain("Write task deliverables in the task working directory");
|
||||
expect(agentFilesInstructions).toContain("Preserve my personal notes.");
|
||||
expect(agentFilesInstructions).toMatch(/Read-only instruction sibling root: \/runtime\/instructions$/);
|
||||
expect(composeNativeSystemInstructions(parsed.runtimeContext, "Follow sibling.md")).not.toContain("$AGENT_HOME");
|
||||
expect(canonicalNativeRuntimeContextDigest({
|
||||
...context,
|
||||
mcp: { ...context.mcp, bindingId: "native-mcp:run-2" },
|
||||
@@ -325,6 +341,20 @@ describe("NativeExecutionInputV1", () => {
|
||||
})).toThrow("eventExpiryDays");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[17, "sha256:a1d976c437cb736c9cce8ebe8b8baf59e571761a8d00e8b1885e72dd906d8f21"],
|
||||
[18, "sha256:9d3e7d8269f1a0af94616552dfc69671932688b81dbbd5bab9ef356b3a9cbccb"],
|
||||
] as const)("decodes a persisted Pi profile-%s run without rewriting its identity", (agentProfileVersion, commandDigest) => {
|
||||
const { permissionPolicy: _permissionPolicy, ...nativeProfile } = QUALIFIED_ACPX_PROFILES.pi;
|
||||
const profile = { ...nativeProfile, agentProfileVersion, commandDigest };
|
||||
const persisted = { ...input, session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
provider: { kind: "acpx", agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
piThinkingLevel: "low", permissionPolicy: "interactive", profile } };
|
||||
const parsed = parseNativeExecutionInput(persisted);
|
||||
expect(parsed.provider).toEqual(persisted.provider);
|
||||
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
|
||||
const provider = {
|
||||
kind: "acpx",
|
||||
@@ -357,11 +387,11 @@ describe("NativeExecutionInputV1", () => {
|
||||
profile: provider.profile,
|
||||
});
|
||||
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
||||
for (const unsupportedVersion of [0, 13, 1.5, "12", null]) {
|
||||
for (const unsupportedVersion of [0, Math.max(16, QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion) + 1, 1.5, "14", null]) {
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
provider: { ...provider, profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } },
|
||||
provider: { ...provider, piThinkingLevel: "low", profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } },
|
||||
})).toThrow("qualified ACPX v1 profile");
|
||||
}
|
||||
expect(buildNativeModelEnvelope(parsed).workspace).toEqual({ cwd: "/safe/workspace" });
|
||||
@@ -386,6 +416,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
kind: "acpx", agent: declaration.agent,
|
||||
model: qualificationModel || "explicit-provider-model",
|
||||
permissionPolicy, profile,
|
||||
...(declaration.agent === "pi" ? { piThinkingLevel: "low" } : {}),
|
||||
};
|
||||
const parsed = parseNativeExecutionInput({
|
||||
...input,
|
||||
@@ -533,55 +564,6 @@ describe("native task context ownership", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["agent", "plan", "ask"])("round-trips Cursor mode %s through the closed execution contract", cursorMode => {
|
||||
const { qualificationModel: _model, reportedModelId: _reported, permissionPolicy: _permission,
|
||||
modelPolicy: _policy, qualificationStatus: _status, ...profile } = QUALIFIED_ACPX_PROFILES.cursor;
|
||||
const value = { ...currentInput(), session: { ...currentInput().session, driverKind: "acpx_runtime" },
|
||||
provider: { kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "deny-all", cursorMode, profile } };
|
||||
const result = parseNativeExecutionInput(value);
|
||||
expect(result.provider).toEqual(value.provider);
|
||||
expect(parseNativeExecutionInput(result)).toEqual(result);
|
||||
for (const mode of [null, "", "PLAN", "auto", true, { toString: () => "plan" }]) {
|
||||
expect(() => parseNativeExecutionInput({ ...value, provider: { ...value.provider, cursorMode: mode } })).toThrow("cursorMode");
|
||||
}
|
||||
expect(() => parseNativeExecutionInput({ ...value, provider: { ...value.provider, agent: "copilot" } })).toThrow("cursorMode");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ driverKind: "opencode_server", provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "deny" } },
|
||||
{ driverKind: "acpx_runtime", provider: {
|
||||
kind: "acpx", agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "deny-all",
|
||||
profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "pi", agentProfileVersion: 1,
|
||||
agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
agentRuntimeVersion: "0.84.2", commandDigest: `sha256:${"a".repeat(64)}` },
|
||||
} },
|
||||
{ driverKind: "openai_dot_mcp", provider: {
|
||||
kind: "openai_dot", model: null,
|
||||
binding: { bindingId: "dot-binding", bindingGeneration: 1, companyId: input.binding.companyId, agentId: input.binding.agentId,
|
||||
acceptByUnixMs: 1_000, expiresAtUnixMs: 2_000 },
|
||||
} },
|
||||
])("preserves an unregistered saved prompt for $provider.kind", ({ driverKind, provider }) => {
|
||||
const current = currentInput();
|
||||
if (!("runtimeContext" in current)) throw new Error("Expected a runtime context");
|
||||
const text = "Saved instructions absent from the current release.";
|
||||
const context = { ...current.runtimeContext,
|
||||
prompt: { revision: "saved-prompt-before-upgrade", text, digest: createHash("sha256").update(text).digest("hex") } };
|
||||
context.aggregateDigest = canonicalNativeRuntimeContextDigest(context);
|
||||
const persisted = JSON.parse(JSON.stringify({
|
||||
...current, provider, session: { ...current.session, driverKind }, runtimeContext: context,
|
||||
...(provider.kind === "openai_dot" ? {
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA_V6,
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
credentialBindings: [],
|
||||
} : {}),
|
||||
}));
|
||||
const recovered = parseNativeExecutionInput(persisted);
|
||||
expect(recovered.runtimeContext).toEqual(context);
|
||||
expect(recovered.provider).toEqual(provider);
|
||||
expect(recovered.session.driverKind).toBe(driverKind);
|
||||
expect(parseNativeExecutionInput(recovered)).toEqual(recovered);
|
||||
});
|
||||
|
||||
it("carries an opaque provider mode without a vendor restriction and fences obsolete field names", () => {
|
||||
const current = currentInput();
|
||||
const provider = {
|
||||
|
||||
@@ -130,6 +130,7 @@ export type NativeProviderConfig =
|
||||
model: string;
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
/** Present only in persisted v1-v3 inputs. */
|
||||
permissionPolicy?: "interactive";
|
||||
profile: NativeAcpxProfileSnapshot;
|
||||
@@ -145,6 +146,7 @@ export type NativeProviderConfigV4 =
|
||||
model: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
profile: NativeAcpxProfileSnapshot;
|
||||
};
|
||||
|
||||
@@ -502,7 +504,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
: provider.kind === "aws_agentcore"
|
||||
? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"]
|
||||
: provider.kind === "acpx"
|
||||
? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", ...(isV4 ? ["mode"] : [])]
|
||||
? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : [])]
|
||||
: provider.kind === "codex" && isV4
|
||||
? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])]
|
||||
: provider.kind === "opencode" && isV4
|
||||
@@ -622,6 +624,9 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
invocationLimits: { maxIterations, maxOutputTokens, timeoutSeconds },
|
||||
};
|
||||
} else if (provider.kind === "acpx") {
|
||||
if (provider.piThinkingLevel !== undefined && (provider.agent !== "pi" || (typeof provider.piThinkingLevel !== "string" || !["off", "low", "high", "max"].includes(provider.piThinkingLevel)))) {
|
||||
throw new NativeExecutionInputError("input.provider.piThinkingLevel must be off, low, high, or max and is supported only for Pi");
|
||||
}
|
||||
if (provider.mode !== undefined && !isProviderMode(provider.mode)) {
|
||||
throw new NativeExecutionInputError("input.provider.mode must be a bounded nonempty provider mode identifier");
|
||||
}
|
||||
@@ -660,6 +665,9 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
|
||||
}
|
||||
if (provider.agent === "pi" && profile.agentProfileVersion >= 13 && provider.piThinkingLevel === undefined) {
|
||||
throw new NativeExecutionInputError("input.provider.piThinkingLevel is required for Pi profile 13 or later");
|
||||
}
|
||||
const runtimePackage = nullableText(profile.agentRuntimePackage, "input.provider.profile.agentRuntimePackage");
|
||||
const runtimeVersion = nullableText(profile.agentRuntimeVersion, "input.provider.profile.agentRuntimeVersion");
|
||||
if ((runtimePackage === null) !== (runtimeVersion === null)) {
|
||||
@@ -673,6 +681,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode }
|
||||
: { permissionPolicy: "interactive" as const }),
|
||||
...(provider.mode === undefined ? {} : { mode: provider.mode as string }),
|
||||
...(provider.piThinkingLevel === undefined ? {} : { piThinkingLevel: provider.piThinkingLevel as "off" | "low" | "high" | "max" }),
|
||||
profile: {
|
||||
driverKind: "acpx_runtime",
|
||||
protocolVersion: 1,
|
||||
|
||||
@@ -173,7 +173,7 @@ export function composeNativeSystemInstructions(context: NativeRuntimeContextSna
|
||||
entryContent.trim(),
|
||||
context.connectionInstructions?.text,
|
||||
context.instructions.workingCopy?.kind === "agent_files"
|
||||
? `Your persistent agent directory (AGENT_HOME) is ${context.instructions.workingCopy.rootPath}. Your instruction entry is ${context.instructions.workingCopy.entryPath}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.`
|
||||
? `Your persistent agent directory (AGENT_HOME) is ${context.instructions.workingCopy.rootPath}. This is the current turn's copy; its absolute path may change between turns. In shell commands, use the current $AGENT_HOME environment variable instead of an absolute agent-directory path from an earlier turn. Your instruction entry is ${context.instructions.workingCopy.entryPath}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.`
|
||||
: context.instructions.workingCopy
|
||||
? `Your editable agent instruction file is ${context.instructions.workingCopy.rootPath}/${context.instructions.workingCopy.entryPath}. Edit this registered private copy normally. After this run stops, Paperclip saves changed content as a persistent revision if your responsible user still has permission and the baseline has not changed. Check the run's instruction-save receipt before claiming persistence. Conflicts are preserved for explicit resolution. Repository instruction files, skills, and this run's loaded prompt are separate and are not collected.`
|
||||
: null,
|
||||
|
||||
@@ -550,6 +550,8 @@ it("preserves the controller-selected ACPX provider package root", () => {
|
||||
|
||||
it.each([
|
||||
["pi", "OPENROUTER_API_KEY"],
|
||||
["pi", "AWS_ACCESS_KEY_ID"],
|
||||
["pi", "CUSTOM_PI_API_KEY"],
|
||||
["cursor", "CURSOR_API_KEY"],
|
||||
["cursor", "CURSOR_AUTH_TOKEN"],
|
||||
["copilot", "COPILOT_GITHUB_TOKEN"],
|
||||
@@ -557,13 +559,14 @@ it.each([
|
||||
const launches: RunnerProcessLaunchSpec[] = [];
|
||||
vi.stubEnv(key, "ambient-must-not-cross");
|
||||
vi.stubEnv(ACPX_CREDENTIAL_BINDING_ENV, "ambient-forged-marker");
|
||||
const custom = key === "CUSTOM_PI_API_KEY" ? { PAPERCLIP_PI_PROVIDERS: JSON.stringify({ private: { baseUrl: "https://provider.invalid", apiKey: key } }) } : {};
|
||||
try {
|
||||
for (const explicit of [true, false]) {
|
||||
const environment = createCapabilityRunnerdProviderEnvironment({
|
||||
provider: "acpx", identity, codexHome: "/fixture/home",
|
||||
runtimeContextPath: "/fixture/context.json", hasRuntimeContext: false,
|
||||
options: { acpxAgent: agent,
|
||||
environment: explicit ? { [key]: "explicit-fixture-credential", [ACPX_CREDENTIAL_BINDING_ENV]: "caller-forged-marker", DATABASE_URL: "must-not-cross" } : undefined },
|
||||
environment: explicit ? { ...custom, [key]: "explicit-fixture-credential", [ACPX_CREDENTIAL_BINDING_ENV]: "caller-forged-marker", DATABASE_URL: "must-not-cross" } : undefined },
|
||||
});
|
||||
const handle = spawnRunner({
|
||||
connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" },
|
||||
@@ -582,7 +585,7 @@ it.each([
|
||||
const receipt = launch.environment[ACPX_CREDENTIAL_BINDING_ENV];
|
||||
expect(receipt).toBeDefined();
|
||||
expect(JSON.parse(receipt!)).toEqual({ schema: "paperclip.acpx_credential_binding.v1", agent,
|
||||
sessionId: identity.normalizedSessionId, names: explicit ? [key] : [] });
|
||||
sessionId: identity.normalizedSessionId, names: explicit ? [...(key === "CUSTOM_PI_API_KEY" ? ["PAPERCLIP_PI_PROVIDERS"] : []), key] : [] });
|
||||
expect(receipt).not.toContain("fixture-credential");
|
||||
expect(launch.environment.DATABASE_URL).toBeUndefined();
|
||||
const provider = createAcpxSidecarHostEnvironment(launch.environment, agent, identity.normalizedSessionId);
|
||||
|
||||
@@ -30,7 +30,8 @@ import type { Duplex } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js";
|
||||
import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS } from "../drivers/acpx/environment.js";
|
||||
import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js";
|
||||
import { piCredentialNames } from "../drivers/acpx/pi-provider-config.js";
|
||||
import { githubCredentialEnvironment } from "../github-credential-environment.js";
|
||||
import {
|
||||
validatePrpEvent,
|
||||
@@ -3392,7 +3393,7 @@ const runnerExplicitProviderEnvironmentKeys = [
|
||||
"PAPERCLIP_AGENT_KEY_ID",
|
||||
"PAPERCLIP_AGENT_PUBLIC_KEY",
|
||||
"PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
...ACPX_CREDENTIAL_NAMES.pi,
|
||||
...ACPX_CREDENTIAL_NAMES.pi.filter(name => !name.startsWith("AWS_")),
|
||||
...ACPX_CREDENTIAL_NAMES.cursor,
|
||||
...ACPX_CREDENTIAL_NAMES.copilot,
|
||||
ACPX_CREDENTIAL_BINDING_ENV,
|
||||
@@ -3431,6 +3432,7 @@ const runnerExplicitProviderEnvironmentKeys = [
|
||||
|
||||
function runnerEnvironment(
|
||||
ticket: string,
|
||||
normalizedSessionId: string,
|
||||
explicitSource?: NodeJS.ProcessEnv,
|
||||
): NodeJS.ProcessEnv {
|
||||
const platformSource = explicitSource ?? process.env;
|
||||
@@ -3454,7 +3456,21 @@ function runnerEnvironment(
|
||||
if (explicitSource[key] !== undefined) environment[key] = explicitSource[key];
|
||||
}
|
||||
}
|
||||
Object.assign(environment, githubCredentialEnvironment(explicitSource), configuredEnvironment(explicitSource));
|
||||
// Pi custom-provider references and direct cloud credentials cross only
|
||||
// under the controller's exact run/session credential binding.
|
||||
const marker = explicitSource[ACPX_CREDENTIAL_BINDING_ENV];
|
||||
let binding: unknown;
|
||||
if (marker !== undefined && Buffer.byteLength(marker) <= 4_096) {
|
||||
try { binding = JSON.parse(marker); } catch { /* Sidecar admission rejects invalid markers. */ }
|
||||
}
|
||||
if (binding !== null && typeof binding === "object" && !Array.isArray(binding)
|
||||
&& (binding as Record<string, unknown>).agent === "pi") {
|
||||
const bound = createAcpxSidecarHostEnvironment(explicitSource, "pi", normalizedSessionId);
|
||||
for (const key of piCredentialNames(explicitSource)) {
|
||||
if (bound[key] !== undefined) environment[key] = bound[key];
|
||||
}
|
||||
}
|
||||
Object.assign(environment, githubCredentialEnvironment(explicitSource));
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
@@ -3602,7 +3618,7 @@ export function spawnRunner(options: {
|
||||
}
|
||||
|
||||
const command = options.runnerBinaryPath ?? runnerBinary;
|
||||
const environment = runnerEnvironment(options.ticket, options.environment);
|
||||
const environment = runnerEnvironment(options.ticket, options.identity.normalizedSessionId, options.environment);
|
||||
const withRestart = (handle: RunnerProcessHandle): RunnerProcessHandle => ({
|
||||
...handle,
|
||||
restart: (ticket) => spawnRunner({ ...options, ticket }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AcpPermissionDecision, AcpPermissionRequest } from "acpx/runtime";
|
||||
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
|
||||
import { cursorToolIdentity } from "./cursor-plan-tool-identity.js";
|
||||
|
||||
import { safeCopilotEditTarget } from "./copilot-permission-context.js";
|
||||
|
||||
export type AcpxPermissionAction = "accept" | "accept_for_session" | "decline" | "cancel";
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("Codex ACPX harness driver", () => {
|
||||
});
|
||||
|
||||
it.each(["tool-1", "tool with spaces", "🧭/plan", "x".repeat(300)])("binds a native Cursor plan request to its actual projected tool identity: %s", async toolCallId => {
|
||||
const fixture = driverFixture({ agent: "cursor", model: "explicit-test-model", cursorMode: "plan", providerPolicy: { readOnly: false } }, {
|
||||
const fixture = driverFixture({ agent: "cursor", model: "explicit-test-model", mode: "plan", providerPolicy: { readOnly: false } }, {
|
||||
runtimeEvents: [{ type: "tool_call", tag: "tool_call", toolCallId, title: "arbitrary tool display", kind: "execute", status: "pending" }],
|
||||
});
|
||||
const session = await fixture.driver.openSession({ runId: "run-plan-identity", normalizedSessionId: "session-1", workingDirectory: "/workspace" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type CopilotToolEvidence } from "./copilot-tool-evidence.js";
|
||||
import { isProviderMode } from "../../contracts/provider-mode.js";
|
||||
import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "./profile-activity.js";
|
||||
import type { CopilotToolEvidence } from "./copilot-tool-evidence.js";
|
||||
|
||||
import { requireAcpxResponseDelivery } from "./response-delivery.js";
|
||||
import { createPiMessageProjection, piBoundaryClearsFinal, type PiProjectedMessageEvent } from "./pi-message-projection.js";
|
||||
import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "./profile-extensions.js";
|
||||
@@ -146,6 +147,7 @@ export interface CodexAcpxDriverOptions {
|
||||
model: string;
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
providerPolicy?: { readOnly: boolean };
|
||||
runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"];
|
||||
systemInstructions?: string;
|
||||
@@ -460,6 +462,7 @@ export class CodexAcpxDriver implements HarnessDriver {
|
||||
model: this.#options.model,
|
||||
permissionMode: this.#options.permissionMode ?? "approve-all",
|
||||
mode: this.#options.mode,
|
||||
piThinkingLevel: this.#options.piThinkingLevel,
|
||||
providerPolicy: this.#options.providerPolicy,
|
||||
runtimeContext: this.#options.runtimeContext,
|
||||
systemInstructions: this.#options.systemInstructions,
|
||||
@@ -914,6 +917,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("ACP tool activity could not be retained");
|
||||
},
|
||||
});
|
||||
this.#copilotToolEvidence = toolEvidence && "captureSemanticReceipt" in toolEvidence ? toolEvidence as CopilotToolEvidence : undefined;
|
||||
let turn: AcpxRuntimeTurn;
|
||||
const usageBefore = await readUsageStatus(this.#host);
|
||||
try {
|
||||
@@ -1285,6 +1289,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
effectiveModel: identity.effectiveModel,
|
||||
permissionMode: identity.permissionMode,
|
||||
...(identity.mode === undefined ? {} : { mode: identity.mode }),
|
||||
...(identity.piThinkingLevel === undefined ? {} : { piThinkingLevel: identity.piThinkingLevel }),
|
||||
providerLifetimeFenceCandidates:
|
||||
identity.providerLifetimeFenceCandidates,
|
||||
},
|
||||
@@ -2148,6 +2153,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void {
|
||||
identity.permissionMode,
|
||||
)) ||
|
||||
(identity.mode !== undefined && !isProviderMode(identity.mode)) ||
|
||||
(identity.piThinkingLevel !== undefined && !["off", "low", "high", "max"].includes(identity.piThinkingLevel)) ||
|
||||
!validProviderLifetimeFenceCandidates(
|
||||
identity.providerLifetimeFenceCandidates,
|
||||
)
|
||||
|
||||
@@ -232,6 +232,14 @@ describe("Codex ACPX runtime adapter", () => {
|
||||
let created!: AcpRuntimeOptions & { onAgentInitialize?: (result: unknown) => void };
|
||||
const options = openOptions(fakeCommand());
|
||||
options.profile = { ...options.profile, agent: "pi" };
|
||||
options.piThinkingLevel = "low";
|
||||
vi.mocked(runtime.setConfigOption).mockImplementation(async input => {
|
||||
const guard = created.protocolGuardFactory!();
|
||||
guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } });
|
||||
guard("inbound", { id: 0, result: { modes: { currentModeId: "high" }, configOptions: [{ id: "thought_level", currentValue: "high" }] } });
|
||||
guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } });
|
||||
guard("inbound", { id: 1, result: { configOptions: [{ id: "thought_level", currentValue: "low" }] } });
|
||||
});
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(),
|
||||
createRuntime: (value) => { created = value; return runtime; },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import {
|
||||
@@ -30,9 +31,9 @@ import {
|
||||
import type { AcpxModelStatus } from "./model-verification.js";
|
||||
import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js";
|
||||
import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js";
|
||||
import { admitCursorInstructions, createCursorInstructionAdmission } from "./cursor-instructions.js";
|
||||
import { createAcpxModeBinding } from "./provider-mode.js";
|
||||
import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js";
|
||||
|
||||
const VERIFIED_COMMAND_SENTINEL = "paperclip-verified-acpx-command";
|
||||
const DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS = 2_000;
|
||||
@@ -302,6 +303,8 @@ export async function openQualifiedAcpxRuntime(
|
||||
};
|
||||
const commandLaunches = { count: 0, refreshConsumedCommand: options.refreshConsumedCommand };
|
||||
const modeBinding = createAcpxModeBinding(options.profile.agent, options.mode);
|
||||
const selectedPiThinkingLevel = resolvePiThinkingLevel(options.profile.agent, options.piThinkingLevel);
|
||||
const piThinking = selectedPiThinkingLevel ? createPiThinkingAdmission(selectedPiThinkingLevel, { restoring: options.restoringSession }) : undefined;
|
||||
const cursorInstructions = options.profile.agent === "cursor"
|
||||
? createCursorInstructionAdmission(options.systemInstructions)
|
||||
: null;
|
||||
@@ -312,6 +315,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
const mode = modeBinding?.createGuard();
|
||||
return (direction: "inbound" | "outbound", message: unknown) => { instructions?.(direction, message); mode?.(direction, message); };
|
||||
} } : {}),
|
||||
...(piThinking ? { protocolGuardFactory: () => piThinking.createGuard() } : {}),
|
||||
sessionStore,
|
||||
agentRegistry: createRegistry({
|
||||
// Preserve Claude's ACP capability identity. This is metadata only: the
|
||||
@@ -511,6 +515,18 @@ export async function openQualifiedAcpxRuntime(
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
return ensuredHandle;
|
||||
}) : piThinking ? ensuredSession.then(async (ensuredHandle) => {
|
||||
handle = ensuredHandle;
|
||||
options.signal?.throwIfAborted();
|
||||
if (!piThinking.isReady()) {
|
||||
if (!runtime.setConfigOption) throw new Error("Pi thinking admission requires native configuration");
|
||||
await runtime.setConfigOption({ handle: ensuredHandle, key: "thought_level", value: selectedPiThinkingLevel! });
|
||||
piThinking.assertReady();
|
||||
await children.verifyLifetimeOwnership();
|
||||
options.signal?.throwIfAborted();
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
return ensuredHandle;
|
||||
}) : ensuredSession)
|
||||
.catch((error: unknown) => {
|
||||
throw classifySessionEnsureFailure(error);
|
||||
@@ -526,6 +542,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
: await boundedHandshake;
|
||||
cursorInstructions?.assertReady();
|
||||
modeBinding?.assertReady();
|
||||
piThinking?.assertReady();
|
||||
// A provider can answer only after the verified sentinel is armed, but do
|
||||
// not admit the session until the owner has observed that exact handoff.
|
||||
await children.verifyLifetimeOwnership();
|
||||
@@ -580,7 +597,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
return runtimePort(
|
||||
runtime,
|
||||
handle,
|
||||
{ ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}) },
|
||||
{ ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}) },
|
||||
baseStore,
|
||||
children,
|
||||
runtimeCloseTimeoutMs,
|
||||
|
||||
@@ -76,4 +76,33 @@ describe("ACPX verified command lease owner", () => {
|
||||
await owner.command.close();
|
||||
expect(initial.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cancels a pending refresh and joins its partial-copy cleanup before retiring ownership", async () => {
|
||||
const initial = lease();
|
||||
let signal!: AbortSignal;
|
||||
let entered!: () => void; let drained!: () => void;
|
||||
const acquiring = new Promise<void>(resolve => { entered = resolve; });
|
||||
const cleanup = new Promise<void>(resolve => { drained = resolve; });
|
||||
const owner = createAcpxCommandLeaseOwner(initial, async activeSignal => {
|
||||
signal = activeSignal; entered();
|
||||
await new Promise<void>(resolve => activeSignal.addEventListener("abort", () => resolve(), { once: true }));
|
||||
await cleanup;
|
||||
activeSignal.throwIfAborted();
|
||||
throw new Error("cancelled acquisition cannot return a lease");
|
||||
});
|
||||
owner.command.spawn();
|
||||
const refresh = owner.refreshConsumedCommand();
|
||||
const rejectedRefresh = expect(refresh).rejects.toThrow("owner is closing");
|
||||
await acquiring;
|
||||
let retired = false;
|
||||
const close = owner.command.close().then(() => { retired = true; });
|
||||
expect(signal.aborted).toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(retired).toBe(false);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
drained();
|
||||
await rejectedRefresh; await close;
|
||||
expect(retired).toBe(true);
|
||||
expect(initial.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,14 @@ import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
/** Keep each launch single-use while owning replacements for transient ACP controls. */
|
||||
export function createAcpxCommandLeaseOwner(
|
||||
initial: VerifiedAcpxCommandLease,
|
||||
openCommand: () => Promise<VerifiedAcpxCommandLease>,
|
||||
openCommand: (signal: AbortSignal) => Promise<VerifiedAcpxCommandLease>,
|
||||
) {
|
||||
const leases = new Set([initial]);
|
||||
let current = initial;
|
||||
let consumed = false;
|
||||
let closing = false;
|
||||
let refresh: Promise<void> | null = null;
|
||||
const admission = new AbortController();
|
||||
const command: VerifiedAcpxCommandLease = {
|
||||
spawn(...args) {
|
||||
if (closing) throw new Error("Verified ACPX command owner is closing");
|
||||
@@ -18,6 +19,7 @@ export function createAcpxCommandLeaseOwner(
|
||||
},
|
||||
async close() {
|
||||
closing = true;
|
||||
admission.abort(new Error("Verified ACPX command owner is closing"));
|
||||
// Late acquisitions remain owned. Retry every lease whose close fails.
|
||||
await refresh?.catch(() => undefined);
|
||||
const failures: unknown[] = [];
|
||||
@@ -39,7 +41,7 @@ export function createAcpxCommandLeaseOwner(
|
||||
if (!consumed) return;
|
||||
if (!refresh) {
|
||||
refresh = Promise.resolve()
|
||||
.then(openCommand)
|
||||
.then(() => openCommand(admission.signal))
|
||||
.then((replacement) => {
|
||||
leases.add(replacement);
|
||||
if (closing) throw new Error("Verified ACPX command owner closed during refresh");
|
||||
|
||||
@@ -82,7 +82,7 @@ function detailFields(data: Record<string, unknown>): Array<{ name: string; valu
|
||||
// Preserve the complete field path for sensitive-key redaction. The
|
||||
// normalizer admits only known, typed counters/flags: their numeric token
|
||||
// counts are not credentials, so they retain value-only redaction.
|
||||
const safe = typeof value === "string" ? redactSemanticValue(value, name) : value;
|
||||
const safe = typeof value === "string" ? name.split(".").reduce<unknown>((safe, segment) => redactSemanticValue(safe, segment), value) : value;
|
||||
details.push({ name: bounded(name, 160), value: bounded(String(safe), 4000) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,8 +59,36 @@ async function permissionPolicyClosure(extraClassifierImport = false): Promise<S
|
||||
vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() }));
|
||||
|
||||
describe("Copilot build-owned installation", () => {
|
||||
it("admits the current v12 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8"));
|
||||
it("preserves v12 history and changes only the version and four shared source bindings", () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v13-identity.json", import.meta.url), "utf8"));
|
||||
const changed = Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort();
|
||||
expect(changed).toEqual(["agentProfileVersion", "semanticDirectDriverSourceSha256", "semanticSchemaBundleSourceSha256", "semanticSidecarProtocolSourceSha256", "semanticSidecarSourceSha256"]);
|
||||
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
|
||||
expect(prior.declaration.agentProfileVersion).toBe(12);
|
||||
expect(current.declaration.agentProfileVersion).toBe(13);
|
||||
const sortedPrior = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sortedPrior)).digest("hex")}`).toBe(prior.commandDigest);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("preserves v13 history while binding the shared live request snapshot", () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v13-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v14-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["agentProfileVersion", "semanticSidecarSourceSha256"]);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("preserves v14 history while binding the corrected outbound ACPX delivery", () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v14-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v15-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["acpxPatchSha256", "agentProfileVersion"]);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("admits the current v16 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v16-identity.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT);
|
||||
@@ -79,8 +107,8 @@ describe("Copilot build-owned installation", () => {
|
||||
for (const [relative, field] of [...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES, ["copilot-policy.ts", "policySha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) {
|
||||
expect(createHash("sha256").update(readFileSync(new URL(relative!, import.meta.url))).digest("hex")).toBe(identity.declaration[field!]);
|
||||
}
|
||||
expect(readFileSync(new URL("../../../scripts/build-copilot-distribution.mjs", import.meta.url), "utf8")).toContain(identity.commandDigest);
|
||||
expect(readFileSync(new URL("../../../runner/crates/runner-core/src/acpx_provider_backend.rs", import.meta.url), "utf8")).toContain(identity.commandDigest);
|
||||
expect(readFileSync(new URL("../../../acpx-profiles.json", import.meta.url), "utf8")).toContain(identity.commandDigest);
|
||||
expect(readFileSync(new URL("../../../runner/crates/runner-core/src/generated_acpx_profiles.rs", import.meta.url), "utf8")).toContain(identity.commandDigest);
|
||||
});
|
||||
it("binds the complete executable permission-policy import closure", async () => {
|
||||
expect(await permissionPolicyClosure()).toEqual(new Set(PERMISSION_POLICY_SOURCES.map(([path]) => new URL(path, import.meta.url).href)));
|
||||
@@ -91,7 +119,7 @@ describe("Copilot build-owned installation", () => {
|
||||
expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]);
|
||||
});
|
||||
it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v16-identity.json", import.meta.url), "utf8"));
|
||||
identity.declaration[field] = "0".repeat(64);
|
||||
const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest);
|
||||
@@ -137,4 +165,16 @@ describe("Copilot build-owned installation", () => {
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects the historical v12 digest even when the caller supplies the current version", async () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8"));
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
for (const override of [
|
||||
{ commandDigest: prior.commandDigest },
|
||||
{ agentProfileVersion: prior.declaration.agentProfileVersion },
|
||||
{ commandDigest: prior.commandDigest, agentProfileVersion: prior.declaration.agentProfileVersion },
|
||||
]) {
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override })).rejects.toThrow("exact pinned");
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { join } from "node:path";
|
||||
import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js";
|
||||
import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js";
|
||||
import { COPILOT_LAUNCH_ARGUMENTS, COPILOT_VERSION } from "./copilot-profile.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type AcpxReleaseProfile } from "./qualified-profiles.js";
|
||||
|
||||
export const COPILOT_CLOSURE_SHA256 = Object.freeze({
|
||||
"darwin-arm64": "362f2663e967fb9e34a814bac4619cbf89e6b23069c4051ab1f2f686852d0a32",
|
||||
@@ -11,7 +11,7 @@ export const COPILOT_CLOSURE_SHA256 = Object.freeze({
|
||||
});
|
||||
|
||||
/** Admission primitive only. Pending candidates remain gated by qualification. */
|
||||
export async function verifyCopilotInstallation(profile: QualifiedAcpxProfile): Promise<VerifiedAcpxInstallation> {
|
||||
export async function verifyCopilotInstallation(profile: AcpxReleaseProfile): Promise<VerifiedAcpxInstallation> {
|
||||
const expected = QUALIFIED_ACPX_PROFILES.copilot;
|
||||
if (profile.agent !== "copilot" || profile.agentProfileVersion !== expected.agentProfileVersion
|
||||
|| profile.acpxVersion !== expected.acpxVersion || profile.agentServerPackage !== "@github/copilot"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { safeCopilotEditTarget } from "./copilot-permission-context.js";
|
||||
const LIMIT = 256;
|
||||
const CATEGORY = "copilot_tool_evidence_v1";
|
||||
type Fields = Record<string, string | number | boolean | null>;
|
||||
interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence }
|
||||
interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence; pendingReadNotice?: boolean }
|
||||
const record = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
||||
const identity = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v);
|
||||
const shellIdentity = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(v);
|
||||
@@ -101,7 +101,13 @@ export function createCopilotToolEvidence(binding: {
|
||||
if (state.kind && state.kind !== call.kind) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_kind" }); return; }
|
||||
state.kind = call.kind;
|
||||
}
|
||||
if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory);
|
||||
let publishPendingRead = false;
|
||||
if (state.kind === "read") {
|
||||
state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory);
|
||||
if (state.read.pendingOriginInput && call.tag === "tool_call") state.pendingReadNotice = true;
|
||||
if (state.pendingReadNotice && state.read.pendingOriginInput) return;
|
||||
if (state.pendingReadNotice) { state.pendingReadNotice = false; publishPendingRead = true; }
|
||||
}
|
||||
if (call.rawInput !== undefined) {
|
||||
// Retain only a bounded digest of the native arguments, never their text.
|
||||
let inputDigest: string | undefined;
|
||||
@@ -113,6 +119,7 @@ export function createCopilotToolEvidence(binding: {
|
||||
if (state.input && state.input !== fingerprint) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; }
|
||||
state.input = fingerprint; state.fields = fields;
|
||||
}
|
||||
if (publishPendingRead) notice("tool", id, { ...state.fields, status: "pending", operation: "read", ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) });
|
||||
const status = ["pending", "in_progress", "completed", "failed"].includes(String(call.status)) ? String(call.status) : undefined;
|
||||
if (!status) return;
|
||||
const fields: Fields = { ...state.fields, status, ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) };
|
||||
|
||||
@@ -87,18 +87,19 @@ describe("ACPX driver profile", () => {
|
||||
expect(
|
||||
validateAcpxDriverConfig({
|
||||
agent: "pi",
|
||||
piThinkingLevel: "low",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
config: { agent: "pi", permissionMode: "approve-all" },
|
||||
});
|
||||
for (const agent of ["cursor", "copilot"]) {
|
||||
for (const agent of ["copilot"]) {
|
||||
expect(validateAcpxDriverConfig({ agent, model: "explicit-model" }))
|
||||
.toMatchObject({ ok: false, issues: [{ path: "agent", code: "qualification_pending" }] });
|
||||
}
|
||||
expect(validateAcpxDriverConfig({ agent: "pi", model: "another-model" }))
|
||||
.toMatchObject({ ok: false, issues: [{ path: "model", code: "invalid_model" }] });
|
||||
expect(validateAcpxDriverConfig({ agent: "pi", piThinkingLevel: "low", model: "another-model" }))
|
||||
.toMatchObject({ ok: true, config: { model: "another-model" } });
|
||||
expect(
|
||||
validateAcpxDriverConfig({
|
||||
agent: "claude",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js";
|
||||
import type {
|
||||
HarnessDriverConfigValidation,
|
||||
HarnessDriverDescriptor,
|
||||
@@ -21,12 +22,13 @@ const ACPX_PERMISSION_MODES = [
|
||||
"approve-reads",
|
||||
"deny-all",
|
||||
] as const;
|
||||
const ACPX_CONFIG_FIELDS = new Set(["agent", "model", "permissionMode"]);
|
||||
const ACPX_CONFIG_FIELDS = new Set(["agent", "model", "permissionMode", "piThinkingLevel"]);
|
||||
|
||||
export interface ValidatedAcpxDriverConfig extends Record<string, unknown> {
|
||||
agent: QualifiedAcpxAgent;
|
||||
model: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
}
|
||||
|
||||
export function acpxCapabilities(
|
||||
@@ -129,10 +131,13 @@ export function validateAcpxDriverConfig(
|
||||
);
|
||||
}
|
||||
|
||||
let piThinkingLevel: PiThinkingLevel | undefined;
|
||||
try { piThinkingLevel = resolvePiThinkingLevel(agent, config.piThinkingLevel); } catch (error) { return invalid("piThinkingLevel", "invalid_pi_thinking_level", safeErrorMessage(error)); }
|
||||
const validated: ValidatedAcpxDriverConfig = {
|
||||
agent,
|
||||
model,
|
||||
permissionMode,
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
};
|
||||
return { ok: true, config: validated, issues: [] };
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ describe("ACPX launch environment", () => {
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
OPENROUTER_API_KEY: "openrouter-secret",
|
||||
OPENAI_API_KEY: "openai-secret",
|
||||
ANTHROPIC_API_KEY: "anthropic-secret",
|
||||
});
|
||||
expect(codex.env).not.toHaveProperty("PAPERCLIP_NATIVE_MCP_TOKEN");
|
||||
expect(codex.env).not.toHaveProperty(
|
||||
@@ -84,7 +86,7 @@ describe("ACPX launch environment", () => {
|
||||
};
|
||||
expect(createSanitizedAcpxSpawnInput(source, "cursor").env).toEqual({ CURSOR_API_KEY: "cursor-key", CURSOR_AUTH_TOKEN: "cursor-token" });
|
||||
expect(createSanitizedAcpxSpawnInput(source, "copilot").env).toEqual({ COPILOT_GITHUB_TOKEN: "copilot-key" });
|
||||
expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({});
|
||||
expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({ COPILOT_GITHUB_TOKEN: "copilot-key" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { configuredEnvironment } from "../../configured-environment.js";
|
||||
import { PI_CREDENTIAL_NAMES, piCredentialNames } from "./pi-provider-config.js";
|
||||
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
|
||||
export const ACPX_CREDENTIAL_BINDING_ENV = "PAPERCLIP_ACPX_CREDENTIAL_BINDING";
|
||||
export const ACPX_CREDENTIAL_NAMES: Readonly<Record<QualifiedAcpxAgent, readonly string[]>> = {
|
||||
grok: ["XAI_API_KEY"],
|
||||
pi: ["OPENROUTER_API_KEY"],
|
||||
pi: PI_CREDENTIAL_NAMES,
|
||||
cursor: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
copilot: ["COPILOT_GITHUB_TOKEN"],
|
||||
claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "AWS_BEARER_TOKEN_BEDROCK"],
|
||||
@@ -22,7 +22,7 @@ export function createAcpxCredentialBinding(
|
||||
if (!isCandidate(agent)) return undefined;
|
||||
return JSON.stringify({
|
||||
schema: "paperclip.acpx_credential_binding.v1", agent, sessionId,
|
||||
names: ACPX_CREDENTIAL_NAMES[agent].filter(name => environment !== undefined
|
||||
names: (agent === "pi" ? piCredentialNames(environment) : ACPX_CREDENTIAL_NAMES[agent]).filter(name => environment !== undefined
|
||||
&& Object.hasOwn(environment, name) && Boolean(environment[name]?.trim())),
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export function createAcpxSidecarHostEnvironment(
|
||||
if (!isCandidate(agent)) return environment;
|
||||
const invalid = () => new Error("Candidate ACPX credentials require an explicit matching session binding");
|
||||
const raw = environment[ACPX_CREDENTIAL_BINDING_ENV];
|
||||
const credentialNames = agent === "pi" ? piCredentialNames(environment) : ACPX_CREDENTIAL_NAMES[agent];
|
||||
let names: string[] = [];
|
||||
if (raw !== undefined) {
|
||||
if (Buffer.byteLength(raw) > 4_096) throw invalid();
|
||||
@@ -46,14 +47,14 @@ export function createAcpxSidecarHostEnvironment(
|
||||
if (Object.keys(value).sort().join(",") !== "agent,names,schema,sessionId"
|
||||
|| value.schema !== "paperclip.acpx_credential_binding.v1" || value.agent !== agent
|
||||
|| value.sessionId !== sessionId || !Array.isArray(value.names)
|
||||
|| value.names.length > ACPX_CREDENTIAL_NAMES[agent].length
|
||||
|| value.names.some(name => typeof name !== "string" || !ACPX_CREDENTIAL_NAMES[agent].includes(name))
|
||||
|| value.names.length > credentialNames.length
|
||||
|| value.names.some(name => typeof name !== "string" || !credentialNames.includes(name))
|
||||
|| new Set(value.names).size !== value.names.length) throw invalid();
|
||||
names = value.names as string[];
|
||||
}
|
||||
const result = { ...environment };
|
||||
delete result[ACPX_CREDENTIAL_BINDING_ENV];
|
||||
for (const name of ACPX_CREDENTIAL_NAMES[agent]) {
|
||||
for (const name of credentialNames) {
|
||||
if (names.includes(name)) {
|
||||
if (!Object.hasOwn(environment, name) || !environment[name]?.trim()) throw invalid();
|
||||
} else {
|
||||
@@ -88,8 +89,8 @@ export function createSanitizedAcpxSpawnInput(
|
||||
): SanitizedAcpxSpawnInput {
|
||||
const source = environment ?? process.env;
|
||||
const candidate = isCandidate(agent);
|
||||
const result: NodeJS.ProcessEnv = configuredEnvironment(environment);
|
||||
const credentialNames = ACPX_CREDENTIAL_NAMES[agent];
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
const credentialNames = agent === "pi" ? piCredentialNames(environment) : ACPX_CREDENTIAL_NAMES[agent];
|
||||
const allowed = new Set([
|
||||
"PATH",
|
||||
"LANG",
|
||||
|
||||
@@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "pi",
|
||||
"agentProfileVersion": 1,
|
||||
"agentProfileVersion": 20,
|
||||
"agentServerPackage": "pi-acp",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
|
||||
"agentRuntimeVersion": "0.84.2",
|
||||
"commandDigest": "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f",
|
||||
"agentRuntimeVersion": "1.0.0",
|
||||
"commandDigest": "sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"cursor": {
|
||||
@@ -35,12 +35,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "cursor",
|
||||
"agentProfileVersion": 14,
|
||||
"agentProfileVersion": 15,
|
||||
"agentServerPackage": "cursor-agent",
|
||||
"agentServerVersion": "2026.09.26-dd393fe",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:a5e70580e4933a1a9248cd3c1b16500c6c93e1e14913e0a98cd5ef878bd53d39",
|
||||
"commandDigest": "sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"copilot": {
|
||||
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 2,
|
||||
"agentProfileVersion": 16,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
|
||||
"commandDigest": "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c",
|
||||
"qualificationStatus": "pending",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
|
||||
@@ -43,6 +43,9 @@ const DEPENDENCY_ANCESTOR_FD_START = 5;
|
||||
const MAX_DEPENDENCY_ANCESTORS = 64;
|
||||
const PROVIDER_WATCHDOG_HANDSHAKE_TIMEOUT_MS = 2_000;
|
||||
const PROVIDER_GUARDIAN_HANDSHAKE_TIMEOUT_MS = 5_000;
|
||||
// Cover bounded runtime close and TERM/KILL verification without allowing a
|
||||
// surviving provider to hold the entire cleanup result indefinitely.
|
||||
const NATIVE_SNAPSHOT_EXIT_TIMEOUT_MS = 10_000;
|
||||
const VERIFIED_PROVIDER_RUNTIME_TARGET_ENV =
|
||||
"PAPERCLIP_ACPX_VERIFIED_PROVIDER_RUNTIME_TARGET";
|
||||
|
||||
@@ -406,7 +409,7 @@ export interface VerifiedAcpxInstallation {
|
||||
readonly commandDigest: string;
|
||||
readonly agentServerPackageJsonPath: string | null;
|
||||
readonly agentRuntimePackageJsonPath: string | null;
|
||||
openCommand(): Promise<VerifiedAcpxCommandLease>;
|
||||
openCommand(options?: { signal?: AbortSignal }): Promise<VerifiedAcpxCommandLease>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,8 +428,8 @@ export async function verifyNativeAcpxInstallation(
|
||||
commandDigest: `sha256:${declaration.expectedClosureSha256}`,
|
||||
agentServerPackageJsonPath: declaration.manifestPath,
|
||||
agentRuntimePackageJsonPath: null,
|
||||
async openCommand(): Promise<VerifiedAcpxCommandLease> {
|
||||
const native = await createNativeAcpxDistributionSnapshot(declaration, entries);
|
||||
async openCommand(options?: { signal?: AbortSignal }): Promise<VerifiedAcpxCommandLease> {
|
||||
const native = await createNativeAcpxDistributionSnapshot(declaration, entries, options?.signal);
|
||||
const lease = commandLease(
|
||||
native.snapshot.roots[0]!, NATIVE_ACPX_BOOTSTRAP_NAME, "commonjs",
|
||||
native.bootstrap, native.commandDirectory, [], 0, "commonjs", [],
|
||||
@@ -1369,6 +1372,17 @@ function commandLease(
|
||||
): VerifiedAcpxCommandLease {
|
||||
let consumed = false;
|
||||
let directoriesReleased = false;
|
||||
let spawnedChild: ChildProcess | null = null;
|
||||
let childExit: Promise<void> | null = null;
|
||||
let snapshotCleanup: Promise<void> | null = null;
|
||||
const cleanSnapshot = (): Promise<void> => {
|
||||
if (snapshotCleanup === null) {
|
||||
snapshotCleanup = Promise.resolve().then(() => privateSnapshot?.close());
|
||||
// Exit-triggered cleanup remains observable at the authoritative close.
|
||||
void snapshotCleanup.catch(() => undefined);
|
||||
}
|
||||
return snapshotCleanup;
|
||||
};
|
||||
const releaseDirectories = async (): Promise<void> => {
|
||||
if (directoriesReleased) return;
|
||||
directoriesReleased = true;
|
||||
@@ -1384,11 +1398,35 @@ function commandLease(
|
||||
void releaseDirectories().catch(() => undefined);
|
||||
};
|
||||
const close = async (): Promise<void> => {
|
||||
if (consumed) return;
|
||||
if (consumed && privateSnapshot === null) return;
|
||||
consumed = true;
|
||||
verifiedBytes.fill(0);
|
||||
await releaseDirectories();
|
||||
await privateSnapshot?.close();
|
||||
// A spawned provider may still read the snapshot. Runtime shutdown runs in
|
||||
// parallel and retires that child; do not remove its bytes or report command
|
||||
// retirement until observed exit and complete snapshot deletion.
|
||||
if (childExit !== null) {
|
||||
let exitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
childExit,
|
||||
new Promise<never>((_, reject) => {
|
||||
exitTimer = setTimeout(() => reject(new Error(
|
||||
"ACPX provider survived native snapshot retirement deadline",
|
||||
)), NATIVE_SNAPSHOT_EXIT_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (exitTimer !== undefined) clearTimeout(exitTimer);
|
||||
}
|
||||
}
|
||||
const cleanup = cleanSnapshot();
|
||||
try {
|
||||
await cleanup;
|
||||
} catch (error) {
|
||||
if (snapshotCleanup === cleanup) snapshotCleanup = null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return {
|
||||
spawn(
|
||||
@@ -1533,6 +1571,21 @@ function commandLease(
|
||||
],
|
||||
},
|
||||
);
|
||||
spawnedChild = child;
|
||||
if (privateSnapshot !== null) {
|
||||
childExit = new Promise<void>((resolve) => {
|
||||
child.once("exit", () => resolve());
|
||||
child.once("error", () => {
|
||||
// A failed spawn has no process. Errors from an admitted child
|
||||
// do not prove it exited and must retain its snapshot.
|
||||
if (child.pid === undefined) resolve();
|
||||
});
|
||||
});
|
||||
child.once("exit", () => { void cleanSnapshot(); });
|
||||
child.once("error", () => {
|
||||
if (child.pid === undefined) void cleanSnapshot();
|
||||
});
|
||||
}
|
||||
if (guarded) {
|
||||
const guardianOwnerPipe = child.stdio[
|
||||
providerOwnershipFd - 1
|
||||
@@ -1562,12 +1615,11 @@ function commandLease(
|
||||
} catch (error) {
|
||||
verifiedBytes.fill(0);
|
||||
releaseDirectoriesBestEffort();
|
||||
void privateSnapshot?.close();
|
||||
spawnedChild?.kill();
|
||||
void Promise.resolve(childExit).then(cleanSnapshot).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
releaseDirectoriesBestEffort();
|
||||
child.once("exit", () => { void privateSnapshot?.close(); });
|
||||
child.once("error", () => { void privateSnapshot?.close(); });
|
||||
const sourceInput = child.stdio[COMMAND_SOURCE_FD] as Writable | null;
|
||||
if (sourceInput === null) {
|
||||
verifiedBytes.fill(0);
|
||||
|
||||
@@ -94,6 +94,39 @@ async function output(child: ChildProcess): Promise<{ text: string; error: strin
|
||||
}
|
||||
|
||||
describe("native ACPX execution closure", () => {
|
||||
it("reuses shared parent prefixes with exactly the original levels, insertion order and sorted batches", async () => {
|
||||
const declaration = await fixture();
|
||||
const entries = await readNativeAcpxDistributionEntries(declaration);
|
||||
const deep = Array.from({ length: 36 }, (_, index) => `d${index}`).join("/");
|
||||
const paths = ["plain", "a-/one", "a.b/two", "a/a", "a/b/a", "a/b/c/a", "a/b/c/b", "a/bb/a", "a/c/a", "a0/a", "space dir/@scope/pkg/a", "space dir/@scope/pkg/b", "日本/é/a", `${deep}/a`, `${deep}/b`, `${deep}/more/c`];
|
||||
for (const path of paths) {
|
||||
await mkdir(join(declaration.distributionRoot, dirname(path)), { recursive: true });
|
||||
await writeFile(join(declaration.distributionRoot, path), path, { mode: 0o600 });
|
||||
entries.push({ path, sha256: hash(path), size: Buffer.byteLength(path), executable: false });
|
||||
}
|
||||
entries.sort((a, b) => a.path < b.path ? -1 : 1);
|
||||
const creatingStart = vi.mocked(mkdir).mock.calls.length;
|
||||
const sealingStart = vi.mocked(chmod).mock.calls.length;
|
||||
const created = await createNativeAcpxDistributionSnapshot({ ...declaration, expectedClosureSha256: hash(JSON.stringify(entries)) }, entries);
|
||||
try {
|
||||
const root = created.snapshot.roots[0]!;
|
||||
const levels = new Map<number, Set<string>>();
|
||||
const directories = new Set([dirname(root), root]);
|
||||
// Frozen reference: the original repeated-prefix planner.
|
||||
for (const entry of entries) {
|
||||
const parts = entry.path.split("/");
|
||||
for (let depth = 1; depth < parts.length; depth++) {
|
||||
const path = join(root, ...parts.slice(0, depth));
|
||||
const level = levels.get(depth) ?? new Set<string>();
|
||||
level.add(path); levels.set(depth, level); directories.add(path);
|
||||
}
|
||||
}
|
||||
const expectedCreation = [root, ...[...levels.keys()].sort((a, b) => a - b).flatMap(depth => [...levels.get(depth)!].sort())];
|
||||
expect(vi.mocked(mkdir).mock.calls.slice(creatingStart)).toEqual(expectedCreation.map(path => [path, { mode: 0o700 }]));
|
||||
expect(vi.mocked(chmod).mock.calls.slice(sealingStart)).toEqual([...directories].map(path => [path, 0o500]));
|
||||
for (const path of directories) expect((await stat(path)).mode & 0o777).toBe(0o500);
|
||||
} finally { await created.commandDirectory.close(); await created.snapshot.close(); }
|
||||
});
|
||||
it("rejects paths, ordering, oversized files and altered manifest pins", () => {
|
||||
const entry = { path: "runtime", sha256: "a".repeat(64), size: 10, executable: true };
|
||||
for (const entries of [[{ ...entry, path: "../escape" }], [{ ...entry, path: "/absolute" }], [entry, entry], [{ ...entry, size: 2 ** 40 }], [{ ...entry, unknown: 1 }]]) {
|
||||
@@ -178,6 +211,109 @@ describe("native ACPX execution closure", () => {
|
||||
expect(() => lease.spawn()).toThrow("closed");
|
||||
await lease.close();
|
||||
});
|
||||
it("waits for consumed native snapshot deletion before command retirement completes", async () => {
|
||||
const declaration = await fixture();
|
||||
const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand();
|
||||
const deleting = gate(), releaseDeletion = gate();
|
||||
const originalRm = vi.mocked(rm).getMockImplementation()!;
|
||||
let snapshotRoot = "";
|
||||
vi.mocked(rm).mockImplementation(async (path, options) => {
|
||||
if (String(path).includes("paperclip-acpx-native-")) {
|
||||
snapshotRoot = String(path);
|
||||
deleting.release();
|
||||
await releaseDeletion.promise;
|
||||
}
|
||||
return originalRm(path, options);
|
||||
});
|
||||
let retired = false;
|
||||
let closing: Promise<void> | undefined;
|
||||
try {
|
||||
expect((await output(lease.spawn())).code).toBe(0);
|
||||
await deleting.promise;
|
||||
closing = lease.close().then(() => { retired = true; });
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(retired).toBe(false);
|
||||
} finally {
|
||||
releaseDeletion.release();
|
||||
await closing;
|
||||
}
|
||||
await expect(stat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
it("retains spawned native bytes until the exact child exits", async () => {
|
||||
const declaration = await fixture({ script: '#!/bin/sh\nprintf ready\nexec sleep 1000\n' });
|
||||
const creatingStart = vi.mocked(mkdir).mock.calls.length;
|
||||
const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand();
|
||||
const snapshotRoot = dirname(String(vi.mocked(mkdir).mock.calls[creatingStart]![0]));
|
||||
const child = lease.spawn();
|
||||
const exited = once(child, "close");
|
||||
child.stderr!.resume();
|
||||
let retired = false;
|
||||
let closing: Promise<void> | undefined;
|
||||
try {
|
||||
await once(child.stdout!, "data");
|
||||
closing = lease.close().then(() => { retired = true; });
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(retired).toBe(false);
|
||||
expect((await stat(snapshotRoot)).isDirectory()).toBe(true);
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await exited;
|
||||
await closing;
|
||||
}
|
||||
await expect(stat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
it("reports exit-triggered native deletion failure and retries only its retained cleanup", async () => {
|
||||
const declaration = await fixture();
|
||||
const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand();
|
||||
const deleting = gate();
|
||||
const originalRm = vi.mocked(rm).getMockImplementation()!;
|
||||
let snapshotRoot = "", failed = false;
|
||||
vi.mocked(rm).mockImplementation(async (path, options) => {
|
||||
if (String(path).includes("paperclip-acpx-native-") && !failed) {
|
||||
failed = true;
|
||||
snapshotRoot = String(path);
|
||||
deleting.release();
|
||||
throw new Error("native snapshot deletion denied");
|
||||
}
|
||||
return originalRm(path, options);
|
||||
});
|
||||
expect((await output(lease.spawn())).code).toBe(0);
|
||||
await deleting.promise;
|
||||
await expect(lease.close()).rejects.toThrow("native snapshot deletion denied");
|
||||
expect((await stat(snapshotRoot)).isDirectory()).toBe(true);
|
||||
await lease.close();
|
||||
await expect(stat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
it("bounds retirement of a surviving native child and retains bytes for cleanup retry", async () => {
|
||||
const declaration = await fixture({ script: '#!/bin/sh\nprintf ready\nexec sleep 1000\n' });
|
||||
const creatingStart = vi.mocked(mkdir).mock.calls.length;
|
||||
const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand();
|
||||
const snapshotRoot = dirname(String(vi.mocked(mkdir).mock.calls[creatingStart]![0]));
|
||||
const child = lease.spawn();
|
||||
const exited = once(child, "close");
|
||||
child.stderr!.resume();
|
||||
let closing: Promise<void> | undefined;
|
||||
try {
|
||||
await once(child.stdout!, "data");
|
||||
vi.useFakeTimers();
|
||||
closing = lease.close();
|
||||
const disposition = Promise.race([
|
||||
closing.then(() => "closed", () => "failed"),
|
||||
new Promise<string>(resolve => setTimeout(() => resolve("unbounded"), 11_000)),
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
expect(await disposition).toBe("failed");
|
||||
await expect(closing).rejects.toThrow("native snapshot retirement deadline");
|
||||
expect((await stat(snapshotRoot)).isDirectory()).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
child.kill("SIGTERM");
|
||||
await exited;
|
||||
await closing?.catch(() => undefined);
|
||||
await lease.close();
|
||||
}
|
||||
await expect(stat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
it("gives packaged executables a fresh private extraction cache each launch", async () => {
|
||||
const declaration = { ...await fixture(), isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME" as const };
|
||||
const install = await verifyNativeAcpxInstallation(declaration);
|
||||
@@ -221,6 +357,56 @@ describe("native ACPX execution closure", () => {
|
||||
const denied = await output((await (await verifyNativeAcpxInstallation(evil)).openCommand()).spawn());
|
||||
expect(denied.code).not.toBe(0); expect(denied.error).toContain("escaped its closed distribution");
|
||||
}, 30_000);
|
||||
it.each([false, true])("runs real Node module hooks with interleaved formats and tamper=%s", async tamper => {
|
||||
const script = tamper ? `
|
||||
const fs = require("node:fs");
|
||||
const { registerHooks } = require("node:module");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
registerHooks({ resolve(specifier, context, next) {
|
||||
const result = next(specifier, context);
|
||||
if (result.url.endsWith("/value.mjs")) {
|
||||
const path = fileURLToPath(result.url);
|
||||
fs.chmodSync(path, 0o600);
|
||||
fs.writeFileSync(path, 'console.log("tampered-code-executed");export default 99;');
|
||||
}
|
||||
return result;
|
||||
}});
|
||||
import("./value.mjs").then(() => { process.exitCode = 9; })
|
||||
.catch(error => { console.error(error.message); process.exitCode = 17; });
|
||||
` : `
|
||||
const resolved = require.resolve("./value.cjs");
|
||||
require("node:fs");
|
||||
const commonjs = require(resolved);
|
||||
const json = require("./value.json");
|
||||
import("./value.mjs").then(module => {
|
||||
console.log(JSON.stringify([commonjs, json.value, module.default, require(resolved)]));
|
||||
}).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
`;
|
||||
const declaration = await fixture({ node: true, script });
|
||||
const entries = await readNativeAcpxDistributionEntries(declaration);
|
||||
for (const [path, source] of Object.entries({
|
||||
"value.cjs": "module.exports = 17;",
|
||||
"value.json": '{"value":31}',
|
||||
"value.mjs": "export default 23;",
|
||||
})) {
|
||||
await writeFile(join(declaration.distributionRoot, path), source, { mode: 0o600 });
|
||||
entries.push({ path, sha256: hash(source), size: Buffer.byteLength(source), executable: false });
|
||||
}
|
||||
entries.sort((a, b) => a.path < b.path ? -1 : 1);
|
||||
await writeFile(declaration.manifestPath, JSON.stringify({ entries }));
|
||||
const lease = await (await verifyNativeAcpxInstallation({ ...declaration, expectedClosureSha256: hash(JSON.stringify(entries)) })).openCommand();
|
||||
try {
|
||||
const result = await output(lease.spawn());
|
||||
if (tamper) {
|
||||
expect(result.code, result.error).toBe(17);
|
||||
expect(result.error).toContain("digest mismatch");
|
||||
expect(result.text).not.toContain("tampered-code-executed");
|
||||
} else {
|
||||
expect(result.code, result.error).toBe(0);
|
||||
expect(result.text).toBe("[17,31,23,17]\n");
|
||||
}
|
||||
} finally { await lease.close(); }
|
||||
}, 30_000);
|
||||
it("creates each private parent once and seals every directory before returning", async () => {
|
||||
const { declaration, entries } = await directoryFixture();
|
||||
const creatingStart = vi.mocked(mkdir).mock.calls.length;
|
||||
@@ -366,6 +552,36 @@ describe("native ACPX execution closure", () => {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
await expect(stat(removals[0]!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
it("aborts a pending native copy, drains admitted reads and removes its partial snapshot", async () => {
|
||||
const { declaration, entries } = await manyFileFixture(Array.from({ length: 40 }, (_, index) => 91 + index));
|
||||
const prototype = await filePrototype(join(declaration.distributionRoot, "runtime"));
|
||||
const originalRead = prototype.read;
|
||||
const entered = gate(); const hold = gate(); const controller = new AbortController();
|
||||
const readSizes: number[] = []; const removalStart = vi.mocked(rm).mock.calls.length;
|
||||
vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: any[]): Promise<any> {
|
||||
readSizes.push(args[0].length); entered.release(); await hold.promise;
|
||||
return originalRead.apply(this, args as never);
|
||||
});
|
||||
const creating = createNativeAcpxDistributionSnapshot(declaration, entries, controller.signal);
|
||||
let settled = false; let unexpected: Awaited<typeof creating> | undefined;
|
||||
void creating.then(value => { settled = true; unexpected = value; }, () => { settled = true; });
|
||||
try {
|
||||
await entered.promise;
|
||||
controller.abort(new Error("owned command refresh cancelled"));
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
expect(vi.mocked(rm).mock.calls).toHaveLength(removalStart);
|
||||
hold.release();
|
||||
await expect(creating).rejects.toThrow("owned command refresh cancelled");
|
||||
expect(readSizes).not.toContain(123);
|
||||
const removals = vi.mocked(rm).mock.calls.slice(removalStart).map(([path]) => String(path)).filter(path => /paperclip-acpx-native-/.test(path));
|
||||
expect(removals).toHaveLength(1);
|
||||
await expect(stat(removals[0]!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
hold.release(); await creating.catch(() => undefined);
|
||||
if (unexpected) { await unexpected.commandDirectory.close(); await unexpected.snapshot.close(); }
|
||||
}
|
||||
});
|
||||
it("rejects a source mutation while another file is being copied", async () => {
|
||||
const { declaration, entries } = await manyFileFixture([101, 102]);
|
||||
const prototype = await filePrototype(join(declaration.distributionRoot, "runtime"));
|
||||
|
||||
@@ -81,7 +81,8 @@ export async function readNativeAcpxDistributionEntries(input: NativeAcpxDistrib
|
||||
* Freeze only manifest-admitted bytes. Native trees deliberately have a separate
|
||||
* bound; the tighter JavaScript/npm snapshot limits remain unchanged.
|
||||
*/
|
||||
export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDistributionInput, entries: NativeAcpxDistributionEntry[]): Promise<NativeAcpxDistributionSnapshot> {
|
||||
export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDistributionInput, entries: NativeAcpxDistributionEntry[], signal?: AbortSignal): Promise<NativeAcpxDistributionSnapshot> {
|
||||
signal?.throwIfAborted();
|
||||
// Callers cannot substitute entries between manifest validation and copying.
|
||||
entries = parseNativeAcpxDistributionEntries({ entries }, input.expectedClosureSha256);
|
||||
if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("Native ACPX snapshots require Linux or macOS");
|
||||
@@ -100,31 +101,44 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist
|
||||
};
|
||||
let commandDirectory: FileHandle | undefined;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
if (!same(rootBefore, await heldRoot.stat({ bigint: true }))) throw new Error("Native ACPX distribution root changed before snapshot");
|
||||
await mkdir(packageRoot, { mode: 0o700 });
|
||||
if (input.isolatedCacheEnvironmentName) await mkdir(cacheRoot, { mode: 0o700 });
|
||||
// Build each private parent once. Level ordering prevents child creation
|
||||
// from racing its parent; batches bound work and drain before any cleanup.
|
||||
const parentLevels = new Map<number, Set<string>>();
|
||||
let previousParts: string[] = [];
|
||||
const parentPaths = [packageRoot];
|
||||
for (const entry of entries) {
|
||||
const parts = entry.path.split("/");
|
||||
for (let depth = 1; depth < parts.length; depth++) {
|
||||
const path = join(packageRoot, ...parts.slice(0, depth));
|
||||
let shared = 0;
|
||||
// Canonical sorted paths keep each parent prefix contiguous. Reuse the
|
||||
// previous entry's joined parents instead of rebuilding every prefix.
|
||||
while (shared < parts.length - 1 && shared < previousParts.length - 1 && parts[shared] === previousParts[shared]) shared++;
|
||||
parentPaths.length = shared + 1;
|
||||
for (let depth = shared + 1; depth < parts.length; depth++) {
|
||||
const path = join(parentPaths[depth - 1]!, parts[depth - 1]!);
|
||||
parentPaths.push(path);
|
||||
const level = parentLevels.get(depth) ?? new Set<string>();
|
||||
level.add(path); parentLevels.set(depth, level); directories.add(path);
|
||||
}
|
||||
previousParts = parts;
|
||||
}
|
||||
const directoryBatch = async (paths: string[], operation: (path: string) => Promise<unknown>): Promise<void> => {
|
||||
for (let start = 0; start < paths.length; start += NATIVE_DIRECTORY_CONCURRENCY) {
|
||||
signal?.throwIfAborted();
|
||||
const settled = await Promise.allSettled(paths.slice(start, start + NATIVE_DIRECTORY_CONCURRENCY).map(operation));
|
||||
const failed = settled.find(result => result.status === "rejected");
|
||||
if (failed?.status === "rejected") throw failed.reason;
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
};
|
||||
for (const depth of [...parentLevels.keys()].sort((a, b) => a - b)) {
|
||||
await directoryBatch([...parentLevels.get(depth)!].sort(), path => mkdir(path, { mode: 0o700 }));
|
||||
}
|
||||
const copyEntry = async (entry: NativeAcpxDistributionEntry): Promise<void> => {
|
||||
signal?.throwIfAborted();
|
||||
const path = join(source, ...entry.path.split("/"));
|
||||
if (await realpath(path) !== path) throw new Error("Native ACPX closure contains a symbolic link");
|
||||
// Bind metadata to the descriptor we will read, without a redundant
|
||||
@@ -137,6 +151,7 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist
|
||||
const bytes = Buffer.alloc(entry.size);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
signal?.throwIfAborted();
|
||||
const read = await file.read(bytes, offset, bytes.length - offset, offset);
|
||||
if (read.bytesRead === 0) throw new Error("Native ACPX closure file ended during snapshot");
|
||||
offset += read.bytesRead;
|
||||
@@ -144,6 +159,7 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist
|
||||
if (!same(before, await file.stat({ bigint: true })) || !same(before, await lstat(path, { bigint: true })) || await realpath(path) !== path) throw new Error("Native ACPX closure file changed while read");
|
||||
if (sha256(bytes) !== entry.sha256) throw new Error(`Native ACPX closure file digest mismatch: ${entry.path}`);
|
||||
const target = join(packageRoot, ...entry.path.split("/"));
|
||||
signal?.throwIfAborted();
|
||||
await writeFile(target, bytes, { mode: entry.executable ? 0o500 : 0o400, flag: "wx" });
|
||||
} finally { await file.close(); }
|
||||
};
|
||||
@@ -154,20 +170,36 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist
|
||||
let activeBytes = 0;
|
||||
let failed = false;
|
||||
let failure: unknown;
|
||||
// Only the admission loop waits for capacity. Notify that waiter once per
|
||||
// completion instead of attaching Promise.race handlers to every active
|
||||
// copy on each admission (including long-lived large-file reads).
|
||||
let capacityAvailable: (() => void) | undefined;
|
||||
for (const entry of entries) {
|
||||
// An aborted acquisition must still drain its admitted reads before
|
||||
// deleting the partial tree. Stop scheduling copies at the same bound
|
||||
// used for any observed copy failure.
|
||||
if (signal?.aborted && !failed) { failed = true; failure = signal.reason; }
|
||||
while (!failed && (active.size >= NATIVE_COPY_CONCURRENCY
|
||||
|| (active.size > 0 && activeBytes + entry.size > NATIVE_COPY_BUFFER_BYTES))) {
|
||||
await Promise.race(active);
|
||||
await new Promise<void>(resolve => { capacityAvailable = resolve; });
|
||||
if (signal?.aborted && !failed) { failed = true; failure = signal.reason; }
|
||||
}
|
||||
if (failed) break;
|
||||
activeBytes += entry.size;
|
||||
const copying = copyEntry(entry).catch(error => {
|
||||
if (!failed) { failed = true; failure = error; }
|
||||
}).finally(() => { activeBytes -= entry.size; active.delete(copying); });
|
||||
}).finally(() => {
|
||||
activeBytes -= entry.size;
|
||||
active.delete(copying);
|
||||
const notify = capacityAvailable;
|
||||
capacityAvailable = undefined;
|
||||
notify?.();
|
||||
});
|
||||
active.add(copying);
|
||||
}
|
||||
await Promise.all(active);
|
||||
if (failed) throw failure;
|
||||
signal?.throwIfAborted();
|
||||
// Completion order must not change the module guard or manifest.
|
||||
for (const entry of entries) digests[join(packageRoot, ...entry.path.split("/"))] = entry.sha256;
|
||||
if (!same(rootBefore, await heldRoot.stat({ bigint: true })) || !same(rootBefore, await lstat(source, { bigint: true }))) throw new Error("Native ACPX distribution root changed during snapshot");
|
||||
|
||||
@@ -259,6 +259,19 @@ describe("Pi ACP bridge", () => {
|
||||
} });
|
||||
});
|
||||
|
||||
it("preserves usage without reporting an acknowledged cancellation as a service failure", () => {
|
||||
const usage = new PiTurnUsage();
|
||||
usage.accept({ role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 4, output: 2 } });
|
||||
expect(usage.response("cancelled")).toEqual({ usage: {
|
||||
inputTokens: 4, outputTokens: 2, _meta: { paperclipPi: { provenance: "assistant_message_receipts" } },
|
||||
} });
|
||||
// Ordinary settlement keeps the provider failure and partial accounting.
|
||||
expect(usage.response("end_turn")).toMatchObject({
|
||||
usage: { inputTokens: 4, outputTokens: 2 },
|
||||
_meta: { jetbrains: { air: { sessionFailure: { severity: "error" } } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("accounts compaction receipts once and does not fabricate partial coverage", () => {
|
||||
const usage = new PiTurnUsage();
|
||||
usage.accept({ role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } } });
|
||||
|
||||
@@ -568,10 +568,10 @@ export class PiTurnUsage {
|
||||
this.failed = previousFailure;
|
||||
}
|
||||
|
||||
response(): RecordValue {
|
||||
response(stopReason?: "end_turn" | "cancelled"): RecordValue {
|
||||
return {
|
||||
...(this.observed ? { usage: { ...Object.fromEntries(Object.entries(this.total).filter(([name]) => !this.unknown.has(name as keyof typeof this.total))), _meta: { paperclipPi: { provenance: this.compactionObserved ? "assistant_message_and_compaction_receipts" : "assistant_message_receipts", ...(this.costObserved && !this.costIncomplete ? { costUsd: this.cost, costSource: "pi_pricing_estimate" } : {}) } } } } : {}),
|
||||
...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}),
|
||||
...(this.failed && stopReason !== "cancelled" ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* three pins. Never accept a digest supplied only by an installed manifest.
|
||||
*/
|
||||
export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({
|
||||
"darwin-arm64": "e17be4d27c589b8f8b5de7d00686c39873fe6f902a3f133bdaf1a9f94dee00a2",
|
||||
"darwin-x64": "03351f4a250a8db0e79411a9079b43a0ff05f72a2aff41fae17f1fc2de24bd41",
|
||||
"linux-x64": "29dfc829700c57392f1d56373078dcfc80674cbd50f65674ec64fced81dffb68",
|
||||
"darwin-arm64": "e076276c674dfffccbb488883e18571d77d7225d801fabf5c8391773ddbbfc6c",
|
||||
"darwin-x64": "eafd44e672dec4966ef6f038620f003e5d492c889d475886cd66be2e9c2bff1d",
|
||||
"linux-x64": "f493df174cfecba3c31c524aa486ae37663cd68f8fcc0d9556e3f615c4a40ce9",
|
||||
});
|
||||
@@ -9,11 +9,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "./pi-closure-pins.js";
|
||||
import { PI_NODE_VERSION } from "./pi-node-pins.js";
|
||||
import { assertPiInstallationProfile, verifyPiInstallation } from "./pi-installation.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type AcpxReleaseProfile } from "./qualified-profiles.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 12 });
|
||||
const candidate = (): AcpxReleaseProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi });
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root);
|
||||
@@ -26,11 +26,57 @@ async function fixture() {
|
||||
}
|
||||
|
||||
describe("Pi installation factory", () => {
|
||||
it("preserves profile 13 and changes only the version and corrected ACPX patch binding", async () => {
|
||||
const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v13-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v14-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()).toEqual(["acpxPatchSha256", "agentProfileVersion"]);
|
||||
});
|
||||
|
||||
it("preserves profile 14 and binds the method-specific question extension in profile 15", async () => {
|
||||
const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v14-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v15-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort())
|
||||
.toEqual(["agentProfileVersion", "closure", "extensionSha256", "nativeQuestions"]);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
});
|
||||
|
||||
it("preserves profile 16 and changes only the provider credential policy in profile 17", async () => {
|
||||
const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v16-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v17-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort())
|
||||
.toEqual(["agentProfileVersion", "providerConfigurationSourceSha256"]);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
});
|
||||
|
||||
it("preserves profile 17 and binds acknowledged cancellation in profile 18", async () => {
|
||||
const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v17-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v18-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort())
|
||||
.toEqual(["agentProfileVersion", "closure", "helperSha256", "nativeCancellation", "wrapperSha256"]);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
});
|
||||
|
||||
it("preserves profile 18 and binds root question field types in profile 19", async () => {
|
||||
const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v18-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v19-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort())
|
||||
.toEqual(["agentProfileVersion", "closure", "extensionSha256", "nativeQuestions"]);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
});
|
||||
|
||||
it("revises only the shared Codex environment attestation while preserving Pi runtime bytes", async () => {
|
||||
const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8"));
|
||||
const prior = await read(19), current = await read(20);
|
||||
expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort())
|
||||
.toEqual(["agentProfileVersion", "runtimeSandboxSourceSha256"]);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
});
|
||||
|
||||
it("binds the profile declaration to the reviewed patch and platform closure pins", async () => {
|
||||
const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
||||
const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
||||
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value);
|
||||
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v12-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v20-identity.json", import.meta.url), "utf8"));
|
||||
const declaration = identity.declaration;
|
||||
expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`);
|
||||
@@ -40,6 +86,9 @@ describe("Pi installation factory", () => {
|
||||
}
|
||||
expect(declaration.closure).toEqual(PI_DISTRIBUTION_CLOSURE_SHA256);
|
||||
expect(declaration.nodeVersion).toBe(PI_NODE_VERSION);
|
||||
for (const [path, field] of [["pi-provider-config.ts", "providerConfigurationSourceSha256"], ["environment.ts", "credentialEnvironmentSourceSha256"], ["runtime-sandbox.ts", "runtimeSandboxSourceSha256"], ["recovery-identity.ts", "recoveryIdentitySourceSha256"]]) {
|
||||
expect(hash(await readFile(new URL(`./${path}`, import.meta.url)))).toBe(declaration[field!]);
|
||||
}
|
||||
expect(hash(await readFile(new URL("../../../../../patches/acpx@0.13.1.patch", import.meta.url)))).toBe(declaration.acpxPatchSha256);
|
||||
expect(hash(await readFile(new URL("./pi-message-projection.ts", import.meta.url)))).toBe(declaration.messageProjectionSha256);
|
||||
expect(hash(await readFile(new URL("./pi-extension-adapter.ts", import.meta.url)))).toBe(declaration.noticeProjectionSha256);
|
||||
@@ -52,23 +101,27 @@ describe("Pi installation factory", () => {
|
||||
expect(materializer).toContain(`helperSha256: "${declaration.helperSha256}"`);
|
||||
// This exact patch produced the reviewed current wrapper/closure.
|
||||
// Pin it separately so wrapper-only edits cannot keep an unchanged declaration.
|
||||
expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("93254de82ae779587611748cfb963475c3439e033f1582f06dfdcc7f7b937546");
|
||||
expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("a7c63900b513490cceed9e41bc3bd06f03f2cd56265f434aa2c5dc7925fa76b4");
|
||||
});
|
||||
|
||||
it("rejects legacy profiles and caller-selected identities", () => {
|
||||
it("rejects legacy profiles and tampered executable identities", () => {
|
||||
expect(() => assertPiInstallationProfile(candidate())).not.toThrow();
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 12");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 12");
|
||||
for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { reportedModelId: "different" }, { agentRuntimePackage: "ambient-pi" }]) {
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 20");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 20");
|
||||
for (const priorVersion of [15, 16, 17, 18, 19]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 20");
|
||||
for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) {
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,10 +7,9 @@ import { PI_NODE_VERSION } from "./pi-node-pins.js";
|
||||
import { verifyPiRuntimeLayoutForNativeSnapshot, type PiRuntimeManifest } from "./pi-verified-runtime.js";
|
||||
import { readNativeAcpxDistributionEntries } from "./native-distribution-integrity.js";
|
||||
import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type AcpxReleaseProfile } from "./qualified-profiles.js";
|
||||
|
||||
const MAX_DISTRIBUTION_METADATA_BYTES = 4 * 1024 * 1024;
|
||||
const PI_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731";
|
||||
const FIXED_PATHS = Object.freeze({
|
||||
node: "node/bin/node",
|
||||
piEntrypoint: "node_modules/@earendil-works/pi-coding-agent/dist/cli.js",
|
||||
@@ -23,10 +22,10 @@ function record(value: unknown): Record<string, unknown> {
|
||||
}
|
||||
|
||||
/** Candidate identity is build-owned; model selection cannot substitute a CLI. */
|
||||
export function assertPiInstallationProfile(profile: QualifiedAcpxProfile): void {
|
||||
export function assertPiInstallationProfile(profile: AcpxReleaseProfile): void {
|
||||
const trusted = QUALIFIED_ACPX_PROFILES.pi;
|
||||
if (profile.agentProfileVersion !== 12) throw new Error("Pi rich ACP requires profile version 12; reopen the previous session");
|
||||
if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "1.0.0" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive" || profile.qualificationModel !== PI_MODEL || profile.reportedModelId !== PI_MODEL) throw new Error("Pi distribution profile differs from its trusted declaration");
|
||||
if (profile.agentProfileVersion !== trusted.agentProfileVersion) throw new Error(`Pi rich ACP requires profile version ${trusted.agentProfileVersion}; reopen the previous session`);
|
||||
if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "1.0.0" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive") throw new Error("Pi distribution profile differs from its trusted declaration");
|
||||
}
|
||||
|
||||
async function readDistributionMetadata(path: string): Promise<Record<string, unknown>> {
|
||||
@@ -47,7 +46,7 @@ async function readDistributionMetadata(path: string): Promise<Record<string, un
|
||||
* native closure supplies the snapshot and provider-group lifetime guardian.
|
||||
* Qualification status remains the caller's production admission gate.
|
||||
*/
|
||||
export async function verifyPiInstallation(profile: QualifiedAcpxProfile): Promise<VerifiedAcpxInstallation> {
|
||||
export async function verifyPiInstallation(profile: AcpxReleaseProfile): Promise<VerifiedAcpxInstallation> {
|
||||
assertPiInstallationProfile(profile);
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
if (!Object.hasOwn(PI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error("Pi distribution target is unsupported");
|
||||
@@ -92,12 +91,13 @@ export async function verifyPiInstallation(profile: QualifiedAcpxProfile): Promi
|
||||
commandDigest: profile.commandDigest,
|
||||
agentServerPackageJsonPath: join(runtimeRoot, "node_modules/pi-acp/package.json"),
|
||||
agentRuntimePackageJsonPath: join(runtimeRoot, "node_modules/@earendil-works/pi-coding-agent/package.json"),
|
||||
async openCommand() {
|
||||
async openCommand(options?: { signal?: AbortSignal }) {
|
||||
options?.signal?.throwIfAborted();
|
||||
await assertDirectories();
|
||||
// The native primitive reads every admitted file through held descriptors
|
||||
// into a new immutable snapshot. The bootstrap derives its own launch
|
||||
// environment from that snapshot, never these mutable installation paths.
|
||||
return native.openCommand();
|
||||
return native.openCommand(options);
|
||||
},
|
||||
});
|
||||
}
|
||||
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user