diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts index e6815a43a5..654ddfa476 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -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"), diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-native-admission.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-native-admission.test.ts new file mode 100644 index 0000000000..7d2fec870c --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-native-admission.test.ts @@ -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[] = []; const skillCloses: ReturnType[] = []; + 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]!); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts index dcf73b048c..6a08c7a3f9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.test.ts @@ -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(); - 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"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts index 541e01cd87..d5399439b5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-verified-runtime.ts @@ -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 { +/** 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 { + 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 { export async function verifyPiRuntimeManifest( root: string, manifest: PiRuntimeManifest, ): Promise<{ environment: Record; 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 } { 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(); 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): Promise<{ environment: Record; manifestDigest: string }> { const physicalRoot = await realpath(root); const boundPath = async (path: string): Promise => { 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 }; +}