mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Reconcile Pi prerequisites with the integrated runtime while holding admission
This commit is contained in:
1 parent
20ce062701
commit
f620e7157b
313 files changed
+13086
-2187
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { mkdtemp, mkdir, realpath, 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 { buildPiSetupEnvironment, registerRuntimeCommands, resolvePiProvisioner, resolveRemoteCompanionImporter } 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 realpath(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("rejects unsupported runtime setup providers before resolution", async () => {
|
||||
const program = new Command(); registerRuntimeCommands(program);
|
||||
await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported runtime setup: paperclipai runtime setup pi or cursor");
|
||||
});
|
||||
|
||||
it("resolves the companion importer only inside the actual public server tar layout", async () => {
|
||||
const f = await fixture(); const path = join(f.root, "dist/services/native-runtime/remote-pi-companion.js");
|
||||
await mkdir(join(f.root, "dist/services/native-runtime"), { recursive: true });
|
||||
await writeFile(path, "throw new Error('resolution must not execute importer')");
|
||||
expect(await resolveRemoteCompanionImporter(f.url)).toBe(path);
|
||||
const other = await fixture(); await rm(path); await symlink(join(other.cli, "provision-pi.cjs"), path);
|
||||
await expect(resolveRemoteCompanionImporter(f.url)).rejects.toThrow("escapes");
|
||||
});
|
||||
it("requires an explicit digest for the companion import command", async () => {
|
||||
const program = new Command(); program.exitOverride().configureOutput({ writeErr() {} }); registerRuntimeCommands(program);
|
||||
await expect(program.parseAsync(["node", "paperclipai", "runtime", "import-remote", "/unused"])).rejects.toThrow("sha256");
|
||||
});
|
||||
|
||||
it("passes explicit setup network settings without provider keys or ambient Node/npm configuration", () => {
|
||||
const network = { PATH: "/public/bin", LC_ALL: "C.UTF-8", HTTPS_PROXY: "http://proxy.example:8080", HTTP_PROXY: "http://proxy.example:8080", NO_PROXY: "localhost", SSL_CERT_FILE: "/public/ca.pem", SSL_CERT_DIR: "/public/certs", NODE_EXTRA_CA_CERTS: "/public/extra-ca.pem" };
|
||||
const environment = buildPiSetupEnvironment({ ...network, LANG: "foreign", HOME: "/private/home", OPENROUTER_API_KEY: "must-not-forward", ANTHROPIC_API_KEY: "must-not-forward", NPM_TOKEN: "must-not-forward", npm_config_registry: "https://foreign.example", NODE_OPTIONS: "--require /foreign.js", NODE_PATH: "/foreign/modules", NODE_TLS_REJECT_UNAUTHORIZED: "0" });
|
||||
expect(environment).toEqual({ ...network, LANG: "C.UTF-8" });
|
||||
expect(buildPiSetupEnvironment({})).toEqual({ PATH: "/usr/bin:/bin", LANG: "C.UTF-8" });
|
||||
});
|
||||
|
||||
it("preserves lowercase proxy settings and leaves precedence to Node/npm", () => {
|
||||
const lower = { http_proxy: "http://lower-proxy.example:8080", https_proxy: "http://lower-proxy.example:8080", no_proxy: "localhost" };
|
||||
expect(buildPiSetupEnvironment(lower)).toEqual({ PATH: "/usr/bin:/bin", LANG: "C.UTF-8", ...lower });
|
||||
expect(buildPiSetupEnvironment({ ...lower, HTTPS_PROXY: "http://upper-proxy.example:8080", OPENROUTER_API_KEY: "must-not-forward" })).toEqual({ PATH: "/usr/bin:/bin", LANG: "C.UTF-8", ...lower, HTTPS_PROXY: "http://upper-proxy.example:8080" });
|
||||
});
|
||||
@@ -1,9 +1,63 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { lstat, readFile, realpath } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import type { Command } from "commander";
|
||||
|
||||
/** Resolve the public server dependency, without importing/starting the server. */
|
||||
export async function resolvePiProvisioner(serverUrl: string): Promise<string> {
|
||||
const url = new URL(serverUrl);
|
||||
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Pi setup requires an installed Paperclip server");
|
||||
const entry = await realpath(fileURLToPath(url));
|
||||
if (!entry.endsWith("/dist/index.js")) throw new Error("Pi setup requires the published server layout");
|
||||
const root = resolve(dirname(entry), "..");
|
||||
const manifest = join(root, "package.json");
|
||||
const info = await lstat(manifest);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Pi setup server package identity is invalid");
|
||||
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-pi.cjs");
|
||||
if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Pi setup entrypoint escapes its server package");
|
||||
return provisioner;
|
||||
}
|
||||
|
||||
export async function resolveRemoteCompanionImporter(serverUrl: string): Promise<string> {
|
||||
const provisioner = await resolvePiProvisioner(serverUrl);
|
||||
const serverRoot = resolve(dirname(provisioner), "../../../..");
|
||||
const modulePath = join(serverRoot, "dist/services/native-runtime/remote-pi-companion.js");
|
||||
if (await realpath(modulePath) !== modulePath || !(await lstat(modulePath)).isFile()) throw new Error("Remote companion importer escapes its installed server");
|
||||
return modulePath;
|
||||
}
|
||||
|
||||
/** Public downloads may use operator network settings, never application secrets. */
|
||||
export function buildPiSetupEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const environment: NodeJS.ProcessEnv = { PATH: source.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" };
|
||||
for (const key of ["LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"]) {
|
||||
if (typeof source[key] === "string") environment[key] = source[key];
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
export async function setupPiRuntime(): Promise<void> {
|
||||
const provisioner = await resolvePiProvisioner(import.meta.resolve("@paperclipai/server"));
|
||||
// Only explicit setup downloads. Enable Node's proxy handling for the helper;
|
||||
// provider keys, npm configuration, HOME and Node injection remain excluded.
|
||||
const child = spawn(process.execPath, ["--use-env-proxy", provisioner], {
|
||||
stdio: "inherit", env: buildPiSetupEnvironment(),
|
||||
});
|
||||
const cancel = () => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); };
|
||||
process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
|
||||
try {
|
||||
await new Promise<void>((accept, reject) => {
|
||||
let spawnError: Error | undefined;
|
||||
child.on("error", error => { spawnError = error; });
|
||||
child.once("close", (code, signal) => {
|
||||
if (spawnError) reject(spawnError);
|
||||
else if (code !== 0 || signal) reject(new Error("Pi setup did not finish. Review its error; an existing invalid installation is never replaced automatically."));
|
||||
else accept();
|
||||
});
|
||||
});
|
||||
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
||||
}
|
||||
|
||||
/** Resolve the installed public dependency without importing or starting it. */
|
||||
export async function resolveCursorProvisioner(serverUrl: string): Promise<string> {
|
||||
const url = new URL(serverUrl);
|
||||
@@ -39,12 +93,29 @@ export async function setupCursorRuntime(): Promise<void> {
|
||||
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
||||
}
|
||||
|
||||
|
||||
export function registerRuntimeCommands(program: Command): void {
|
||||
program.command("runtime").description("Manage explicitly installed agent runtimes")
|
||||
.command("setup <provider>")
|
||||
.description("Install and verify the pinned Cursor runtime for this host (public downloads; no model calls)")
|
||||
const runtime = program.command("runtime").description("Manage explicitly installed agent runtimes");
|
||||
runtime.command("import-remote <directory>")
|
||||
.description("Import a verified Linux Pi companion into the installed server (no downloads)")
|
||||
.requiredOption("--sha256 <digest>", "SHA256 of companion.json from the trusted release")
|
||||
.action(async (directory: string, options: { sha256: string }) => {
|
||||
const modulePath = await resolveRemoteCompanionImporter(import.meta.resolve("@paperclipai/server"));
|
||||
const importer = await import(pathToFileURL(modulePath).href) as { importRemotePiCompanion(input: { directory: string; sha256: string; checkCancelled: () => void }): Promise<unknown> };
|
||||
let cancelled = false;
|
||||
const cancel = () => { cancelled = true; };
|
||||
process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
|
||||
try {
|
||||
const result = await importer.importRemotePiCompanion({ directory, sha256: options.sha256,
|
||||
checkCancelled: () => { if (cancelled) throw new Error("Remote companion import cancelled"); } });
|
||||
console.log(JSON.stringify(result));
|
||||
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
||||
});
|
||||
runtime.command("setup <provider>")
|
||||
.description("Install and verify the pinned Pi or Cursor runtime for this host (public downloads; no model calls)")
|
||||
.action(async (provider: string) => {
|
||||
if (provider !== "cursor") throw new Error("Supported runtime setup: paperclipai runtime setup cursor");
|
||||
await setupCursorRuntime();
|
||||
if (provider === "pi") await setupPiRuntime();
|
||||
else if (provider === "cursor") await setupCursorRuntime();
|
||||
else throw new Error("Supported runtime setup: paperclipai runtime setup pi or cursor");
|
||||
});
|
||||
}
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import { registerRuntimeCommands } from "./commands/runtime.js";
|
||||
import { registerEmailCommands } from "./commands/client/email.js";
|
||||
import { registerMcpCommands } from "./commands/mcp.js";
|
||||
import { Command } from "commander";
|
||||
@@ -32,7 +33,6 @@ import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerRuntimeCommands } from "./commands/runtime.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||
import { registerConnectCommand } from "./commands/client/connect.js";
|
||||
@@ -103,6 +103,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") {
|
||||
@@ -126,6 +127,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
});
|
||||
|
||||
registerTestDriveCommand(program);
|
||||
registerRuntimeCommands(program);
|
||||
|
||||
program
|
||||
.command("onboard")
|
||||
@@ -262,7 +264,6 @@ registerSecretCommands(program);
|
||||
registerSkillsCommands(program);
|
||||
registerTeamCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerRuntimeCommands(program);
|
||||
registerEnvLabCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
|
||||
Reference in new issue
Block a user