mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
Preserve explicit Pi setup network settings and current SDK fixtures
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ec67ba1e05
commit
fcef1eae9b
6 files changed
+77
-15
No files matched your search
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { registerRuntimeCommands, resolvePiProvisioner, resolveRemoteCompanionImporter } from "../commands/runtime.js";
|
||||
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() {
|
||||
@@ -43,3 +43,10 @@ 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" });
|
||||
});
|
||||
@@ -27,12 +27,21 @@ export async function resolveRemoteCompanionImporter(serverUrl: string): Promise
|
||||
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", "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 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" },
|
||||
// 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);
|
||||
|
||||
@@ -1043,8 +1043,12 @@ 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.
|
||||
package must be writable by the installing account. Explicit setup preserves
|
||||
`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR` and
|
||||
`NODE_EXTRA_CA_CERTS` for public downloads, and enables Node's environment proxy
|
||||
handling. The provisioner keeps the same closed allowlist. Instance settings,
|
||||
provider credentials, user npm configuration, `HOME`, `NODE_OPTIONS` and
|
||||
`NODE_PATH` are excluded; TLS certificate validation stays enabled.
|
||||
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import { build } from "esbuild";
|
||||
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 => {
|
||||
@@ -33,13 +34,44 @@ test("public server tar layout carries a self-contained host provisioner and exa
|
||||
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);
|
||||
const network = { PATH: "/usr/bin:/bin", 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" };
|
||||
assert.deepEqual(api.provisionEnvironment({ ...network, HOME: "/private/home", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", NODE_OPTIONS: "--require /foreign.js", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" }), { ...network, LANG: "C.UTF-8" });
|
||||
// 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 });
|
||||
await writeFile(deny, `process.nextTick(()=>{const assert=require('node:assert/strict');assert.equal(process.env.HTTP_PROXY,'http://127.0.0.1:9');assert.equal(process.env.HTTPS_PROXY,'http://127.0.0.1:9');assert.equal(process.env.NO_PROXY,'localhost');assert.equal(process.env.NODE_EXTRA_CA_CERTS,'/dev/null');for(const key of ['OPENROUTER_API_KEY','NPM_TOKEN','HOME','NODE_OPTIONS','NODE_PATH','NODE_TLS_REJECT_UNAUTHORIZED'])assert.equal(process.env[key],undefined,key);});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", HTTP_PROXY: "http://127.0.0.1:9", HTTPS_PROXY: "http://127.0.0.1:9", NO_PROXY: "localhost", NODE_EXTRA_CA_CERTS: "/dev/null", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", HOME: "/private/home", NODE_OPTIONS: "", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" }, 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}`]);
|
||||
});
|
||||
|
||||
test("explicit CLI setup passes only network settings to its real child and enables Node proxy handling", async t => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-setup-environment-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const server = join(root, "node_modules/@paperclipai/server");
|
||||
const cli = join(server, "dist/vendor/paperclip-runner/cli");
|
||||
await mkdir(cli, { recursive: true });
|
||||
await writeFile(join(server, "package.json"), JSON.stringify({ name: "@paperclipai/server", type: "module", exports: "./dist/index.js" }));
|
||||
await writeFile(join(server, "dist/index.js"), "throw Error('server must not start')");
|
||||
// A capture child models the public layout only. It cannot download or launch Pi.
|
||||
await writeFile(join(cli, "provision-pi.cjs"), `const assert=require('node:assert/strict');
|
||||
assert.deepEqual(process.execArgv,['--use-env-proxy']);
|
||||
assert.equal(process.env.HTTP_PROXY,'http://127.0.0.1:9');
|
||||
assert.equal(process.env.HTTPS_PROXY,'http://127.0.0.1:9');
|
||||
assert.equal(process.env.NO_PROXY,'localhost');
|
||||
assert.equal(process.env.SSL_CERT_FILE,'/public/ca.pem');
|
||||
assert.equal(process.env.SSL_CERT_DIR,'/public/certs');
|
||||
assert.equal(process.env.NODE_EXTRA_CA_CERTS,'/dev/null');
|
||||
for(const key of ['OPENROUTER_API_KEY','NPM_TOKEN','HOME','NODE_OPTIONS','NODE_PATH','NODE_TLS_REJECT_UNAUTHORIZED']) assert.equal(process.env[key],undefined,key);
|
||||
console.log('SETUP_NETWORK_BOUNDARY_PASS');`);
|
||||
const modulePath = join(root, "runtime.mjs");
|
||||
await build({ entryPoints: [resolve(dirname(new URL(import.meta.url).pathname), "../../../cli/src/commands/runtime.ts")], outfile: modulePath, bundle: true, platform: "node", format: "esm", target: "node24", logLevel: "silent" });
|
||||
const result = spawnSync(process.execPath, ["--input-type=module", "-e", "const runtime=await import(process.argv[1]);await runtime.setupPiRuntime();", modulePath], {
|
||||
encoding: "utf8", timeout: 10_000, env: { PATH: "/usr/bin:/bin", HTTP_PROXY: "http://127.0.0.1:9", HTTPS_PROXY: "http://127.0.0.1:9", NO_PROXY: "localhost", SSL_CERT_FILE: "/public/ca.pem", SSL_CERT_DIR: "/public/certs", NODE_EXTRA_CA_CERTS: "/dev/null", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", HOME: "/private/home", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" },
|
||||
});
|
||||
assert.ifError(result.error); assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /SETUP_NETWORK_BOUNDARY_PASS/);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, /sensitive-canary/);
|
||||
});
|
||||
@@ -9,6 +9,14 @@ 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 function provisionEnvironment(source = process.env) {
|
||||
const environment = { PATH: source.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" };
|
||||
for (const key of ["LC_ALL", "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 provisionPackageRoot(entrypoint) {
|
||||
const canonical = await realpath(entrypoint);
|
||||
if (canonical !== resolve(entrypoint)) throw new Error("Pi setup entrypoint must not be linked");
|
||||
@@ -116,9 +124,9 @@ export async function provisionPi(entrypoint, checkCancelled = () => {}) {
|
||||
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" };
|
||||
// Preserve the same closed network allowlist as the explicit CLI command.
|
||||
// Never forward provider keys, HOME config, NODE_OPTIONS or npm configuration.
|
||||
const environment = provisionEnvironment();
|
||||
for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, environment);
|
||||
let cancelled = false;
|
||||
const cancel = () => { cancelled = true; };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -10,6 +11,7 @@ import { buildPaperclipServerEnvironment } from "./harness-env.js";
|
||||
import { setupLiveFixtures } from "./live-fixtures.js";
|
||||
import type { RunnerApi } from "./api.js";
|
||||
const cell = runnerMatrix.find(e => e.id === "extended-harnesses.runner-acpx-pi.daytona.hello-complete")!;
|
||||
const sdkVersion: string = JSON.parse(readFileSync(new URL("../../packages/plugins/sdk/package.json", import.meta.url), "utf8")).version;
|
||||
const roots: string[] = [];
|
||||
const hash = (v: string) => createHash("sha256").update(v).digest("hex");
|
||||
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
@@ -21,9 +23,9 @@ async function fixture() {
|
||||
await writeFile(join(dir, "package.json"), content); await writeFile(join(dir, entry), "// compiled");
|
||||
return { root: dir, packageSha256: hash(content), entry, entrySha256: hash("// compiled") };
|
||||
}
|
||||
const plugin = await pkg("@paperclipai/plugin-daytona", "0.1.1", { "@paperclipai/plugin-sdk": "0.3.1", "@daytonaio/sdk": "0.203.0" }, { paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js" } });
|
||||
const plugin = await pkg("@paperclipai/plugin-daytona", "0.1.1", { "@paperclipai/plugin-sdk": sdkVersion, "@daytonaio/sdk": "0.203.0" }, { paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js" } });
|
||||
await writeFile(join(plugin.root, "dist/manifest.js"), "// manifest"); await writeFile(join(plugin.root, "dist/worker.js"), "// worker");
|
||||
const authority: InstalledDaytonaPluginAuthority = { schema: "paperclip.e2e.installed-daytona-plugin/v1", graphRoot: root, plugin: { ...plugin, manifestSha256: hash("// manifest"), workerSha256: hash("// worker") }, sdk: await pkg("@paperclipai/plugin-sdk", "0.3.1", { "@paperclipai/shared": "0.3.1" }), shared: await pkg("@paperclipai/shared", "0.3.1"), daytona: await pkg("@daytonaio/sdk", "0.203.0") };
|
||||
const authority: InstalledDaytonaPluginAuthority = { schema: "paperclip.e2e.installed-daytona-plugin/v1", graphRoot: root, plugin: { ...plugin, manifestSha256: hash("// manifest"), workerSha256: hash("// worker") }, sdk: await pkg("@paperclipai/plugin-sdk", sdkVersion, { "@paperclipai/shared": "0.3.1" }), shared: await pkg("@paperclipai/shared", "0.3.1"), daytona: await pkg("@daytonaio/sdk", "0.203.0") };
|
||||
const authorityPath = join(root, "authority.json");
|
||||
const env: NodeJS.ProcessEnv = { PAPERCLIP_RUNNER_E2E_INSTALLED_CLI: "/reviewed/cli", PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256: "reviewed-separately", PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT: "/reviewed/server", PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256: "reviewed-separately", PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN: plugin.root, PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY: authorityPath };
|
||||
async function seal() { const bytes = JSON.stringify(authority); await writeFile(authorityPath, bytes); env.PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY_SHA256 = hash(bytes); }
|
||||
@@ -51,7 +53,7 @@ it("rejects stale authority, worker, manifest and dependency entry bytes", async
|
||||
});
|
||||
it("rejects source exports and workspace dependency versions even with refreshed pins", async () => {
|
||||
const f = await fixture(); const p = join(f.authority.plugin.root,"package.json");
|
||||
const original = { name:"@paperclipai/plugin-daytona",version:"0.1.1",exports:{".":{import:"./dist/index.js"}},paperclipPlugin:{manifest:"./dist/manifest.js",worker:"./dist/worker.js"},dependencies:{"@paperclipai/plugin-sdk":"0.3.1","@daytonaio/sdk":"0.203.0"} };
|
||||
const original = { name:"@paperclipai/plugin-daytona",version:"0.1.1",exports:{".":{import:"./dist/index.js"}},paperclipPlugin:{manifest:"./dist/manifest.js",worker:"./dist/worker.js"},dependencies:{"@paperclipai/plugin-sdk":sdkVersion,"@daytonaio/sdk":"0.203.0"} };
|
||||
for (const manifest of [{ ...original, exports:{".":{import:"./src/index.ts"}} }, { ...original, dependencies:{ ...original.dependencies,"@paperclipai/plugin-sdk":"workspace:*" } }]) {
|
||||
const bytes=JSON.stringify(manifest);await writeFile(p,bytes);f.authority.plugin.packageSha256=hash(bytes);await f.seal();await expect(verifyInstalledDaytonaPlugin(f.env,[cell])).rejects.toThrow(/compiled package exports|dependency identity/);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user