mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
perf(runner): verify Pi launch bytes once into the native snapshot
Cross-bind the Pi layout to its source-pinned native closure before structural discovery. Preserve full byte hashing in every immutable command snapshot and keep the independent generic verifier unchanged. Add corruption, graph, link, repeated-open and runtime-boundary cleanup regressions. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5a6a531575
commit
7b7fcbf96b
4 files changed
+203
-16
No files matched your search
@@ -4,7 +4,8 @@ import { join } from "node:path";
|
||||
import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js";
|
||||
import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "./pi-closure-pins.js";
|
||||
import { PI_NODE_VERSION } from "./pi-node-pins.js";
|
||||
import { verifyPiRuntimeManifest, type PiRuntimeManifest } from "./pi-verified-runtime.js";
|
||||
import { verifyPiRuntimeLayoutForNativeSnapshot, type PiRuntimeManifest } from "./pi-verified-runtime.js";
|
||||
import { readNativeAcpxDistributionEntries } from "./native-distribution-integrity.js";
|
||||
import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js";
|
||||
import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
|
||||
@@ -69,16 +70,20 @@ export async function verifyPiInstallation(profile: QualifiedAcpxProfile): Promi
|
||||
for (const [key, value] of Object.entries(FIXED_PATHS)) {
|
||||
if (manifest[key as keyof typeof FIXED_PATHS] !== value) throw new Error("Pi distribution changed a fixed launch path");
|
||||
}
|
||||
const verified = await verifyPiRuntimeManifest(runtimeRoot, manifest);
|
||||
if (verified.manifestDigest !== metadata.manifestDigest) throw new Error("Pi runtime manifest digest does not match its distribution");
|
||||
const native = await verifyNativeAcpxInstallation({
|
||||
const declaration = {
|
||||
distributionRoot: runtimeRoot,
|
||||
manifestPath: join(assets, "native-closure.json"),
|
||||
expectedClosureSha256: expectedClosure,
|
||||
executable: FIXED_PATHS.node,
|
||||
entrypoint: "pi-entry.cjs",
|
||||
fixedArguments: [],
|
||||
});
|
||||
};
|
||||
// Both declarations must name exactly the same source-pinned bytes. Actual
|
||||
// content admission remains the descriptor-bound immutable snapshot below.
|
||||
const entries = await readNativeAcpxDistributionEntries(declaration);
|
||||
const layout = await verifyPiRuntimeLayoutForNativeSnapshot(runtimeRoot, manifest, { entries }, expectedClosure);
|
||||
if (layout.manifestDigest !== metadata.manifestDigest) throw new Error("Pi runtime manifest digest does not match its distribution");
|
||||
const native = await verifyNativeAcpxInstallation(declaration);
|
||||
return Object.freeze({
|
||||
commandDigest: profile.commandDigest,
|
||||
agentServerPackageJsonPath: join(runtimeRoot, "node_modules/pi-acp/package.json"),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import * as credentials from "./codex-credentials.js";
|
||||
import * as skills from "./runtime-skill-lease.js";
|
||||
import * as bridge from "../runner-tool-bridge.js";
|
||||
import { verifyNativeAcpxInstallation } from "./installation-integrity.js";
|
||||
import { inventoryPiRuntimeFiles, verifyPiRuntimeLayoutForNativeSnapshot, type PiRuntimeManifest } from "./pi-verified-runtime.js";
|
||||
import { AcpxRuntimeHost } from "./runtime-host.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("rejects corrupt Pi bytes at the command boundary before runtime, spawn or bridge and releases lifetime/skills", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-native-admission-")); roots.push(root);
|
||||
const source = join(root, "provider"); const workspace = join(root, "workspace");
|
||||
await mkdir(source); await mkdir(workspace); await mkdir(join(root, "runtime"));
|
||||
for (const name of ["node", "pi.js", "extension.js", "wrapper.js"]) await writeFile(join(source, name), "pinned:" + name);
|
||||
await chmod(join(source, "node"), 0o500);
|
||||
const manifest: PiRuntimeManifest = { schema: "paperclip.pi-runtime-files.v1", node: "node", piEntrypoint: "pi.js", extension: "extension.js", wrapperEntrypoint: "wrapper.js", files: await inventoryPiRuntimeFiles(source) };
|
||||
const entries = await Promise.all(manifest.files.map(async entry => ({ path: entry.path, sha256: entry.sha256.slice(7), size: (await lstat(join(source, entry.path))).size, executable: entry.path === "node" })));
|
||||
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
||||
const closure = createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
||||
const manifestPath = join(root, "native.json"); await writeFile(manifestPath, JSON.stringify({ entries }));
|
||||
const installation = await verifyNativeAcpxInstallation({ distributionRoot: source, manifestPath, expectedClosureSha256: closure, executable: "node", entrypoint: "wrapper.js", fixedArguments: [] });
|
||||
await writeFile(join(source, "wrapper.js"), "x".repeat(entries.find(entry => entry.path === "wrapper.js")!.size));
|
||||
// Declaration/layout resolution intentionally does not claim byte admission.
|
||||
await verifyPiRuntimeLayoutForNativeSnapshot(source, manifest, { entries }, closure);
|
||||
const lifetimeCloses: ReturnType<typeof vi.fn>[] = []; const skillCloses: ReturnType<typeof vi.fn>[] = [];
|
||||
const acquireLifetime = credentials.acquireAcpxProviderLifetimeLease;
|
||||
vi.spyOn(credentials, "acquireAcpxProviderLifetimeLease").mockImplementation(async input => {
|
||||
const lease = await acquireLifetime(input); const close = vi.fn(() => lease.close()); lifetimeCloses.push(close);
|
||||
return { ...lease, close };
|
||||
});
|
||||
const acquireSkills = skills.createAcpxRuntimeSkillLease;
|
||||
vi.spyOn(skills, "createAcpxRuntimeSkillLease").mockImplementation(async input => {
|
||||
const lease = await acquireSkills(input); const close = vi.fn(() => lease.close()); skillCloses.push(close);
|
||||
return { ...lease, close };
|
||||
});
|
||||
const toolBridge = vi.spyOn(bridge, "startRunnerToolBridge");
|
||||
const openRuntime = vi.fn(); const spawn = vi.fn();
|
||||
const openCommand = vi.fn(async () => {
|
||||
const lease = await installation.openCommand();
|
||||
return { close: () => lease.close(), spawn };
|
||||
});
|
||||
await expect(AcpxRuntimeHost.open({
|
||||
runtimeDirectory: join(root, "runtime"), normalizedSessionId: "pi-single-pass-rejection",
|
||||
workingDirectory: workspace, agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionMode: "approve-all", providerPolicy: { readOnly: true },
|
||||
semanticTools: { tools: [], handler: vi.fn() },
|
||||
}, {
|
||||
verifyInstallation: async profile => ({ commandDigest: profile.commandDigest, agentServerPackageJsonPath: join(source, "package.json"), agentRuntimePackageJsonPath: null, openCommand }),
|
||||
openRuntime, reportRetainedCleanupFailure: vi.fn(),
|
||||
})).rejects.toThrow("digest mismatch");
|
||||
expect(openCommand).toHaveBeenCalledOnce();
|
||||
expect(openRuntime).not.toHaveBeenCalled(); expect(spawn).not.toHaveBeenCalled(); expect(toolBridge).not.toHaveBeenCalled();
|
||||
expect(lifetimeCloses).toHaveLength(1); expect(lifetimeCloses[0]).toHaveBeenCalledOnce();
|
||||
expect(skillCloses).toHaveLength(1); expect(skillCloses[0]).toHaveBeenCalledOnce();
|
||||
expect(lifetimeCloses[0]!.mock.invocationCallOrder[0]).toBeLessThan(skillCloses[0]!.mock.invocationCallOrder[0]!);
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { link, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, symlink, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { chmod, link, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, symlink, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { inventoryPiRuntimeFiles, PI_RUNTIME_MANIFEST_SCHEMA, verifyPiRuntimeManifest, type PiRuntimeManifest } from "./pi-verified-runtime.js";
|
||||
import { inventoryPiRuntimeFiles, PI_RUNTIME_MANIFEST_SCHEMA, verifyPiRuntimeManifest, verifyPiRuntimeLayoutForNativeSnapshot, type PiRuntimeManifest } from "./pi-verified-runtime.js";
|
||||
|
||||
import { verifyNativeAcpxInstallation } from "./installation-integrity.js";
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return { ...original, lstat: vi.fn(original.lstat), readdir: vi.fn(original.readdir) };
|
||||
return { ...original, open: vi.fn(original.open), lstat: vi.fn(original.lstat), readdir: vi.fn(original.readdir) };
|
||||
});
|
||||
|
||||
const temporary: string[] = [];
|
||||
@@ -147,3 +149,78 @@ describe("Pi complete runtime manifest", () => {
|
||||
await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("writable name");
|
||||
});
|
||||
});
|
||||
|
||||
async function nativeFixture() {
|
||||
const { root, manifest } = await fixture();
|
||||
await chmod(join(root, "node"), 0o500);
|
||||
const entries = await Promise.all(manifest.files.map(async entry => ({
|
||||
path: entry.path, sha256: entry.sha256.slice(7), size: (await lstat(join(root, entry.path))).size,
|
||||
executable: entry.path === "node",
|
||||
})));
|
||||
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
||||
const closure = createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
||||
const manifestPath = root + "-native.json"; temporary.push(manifestPath);
|
||||
await writeFile(manifestPath, JSON.stringify({ entries }));
|
||||
const input = { distributionRoot: root, manifestPath, expectedClosureSha256: closure, executable: "node", entrypoint: "wrapper.js", fixedArguments: [] };
|
||||
return { root, manifest, entries, closure, input };
|
||||
}
|
||||
|
||||
describe("Pi native launch layout admission", () => {
|
||||
it("cross-binds declarations without opening content, then freshly hashes every command snapshot", async () => {
|
||||
const f = await nativeFixture();
|
||||
const expected = await verifyPiRuntimeManifest(f.root, f.manifest);
|
||||
vi.mocked(open).mockClear();
|
||||
expect(await verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).toEqual({ manifestDigest: expected.manifestDigest });
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
const installation = await verifyNativeAcpxInstallation(f.input);
|
||||
const lease = await installation.openCommand(); await lease.close();
|
||||
const opened = vi.mocked(open).mock.calls.map(([path]) => String(path));
|
||||
for (const entry of f.entries) expect(opened).toContain(join(await realpath(f.root), entry.path));
|
||||
// No cached installation verdict may authorize a later mutable source.
|
||||
await writeFile(join(f.root, "wrapper.js"), "x".repeat(f.entries.find(e => e.path === "wrapper.js")!.size));
|
||||
await expect(installation.openCommand()).rejects.toThrow("digest mismatch");
|
||||
});
|
||||
|
||||
it("rejects a substituted native pin and any Pi/native declaration disagreement", async () => {
|
||||
const f = await nativeFixture();
|
||||
await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, "0".repeat(64))).rejects.toThrow("manifest digest mismatch");
|
||||
const altered = structuredClone(f.manifest); altered.files[0]!.sha256 = `sha256:${"0".repeat(64)}`;
|
||||
await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, altered, { entries: f.entries }, f.closure)).rejects.toThrow("pinned native closure");
|
||||
altered.files.shift();
|
||||
await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, altered, { entries: f.entries }, f.closure)).rejects.toThrow("pinned native closure");
|
||||
});
|
||||
|
||||
it("rejects extra or missing files during complete structural discovery", async () => {
|
||||
const f = await nativeFixture();
|
||||
await writeFile(join(f.root, "extra"), "not admitted");
|
||||
await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).rejects.toThrow("package graph");
|
||||
await rm(join(f.root, "extra")); await rm(join(f.root, "assets/image.wasm"));
|
||||
await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).rejects.toThrow("package graph");
|
||||
});
|
||||
|
||||
it("rejects links even when their names appear in the pinned closure", async () => {
|
||||
const f = await nativeFixture();
|
||||
await rm(join(f.root, "pi.js")); await symlink("wrapper.js", join(f.root, "pi.js"));
|
||||
await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).rejects.toThrow("package graph");
|
||||
await rm(join(f.root, "pi.js")); await link(join(f.root, "wrapper.js"), join(f.root, "pi.js"));
|
||||
await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).rejects.toThrow("writable name");
|
||||
});
|
||||
|
||||
it("never treats layout success as byte admission for same-size corruption", async () => {
|
||||
const f = await nativeFixture();
|
||||
await writeFile(join(f.root, "wrapper.js"), "x".repeat(f.entries.find(e => e.path === "wrapper.js")!.size));
|
||||
await expect(verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure)).resolves.toHaveProperty("manifestDigest");
|
||||
// The independent generic verifier retains its original full-byte guarantee.
|
||||
await expect(verifyPiRuntimeManifest(f.root, f.manifest)).rejects.toThrow("package graph");
|
||||
const installation = await verifyNativeAcpxInstallation(f.input);
|
||||
await expect(installation.openCommand()).rejects.toThrow("digest mismatch");
|
||||
});
|
||||
|
||||
it("rechecks source identities after layout, before granting any command lease", async () => {
|
||||
const f = await nativeFixture();
|
||||
await verifyPiRuntimeLayoutForNativeSnapshot(f.root, f.manifest, { entries: f.entries }, f.closure);
|
||||
const installation = await verifyNativeAcpxInstallation(f.input);
|
||||
await rm(join(f.root, "wrapper.js")); await symlink("pi.js", join(f.root, "wrapper.js"));
|
||||
await expect(installation.openCommand()).rejects.toThrow("symbolic link");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { constants } from "node:fs";
|
||||
import { lstat, open, readdir, readlink, realpath } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { parseNativeAcpxDistributionEntries } from "./native-distribution-integrity.js";
|
||||
|
||||
export const PI_RUNTIME_MANIFEST_SCHEMA = "paperclip.pi-runtime-files.v1" as const;
|
||||
export interface PiRuntimeFile {
|
||||
path: string;
|
||||
@@ -33,12 +35,8 @@ const PI_INVENTORY_DISCOVERY_CONCURRENCY = 32;
|
||||
|
||||
function hash(bytes: Uint8Array | string): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; }
|
||||
|
||||
/**
|
||||
* Inventory a complete provider pack, not just Pi's cli.js. Native modules,
|
||||
* WASM, data, package metadata, and the owned extension all participate.
|
||||
* Used while building a pack; the caller records/signs this immutable result.
|
||||
*/
|
||||
export async function inventoryPiRuntimeFiles(root: string): Promise<PiRuntimeFile[]> {
|
||||
/** Discover the complete graph with the same path, link and identity checks for both callers. */
|
||||
async function discoverPiRuntimeFiles(root: string): Promise<{ files: PiRuntimeFile[]; regular: Array<{ path: string; entry: PiRuntimeFile }> }> {
|
||||
const physicalRoot = await realpath(root);
|
||||
if ((await lstat(root)).isSymbolicLink()) throw new Error("Pi runtime root must not be a symlink");
|
||||
const files: PiRuntimeFile[] = [];
|
||||
@@ -80,6 +78,16 @@ export async function inventoryPiRuntimeFiles(root: string): Promise<PiRuntimeFi
|
||||
}
|
||||
};
|
||||
await visit(physicalRoot);
|
||||
return { files, regular };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory a complete provider pack, not just Pi's cli.js. Native modules,
|
||||
* WASM, data, package metadata, and the owned extension all participate.
|
||||
* Used while building a pack; the caller records/signs this immutable result.
|
||||
*/
|
||||
export async function inventoryPiRuntimeFiles(root: string): Promise<PiRuntimeFile[]> {
|
||||
const { files, regular } = await discoverPiRuntimeFiles(root);
|
||||
// Keep discovery order independent of completion order. Do not cache: every
|
||||
// admission still reads every regular file through its checked descriptor.
|
||||
for (let index = 0; index < regular.length; index += PI_INVENTORY_HASH_CONCURRENCY) {
|
||||
@@ -114,6 +122,13 @@ async function hashFile(path: string): Promise<string> {
|
||||
export async function verifyPiRuntimeManifest(
|
||||
root: string, manifest: PiRuntimeManifest,
|
||||
): Promise<{ environment: Record<string, string>; manifestDigest: string }> {
|
||||
const { sorted, unique } = validatePiRuntimeManifest(manifest);
|
||||
const actual = (await inventoryPiRuntimeFiles(root)).sort((a, b) => a.path.localeCompare(b.path, "en"));
|
||||
if (JSON.stringify(actual) !== JSON.stringify(sorted)) throw new Error("Pi runtime package graph differs from its qualified manifest");
|
||||
return bindPiRuntimeManifestPaths(root, manifest, sorted, unique);
|
||||
}
|
||||
|
||||
function validatePiRuntimeManifest(manifest: PiRuntimeManifest): { sorted: PiRuntimeFile[]; unique: Set<string> } {
|
||||
if (manifest.schema !== PI_RUNTIME_MANIFEST_SCHEMA || !Array.isArray(manifest.files) || manifest.files.length === 0 || manifest.files.length > 100_000) throw new Error("Pi runtime manifest is invalid");
|
||||
const unique = new Set<string>();
|
||||
for (const entry of manifest.files) {
|
||||
@@ -125,8 +140,10 @@ export async function verifyPiRuntimeManifest(
|
||||
? { path: entry.path, kind: entry.kind, sha256: entry.sha256 }
|
||||
: { path: entry.path, kind: entry.kind, target: entry.target, sha256: entry.sha256 })
|
||||
.sort((a, b) => a.path.localeCompare(b.path, "en"));
|
||||
const actual = (await inventoryPiRuntimeFiles(root)).sort((a, b) => a.path.localeCompare(b.path, "en"));
|
||||
if (JSON.stringify(actual) !== JSON.stringify(sorted)) throw new Error("Pi runtime package graph differs from its qualified manifest");
|
||||
return { sorted, unique };
|
||||
}
|
||||
|
||||
async function bindPiRuntimeManifestPaths(root: string, manifest: PiRuntimeManifest, sorted: PiRuntimeFile[], unique: Set<string>): Promise<{ environment: Record<string, string>; manifestDigest: string }> {
|
||||
const physicalRoot = await realpath(root);
|
||||
const boundPath = async (path: string): Promise<string> => {
|
||||
if (!safeRelative(path) || !unique.has(path)) throw new Error("Pi executable is absent from its manifest");
|
||||
@@ -144,3 +161,26 @@ export async function verifyPiRuntimeManifest(
|
||||
manifestDigest: hash(JSON.stringify({ ...manifest, files: sorted })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pi's native launch already hashes every byte while creating its immutable
|
||||
* command snapshot. Check layout and both declarations here, without doing a
|
||||
* second byte pass. This result is NOT byte admission and cannot launch code;
|
||||
* the caller must still acquire a freshly verified native command snapshot.
|
||||
* The generic full-byte verifier above remains independent of this path.
|
||||
*/
|
||||
export async function verifyPiRuntimeLayoutForNativeSnapshot(
|
||||
root: string, manifest: PiRuntimeManifest,
|
||||
nativeManifest: unknown, expectedClosureSha256: string,
|
||||
): Promise<{ manifestDigest: string }> {
|
||||
const entries = parseNativeAcpxDistributionEntries(nativeManifest, expectedClosureSha256);
|
||||
const { sorted, unique } = validatePiRuntimeManifest(manifest);
|
||||
const nativeFiles: PiRuntimeFile[] = entries.map((entry): PiRuntimeFile => ({ path: entry.path, kind: "file", sha256: `sha256:${entry.sha256}` }))
|
||||
.sort((a, b) => a.path.localeCompare(b.path, "en"));
|
||||
if (JSON.stringify(nativeFiles) !== JSON.stringify(sorted)) throw new Error("Pi runtime manifest differs from its pinned native closure");
|
||||
const { files } = await discoverPiRuntimeFiles(root);
|
||||
const layout = (values: PiRuntimeFile[]) => values.map(({ path, kind }) => ({ path, kind })).sort((a, b) => a.path.localeCompare(b.path, "en"));
|
||||
if (JSON.stringify(layout(files)) !== JSON.stringify(layout(sorted))) throw new Error("Pi runtime package graph differs from its qualified manifest");
|
||||
const { manifestDigest } = await bindPiRuntimeManifestPaths(root, manifest, sorted, unique);
|
||||
return { manifestDigest };
|
||||
}
|
||||
Reference in new issue
Block a user