fix(runner): provision pinned Pi in published server installs

Add explicit host-only setup, verify the public server vendor layout, and route readiness through the packaged runner boundary. Preserve exact Pi closure and normal-mode admission checks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-01 22:45:01 -05:00
1 parent c806b94084
commit c27bbcee25
18 files changed
+607 -39

No files matched your search

+32
View File
@@ -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");
});
+52
View File
@@ -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<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 setupPiRuntime(): Promise<void> {
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<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); }
}
export function registerRuntimeCommands(program: Command): void {
program.command("runtime").description("Manage explicitly installed agent runtimes")
.command("setup <provider>")
.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();
});
}
+3
View File
@@ -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")
+32 -2
View File
@@ -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/<platform>`. 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.
@@ -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. |
@@ -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;
@@ -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";
@@ -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; });
}
@@ -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}`]);
});
@@ -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; });
}
@@ -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);
@@ -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<typeof import("node:fs/promises")>();
return { ...actual, mkdir: async (...args: Parameters<typeof actual.mkdir>) => { 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<typeof import("node:fs/promises")>("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}`]);
});
@@ -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();
});
@@ -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); }
}
+2
View File
@@ -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";
@@ -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<typeof import("../vendor/paperclip-runner/index.js")>(),
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
probeAcpxGrokInstallation: vi.fn(async () => undefined),
probeAcpxPiInstallation: vi.fn(async () => undefined),
+1 -1
View File
@@ -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 {
+4
View File
@@ -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;