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 01ca79dbad..1e7fcbdacc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -69,6 +69,27 @@ describe("ACPX installation integrity", () => { expect(createAcpxPackageJsonResolver(root)("qualified-provider")).toBe( providerPackageJson, ); + + const nestedDependencyDirectory = join( + providerDirectory, + "node_modules", + "qualified-dependency", + ); + const nestedDependencyPackageJson = join( + nestedDependencyDirectory, + "package.json", + ); + await mkdir(nestedDependencyDirectory, { recursive: true }); + await writeFile( + nestedDependencyPackageJson, + JSON.stringify({ name: "qualified-dependency", version: "1.0.0" }), + ); + expect( + createAcpxPackageJsonResolver(root)( + "qualified-dependency", + providerPackageJson, + ), + ).toBe(nestedDependencyPackageJson); expect(() => createAcpxPackageJsonResolver("relative/provider-pack"), ).toThrow("explicit normalized absolute path"); @@ -107,9 +128,9 @@ describe("ACPX installation integrity", () => { 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"); + expect(() => createAcpxPackageJsonResolver(root, outsideManifest)).toThrow( + "manifest resolves outside the selected provider root", + ); const ancestorProviderDirectory = join( parent, @@ -388,6 +409,146 @@ describe("ACPX installation integrity", () => { }); }); + it("pins Claude ACP direct dependencies outside its package root", async () => { + const fixture = await installationFixture(); + const command = [ + 'import { qualifiedValue } from "@anthropic-ai/claude-agent-sdk";', + "process.stdout.write(qualifiedValue);", + ].join("\n"); + const dependencyRoot = join(fixture.root, "qualified-dependencies"); + const dependencyFixtures = [ + { + name: "@agentclientprotocol/sdk", + version: "1.3.0", + directory: join(dependencyRoot, "agentclient-sdk"), + }, + { + name: "@anthropic-ai/claude-agent-sdk", + version: "0.3.232", + directory: join(dependencyRoot, "claude-agent-sdk"), + }, + { + name: "zod", + version: "4.4.3", + directory: join(dependencyRoot, "zod"), + }, + ] as const; + await Promise.all([ + writeFile(fixture.commandPath, command), + mkdir(join(fixture.serverDirectory, "node_modules", "@anthropic-ai"), { + recursive: true, + }), + ...dependencyFixtures.map((dependency) => + mkdir(dependency.directory, { recursive: true }), + ), + ]); + await Promise.all([ + writeFile( + fixture.serverPackageJsonPath, + JSON.stringify({ + name: "@agentclientprotocol/claude-agent-acp", + version: "0.70.0", + type: "module", + bin: "bin/server.js", + dependencies: { + "@agentclientprotocol/sdk": "1.3.0", + "@anthropic-ai/claude-agent-sdk": "0.3.232", + zod: "^3.25.0 || ^4.0.0", + }, + }), + ), + ...dependencyFixtures.map((dependency) => + writeFile( + join(dependency.directory, "package.json"), + JSON.stringify({ + name: dependency.name, + version: dependency.version, + type: "module", + exports: "./index.js", + }), + ), + ), + writeFile( + join(dependencyFixtures[1].directory, "index.js"), + 'export const qualifiedValue = "qualified-claude-dependency";', + ), + ]); + await symlink( + dependencyFixtures[1].directory, + join( + fixture.serverDirectory, + "node_modules", + "@anthropic-ai", + "claude-agent-sdk", + ), + ); + const paths = new Map([ + ["@agentclientprotocol/claude-agent-acp", fixture.serverPackageJsonPath], + ...dependencyFixtures.map( + (dependency) => + [ + dependency.name, + join(dependency.directory, "package.json"), + ] as const, + ), + ]); + const profile = { + ...resolveQualifiedAcpxProfile("claude", "claude-sonnet-5"), + commandDigest: `sha256:${createHash("sha256").update(command).digest("hex")}`, + }; + const installation = await verifyQualifiedAcpxInstallation( + profile, + (packageName) => { + const resolved = paths.get(packageName); + if (!resolved) throw new Error(`unexpected package ${packageName}`); + return resolved; + }, + ); + + await expectPinnedOutput( + (await installation.openCommand()).spawn(), + "qualified-claude-dependency", + ); + }); + + it("rejects drift in Claude ACP's qualified dependency versions", async () => { + const fixture = await installationFixture(); + await writeFile( + fixture.serverPackageJsonPath, + JSON.stringify({ + version: "0.70.0", + type: "module", + bin: "bin/server.js", + dependencies: { + "@agentclientprotocol/sdk": "1.3.0", + "@anthropic-ai/claude-agent-sdk": "0.3.232", + zod: "^3.25.0 || ^4.0.0", + }, + }), + ); + const dependencyPackage = join(fixture.root, "dependency", "package.json"); + await mkdir(dirname(dependencyPackage), { recursive: true }); + await writeFile( + dependencyPackage, + JSON.stringify({ version: "unexpected" }), + ); + + await expect( + verifyQualifiedAcpxInstallation( + { + ...resolveQualifiedAcpxProfile("claude", "claude-sonnet-5"), + commandDigest: fixture.profile.commandDigest, + }, + (packageName) => + packageName === "@agentclientprotocol/claude-agent-acp" + ? fixture.serverPackageJsonPath + : dependencyPackage, + ), + ).rejects.toThrow( + "ACPX claude dependency package version mismatch for @agentclientprotocol/sdk", + ); + }); + it.runIf(process.platform === "linux" && process.arch === "x64")( "resolves and pins the qualified Codex native runtime through its transitive packages", async () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 4cedf9040f..553e1360b5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -50,6 +50,28 @@ const QUALIFIED_CODEX_LINUX_X64_RUNTIME = Object.freeze({ "sha256:ac2cfed85fb647d61e0150b8548102b330e4799d9d81ad5d354de701edf6b074", }); +// Claude's ACP server is not a self-contained bundle: its entrypoint imports +// these three packages directly from pnpm's real store paths. Keep that exact +// package graph version-bound and descriptor-pinned instead of granting the +// provider ambient access to the workspace's complete node_modules ancestry. +const QUALIFIED_CLAUDE_PROVIDER_DEPENDENCIES = Object.freeze([ + Object.freeze({ + packageName: "@agentclientprotocol/sdk", + packageVersion: "1.3.0", + dependencyDeclaration: "1.3.0", + }), + Object.freeze({ + packageName: "@anthropic-ai/claude-agent-sdk", + packageVersion: "0.3.232", + dependencyDeclaration: "0.3.232", + }), + Object.freeze({ + packageName: "zod", + packageVersion: "4.4.3", + dependencyDeclaration: "^3.25.0 || ^4.0.0", + }), +]); + const PROVIDER_LIFETIME_WATCHDOG_SOURCE = ` const fs = require("node:fs"); let reaped = false; @@ -214,7 +236,10 @@ try { const providerGuardianOwnership = new WeakMap>(); const providerExitProof = new WeakMap>(); -export type AcpxPackageJsonResolver = (packageName: string) => string; +export type AcpxPackageJsonResolver = ( + packageName: string, + issuerPackageJsonPath?: string, +) => string; export function createAcpxPackageJsonResolver( providerPackageRoot: string | undefined, @@ -259,10 +284,18 @@ export function createAcpxPackageJsonResolver( "ACPX provider node_modules resolves outside the selected provider root", ); } - const providerRequire = createRequire(canonicalManifest); - return (packageName) => { + return (packageName, issuerPackageJsonPath) => { + const canonicalIssuer = + issuerPackageJsonPath === undefined + ? canonicalManifest + : realpathSync(issuerPackageJsonPath); + if (!pathIsInside(canonicalRoot, canonicalIssuer)) { + throw new Error( + `ACPX provider package issuer for ${packageName} resolves outside the selected provider root`, + ); + } const packageJsonPath = realpathSync( - providerRequire.resolve(`${packageName}/package.json`), + createRequire(canonicalIssuer).resolve(`${packageName}/package.json`), ); if (!pathIsInside(canonicalNodeModules, packageJsonPath)) { throw new Error( @@ -380,6 +413,7 @@ interface AcpxPackageMetadata { version?: string; bin?: unknown; type?: unknown; + dependencies?: unknown; optionalDependencies?: unknown; } @@ -480,6 +514,48 @@ export async function verifyQualifiedAcpxInstallation( throw new Error("Qualified ACPX runtime version omitted its package"); } + const supplementalPackages: Array<{ + directory: string; + format: AcpxCommandFormat; + }> = []; + if (profile.agent === "claude") { + const declaredDependencies = serverPackage.dependencies; + if ( + typeof declaredDependencies !== "object" || + declaredDependencies === null || + Array.isArray(declaredDependencies) + ) { + throw new Error("ACPX claude package omitted its qualified dependencies"); + } + for (const expected of QUALIFIED_CLAUDE_PROVIDER_DEPENDENCIES) { + if ( + (declaredDependencies as Record)[ + expected.packageName + ] !== expected.dependencyDeclaration + ) { + throw new Error( + `ACPX claude package dependency mismatch for ${expected.packageName}`, + ); + } + const dependencyPackageJsonPath = await realpath( + resolvePackageJson(expected.packageName, serverPackageJsonPath), + ); + const dependencyPackage = await readPackageJson( + dependencyPackageJsonPath, + expected.packageName, + ); + if (dependencyPackage.version !== expected.packageVersion) { + throw new Error( + `ACPX claude dependency package version mismatch for ${expected.packageName}: expected ${expected.packageVersion}, received ${dependencyPackage.version ?? "unknown"}`, + ); + } + supplementalPackages.push({ + directory: dirname(dependencyPackageJsonPath), + format: packageModuleFormat(dependencyPackage.type), + }); + } + } + const serverDependencyAncestors = await inspectDependencyAncestors( commandDirectory, packageDirectory, @@ -510,6 +586,22 @@ export async function verifyQualifiedAcpxInstallation( dependencyAncestorFormats.push(runtimePackageFormat ?? "commonjs"); } } + for (const supplemental of supplementalPackages) { + if ( + supplemental.directory !== commandDirectory && + !dependencyAncestors.some( + (ancestor) => ancestor.path === supplemental.directory, + ) + ) { + dependencyAncestors.push( + await inspectExplicitDependencyRoot( + supplemental.directory, + `${profile.agent} dependency`, + ), + ); + dependencyAncestorFormats.push(supplemental.format); + } + } if (dependencyAncestors.length > MAX_DEPENDENCY_ANCESTORS) { throw new Error("ACPX provider dependency ancestry exceeds its bound"); } @@ -593,17 +685,22 @@ export async function verifyQualifiedAcpxInstallation( }); } -function defaultPackageJsonResolver(packageName: string): string { +function defaultPackageJsonResolver( + packageName: string, + issuerPackageJsonPath?: string, +): string { const providerPackageRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT; if (providerPackageRoot !== undefined) { return createAcpxPackageJsonResolver( providerPackageRoot, process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST, - )(packageName); + )(packageName, issuerPackageJsonPath); } // Source-mode and direct runtimes still have a stable module URL. The // descriptor-backed runner sidecar always receives the explicit root above. - return createRequire(import.meta.url).resolve(`${packageName}/package.json`); + return createRequire(issuerPackageJsonPath ?? import.meta.url).resolve( + `${packageName}/package.json`, + ); } async function readPackageJson(