fix(runner): qualify Claude ACP dependencies

This commit is contained in:
Dotta 2026-09-03 02:48:39 -05:00
parent 3a615a4743
commit d8ab1f0f3b
2 changed files with 268 additions and 10 deletions

View File

@ -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<string, string>([
["@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 () => {

View File

@ -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<ChildProcess, Promise<void>>();
const providerExitProof = new WeakMap<ChildProcess, Promise<void>>();
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<string, unknown>)[
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(