diff --git a/cli/src/__tests__/runtime.test.ts b/cli/src/__tests__/runtime.test.ts new file mode 100644 index 0000000000..8371b2c2d8 --- /dev/null +++ b/cli/src/__tests__/runtime.test.ts @@ -0,0 +1,32 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Command } from "commander"; +import { afterEach, expect, it } from "vitest"; +import { registerRuntimeCommands, resolvePiProvisioner } from "../commands/runtime.js"; +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "paperclip-cli-runtime-")); roots.push(root); + const cli = join(root, "dist/vendor/paperclip-runner/cli"); await mkdir(cli, { recursive: true }); + await writeFile(join(root, "package.json"), '{"name":"@paperclipai/server"}'); + await writeFile(join(root, "dist/index.js"), "throw new Error('server must not start during setup resolution')"); + await writeFile(join(cli, "provision-pi.cjs"), "fixture"); + return { root, cli, url: pathToFileURL(join(root, "dist/index.js")).href }; +} +it("locates the public server's setup entrypoint without importing its API server", async () => { + const f = await fixture(); expect(await resolvePiProvisioner(f.url)).toBe(join(f.cli, "provision-pi.cjs")); +}); +it("rejects foreign package identity and a setup entrypoint outside that package", async () => { + const f = await fixture(); const other = await fixture(); + await writeFile(join(f.root, "package.json"), '{"name":"foreign"}'); + await expect(resolvePiProvisioner(f.url)).rejects.toThrow("identity"); + await writeFile(join(f.root, "package.json"), '{"name":"@paperclipai/server"}'); + await rm(join(f.cli, "provision-pi.cjs")); await symlink(join(other.cli, "provision-pi.cjs"), join(f.cli, "provision-pi.cjs")); + await expect(resolvePiProvisioner(f.url)).rejects.toThrow("escapes"); +}); +it("provides an explicit Pi-only operator command and rejects other 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"); +}); diff --git a/cli/src/commands/runtime.ts b/cli/src/commands/runtime.ts new file mode 100644 index 0000000000..d4b48980ff --- /dev/null +++ b/cli/src/commands/runtime.ts @@ -0,0 +1,52 @@ +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 type { Command } from "commander"; + +/** Resolve the public server dependency, without importing/starting the server. */ +export async function resolvePiProvisioner(serverUrl: string): Promise { + const url = new URL(serverUrl); + if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Pi setup requires an installed Paperclip server"); + const entry = await realpath(fileURLToPath(url)); + if (!entry.endsWith("/dist/index.js")) throw new Error("Pi setup requires the published server layout"); + const root = resolve(dirname(entry), ".."); + const manifest = join(root, "package.json"); + const info = await lstat(manifest); + if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Pi setup server package identity is invalid"); + const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-pi.cjs"); + if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Pi setup entrypoint escapes its server package"); + return provisioner; +} + +export async function setupPiRuntime(): Promise { + const provisioner = await resolvePiProvisioner(import.meta.resolve("@paperclipai/server")); + // Only the explicit setup command can download pinned public dependencies. + // Do not forward provider credentials, proxy/npm config, HOME or NODE_OPTIONS. + const child = spawn(process.execPath, [provisioner], { + stdio: "inherit", env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" }, + }); + const cancel = () => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); }; + process.on("SIGINT", cancel); process.on("SIGTERM", cancel); + try { + await new Promise((accept, reject) => { + let spawnError: Error | undefined; + child.on("error", error => { spawnError = error; }); + child.once("close", (code, signal) => { + if (spawnError) reject(spawnError); + else if (code !== 0 || signal) reject(new Error("Pi setup did not finish. Review its error; an existing invalid installation is never replaced automatically.")); + else accept(); + }); + }); + } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); } +} + +export function registerRuntimeCommands(program: Command): void { + program.command("runtime").description("Manage explicitly installed agent runtimes") + .command("setup ") + .description("Install and verify the pinned Pi runtime for this host (public downloads; no model calls)") + .action(async (provider: string) => { + if (provider !== "pi") throw new Error("Supported explicit runtime setup: paperclipai runtime setup pi"); + await setupPiRuntime(); + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index b2f722a4a0..a14964a12d 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,3 +1,4 @@ +import { registerRuntimeCommands } from "./commands/runtime.js"; import { registerEmailCommands } from "./commands/client/email.js"; import { Command } from "commander"; import { warnIfUnsupportedNodeVersion } from "@paperclipai/shared/node-version"; @@ -101,6 +102,7 @@ program .action(updateCommand); program.hook("preAction", async (_thisCommand, actionCommand) => { + if (actionCommand.parent?.name() === "runtime") return; // Public runtime setup never reads instance config or credentials. const options = actionCommand.optsWithGlobals() as DataDirOptionLike & TestDriveOptions; let dataDirOptions: DataDirOptionLike = options; if (actionCommand.name() === "test-drive") { @@ -124,6 +126,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => { }); registerTestDriveCommand(program); +registerRuntimeCommands(program); program .command("onboard") diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 1926a38019..c4e730ec27 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -187,8 +187,8 @@ addressed by v4. Version 2 hello completion and every failure remain retained. The wider local and Linux x64 Daytona matrix remains pending; this document does not promote the candidate to a qualified production runtime. -The runner pins `pi-acp@0.0.33` and -`@earendil-works/pi-coding-agent@0.84.2`. The candidate model is +At this historical pre-1.0 checkpoint, the runner pinned `pi-acp@0.0.33` and +`@earendil-works/pi-coding-agent@0.84.2`. The candidate model was `openrouter/deepseek/deepseek-v4-flash-0731`; only an explicitly bound OpenRouter credential may reach this profile. `patches/pi-acp@0.0.33.patch` repairs the ACP wrapper. `pi-runtime-extension.ts` supplies the runner-owned semantic bridge and @@ -963,3 +963,33 @@ that ID unchanged into the shared MCP dedupe boundary. This is a further integration defect. The shared dedupe guard and canonical grader remain intact. Both runs lack terminal usage; measured billing is $0.002440082 and cleanup passes. Restart and refreshed hello are held until this defect is repaired. + +## Explicit host installation + +For a published local Paperclip installation, run `paperclipai runtime setup pi` +with the same installed CLI and account that owns the server package. This is an +explicit download and verification step; npm installation and agent launch never +perform it automatically. It installs only this host's supported platform +(macOS ARM64, macOS x64, or Linux x64), using the source-pinned Node archive, npm +lock, wrapper patch and complete Pi closure. Node 24, npm, git, tar and the normal +platform dependency inspector (`otool` or `ldd`) must be available. The server +package must be writable by the installing account. Setup forwards no instance +configuration, provider credentials, npm configuration or proxy credentials. + +The public server carries a self-contained setup tool and small pinned inputs in +`dist/vendor/paperclip-runner/cli`; the installed host closure lives in that +server package's `provider-assets/pi/`. Setup validates an existing +closure again before accepting it. A corrupt existing installation is left +untouched and rejected; reinstall the same Paperclip release into a clean package +location and repeat setup. Concurrent setup is rejected. Cancellation drains the +current bounded download/build command before removing its private staging tree; +allow that cleanup to complete before trying again. + +Then select Pi with the exact model +`openrouter/deepseek/deepseek-v4-flash-0731` and bind an OpenRouter credential +through the normal provider credential UI. Setup itself makes no model request. +A missing host closure produces explicit setup guidance. Daytona uses the +separately built and verified Linux provider pack in its runner image; running +local setup does not install or qualify a remote image. Published-tar local and +Daytona startup evidence must bind the final installation candidate, with no +candidate qualification flags, before a production-readiness claim. diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index 826478c83e..482141a73f 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -1,6 +1,48 @@ # Rich ACP integration and qualification report -Current source checkpoint (2026-10-01): **Cursor v10, Copilot v12 and Pi v10 +## Current Pi 1.0 qualification checkpoint — October 2, 2026 + +Pi now uses `@earendil-works/pi-coding-agent@1.0.0`, `pi-acp@0.0.33`, +ACPX `0.13.1`, and profile **12**. Its exact model remains +`openrouter/deepseek/deepseek-v4-flash-0731`. Cursor v10 and Copilot v12 +remain gated while Pi qualification is completed. The held Pi admission branch +is preparation for testing normal installation; it is not production qualification. + +The first paid Pi 1.0 local hello on profile 11 failed: Pi's serialized RPC +message updates no longer carry the old SDK-shaped partial message. That failure +is retained, with $0.000142518 observed on the dedicated OpenRouter key and +complete owned-process cleanup. Profile 12 repairs the actual RPC boundary; +its local-only tests exercise the real Pi CLI, wrapper and owned extension. +They do not replace authenticated product tests. + +At runtime source `b9e5d6ecdb05ab7244c90976e07c950f8d09b15b`, the fresh +macOS ARM64 daemon and verified pack pass the original no-key startup test +(6.741 seconds to settlement) and all 48 native/ACP contract tests without skips. +The original evidence collector rejected Node 24's summary format after the +runtime checks had passed. Its failure is preserved; independently reviewed +offline finalization verifies those same logs and full asset inventories without +rerunning the tests. Native Intel verification and the final installed-package +local/Daytona tests remain separate gates. + +Full repository typecheck, tests, token gates, Product E2E typecheck/unit checks, +and build passed on the earlier source `efe019a79f50440d7bd6c3bc6c75fb8f18953093`. +Its Runner verification also passed. These results are historical prerequisites; +profile 12 and the final installation changes still need their own verification. + +The dedicated Pi test key has a $5 lifetime OpenRouter-credit limit. Its BYOK +charges are not included in that provider-enforced limit. Qualification therefore +also requires the reviewed operational policy verifying no configured BYOK +credentials, fresh account/key evidence, one paid case at a time, and usage +monitoring. The key's $5 reservation is counted once within the combined $100 +campaign budget. Native price estimates are never substituted for provider bills. + +The [Pi capability inventory](runner-pi-capabilities.md#pi-10-candidate-2026-10-02-profile-v12) +records Pi 1.0's native interfaces, wrapper changes, and unused capabilities. +The comparison below remains the supported-surface map; older paid observations +retain their named historical profiles. Current Pi 1.0 paid Product, Runner +protocol, native controls, and Daytona qualification are still pending. + +Historical source checkpoint (2026-10-01): **Cursor v10, Copilot v12 and Pi v10 remain unqualified**. Copilot receipt v2 distinguishes original provider arguments from the validated outgoing completion input. The sidecar commits the captured normalized digest only after its exact pending call receives a @@ -51,7 +93,7 @@ as `25303cf84953985b961b95f89aff0bdb864b2d65`, from head unchanged; this adds no paid proof. Those definitions remain the historical Cursor v8 checkpoint and require a separate reviewed Cursor v9 update. -Current checkpoint (2026-09-30): **Cursor v9, Copilot v8 and Pi v10 remain unqualified.** The frozen runtime is `5c69b69ef2aeab8d8a367b25a8d894cc5308befa`; controller candidate is `a8df2064d68f40fbf4dec670c4b8478c4b1b1b3f`. All profiles bind shared ACPX patch SHA-256 `bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e`. No profile is promoted and no prior grade is changed. +Historical checkpoint (2026-09-30): **Cursor v9, Copilot v8 and Pi v10 remain unqualified.** The frozen runtime is `5c69b69ef2aeab8d8a367b25a8d894cc5308befa`; controller candidate is `a8df2064d68f40fbf4dec670c4b8478c4b1b1b3f`. All profiles bind shared ACPX patch SHA-256 `bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e`. No profile is promoted and no prior grade is changed. Current-profile protocol eval definitions merged in [paperclip-evals #36](https://github.com/paperclipai/paperclip-evals/pull/36) as `d987357461933baca0d4c10cc081f40e7eae5c1b`: 136 deterministic tests and 21 validated cells. These definitions do not supply paid qualification evidence. @@ -786,7 +828,7 @@ qualification gates, not claims that a JavaScript path check confines a shell. | P1 | User attachments and image prompting | `AcpxRuntimeTurnInput` and the common runtime adapter currently forward text only, despite underlying image-input support. Implement validated attachment-to-ACP content conversion and model-specific capability admission, then qualify real local/Daytona image prompts. This is an implementation gap as well as a live-verification gap. | | P1 | Copilot native session event attribution | `_session_event` omits an originating turn. Preserve bounded event fields as session-scoped notices with `turnAttribution: unknown`; typed delegation, artifacts and compaction need an explicit native correlation contract before turn-owned projection. Standard correlated ACP events remain separate. | | P1 | Copilot session-store files and export/artifact URIs | Provider paths are not task-workspace paths. Add a separately authorized export flow with validated bytes and provenance; do not resolve arbitrary URLs or auto-register. | -| P1 | Pi native fork/history/export interfaces | Pinned Pi 0.84.2 native RPC exposes `fork(entryId)`, `clone`, `get_fork_messages` and `export_html`. The wrapper does not map them to runner controls. Add durable branch lineage for fork/clone and an authorized, contained artifact flow for HTML export before exposing them; do not label these native capabilities absent. | +| P1 | Pi native fork/history/export interfaces | Pi native RPC exposes `fork(entryId)`, `clone`, `get_fork_messages` and `export_html`; these remain available in the pinned 1.0.0 release. The wrapper does not map them to runner controls. Add durable branch lineage for fork/clone and an authorized, contained artifact flow for HTML export before exposing them; do not label these native capabilities absent. | | P1 | Complete usage/billing provenance | Missing cache fields remain unknown. Pi price estimates are displayed separately. Budget qualification requires actual spend coverage, not an estimate presented as a bill. | | P1 | Fork/history and richer configuration controls | Cursor session mode now has explicit admission-bound setup. Arbitrary session discovery/forking, mid-turn configuration changes and the parameterized model picker still need company-scoped controls and durable lineage. | | P1 | Exact pending-request restoration after process death | Session transcript restoration does not restore callbacks. Expire unresolved requests unless a provider proves exact restoration. | diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs index a3a2aa9f8c..e40f81f9b9 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs @@ -99,9 +99,36 @@ export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) { } results.push({ entrypoint, result, verifiedResult }); } + await bundlePiProvisioner({ write }); return results; } +/** Explicit setup tooling; no provider payload is included in the npm tarball. */ +export async function bundlePiProvisioner({ write = true, outputRoot = resolve(packageRoot, "dist/cli") } = {}) { + const entrypoint = { name: "provision-pi", source: resolve(packageRoot, "scripts/provision-pi.mjs") }; + const result = await build({ + entryPoints: [entrypoint.source], outfile: resolve(outputRoot, "provision-pi.cjs"), + bundle: true, platform: "node", format: "cjs", target: "node24", packages: "bundle", + splitting: false, sourcemap: false, legalComments: "none", metafile: true, + treeShaking: true, write, logLevel: "silent", + banner: { js: 'const __paperclipVerifiedEntrypointUrl = require("node:url").pathToFileURL(__filename).href;' }, + define: { "import.meta.dirname": "__dirname", "import.meta.url": "__paperclipVerifiedEntrypointUrl" }, + }); + assertSelfContainedBundle(entrypoint, result); + if (write) { + const inputs = resolve(outputRoot, "pi-provision-inputs"); + await mkdir(inputs, { recursive: true }); + for (const [source, name] of [ + ["scripts/pi-distribution/package.json", "package.json"], + ["scripts/pi-distribution/package-lock.json", "package-lock.json"], + ["../../patches/pi-acp@0.0.33.patch", "pi-acp.patch"], + ["src/drivers/acpx/pi-acp-runtime.ts", "pi-acp-runtime.ts"], + ["src/drivers/acpx/pi-runtime-extension.ts", "pi-runtime-extension.ts"], + ]) await cp(resolve(packageRoot, source), resolve(inputs, name)); + } + return result; +} + const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null; diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs index 223af55817..fd2c324822 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs @@ -38,3 +38,6 @@ test("written provider entrypoints satisfy qualified launch permissions", async } } }); + +// Package-layout regression runs in the existing verified-entrypoint test lane. +import "./provision-pi-package.test.mjs"; diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index d61cfbc073..2e4dd8f10f 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -110,7 +110,15 @@ export function piDistributionBootstrapSource() { } /** Build on the target platform. No lifecycle scripts, model requests, or auth. */ -export async function materializePiDistribution({ outputRoot, nodeExecutable, npmExecutable = "npm" }) { +export async function materializePiDistribution({ outputRoot, nodeExecutable, npmExecutable = "npm", inputs, checkCancelled = () => {} }) { + // Cancellation is observed between bounded subprocesses. A setup signal must + // not abandon an npm/tar child while it still owns staging files. + const runOwned = async (...args) => { checkCancelled(); const result = await run(...args); checkCancelled(); return result; }; + checkCancelled(); + const inputLockDirectory = inputs?.lockDirectory ?? lockDirectory; + const inputPatchPath = inputs?.patchPath ?? patchPath; + const helperSourcePath = inputs?.helperSourcePath ?? join(packageRoot, "src/drivers/acpx/pi-acp-runtime.ts"); + const extensionSourcePath = inputs?.extensionSourcePath ?? join(packageRoot, "src/drivers/acpx/pi-runtime-extension.ts"); if (typeof outputRoot !== "string" || !outputRoot || !isAbsolute(outputRoot)) throw new Error("Pi distribution output must be absolute"); const output = resolve(outputRoot); if (["/", workspaceRoot, packageRoot].includes(output)) throw new Error("Refusing unsafe Pi distribution output"); @@ -137,15 +145,15 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np if (hash(bytes) !== nodePin.archiveSha256) throw new Error("Pi Node archive does not match its release pin"); const archivePath = join(staging, archiveName); await writeFile(archivePath, bytes); const nodeRoot = join(staging, "node-extract"); await mkdir(nodeRoot); - await run("tar", ["-xzf", archivePath, "-C", nodeRoot, "--strip-components=2", `node-v${PI_NODE_VERSION}-${target}/bin/node`], { timeout: 30_000 }); + await runOwned("tar", ["-xzf", archivePath, "-C", nodeRoot, "--strip-components=2", `node-v${PI_NODE_VERSION}-${target}/bin/node`], { timeout: 30_000 }); node = join(nodeRoot, "node"); } if (hash(await readFile(node)) !== nodePin.executableSha256 || (await lstat(node)).size !== nodePin.executableSize) throw new Error("Pi Node executable does not match its target release pin"); - const version = (await run(node, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim(); + const version = (await runOwned(node, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim(); if (version !== `v${PI_NODE_VERSION}`) throw new Error("Pi distribution requires exact pinned Node version"); - if ((await run(node, ["-p", "process.versions.undici"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== PI_DISTRIBUTION_PINS.nodeBundledUndici) throw new Error("Pi Node bundled Undici differs from its reviewed security pin"); + if ((await runOwned(node, ["-p", "process.versions.undici"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== PI_DISTRIBUTION_PINS.nodeBundledUndici) throw new Error("Pi Node bundled Undici differs from its reviewed security pin"); await mkdir(runtimeRoot); - await Promise.all(["package.json", "package-lock.json"].map((name) => copyFile(join(lockDirectory, name), join(runtimeRoot, name)))); + await Promise.all(["package.json", "package-lock.json"].map((name) => copyFile(join(inputLockDirectory, name), join(runtimeRoot, name)))); await writeFile(join(runtimeRoot, ".npmrc"), "registry=https://registry.npmjs.org/\nignore-scripts=true\naudit=false\nfund=false\n"); await writeFile(join(runtimeRoot, ".npmrc-global"), ""); const buildHome = join(staging, "build-home"); await mkdir(buildHome); @@ -153,30 +161,35 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np // .npmrc. Public registry downloads need no private application credential. const environment = Object.fromEntries(["PATH", "LANG", "LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"].flatMap((key) => typeof process.env[key] === "string" ? [[key, process.env[key]]] : [])); environment.HOME = buildHome; - await run(npmExecutable, piDistributionInstallCommand(), { cwd: runtimeRoot, env: environment, timeout: 300_000, maxBuffer: 4 * 1024 * 1024 }); + await runOwned(npmExecutable, piDistributionInstallCommand(), { cwd: runtimeRoot, env: environment, timeout: 300_000, maxBuffer: 4 * 1024 * 1024 }); const installedLockBytes = await readFile(join(runtimeRoot, "package-lock.json")); - if (!installedLockBytes.equals(await readFile(join(lockDirectory, "package-lock.json")))) throw new Error("Pi installation changed its committed lock"); + if (!installedLockBytes.equals(await readFile(join(inputLockDirectory, "package-lock.json")))) throw new Error("Pi installation changed its committed lock"); const lock = JSON.parse(installedLockBytes.toString("utf8")); const packageCount = await verifyLockedPiPackageGraph(runtimeRoot, lock); const wrapper = join(runtimeRoot, "node_modules/pi-acp"); - await run("git", ["apply", "--check", patchPath], { cwd: wrapper, env: environment, timeout: 10_000 }); - await run("git", ["apply", patchPath], { cwd: wrapper, env: environment, timeout: 10_000 }); + await runOwned("git", ["apply", "--check", inputPatchPath], { cwd: wrapper, env: environment, timeout: 10_000 }); + await runOwned("git", ["apply", inputPatchPath], { cwd: wrapper, env: environment, timeout: 10_000 }); const [wrapperBytes, helperBytes, helperSource] = await Promise.all([ readFile(join(wrapper, "dist/index.js")), readFile(join(wrapper, "dist/paperclip-runtime.js")), - readFile(join(packageRoot, "src/drivers/acpx/pi-acp-runtime.ts"), "utf8"), + readFile(helperSourcePath, "utf8"), ]); - const stripped = stripTypeScriptTypes(helperSource).split("\n").map((line) => line.trimEnd()).join("\n"); + // Installed CLI users may run another supported Node patch. Generate the + // exact pinned closure with its verified Node, not the host's TS stripper. + const stripPinnedSource = async (path, source) => inputs + ? (await runOwned(node, ["--input-type=module", "-e", 'import {readFileSync} from "node:fs"; import {stripTypeScriptTypes} from "node:module"; process.stdout.write(stripTypeScriptTypes(readFileSync(process.argv[1],"utf8")));', path], { env: {}, timeout: buildNodeStartupTimeout(), maxBuffer: 4 * 1024 * 1024 })).stdout + : stripTypeScriptTypes(source); + const stripped = (await stripPinnedSource(helperSourcePath, helperSource)).split("\n").map((line) => line.trimEnd()).join("\n"); if (hash(wrapperBytes) !== PI_DISTRIBUTION_PINS.wrapperSha256 || hash(helperBytes) !== PI_DISTRIBUTION_PINS.helperSha256 || helperBytes.toString("utf8") !== stripped) throw new Error("Pi wrapper patch does not match its qualified source"); await mkdir(join(runtimeRoot, "extensions")); - await writeFile(join(runtimeRoot, "extensions/paperclip.js"), stripTypeScriptTypes(await readFile(join(packageRoot, "src/drivers/acpx/pi-runtime-extension.ts"), "utf8")).replace('from "./pi-acp-runtime.js"', 'from "../node_modules/pi-acp/dist/paperclip-runtime.js"')); + await writeFile(join(runtimeRoot, "extensions/paperclip.js"), (await stripPinnedSource(extensionSourcePath, await readFile(extensionSourcePath, "utf8"))).replace('from "./pi-acp-runtime.js"', 'from "../node_modules/pi-acp/dist/paperclip-runtime.js"')); await mkdir(join(runtimeRoot, "node/bin"), { recursive: true }); const copiedNode = join(runtimeRoot, "node/bin/node"); await copyFile(node, copiedNode); await chmod(copiedNode, 0o755); const dependencyListing = process.platform === "darwin" - ? (await run("/usr/bin/otool", ["-L", copiedNode], { env: {}, timeout: 10_000 })).stdout - : (await run("ldd", [copiedNode], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 })).stdout; + ? (await runOwned("/usr/bin/otool", ["-L", copiedNode], { env: {}, timeout: 10_000 })).stdout + : (await runOwned("ldd", [copiedNode], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 })).stdout; assertPiNodeSystemDependencies(dependencyListing, process.platform); - if ((await run(copiedNode, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== version) throw new Error("Copied Pi Node cannot execute after relocation"); + if ((await runOwned(copiedNode, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== version) throw new Error("Copied Pi Node cannot execute after relocation"); await rm(buildHome, { recursive: true }); // npm-generated .bin links and hidden lock metadata are not package payload // files. No launch uses PATH; excluding them makes the closure regular-only. @@ -206,6 +219,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np runtimeRoot: "runtime", nativeClosureSha256, manifest, }; await writeFile(join(staging, "pi-distribution.json"), `${JSON.stringify(metadata, null, 2)}\n`); + checkCancelled(); await rename(staging, output); const finalRoot = join(output, "runtime"); const binding = await verifyPiRuntimeManifest(finalRoot, manifest); @@ -218,11 +232,12 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np } catch (error) { await rm(staging, { recursive: true, force: true }); throw error; } } -if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { +if (import.meta.url.endsWith("/materialize-pi-distribution.mjs") && process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { const args = process.argv.slice(2).filter((arg) => arg !== "--"); const outputArgs = args.filter((arg) => !arg.startsWith("--node=")); const nodeArgs = args.filter((arg) => arg.startsWith("--node=")); if (outputArgs.length !== 1 || nodeArgs.length > 1) throw new Error("Usage: node scripts/materialize-pi-distribution.mjs /absolute/output-directory [--node=/absolute/portable-node]"); - const result = await materializePiDistribution({ outputRoot: outputArgs[0], ...(nodeArgs.length ? { nodeExecutable: nodeArgs[0].slice("--node=".length) } : {}) }); - process.stdout.write(`${JSON.stringify({ outputRoot: result.outputRoot, manifestPath: result.manifestPath, manifestDigest: result.manifestDigest, packageCount: result.metadata.packageCount })}\n`); + materializePiDistribution({ outputRoot: outputArgs[0], ...(nodeArgs.length ? { nodeExecutable: nodeArgs[0].slice("--node=".length) } : {}) }).then(result => { + process.stdout.write(`${JSON.stringify({ outputRoot: result.outputRoot, manifestPath: result.manifestPath, manifestDigest: result.manifestDigest, packageCount: result.metadata.packageCount })}\n`); + }).catch(error => { console.error(error.message); process.exitCode = 1; }); } diff --git a/packages/paperclip-runner/scripts/provision-pi-package.test.mjs b/packages/paperclip-runner/scripts/provision-pi-package.test.mjs new file mode 100644 index 0000000000..d40eb68212 --- /dev/null +++ b/packages/paperclip-runner/scripts/provision-pi-package.test.mjs @@ -0,0 +1,45 @@ +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 { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +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-")); + t.after(() => rm(root, { recursive: true, force: true })); + const pkg = join(root, "package"); const cli = join(pkg, "dist/vendor/paperclip-runner/cli"); + await mkdir(cli, { recursive: true }); + await writeFile(join(pkg, "package.json"), '{"name":"@paperclipai/server","type":"module"}'); + await writeFile(join(pkg, "dist/index.js"), 'throw new Error("do not start the server");'); + await writeFile(join(cli, "acpx-runtime-sidecar.cjs"), "// layout fixture only"); + await bundlePiProvisioner({ outputRoot: cli }); + const inputs = join(cli, "pi-provision-inputs"); + assert.deepEqual((await readdir(inputs)).sort(), ["package-lock.json", "package.json", "pi-acp-runtime.ts", "pi-acp.patch", "pi-runtime-extension.ts"]); + const packageRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); + for (const [source, destination] of [ + ["scripts/pi-distribution/package.json", "package.json"], ["scripts/pi-distribution/package-lock.json", "package-lock.json"], + ["../../patches/pi-acp@0.0.33.patch", "pi-acp.patch"], ["src/drivers/acpx/pi-acp-runtime.ts", "pi-acp-runtime.ts"], + ["src/drivers/acpx/pi-runtime-extension.ts", "pi-runtime-extension.ts"], + ]) assert.deepEqual(await readFile(resolve(packageRoot, source)), await readFile(join(inputs, destination))); + // Exercise npm's actual package/ prefix after archive extraction, without + // pretending this small fixture is a complete published Paperclip release. + const archive = join(root, "server.tgz"); + execFileSync("tar", ["-czf", archive, "-C", root, "package"], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 }); + const installed = join(root, "installed"); await mkdir(installed); + execFileSync("tar", ["-xzf", archive, "-C", installed], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 }); + const installedPackage = join(installed, "package"); const entry = join(installedPackage, "dist/vendor/paperclip-runner/cli/provision-pi.cjs"); + const api = createRequire(import.meta.url)(entry); + assert.equal((await api.provisionPackageRoot(entry)).root, installedPackage); + // 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 }); + const deny = join(root, "deny.cjs"); + await writeFile(deny, `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", OPENROUTER_API_KEY: "sensitive-canary", NODE_OPTIONS: "" }, 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}`]); +}); diff --git a/packages/paperclip-runner/scripts/provision-pi.mjs b/packages/paperclip-runner/scripts/provision-pi.mjs new file mode 100644 index 0000000000..257571e8c4 --- /dev/null +++ b/packages/paperclip-runner/scripts/provision-pi.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** Explicit operator setup only. Never imported by npm lifecycle or agent launch. */ +import { constants } from "node:fs"; +import { lstat, mkdir, mkdtemp, open, realpath, readdir, rename, rm, unlink, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { materializePiDistribution } from "./materialize-pi-distribution.mjs"; +import { verifyPiInstallation } from "../src/drivers/acpx/pi-installation.ts"; +import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts"; +import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/pi-closure-pins.ts"; + +export async function provisionPackageRoot(entrypoint) { + const canonical = await realpath(entrypoint); + if (canonical !== resolve(entrypoint)) throw new Error("Pi setup entrypoint must not be linked"); + if (basename(canonical) !== "provision-pi.cjs" || !(await lstat(canonical)).isFile()) throw new Error("Pi setup requires its installed entrypoint"); + const cli = dirname(canonical); + const vendored = cli.endsWith("/dist/vendor/paperclip-runner/cli"); + if (!vendored && !cli.endsWith("/dist/cli")) throw new Error("Pi setup requires the installed runner or server layout"); + const root = resolve(cli, vendored ? "../../../.." : "../.."); + const manifest = join(root, "package.json"); + const handle = await open(manifest, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.size > 65536n || before.nlink !== 1n) throw new Error("Pi setup package manifest is invalid"); + const bytes = await handle.readFile(); const after = await handle.stat({ bigint: true }); const named = await lstat(manifest, { bigint: true }); + if (before.ino !== after.ino || before.dev !== after.dev || before.ctimeNs !== after.ctimeNs || before.mtimeNs !== after.mtimeNs || bytes.length !== Number(before.size) || named.ino !== before.ino || named.dev !== before.dev) throw new Error("Pi setup package manifest changed"); + const expected = vendored ? "@paperclipai/server" : "@paperclipai/paperclip-runner"; + if (JSON.parse(bytes.toString()).name !== expected) throw new Error("Pi setup package identity does not match its layout"); + } finally { await handle.close(); } + return { root, manifest, cli }; +} +async function verifiedInstallation(root, manifest) { + const keys = ["PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"]; + const previous = keys.map(key => process.env[key]); + process.env[keys[0]] = root; process.env[keys[1]] = manifest; + try { const installation = await verifyPiInstallation(QUALIFIED_ACPX_PROFILES.pi); const lease = await installation.openCommand(); await lease.close(); } + finally { keys.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; }); } +} +async function absent(path) { try { await lstat(path); return false; } catch (error) { if (error.code === "ENOENT") return true; throw error; } } +async function containedDirectory(root, path) { + await mkdir(path, { recursive: true, mode: 0o700 }); + if (await realpath(path) !== path || !(await lstat(path)).isDirectory() || !path.startsWith(root + "/")) throw new Error("Pi setup asset directory escapes its package"); +} +export async function provisionPi(entrypoint, checkCancelled = () => {}) { + checkCancelled(); + const target = `${process.platform}-${process.arch}`; + if (!Object.hasOwn(PI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error(`Pi is not qualified for platform ${target}`); + const { root, manifest, cli } = await provisionPackageRoot(entrypoint); + const assets = join(root, "provider-assets"); const parent = join(assets, "pi"); + await containedDirectory(root, assets); await containedDirectory(root, parent); + const lockPath = join(parent, `.setup-${target}.lock`); + const lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + let lockIdentity; let temporary; let temporaryIdentity; let published; let committed = false; + const output = join(parent, target); + try { + lockIdentity = await lock.stat({ bigint: true }); + checkCancelled(); + if (!(await absent(output))) { + await verifiedInstallation(root, manifest); + return { status: "verified_existing", target, profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest }; + } + temporary = await mkdtemp(join(parent, ".setup-private-")); + temporaryIdentity = await lstat(temporary, { bigint: true }); + const stagingRoot = join(temporary, "package"); await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(join(stagingRoot, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" }), { flag: "wx", mode: 0o600 }); + const stagedOutput = join(stagingRoot, "provider-assets/pi", target); + const inputs = join(cli, "pi-provision-inputs"); + await materializePiDistribution({ outputRoot: stagedOutput, checkCancelled, inputs: { + lockDirectory: inputs, patchPath: join(inputs, "pi-acp.patch"), + helperSourcePath: join(inputs, "pi-acp-runtime.ts"), extensionSourcePath: join(inputs, "pi-runtime-extension.ts"), + } }); + await verifiedInstallation(stagingRoot, join(stagingRoot, "package.json")); + if (!(await absent(output))) throw new Error("Pi setup destination appeared during installation; refusing replacement"); + checkCancelled(); + // mkdir is exclusive: rename(directory) would replace an empty concurrent + // destination. Claim a private final root instead; readiness fails closed + // until every entry is installed and the complete closure verifies. + await mkdir(output, { mode: 0o700 }); + published = await lstat(output, { bigint: true }); + for (const name of await readdir(stagedOutput)) { + const named = await lstat(output, { bigint: true }); + if (named.dev !== published.dev || named.ino !== published.ino || named.isSymbolicLink()) throw new Error("Pi setup output ownership changed during publication"); + await rename(join(stagedOutput, name), join(output, name)); + } + await verifiedInstallation(root, manifest); + checkCancelled(); + committed = true; + return { status: "installed_verified", target, profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest }; + } finally { + // Drain every cleanup even if one fails. Never remove a pre-existing or + // replaced destination or another invocation's lock. + const cleanup = []; + if (published && !committed) cleanup.push((async () => { + const named = await lstat(output, { bigint: true }); + if (named.dev !== published.dev || named.ino !== published.ino || named.isSymbolicLink()) throw new Error("Pi setup output ownership changed; refusing cleanup"); + await rm(output, { recursive: true }); + })()); + if (temporary) cleanup.push((async () => { + const named = await lstat(temporary, { bigint: true }); + if (!temporaryIdentity || named.dev !== temporaryIdentity.dev || named.ino !== temporaryIdentity.ino || named.isSymbolicLink()) throw new Error("Pi setup staging ownership changed; refusing cleanup"); + await rm(temporary, { recursive: true }); + })()); + cleanup.push((async () => { + try { + lockIdentity ??= await lock.stat({ bigint: true }); + const named = await lstat(lockPath, { bigint: true }); + if (named.dev !== lockIdentity.dev || named.ino !== lockIdentity.ino) throw new Error("Pi setup lock ownership changed; refusing cleanup"); + await unlink(lockPath); + } finally { await lock.close(); } + })()); + const results = await Promise.allSettled(cleanup); + const failures = results.filter(result => result.status === "rejected"); + if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Pi setup cleanup failed; inspect the package before retrying"); + } +} +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const args = process.argv.slice(2); + if (args.length) throw new Error("Usage: paperclipai runtime setup pi (explicit host-platform installation; no model calls)"); + // Setup uses only public, source-pinned downloads. Never forward credentials, + // HOME config, proxy configuration, NODE_OPTIONS or npm configuration. + const environment = { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" }; + for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, environment); + let cancelled = false; + const cancel = () => { cancelled = true; }; + process.on("SIGINT", cancel); process.on("SIGTERM", cancel); + // Each active materializer subprocess has its original <=300s timeout. A + // cancellation waits for that owned child before removing its files. + const deadline = setTimeout(cancel, 900_000); deadline.unref(); + provisionPi(fileURLToPath(import.meta.url), () => { if (cancelled) throw new Error("Pi setup cancelled; no installation was admitted"); }).finally(() => { + clearTimeout(deadline); process.off("SIGINT", cancel); process.off("SIGTERM", cancel); + }).then(value => console.log(JSON.stringify(value))) + .catch(error => { console.error(`Pi setup failed: ${error.message}`); process.exitCode = 1; }); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts index 2588b7d9d2..711e51c120 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -60,7 +60,11 @@ export async function verifyPiInstallation(profile: QualifiedAcpxProfile): Promi if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path) throw new Error("Pi distribution escaped its fixed asset directory"); } }; - await assertDirectories(); + try { await assertDirectories(); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("Pi runtime is not installed on this host; run paperclipai runtime setup pi before selecting Pi"); + throw error; + } const metadataPath = join(assets, "pi-distribution.json"); const metadata = await readDistributionMetadata(metadataPath); const targetMetadata = record(metadata.target); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-provision.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-provision.test.ts new file mode 100644 index 0000000000..2656cd7978 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-provision.test.ts @@ -0,0 +1,113 @@ +import { mkdtemp, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { provisionPackageRoot, provisionPi } from "../../../scripts/provision-pi.mjs"; + +const mocks = vi.hoisted(() => ({ verify: vi.fn(), build: vi.fn(), close: vi.fn(), beforeMkdir: vi.fn() })); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, mkdir: async (...args: Parameters) => { await mocks.beforeMkdir(...args); return actual.mkdir(...args); } }; +}); +vi.mock("./pi-installation.ts", () => ({ verifyPiInstallation: mocks.verify })); +vi.mock("../../../scripts/materialize-pi-distribution.mjs", () => ({ materializePiDistribution: mocks.build })); +const roots: string[] = []; +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +beforeEach(() => { + vi.clearAllMocks(); + mocks.beforeMkdir.mockReset(); + mocks.verify.mockImplementation(async () => ({ openCommand: async () => ({ close: mocks.close }) })); + mocks.build.mockImplementation(async ({ outputRoot }: { outputRoot: string }) => { await mkdir(outputRoot, { recursive: true }); await writeFile(join(outputRoot, "owned"), "fixture"); }); +}); +async function fixture(vendored = true) { + const root = await mkdtemp(join(tmpdir(), "pi-provision-")); roots.push(root); + const cli = join(root, vendored ? "dist/vendor/paperclip-runner/cli" : "dist/cli"); + await mkdir(cli, { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: vendored ? "@paperclipai/server" : "@paperclipai/paperclip-runner" })); + const entry = join(cli, "provision-pi.cjs"); await writeFile(entry, "fixture"); + return { root, cli, entry, parent: join(root, "provider-assets/pi"), output: join(root, "provider-assets/pi", `${process.platform}-${process.arch}`) }; +} +it("recognizes both published layouts without resolving a private npm package", async () => { + for (const vendored of [true, false]) { const f = await fixture(vendored); expect((await provisionPackageRoot(f.entry)).root).toBe(f.root); } +}); +it("rejects wrong package, linked entrypoint and escaping asset roots before materialization", async () => { + const f = await fixture(); const other = await fixture(); + await writeFile(join(f.root, "package.json"), '{"name":"foreign"}'); + await expect(provisionPi(f.entry)).rejects.toThrow("identity"); + await writeFile(join(f.root, "package.json"), '{"name":"@paperclipai/server"}'); + await rm(f.entry); await symlink(other.entry, f.entry); + await expect(provisionPi(f.entry)).rejects.toThrow("linked"); + await rm(f.entry); await writeFile(f.entry, "fixture"); await symlink(other.root, join(f.root, "provider-assets")); + await expect(provisionPi(f.entry)).rejects.toThrow("escapes"); + expect(mocks.build).not.toHaveBeenCalled(); +}); +it("verifies an existing cache in full and never repairs a rejected cache automatically", async () => { + const f = await fixture(); await mkdir(f.output, { recursive: true }); await writeFile(join(f.output, "original"), "retain"); + expect(await provisionPi(f.entry)).toMatchObject({ status: "verified_existing" }); + mocks.verify.mockRejectedValueOnce(new Error("closure differs")); + await expect(provisionPi(f.entry)).rejects.toThrow("closure differs"); + expect(await readFile(join(f.output, "original"), "utf8")).toBe("retain"); + expect(mocks.build).not.toHaveBeenCalled(); expect(await readdir(f.parent)).toEqual([`${process.platform}-${process.arch}`]); +}); +it("publishes only after staging verification, then verifies the actual package authority", async () => { + const f = await fixture(); vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "untrusted-ambient"); + expect(await provisionPi(f.entry)).toMatchObject({ status: "installed_verified" }); + expect(mocks.verify).toHaveBeenCalledTimes(2); expect(mocks.close).toHaveBeenCalledTimes(2); + expect(process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT).toBe("untrusted-ambient"); + expect(await readdir(f.parent)).toEqual([`${process.platform}-${process.arch}`]); +}); +it.each(["build", "staging", "published"])("cleans only its owned transaction after %s failure", async stage => { + const f = await fixture(); + if (stage === "build") mocks.build.mockRejectedValueOnce(new Error("failed")); + if (stage === "staging") mocks.verify.mockRejectedValueOnce(new Error("failed")); + if (stage === "published") mocks.verify.mockResolvedValueOnce({ openCommand: async () => ({ close: mocks.close }) }).mockRejectedValueOnce(new Error("failed")); + await expect(provisionPi(f.entry)).rejects.toThrow("failed"); + expect(await readdir(f.parent)).toEqual([]); +}); +it("refuses a concurrent setup without deleting its lock or launching work", async () => { + const f = await fixture(); await mkdir(f.parent, { recursive: true }); + const name = `.setup-${process.platform}-${process.arch}.lock`; await writeFile(join(f.parent, name), "other"); + await expect(provisionPi(f.entry)).rejects.toThrow(); expect(mocks.build).not.toHaveBeenCalled(); + expect(await readFile(join(f.parent, name), "utf8")).toBe("other"); +}); +it("honors cancellation after owned materialization without publishing or leaving temporary files", async () => { + const f = await fixture(); let cancelled = false; + mocks.build.mockImplementationOnce(async ({ outputRoot }: { outputRoot: string }) => { await mkdir(outputRoot, { recursive: true }); cancelled = true; }); + await expect(provisionPi(f.entry, () => { if (cancelled) throw new Error("cancelled"); })).rejects.toThrow("cancelled"); + expect(await readdir(f.parent)).toEqual([]); +}); + +it("does not replace an empty destination that appears during preparation", async () => { + const f = await fixture(); + mocks.verify.mockImplementationOnce(async () => { await mkdir(f.output); return { openCommand: async () => ({ close: mocks.close }) }; }); + await expect(provisionPi(f.entry)).rejects.toThrow("destination appeared"); + expect(await readdir(f.output)).toEqual([]); +}); +it("retains a replaced staging root while still releasing its own lock", async () => { + const f = await fixture(); + let moved: string | undefined; + mocks.build.mockImplementationOnce(async ({ outputRoot }: { outputRoot: string }) => { + const temporary = outputRoot.slice(0, outputRoot.indexOf("/package/provider-assets/")); + moved = `${temporary}-original`; await rename(temporary, moved); + await mkdir(temporary); await writeFile(join(temporary, "foreign"), "retain"); + throw new Error("materialization failed"); + }); + await expect(provisionPi(f.entry)).rejects.toThrow("cleanup failed"); + const names = await readdir(f.parent); + expect(names.some(name => name.endsWith(".lock"))).toBe(false); + const replaced = names.find(name => !name.endsWith("-original"))!; + expect(await readFile(join(f.parent, replaced, "foreign"), "utf8")).toBe("retain"); + expect(moved).toBeDefined(); // fixture teardown owns both test-created roots +}); + +it("uses exclusive publication when an empty target appears after the absence check", async () => { + const f = await fixture(); + const actual = await vi.importActual("node:fs/promises"); + mocks.beforeMkdir.mockImplementationOnce(() => undefined); + mocks.beforeMkdir.mockImplementation(async (path: unknown) => { + if (path === f.output) { mocks.beforeMkdir.mockReset(); await actual.mkdir(f.output); } + }); + await expect(provisionPi(f.entry)).rejects.toThrow(/EEXIST/); + expect(await readdir(f.output)).toEqual([]); + expect(await readdir(f.parent)).toEqual([`${process.platform}-${process.arch}`]); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts index f6c3cfeb5c..911b10a540 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts @@ -45,3 +45,40 @@ it("rejects external manifests, links, asset escapes and incomplete authority", vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toThrow("no bound"); }); + +async function serverFixture() { + const path = await root(); + await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + await mkdir(join(path, "dist/vendor/paperclip-runner/cli"), { recursive: true }); + await writeFile(join(path, "dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs"), "// bundled sidecar"); + return path; +} +it("admits the public server's exact vendored layout for readiness and descriptor execution", async () => { + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + const path = await serverFixture(); + const url = pathToFileURL(join(path, "dist/vendor/paperclip-runner/drivers/acpx/pi-installation.js")).href; + expect(resolveRunnerProviderAssetsRoot(url, "pi")).toBe(join(path, "provider-assets/pi")); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + expect(resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toBe(join(path, "provider-assets/pi")); +}); +it("rejects an unrelated server manifest, escaped vendor sidecar and linked manifest", async () => { + const path = await serverFixture(); const outside = await root(); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + await mkdir(join(path, "nested")); + await writeFile(join(path, "nested/package.json"), JSON.stringify({ name: "@paperclipai/server" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "nested/package.json")); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("outside its package root"); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + await rm(join(path, "dist/vendor/paperclip-runner/cli"), { recursive: true }); + await symlink(outside, join(path, "dist/vendor/paperclip-runner/cli")); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("escaped"); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + await rm(join(path, "package.json")); + await writeFile(join(outside, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + await symlink(join(outside, "package.json"), join(path, "package.json")); + expect(() => resolveRunnerProviderAssetsRoot(pathToFileURL(join(path, "dist/vendor/paperclip-runner/drivers/acpx/pi-installation.js")).href, "pi")).toThrow(); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts index 326740c00c..012ccc5fd2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts @@ -4,6 +4,16 @@ import { fileURLToPath } from "node:url"; type NativeProvider = "cursor" | "copilot" | "pi"; const RUNNER_PACKAGE_NAME = "@paperclipai/paperclip-runner"; +const SERVER_PACKAGE_NAME = "@paperclipai/server"; +function assertServerVendorLayout(root: string): void { + const sidecar = join(root, "dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs"); + for (const part of ["dist", "dist/vendor", "dist/vendor/paperclip-runner", "dist/vendor/paperclip-runner/cli"]) { + const path = join(root, part); const info = lstatSync(path); + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path) throw new Error("Runner server vendor layout escaped its package"); + } + const info = lstatSync(sidecar); + if (!info.isFile() || info.isSymbolicLink() || realpathSync(sidecar) !== sidecar) throw new Error("Runner server vendor sidecar is invalid"); +} /** Resolve only runner-owned package assets, including descriptor-loaded sidecars. */ export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: NativeProvider): string { @@ -19,23 +29,23 @@ export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: Nat if (!inside(packageRoot, canonicalManifest)) throw new Error("Runner provider manifest escapes its bound package root"); // The authority comes from runnerd's selected provider pack, never provider // environment. Verify the selected manifest itself before deriving assets. - const fd = openSync(manifest, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const before = fstatSync(fd, { bigint: true }); - if (!before.isFile() || before.size < 1n || before.size > 64n * 1024n) throw new Error("Runner provider manifest is not a bounded regular file"); - const bytes = readFileSync(fd); - const after = fstatSync(fd, { bigint: true }); - if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs - || bytes.length !== Number(before.size) || realpathSync(manifest) !== canonicalManifest) throw new Error("Runner provider manifest changed during admission"); - const value = JSON.parse(bytes.toString("utf8")) as { name?: unknown }; - if (value?.name !== RUNNER_PACKAGE_NAME) throw new Error("Runner provider manifest does not name the runner package"); - } finally { closeSync(fd); } + const value = readPackageManifest(manifest, canonicalManifest); + if (value.name === SERVER_PACKAGE_NAME) { + if (canonicalManifest !== join(packageRoot, "package.json")) throw new Error("Runner server manifest is outside its package root"); + assertServerVendorLayout(packageRoot); + } else if (value.name !== RUNNER_PACKAGE_NAME) throw new Error("Runner provider manifest does not name the runner package"); packageRoot = dirname(canonicalManifest); } else { if (boundManifest !== undefined) throw new Error("Runner provider manifest has no bound package root"); const url = new URL(moduleUrl); if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Provider factory is outside a verified package layout"); - if (new RegExp(`/(?:src|dist)/drivers/acpx/${provider}-installation\\.(?:ts|js)$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../../", url)); + if (new RegExp(`/dist/vendor/paperclip-runner/drivers/acpx/${provider}-installation\\.js$`).test(url.pathname)) { + packageRoot = realpathSync(fileURLToPath(new URL("../../../../../", url))); + const manifest = join(packageRoot, "package.json"); + const metadata = readPackageManifest(manifest, manifest); + if (metadata.name !== SERVER_PACKAGE_NAME) throw new Error("Runner server vendor package identity is invalid"); + assertServerVendorLayout(packageRoot); + } else if (new RegExp(`/(?:src|dist)/drivers/acpx/${provider}-installation\\.(?:ts|js)$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../../", url)); else if (/\/dist\/cli\/acpx-runtime-sidecar\.(?:cjs|js)$/.test(url.pathname)) packageRoot = fileURLToPath(new URL("../../", url)); else throw new Error("Provider factory is outside a verified package layout"); packageRoot = realpathSync(packageRoot); @@ -62,3 +72,18 @@ function inside(root: string, path: string): boolean { const rel = relative(root, path); return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); } + +function readPackageManifest(manifest: string, canonicalManifest: string): { name?: unknown } { + const fd = openSync(manifest, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > 64n * 1024n) throw new Error("Runner provider manifest is not a bounded regular file"); + const bytes = readFileSync(fd); + const after = fstatSync(fd, { bigint: true }); + const named = lstatSync(manifest, { bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs + || named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink() + || bytes.length !== Number(before.size) || realpathSync(manifest) !== canonicalManifest) throw new Error("Runner provider manifest changed during admission"); + return JSON.parse(bytes.toString("utf8")) as { name?: unknown }; + } finally { closeSync(fd); } +} diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 458de962dd..2494e177e9 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -77,3 +77,5 @@ export * from "./generated/capability-contract.js"; export * from "./semantic-tools/index.js"; export * as acceptedCapabilitySemanticTools from "./semantic-tools/index.js"; export * from "./compatibility.js"; + +export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "./drivers/acpx/installation-integrity.js"; diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 730717d33f..3e95942501 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,4 +1,4 @@ -import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "@paperclipai/paperclip-runner/live"; +import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/index.js"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils"; import type { ServerAdapterModule } from "../adapters/index.js"; @@ -17,7 +17,8 @@ import { setOverridePaused, } from "../adapters/registry.js"; -vi.mock("@paperclipai/paperclip-runner/live", () => ({ +vi.mock("../vendor/paperclip-runner/index.js", async (importOriginal) => ({ + ...await importOriginal(), probeAcpxClaudeInstallation: vi.fn(async () => undefined), probeAcpxGrokInstallation: vi.fn(async () => undefined), probeAcpxPiInstallation: vi.fn(async () => undefined), diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 9fe15ad220..63e554ec8c 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -432,7 +432,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], }; } - const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } = await import("@paperclipai/paperclip-runner/live"); + const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } = await import("../vendor/paperclip-runner/index.js"); await (profile.acpxAgent === "pi" ? probeAcpxPiInstallation : profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : probeAcpxClaudeInstallation)(profile.model); return { diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 4142f8fbf4..7fe8cd5558 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -130,3 +130,7 @@ export const NativeProviderTerminalFailure = runner.NativeProviderTerminalFailur export const completeTerminatedRemoteNativeSessionCleanup = runner.completeTerminatedRemoteNativeSessionCleanup; export const completeTerminatedLocalNativeSessionCleanup = runner.completeTerminatedLocalNativeSessionCleanup; + +export const probeAcpxClaudeInstallation = runner.probeAcpxClaudeInstallation; +export const probeAcpxGrokInstallation = runner.probeAcpxGrokInstallation; +export const probeAcpxPiInstallation = runner.probeAcpxPiInstallation;