mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +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")
|
||||
|
||||
Reference in new issue
Block a user