From 3d8720c16f14b17c72e0b024631292dbc75c9d04 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 3 Sep 2026 01:45:52 -0500 Subject: [PATCH] fix(runner): restore rooted ACPX package resolution --- packages/paperclip-runner/package.json | 5 +++ .../runner-core/src/acpx_sidecar_transport.rs | 1 + .../durable-prp-control-plane.test.ts | 5 +++ .../durable-prp-control-plane.ts | 1 + .../acpx/installation-integrity.test.ts | 33 +++++++++++++++++++ .../drivers/acpx/installation-integrity.ts | 27 +++++++++++++-- .../src/live/runnerd-codex-transport.test.ts | 25 +++++++++++--- .../src/live/runnerd-codex-transport.ts | 23 ++++++++++--- .../test/acpx-codex-package-contract.test.mjs | 6 ++++ pnpm-lock.yaml | 24 ++++++++++++++ 10 files changed, 139 insertions(+), 11 deletions(-) diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 38478ea72d..b879002160 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -154,13 +154,18 @@ "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.70.0", "@agentclientprotocol/codex-acp": "1.6.2", + "@openai/codex": "0.148.0", "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" + }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 66fb60adc0..55f7b25335 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -120,6 +120,7 @@ impl AcpxSidecarTransport { "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", + "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", ]; keys.extend_from_slice(credential_keys); Self::start_with_environment_keys(config, &keys) diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index fcf8b7309e..bcf5b70085 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -208,6 +208,8 @@ it("preserves the controller-selected ACPX provider package root", () => { environment: { PATH: "/bin", PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack", + PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: + "/verified/provider-pack/package.json", NODE_PATH: "/untrusted/modules", }, processLauncher: (spec) => { @@ -233,6 +235,9 @@ it("preserves the controller-selected ACPX provider package root", () => { expect(launches[0]!.environment.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT).toBe( "/verified/provider-pack", ); + expect( + launches[0]!.environment.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST, + ).toBe("/verified/provider-pack/package.json"); expect(launches[0]!.environment.NODE_PATH).toBeUndefined(); }); diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index dc2360d9fa..0ae8d34dc5 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -1929,6 +1929,7 @@ const runnerExplicitProviderEnvironmentKeys = [ "PAPERCLIP_NATIVE_MCP_TOKEN", "PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", + "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", "PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY", "PAPERCLIP_PROVIDER_TRACE_PATH", "PAPERCLIP_PROVIDER_TRACE_MAX_BYTES", diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index c7b80a22dd..f799d30b93 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -76,6 +76,39 @@ describe("ACPX installation integrity", () => { "explicit normalized absolute path", ); + const runnerPackage = join(root, "packages", "paperclip-runner"); + const runnerManifest = join(runnerPackage, "package.json"); + const pnpmProviderDirectory = join( + root, + "node_modules", + ".pnpm", + "qualified-provider@1.0.0", + "node_modules", + "pnpm-provider", + ); + await Promise.all([ + mkdir(join(runnerPackage, "node_modules"), { recursive: true }), + mkdir(pnpmProviderDirectory, { recursive: true }), + writeFile(runnerManifest, JSON.stringify({ private: true })), + writeFile( + join(pnpmProviderDirectory, "package.json"), + JSON.stringify({ name: "pnpm-provider", version: "1.0.0" }), + ), + ]); + await symlink( + pnpmProviderDirectory, + join(runnerPackage, "node_modules", "pnpm-provider"), + ); + expect( + createAcpxPackageJsonResolver(root, runnerManifest)("pnpm-provider"), + ).toBe(join(pnpmProviderDirectory, "package.json")); + + const outsideManifest = join(parent, "outside-package.json"); + await writeFile(outsideManifest, JSON.stringify({ private: true })); + expect(() => + createAcpxPackageJsonResolver(root, outsideManifest), + ).toThrow("manifest resolves outside the selected provider root"); + const ancestorProviderDirectory = join( parent, "node_modules", diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 84fa23d325..4cedf9040f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -218,6 +218,7 @@ export type AcpxPackageJsonResolver = (packageName: string) => string; export function createAcpxPackageJsonResolver( providerPackageRoot: string | undefined, + providerPackageManifest?: string, ): AcpxPackageJsonResolver { const root = providerPackageRoot?.trim(); if ( @@ -230,7 +231,26 @@ export function createAcpxPackageJsonResolver( "ACPX provider package root must be an explicit normalized absolute path", ); } + const manifest = ( + providerPackageManifest ?? resolve(root, "package.json") + ).trim(); + if ( + !manifest || + !isAbsolute(manifest) || + manifest.includes("\0") || + resolve(manifest) !== manifest + ) { + throw new Error( + "ACPX provider package manifest must be an explicit normalized absolute path", + ); + } const canonicalRoot = realpathSync(root); + const canonicalManifest = realpathSync(manifest); + if (!pathIsInside(canonicalRoot, canonicalManifest)) { + throw new Error( + "ACPX provider package manifest resolves outside the selected provider root", + ); + } const canonicalNodeModules = realpathSync( resolve(canonicalRoot, "node_modules"), ); @@ -239,7 +259,7 @@ export function createAcpxPackageJsonResolver( "ACPX provider node_modules resolves outside the selected provider root", ); } - const providerRequire = createRequire(resolve(root, "package.json")); + const providerRequire = createRequire(canonicalManifest); return (packageName) => { const packageJsonPath = realpathSync( providerRequire.resolve(`${packageName}/package.json`), @@ -576,7 +596,10 @@ export async function verifyQualifiedAcpxInstallation( function defaultPackageJsonResolver(packageName: string): string { const providerPackageRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT; if (providerPackageRoot !== undefined) { - return createAcpxPackageJsonResolver(providerPackageRoot)(packageName); + return createAcpxPackageJsonResolver( + providerPackageRoot, + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST, + )(packageName); } // Source-mode and direct runtimes still have a stable module URL. The // descriptor-backed runner sidecar always receives the explicit root above. diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 087f145529..daa902910b 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -11,6 +11,7 @@ import { import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; @@ -140,14 +141,26 @@ it.each(["acpx-runtime-sidecar.cjs", "opencode-app-server-proxy.cjs"] as const)( }, ); -it("derives the ACPX package root only from the verified dist/cli layout", () => { +it("derives the ACPX package authority only from the verified dist/cli layout", () => { + const runnerPackageRoot = fileURLToPath(new URL("../..", import.meta.url)); expect( - runnerdLaunchProfileInternals.acpxProviderPackageRoot( + runnerdLaunchProfileInternals.acpxProviderPackageAuthority( + resolve(runnerPackageRoot, "dist/cli/acpx-runtime-sidecar.cjs"), + ), + ).toEqual({ + root: resolve(runnerPackageRoot, "../.."), + manifest: resolve(runnerPackageRoot, "package.json"), + }); + expect( + runnerdLaunchProfileInternals.acpxProviderPackageAuthority( "/provider-pack/dist/cli/acpx-runtime-sidecar.cjs", ), - ).toBe("/provider-pack"); + ).toEqual({ + root: "/provider-pack", + manifest: "/provider-pack/package.json", + }); expect(() => - runnerdLaunchProfileInternals.acpxProviderPackageRoot( + runnerdLaunchProfileInternals.acpxProviderPackageAuthority( "/unverified/acpx-runtime-sidecar.cjs", ), ).toThrow("ACPX sidecar must use the provider package dist/cli layout"); @@ -446,6 +459,8 @@ it.each([ PATH: "/bin", ...credentialEnvironment, PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/attacker/package-root", + PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: + "/attacker/package-root/package.json", PAPERCLIP_API_KEY: "must-not-reach-provider", DATABASE_URL: "must-not-reach-provider", }, @@ -472,6 +487,8 @@ it.each([ PAPERCLIP_NORMALIZED_SESSION_ID: "session-1", PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json", PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack", + PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: + "/verified/provider-pack/package.json", }); for (const key of allowed) expect(environment[key]).toBe(credentialEnvironment[key]); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index e64d005d63..7d5c22b148 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1111,7 +1111,10 @@ function resolveBuildOwnedCliArtifact( ); } -function acpxProviderPackageRoot(sidecarScript: string): string { +function acpxProviderPackageAuthority(sidecarScript: string): { + root: string; + manifest: string; +} { const cliDirectory = dirname(sidecarScript); if ( basename(sidecarScript) !== "acpx-runtime-sidecar.cjs" || @@ -1126,8 +1129,14 @@ function acpxProviderPackageRoot(sidecarScript: string): string { // A local source build consumes pnpm's workspace-owned node_modules tree. // A deployed provider pack owns a closed node_modules tree at its own root. return sidecarPackageRoot === packageRoot - ? resolve(packageRoot, "../..") - : sidecarPackageRoot; + ? { + root: resolve(packageRoot, "../.."), + manifest: resolve(packageRoot, "package.json"), + } + : { + root: sidecarPackageRoot, + manifest: resolve(sidecarPackageRoot, "package.json"), + }; } function acpxRunnerLaunchProfile( @@ -1342,6 +1351,8 @@ export function createCapabilityRunnerdProviderEnvironment(input: { input.acpxSidecarPath ?? input.options.acpxSidecarPath ?? resolve(packageRoot, "dist", "cli", "acpx-runtime-sidecar.cjs"); + const providerPackageAuthority = + acpxProviderPackageAuthority(sidecarPath); return { ...createSanitizedAcpxSpawnInput( input.options.environment, @@ -1352,7 +1363,9 @@ export function createCapabilityRunnerdProviderEnvironment(input: { // executes it through /proc/self/fd. Anchor its closed provider package // lookups at the package that owns the already-authenticated bundle. PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: - acpxProviderPackageRoot(sidecarPath), + providerPackageAuthority.root, + PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: + providerPackageAuthority.manifest, ...(input.options.providerRecoveryPolicy === "allow_replacement_after_governed_wait" ? { @@ -3413,7 +3426,7 @@ export const createRunnerdCodexTransport = createCapabilityRunnerdCodexTransport; export const runnerdLaunchProfileInternals = Object.freeze({ - acpxProviderPackageRoot, + acpxProviderPackageAuthority, acpxRunnerLaunchProfile, resolveBuildOwnedCliArtifact, }); 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 3b504d5741..024ad972c2 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -32,6 +32,12 @@ const claudePatch = 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.dependencies.acpx, "0.13.1"); assert.equal( runnerPackage.dependencies["@agentclientprotocol/codex-acp"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a292199ba..c45859465d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,6 +477,9 @@ importers: '@agentclientprotocol/codex-acp': specifier: 1.6.2 version: 1.6.2(patch_hash=grbydorkxatbzksnwwfuawwtim) + '@openai/codex': + specifier: 0.148.0 + version: 0.148.0 acpx: specifier: 0.13.1 version: 0.13.1(patch_hash=lzpwjtiaybzoijy455dfycwavu) @@ -486,6 +489,9 @@ importers: json-schema-to-ts: specifier: ^3.1.1 version: 3.1.1 + node: + specifier: 24.11.0 + version: 24.11.0 opencode-ai: specifier: 1.18.17 version: 1.18.17 @@ -495,6 +501,10 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + optionalDependencies: + '@openai/codex-linux-x64': + specifier: npm:@openai/codex@0.148.0-linux-x64 + version: '@openai/codex@0.148.0-linux-x64' devDependencies: '@paperclipai/paperclip-eval-kernel': specifier: workspace:* @@ -7088,6 +7098,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-bin-setup@1.1.4: + resolution: {integrity: sha512-vWNHOne0ZUavArqPP5LJta50+S8R261Fr5SvGul37HbEDcowvLjwdvd0ZeSr0r2lTSrPxl6okq9QUw8BFGiAxA==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -7101,6 +7114,11 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + node@24.11.0: + resolution: {integrity: sha512-pGLc7hd4xh0doDpnHC5+PL09WLUDKFoBFsC28341AYhHjzTDbNSwY+2615tYY6YFO2WQUKit4yqTwzIHaaityg==} + engines: {npm: '>=5.0.0'} + hasBin: true + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -14577,6 +14595,8 @@ snapshots: negotiator@1.0.0: {} + node-bin-setup@1.1.4: {} + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -14587,6 +14607,10 @@ snapshots: node-releases@2.0.53: {} + node@24.11.0: + dependencies: + node-bin-setup: 1.1.4 + npm-run-path@4.0.1: dependencies: path-key: 3.1.1