mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
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:
1 parent
c806b94084
commit
c27bbcee25
18 files changed
+607
-39
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user