diff --git a/package.json b/package.json index 97d7ec547c..744719a708 100644 --- a/package.json +++ b/package.json @@ -103,8 +103,7 @@ "acpx@0.13.1": "patches/acpx@0.13.1.patch", "@agentclientprotocol/claude-agent-acp@0.70.0": "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch", "@agentclientprotocol/claude-agent-acp@0.73.0": "patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch", - "@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch", - "node@24.11.0": "patches/node@24.11.0.patch" + "@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch" }, "overrides": { "rollup": ">=4.59.0", diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index b879002160..7338a9fa06 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -158,13 +158,13 @@ "acpx": "0.13.1", "ajv": "^8.20.0", "json-schema-to-ts": "^3.1.1", - "node": "24.11.0", "opencode-ai": "1.18.17", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1" }, "optionalDependencies": { - "@openai/codex-linux-x64": "npm:@openai/codex@0.148.0-linux-x64" + "@openai/codex-linux-x64": "npm:@openai/codex@0.148.0-linux-x64", + "node-linux-x64": "24.11.0" }, "peerDependencies": { "react": ">=18", diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 5409aeb05e..0ced556372 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -24,16 +24,14 @@ const outputRoot = resolve( outputArgument ?? join(packageRoot, "provider-pack"), ); if ( - outputRoot === workspaceRoot - || outputRoot === packageRoot - || outputRoot === "/" + outputRoot === workspaceRoot || + outputRoot === packageRoot || + outputRoot === "/" ) { throw new Error(`Refusing unsafe provider-pack output path: ${outputRoot}`); } -const temporaryParent = mkdtempSync( - join(tmpdir(), "paperclip-provider-pack-"), -); +const temporaryParent = mkdtempSync(join(tmpdir(), "paperclip-provider-pack-")); const temporaryRoot = join(temporaryParent, "pack"); function canonicalJson(value) { @@ -56,8 +54,9 @@ function sha256File(path) { function sha256Tree(root) { const hash = createHash("sha256"); const visit = (directory, prefix = "") => { - const entries = readdirSync(directory, { withFileTypes: true }) - .sort((left, right) => left.name.localeCompare(right.name)); + const entries = readdirSync(directory, { withFileTypes: true }).sort( + (left, right) => left.name.localeCompare(right.name), + ); for (const entry of entries) { const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; const absolutePath = join(directory, entry.name); @@ -67,9 +66,13 @@ function sha256Tree(root) { } else if (entry.isFile()) { hash.update(`file\0${relativePath}\0${sha256File(absolutePath)}\n`); } else if (entry.isSymbolicLink()) { - hash.update(`symlink\0${relativePath}\0${readlinkSync(absolutePath)}\n`); + hash.update( + `symlink\0${relativePath}\0${readlinkSync(absolutePath)}\n`, + ); } else { - throw new Error(`Provider pack tree contains unsupported entry ${relativePath}`); + throw new Error( + `Provider pack tree contains unsupported entry ${relativePath}`, + ); } } }; @@ -123,6 +126,22 @@ try { throw new Error(`pnpm deploy failed with exit code ${deployed.status}`); } + // The generic `node` npm package runs an install-time downloader. Depend on + // the immutable Linux x64 artifact directly, then expose it at the stable + // pack-owned path consumed by the verified launch contract. + const packagedNodeRoot = join( + temporaryRoot, + "node_modules", + "node-linux-x64", + ); + const stableNodeRoot = join(temporaryRoot, "node_modules", "node"); + if (!existsSync(packagedNodeRoot) || existsSync(stableNodeRoot)) { + throw new Error( + "Provider pack requires the pinned node-linux-x64 artifact and an unclaimed stable Node path", + ); + } + renameSync(packagedNodeRoot, stableNodeRoot); + // pnpm's generated .bin shims embed the temporary deployment directory in // NODE_PATH. That makes an otherwise identical provider pack hash differ on // every build and leaks a nonexistent host path after relocation. Replace @@ -193,7 +212,10 @@ try { const opencodeExecutable = "node_modules/opencode-ai/bin/opencode.exe"; const nodeCommand = "node_modules/node/bin/node"; const productionLock = "pnpm-lock.yaml"; - copyFileSync(join(workspaceRoot, "pnpm-lock.yaml"), join(temporaryRoot, productionLock)); + copyFileSync( + join(workspaceRoot, "pnpm-lock.yaml"), + join(temporaryRoot, productionLock), + ); for (const relativePath of [ nodeCommand, productionLock, @@ -207,16 +229,14 @@ try { } } - const opencodeProxySha = sha256File( - join(temporaryRoot, opencodeProxyPath), - ); + const opencodeProxySha = sha256File(join(temporaryRoot, opencodeProxyPath)); const acpxSidecarSha = sha256File(join(temporaryRoot, acpxSidecarPath)); const distDigest = sha256Tree(join(temporaryRoot, "dist")); const configuredRevision = process.env.PAPERCLIP_RUNNER_SOURCE_REVISION?.trim(); const revision = - configuredRevision - ?? execFileSync("git", ["rev-parse", "HEAD"], { + configuredRevision ?? + execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot, encoding: "utf8", }).trim(); @@ -225,11 +245,9 @@ try { } const dirty = configuredRevision ? false - : spawnSync( - "git", - ["diff", "--quiet", "--", "packages/paperclip-runner"], - { cwd: workspaceRoot }, - ).status !== 0; + : spawnSync("git", ["diff", "--quiet", "--", "packages/paperclip-runner"], { + cwd: workspaceRoot, + }).status !== 0; const payload = { pins: { nodeMinimum: "24.11.0", diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs index 422649955c..733cc3593e 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -30,10 +30,6 @@ const claudePatch = await readFile( ), "utf8", ); -const nodePatch = await readFile( - new URL("../../../patches/node@24.11.0.patch", import.meta.url), - "utf8", -); const qualifiedProfiles = await readFile( new URL("../src/drivers/acpx/qualified-profiles.ts", import.meta.url), "utf8", @@ -58,12 +54,13 @@ const nativeSessionExecutor = await readFile( ); test("the runner pins every qualified ACPX production dependency", () => { - assert.equal(runnerPackage.dependencies.node, "24.11.0"); assert.equal(runnerPackage.dependencies["@openai/codex"], "0.148.0"); assert.equal( runnerPackage.optionalDependencies["@openai/codex-linux-x64"], "npm:@openai/codex@0.148.0-linux-x64", ); + assert.equal(runnerPackage.optionalDependencies["node-linux-x64"], "24.11.0"); + assert.equal(runnerPackage.dependencies.node, undefined); assert.equal(runnerPackage.dependencies.acpx, "0.13.1"); assert.equal( runnerPackage.dependencies["@agentclientprotocol/codex-acp"], @@ -76,31 +73,30 @@ test("the runner pins every qualified ACPX production dependency", () => { }); test("the patched Codex ACP command digest stays aligned across launch boundaries", () => { - const profileMatch = /agent: "codex"[\s\S]*?commandDigest:\s*"(sha256:[a-f0-9]{64})"/.exec( - qualifiedProfiles, - ); + const profileMatch = + /agent: "codex"[\s\S]*?commandDigest:\s*"(sha256:[a-f0-9]{64})"/.exec( + qualifiedProfiles, + ); assert.ok(profileMatch, "qualified Codex ACPX profile digest"); const digest = profileMatch[1]; - assert.match( - runnerdAcpxBackend, - new RegExp(`"codex"[\\s\\S]*?${digest}`), - ); + assert.match(runnerdAcpxBackend, new RegExp(`"codex"[\\s\\S]*?${digest}`)); assert.match( providerPackBuilder, new RegExp(`acpxProfileDigests:[\\s\\S]*?codex:[\\s\\S]*?${digest}`), ); assert.match( nativeSessionExecutor, - new RegExp(`REMOTE_PROVIDER_PACK_PROFILE_DIGESTS[\\s\\S]*?codex:[\\s\\S]*?${digest}`), + new RegExp( + `REMOTE_PROVIDER_PACK_PROFILE_DIGESTS[\\s\\S]*?codex:[\\s\\S]*?${digest}`, + ), ); }); test("the package exposes only the reviewed runner CLI binaries", () => { assert.deepEqual(runnerPackage.bin, { "paperclip-runner-eval-session": "./dist/cli/eval-session.js", - "paperclip-runner-codex-proxy": - "./dist/cli/codex-app-server-unix-proxy.js", + "paperclip-runner-codex-proxy": "./dist/cli/codex-app-server-unix-proxy.js", "paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js", "paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js", @@ -133,13 +129,12 @@ test("old and new pnpm configuration both apply the exact runtime patches", () = workspace, /claude-agent-acp@0\.70\.0': patches\/@agentclientprotocol__claude-agent-acp@0\.70\.0\.patch/, ); - assert.equal( - rootPackage.pnpm.patchedDependencies["node@24.11.0"], - "patches/node@24.11.0.patch", + assert.equal(rootPackage.pnpm.patchedDependencies["node@24.11.0"], undefined); + assert.doesNotMatch(workspace, /node@24\.11\.0:/); + assert.match( + providerPackBuilder, + /renameSync\(packagedNodeRoot, stableNodeRoot\)/, ); - assert.match(workspace, /node@24\.11\.0: patches\/node@24\.11\.0\.patch/); - assert.match(nodePatch, /- "bin": \{/); - assert.match(nodePatch, /- "node": "bin\/node"/); }); test("the ACPX patch preserves launch-only state and verified spawning", () => { diff --git a/patches/node@24.11.0.patch b/patches/node@24.11.0.patch deleted file mode 100644 index 2278170a9c..0000000000 --- a/patches/node@24.11.0.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/package.json b/package.json ---- a/package.json -+++ b/package.json -@@ -14,9 +14,6 @@ - "scripts": { - "preinstall": "node installArchSpecificPackage" - }, -- "bin": { -- "node": "bin/node" -- }, - "dependencies": { - "node-bin-setup": "^1.0.0" - }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ac292bef3..9723d1c65f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,6 @@ patchedDependencies: embedded-postgres@18.1.0-beta.16: patches/embedded-postgres@18.1.0-beta.16.patch acpx@0.12.0: patches/acpx@0.12.0.patch acpx@0.13.1: patches/acpx@0.13.1.patch - '@agentclientprotocol/claude-agent-acp@0.70.0': patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch - '@agentclientprotocol/claude-agent-acp@0.73.0': patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch - '@agentclientprotocol/codex-acp@1.6.2': patches/@agentclientprotocol__codex-acp@1.6.2.patch - node@24.11.0: patches/node@24.11.0.patch + "@agentclientprotocol/claude-agent-acp@0.70.0": patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch + "@agentclientprotocol/claude-agent-acp@0.73.0": patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch + "@agentclientprotocol/codex-acp@1.6.2": patches/@agentclientprotocol__codex-acp@1.6.2.patch