diff --git a/packages/paperclip-runner/src/control-plane/acpx-controller-recovery.test.ts b/packages/paperclip-runner/src/control-plane/acpx-controller-recovery.test.ts index 446322a0ec9..0f63269c5ee 100644 --- a/packages/paperclip-runner/src/control-plane/acpx-controller-recovery.test.ts +++ b/packages/paperclip-runner/src/control-plane/acpx-controller-recovery.test.ts @@ -8,6 +8,7 @@ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { beforeAll, expect, it, vi } from "vitest"; import { DurablePrpControlPlane, decodeDurablePrpCheckpoint, type DurablePrpCheckpoint } from "./durable-prp-control-plane.js"; +import { copyOwnedNodeRuntime } from "../test-support/owned-node-runtime.js"; import { GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/generated-sidecar-contract.js"; import { createCapabilityRunnerdCodexTransport } from "../live/runnerd-codex-transport.js"; @@ -66,6 +67,7 @@ function sidecarSource(root: string) { async function fixture() { const root = mkdtempSync(resolve(tmpdir(), "acpx-controller-recovery-")); + const { executable: nodeCommand } = await copyOwnedNodeRuntime(resolve(root, "node")); const stateDirectory = resolve(root, "control-plane"); const script = resolve(root, "sidecar.cjs"); writeFileSync(script, sidecarSource(root), { mode: 0o700 }); @@ -74,7 +76,7 @@ async function fixture() { agent: "claude", model: "scripted-model", acpxVersion: profiles.acpxVersion, agentServerPackage: profile.agentServerPackage, agentServerVersion: profile.agentServerVersion, agentRuntimePackage: profile.agentRuntimePackage, agentRuntimeVersion: profile.agentRuntimeVersion, - commandDigest: profile.commandDigest, sidecarCommand: process.execPath, sidecarArgs: [script], + commandDigest: profile.commandDigest, sidecarCommand: nodeCommand, sidecarArgs: [script], runtimeDirectory: runtime, normalizedSessionId: identity.normalizedSessionId, runId: identity.runId, cwd: root, instructions: "Do the supplied synthetic task.", permissionMode: "approve-reads", permissionModePinned: true, runtimeContext: null }; @@ -95,7 +97,7 @@ async function fixture() { "--run-id",identity.runId,"--session-id",identity.normalizedSessionId,"--turn-id",identity.turnId,"--item-id",identity.itemId, "--runner-version",expectedRunnerVersion,"--runner-digest",expectedRunnerDigest,"--max-runtime-ms","0", "--reconnect-grace-ms","15000","--reconnect-delay-ms","25","--acpx-launch-authority-digest","sha256:"+"d".repeat(64), - "--acpx-sidecar-command",process.execPath,"--acpx-sidecar-command-sha256",digest(process.execPath), + "--acpx-sidecar-command",nodeCommand,"--acpx-sidecar-command-sha256",digest(nodeCommand), "--acpx-sidecar-script",script,"--acpx-sidecar-script-sha256",digest(script)]; const startedAt = new Date().toISOString(); const child = spawn(binary,args,{env:{PATH:process.env.PATH,PAPERCLIP_RUNNER_BOOTSTRAP_TICKET:ticket},stdio:["ignore","ignore","pipe"],detached:true}); diff --git a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts index 438a3db12e3..397cd56a238 100644 --- a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { ChildProcess } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyOwnedNodeRuntime } from "../../test-support/owned-node-runtime.js"; import { awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, verifyNativeAcpxInstallation } from "./installation-integrity.js"; import { createNativeAcpxDistributionSnapshot, readNativeAcpxDistributionEntries, parseNativeAcpxDistributionEntries, type NativeAcpxDistributionInput, type NativeAcpxDistributionEntry } from "./native-distribution-integrity.js"; import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js"; @@ -27,14 +28,10 @@ async function fixture(options: { node?: boolean; script?: string } = {}): Promi const root = await mkdtemp(join(tmpdir(), "native-acpx-test-")); roots.push(root); const entries: NativeAcpxDistributionEntry[] = []; if (options.node) { - await copyFile(process.execPath, join(root, "runtime")); await chmod(join(root, "runtime"), 0o700); - const bytes = await readFile(join(root, "runtime")); entries.push({ path: "runtime", sha256: hash(bytes), size: bytes.length, executable: true }); - // Homebrew's test-host Node uses an @rpath libnode dylib. Include that - // dependency in this fixture's closure instead of relying on its old path. - const libraryRoot = join(dirname(process.execPath), "..", "lib"); - for (const name of (await readdir(libraryRoot).catch(() => [] as string[])).filter(name => /^libnode\..*\.dylib$/.test(name))) { - await copyFile(join(libraryRoot, name), join(root, name)); await chmod(join(root, name), 0o600); - const library = await readFile(join(root, name)); entries.push({ path: name, sha256: hash(library), size: library.length, executable: false }); + const { executable, libraries } = await copyOwnedNodeRuntime(root); + const bytes = await readFile(executable); entries.push({ path: "runtime", sha256: hash(bytes), size: bytes.length, executable: true }); + for (const libraryPath of libraries) { + const library = await readFile(libraryPath); entries.push({ path: libraryPath.slice(root.length + 1), sha256: hash(library), size: library.length, executable: false }); } const script = options.script ?? 'console.log("qualified-entry");'; await writeFile(join(root, "entry.cjs"), script, { mode: 0o600 }); diff --git a/packages/paperclip-runner/src/test-support/owned-node-runtime.test.ts b/packages/paperclip-runner/src/test-support/owned-node-runtime.test.ts new file mode 100644 index 00000000000..f99e6e3dafc --- /dev/null +++ b/packages/paperclip-runner/src/test-support/owned-node-runtime.test.ts @@ -0,0 +1,55 @@ +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { copyOwnedNodeRuntime } from "./owned-node-runtime.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); +}); + +it("copies writable host artifacts into an owned closure without changing the source", async () => { + const root = await mkdtemp(join(tmpdir(), "owned-node-test-")); roots.push(root); + const bin = join(root, "host", "bin"), lib = join(root, "host", "lib"); + await mkdir(bin, { recursive: true }); await mkdir(lib); + const source = join(bin, "node"), library = join(lib, "libnode.137.dylib"); + await writeFile(source, "synthetic Node artifact"); await chmod(source, 0o777); + await writeFile(library, "synthetic library"); await chmod(library, 0o666); + await writeFile(join(lib, "unrelated.dylib"), "not in the Node closure"); + const sourceMode = (await stat(source)).mode, libraryMode = (await stat(library)).mode; + + const owned = await copyOwnedNodeRuntime(join(root, "owned"), source); + + expect(owned.libraries).toEqual([join(root, "owned", "libnode.137.dylib")]); + expect(await readFile(owned.executable)).toEqual(await readFile(source)); + expect(await readFile(owned.libraries[0]!)).toEqual(await readFile(library)); + expect((await lstat(owned.executable)).isSymbolicLink()).toBe(false); + if (process.platform !== "win32") { + expect((await stat(owned.executable)).mode & 0o777).toBe(0o700); + expect((await stat(owned.libraries[0]!)).mode & 0o777).toBe(0o600); + } + expect((await stat(source)).mode).toBe(sourceMode); + expect((await stat(library)).mode).toBe(libraryMode); +}); + +it("copies the real host Node bytes without executing the runtime", async () => { + const root = await mkdtemp(join(tmpdir(), "owned-node-test-")); roots.push(root); + const owned = await copyOwnedNodeRuntime(root); + expect((await readFile(owned.executable)).equals(await readFile(process.execPath))).toBe(true); + expect((await lstat(owned.executable)).isFile()).toBe(true); + if (process.platform !== "win32") expect((await stat(owned.executable)).mode & 0o022).toBe(0); +}); + +it("rejects a broken adjacent library directory instead of returning an incomplete closure", async () => { + const root = await mkdtemp(join(tmpdir(), "owned-node-test-")); roots.push(root); + const bin = join(root, "host", "bin"), lib = join(root, "host", "lib"); + await mkdir(bin, { recursive: true }); + const source = join(bin, "node"); + await writeFile(source, "synthetic Node artifact"); + await writeFile(lib, "a file cannot be the library directory"); + await expect(copyOwnedNodeRuntime(join(root, "owned"), source)).rejects.toMatchObject({ + message: `Cannot read Node fixture libraries at ${lib}`, + cause: { code: "ENOTDIR" }, + }); +}); diff --git a/packages/paperclip-runner/src/test-support/owned-node-runtime.ts b/packages/paperclip-runner/src/test-support/owned-node-runtime.ts new file mode 100644 index 00000000000..2575061f1f8 --- /dev/null +++ b/packages/paperclip-runner/src/test-support/owned-node-runtime.ts @@ -0,0 +1,24 @@ +import { chmod, copyFile, mkdir, readdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +/** Copy the test host Node closure without changing its shared executable. */ +export async function copyOwnedNodeRuntime(directory: string, source = process.execPath) { + await mkdir(directory, { recursive: true }); + const executable = join(directory, "runtime"); + await copyFile(source, executable); + await chmod(executable, 0o700); + // Homebrew Node resolves its libnode dylib next to the relocated executable. + const libraryRoot = join(dirname(source), "..", "lib"); + const libraries: string[] = []; + const names = await readdir(libraryRoot).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return [] as string[]; + throw new Error(`Cannot read Node fixture libraries at ${libraryRoot}`, { cause: error }); + }); + for (const name of names.filter(name => /^libnode\..*\.dylib$/.test(name))) { + const library = join(directory, name); + await copyFile(join(libraryRoot, name), library); + await chmod(library, 0o600); + libraries.push(library); + } + return { executable, libraries }; +}