diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7593f838928..ae811577a16 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1460,6 +1460,11 @@ a successful provider run additionally verifies credentials and model access. The server must install and bundle `@agentclientprotocol/claude-agent-acp` at the qualified version, including the patched bridge and pinned SDK. The vendor dependency check covers this dynamic lookup as well as ordinary imports. +Source builds bind the vendored runner to the workspace's pnpm store while +keeping the server manifest as its provider authority. Published npm bundles +retain the pinned SDK JavaScript and declare its native packages as server +optional dependencies, so npm installs the executable for the consumer's +platform. Runtime admission still checks the exact version and executable digest. A missing provider package is reported as **Runtime missing**, with automatic retries stopped. Reinstall or rebuild Paperclip with its provider dependencies, restart the server, and run the agent's runtime test before retrying the task. 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 7231142f0f8..f9f014ee7b3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -54,6 +54,62 @@ afterEach(async () => { }); describe("ACPX installation integrity", () => { + it.each([["linux", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)( + "retains Claude executable admission for a published bundle on %s %s", async (os, cpu) => { + const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-claude-npm-admission-"))); + temporaryDirectories.push(parent); + const root = join(parent, "node_modules/@paperclipai/server"); + const bridge = join(root, "node_modules/@agentclientprotocol/claude-agent-acp"); + const runtime = join(bridge, "node_modules/@anthropic-ai/claude-agent-sdk"); + const name = `@anthropic-ai/claude-agent-sdk-${os}-${cpu}`; + const platformRoot = join(parent, "node_modules", name); + const optionalDependencies = { [name]: "0.3.286" }; + const runtimeMetadata = { name: "@anthropic-ai/claude-agent-sdk", version: "0.3.286" }; + const platformMetadata = { name, version: "0.3.286", os: [os], cpu: [cpu] }; + await Promise.all([mkdir(runtime, { recursive: true }), mkdir(platformRoot, { recursive: true })]); + const command = 'process.stdout.write("unexecuted fixture");'; + const reset = () => Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/server", optionalDependencies })), + writeFile(join(runtime, "package.json"), JSON.stringify(runtimeMetadata)), + writeFile(join(platformRoot, "package.json"), JSON.stringify(platformMetadata)), + ]); + await reset(); + await Promise.all([ + writeFile(join(bridge, "package.json"), JSON.stringify({ name: "@agentclientprotocol/claude-agent-acp", version: "0.73.0", bin: "server.js" })), + writeFile(join(bridge, "server.js"), command), + writeFile(join(platformRoot, "claude"), "unqualified native bytes", { mode: 0o755 }), + ]); + const profile = { ...resolveQualifiedAcpxProfile("claude", "claude-opus-5-5"), commandDigest: `sha256:${createHash("sha256").update(command).digest("hex")}` }; + const resolver = createAcpxPackageJsonResolver(root); + expect(resolver(name, join(runtime, "package.json"))).toBe(join(platformRoot, "package.json")); + expect(() => resolver(name)).toThrow("outside the selected provider root"); + for (const [file, metadata] of [ + [join(root, "package.json"), { name: "@paperclipai/server", optionalDependencies: { [name]: "latest" } }], + [join(runtime, "package.json"), { ...runtimeMetadata, version: "0.3.285" }], + [join(runtime, "package.json"), { ...runtimeMetadata, optionalDependencies: {} }], + [join(platformRoot, "package.json"), { ...platformMetadata, name: "unqualified-provider" }], + [join(platformRoot, "package.json"), { ...platformMetadata, version: "0.3.285" }], + [join(platformRoot, "package.json"), { ...platformMetadata, cpu: ["ia32"] }], + ] as const) { + await writeFile(file, JSON.stringify(metadata)); + expect(() => resolver(name, join(runtime, "package.json"))).toThrow("outside the selected provider root"); + await reset(); + } + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(os); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue(cpu); + try { + await expect(verifyQualifiedAcpxInstallation(profile, (...args) => resolver(...args))).rejects.toThrow("runtime omitted its verified platform executable package"); + await expect(verifyQualifiedAcpxInstallation(profile, resolver)).rejects.toThrow(/digest mismatch/); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", root); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + await expect(verifyQualifiedAcpxInstallation(profile)).rejects.toThrow(/digest mismatch/); + } finally { + vi.unstubAllEnvs(); + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }, + ); it("explains a missing Claude ACP installation without leaking the resolver stack", async () => { const missingPackage = Object.assign(new Error( "Cannot find module '@agentclientprotocol/claude-agent-acp/package.json'\nRequire stack: /srv/private/server.js", diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 119c91e8e24..e96722c9759 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -354,7 +354,7 @@ export function createAcpxPackageJsonResolver( resolvePackageJsonFromIssuer(packageName, canonicalIssuer), ); if (!pathIsInside(canonicalNodeModules, packageJsonPath) - && !isDeclaredHoistedCodexPlatform(canonicalRoot, canonicalManifest, canonicalIssuer, packageName, packageJsonPath)) { + && !isDeclaredHoistedProviderPlatform(canonicalRoot, canonicalManifest, canonicalIssuer, packageName, packageJsonPath)) { throw new Error( `ACPX provider package ${packageName} resolves outside the selected provider root`, ); @@ -365,25 +365,35 @@ export function createAcpxPackageJsonResolver( return resolver; } -function hasPublishedCodexPlatformDeclaration(resolver: AcpxPackageJsonResolver, runtimeManifest: string): boolean { +function hasPublishedProviderPlatformDeclaration( + resolver: AcpxPackageJsonResolver, + runtimeManifest: string, + qualified: { runtimePackageName: string; runtimePackageVersion: string; packageName: string; dependencyDeclaration: string }, +): boolean { const authority = rootedPackageResolvers.get(resolver); if (!authority || !pathIsInside(authority.root, runtimeManifest)) return false; const selected = readResolverPackageJson(authority.manifest); const runtime = readResolverPackageJson(runtimeManifest); const declarations = selected.optionalDependencies as Record | undefined; return selected.name === "@paperclipai/server" - && runtime.name === QUALIFIED_CODEX_LINUX_X64_RUNTIME.runtimePackageName - && runtime.version === QUALIFIED_CODEX_LINUX_X64_RUNTIME.runtimePackageVersion + && runtime.name === qualified.runtimePackageName + && runtime.version === qualified.runtimePackageVersion && runtime.optionalDependencies === undefined - && declarations?.[QUALIFIED_CODEX_LINUX_X64_RUNTIME.packageName] === QUALIFIED_CODEX_LINUX_X64_RUNTIME.dependencyDeclaration; + && declarations?.[qualified.packageName] === qualified.dependencyDeclaration; } // npm installs an optional platform alias outside a bundled server graph. // Admit only the qualified executable package through its declared npm slot; // all executable bytes still pass the existing digest/no-follow admission. -function isDeclaredHoistedCodexPlatform(root: string, manifest: string, issuer: string, packageName: string, packageJsonPath: string): boolean { - const qualified = QUALIFIED_CODEX_LINUX_X64_RUNTIME; - if (packageName !== qualified.packageName || issuer === manifest) return false; +function isDeclaredHoistedProviderPlatform(root: string, manifest: string, issuer: string, packageName: string, packageJsonPath: string): boolean { + const platformQualification = [ + { qualified: QUALIFIED_CODEX_LINUX_X64_RUNTIME, os: "linux", cpu: "x64", alias: true }, + { qualified: QUALIFIED_CLAUDE_LINUX_X64_RUNTIME, os: "linux", cpu: "x64", alias: false }, + { qualified: QUALIFIED_CLAUDE_DARWIN_RUNTIMES.arm64, os: "darwin", cpu: "arm64", alias: false }, + { qualified: QUALIFIED_CLAUDE_DARWIN_RUNTIMES.x64, os: "darwin", cpu: "x64", alias: false }, + ].find(value => value.qualified.packageName === packageName); + if (!platformQualification || issuer === manifest) return false; + const { qualified, os, cpu, alias } = platformQualification; try { const selected = readResolverPackageJson(manifest); const runtime = readResolverPackageJson(issuer); @@ -404,10 +414,10 @@ function isDeclaredHoistedCodexPlatform(root: string, manifest: string, issuer: }); if (!declaredSlot) return false; const platform = readResolverPackageJson(packageJsonPath); - return (platform.name === qualified.packageName || platform.name === qualified.runtimePackageName) + return (platform.name === qualified.packageName || (alias && platform.name === qualified.runtimePackageName)) && platform.version === qualified.packageVersion - && Array.isArray(platform.os) && platform.os.includes("linux") - && Array.isArray(platform.cpu) && platform.cpu.includes("x64"); + && Array.isArray(platform.os) && platform.os.includes(os) + && Array.isArray(platform.cpu) && platform.cpu.includes(cpu); } catch { return false; } } @@ -633,9 +643,9 @@ export async function verifyQualifiedAcpxInstallation( profile: QualifiedAcpxProfile, resolvePackageJson: AcpxPackageJsonResolver = defaultPackageJsonResolver, ): Promise { - // Keep the selected package authority available when its bundled Codex + // Keep the selected package authority available when its bundled provider // wrapper delegates platform installation to the published server manifest. - if (profile.agent === "codex" && resolvePackageJson === defaultPackageJsonResolver && process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT !== undefined) { + if ((profile.agent === "codex" || profile.agent === "claude") && resolvePackageJson === defaultPackageJsonResolver && process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT !== undefined) { resolvePackageJson = createAcpxPackageJsonResolver( process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT, process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST, @@ -1034,8 +1044,8 @@ async function verifyQualifiedRuntimeExecutable(input: { const ownDeclaration = typeof optionalDependencies === "object" && optionalDependencies !== null && !Array.isArray(optionalDependencies) && (optionalDependencies as Record)[qualification.packageName] === qualification.dependencyDeclaration; - const publishedDeclaration = input.profile.agent === "codex" && optionalDependencies === undefined - && hasPublishedCodexPlatformDeclaration(input.resolvePackageJson, input.runtimePackageJsonPath); + const publishedDeclaration = optionalDependencies === undefined + && hasPublishedProviderPlatformDeclaration(input.resolvePackageJson, input.runtimePackageJsonPath, qualification); if (!ownDeclaration && !publishedDeclaration) { throw new Error( `ACPX ${input.profile.agent} runtime omitted its verified platform executable package`, 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 debd0c6e3fe..a64c51c26e2 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -1633,6 +1633,23 @@ it("uses the public server npm package as the authority for vendored sidecars", }); }); +it.each([false, true])("binds a vendored source server to its pnpm dependency root (self-rooted: %s)", async selfRooted => { + const workspace = await mkdtemp(join(tmpdir(), "paperclip-vendored-source-")); + const server = join(workspace, "server"); + await mkdir(join(workspace, "node_modules", ".pnpm"), { recursive: true }); + await mkdir(join(server, "node_modules", ...(selfRooted ? [".pnpm"] : [])), { recursive: true }); + await writeFile(join(workspace, "package.json"), JSON.stringify({ name: "paperclip", private: true })); + await writeFile(join(workspace, "pnpm-workspace.yaml"), "packages:\n - server\n"); + await writeFile(join(server, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + try { + expect(runnerdLaunchProfileInternals.acpxProviderPackageAuthority( + join(server, "dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs"), + )).toEqual({ root: selfRooted ? server : workspace, manifest: join(server, "package.json") }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + it("keeps a self-rooted pnpm deployment inside its dependency authority", async () => { const deploymentRoot = await mkdtemp( join(tmpdir(), "paperclip-deployed-provider-root-"), diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 71103812f2a..4d7be0fbe62 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3030,6 +3030,20 @@ function acpxProviderPackageAuthority( basename(resolve(cliDirectory, "../..")) === "vendor" && basename(resolve(cliDirectory, "../../..")) === "dist") { const serverRoot = resolve(cliDirectory, "../../../.."); + const workspaceRoot = dirname(serverRoot); + // Source/Docker builds share the workspace's pnpm store. Published npm + // bundles and self-rooted pnpm deployments retain their server authority. + if (basename(serverRoot) === "server" && + !existsSync(resolve(serverRoot, "node_modules/.pnpm")) && + existsSync(resolve(workspaceRoot, "node_modules/.pnpm")) && + existsSync(resolve(workspaceRoot, "pnpm-workspace.yaml")) && + existsSync(resolve(workspaceRoot, "package.json"))) { + const workspace = JSON.parse(readFileSync(resolve(workspaceRoot, "package.json"), "utf8")); + const server = JSON.parse(readFileSync(resolve(serverRoot, "package.json"), "utf8")); + if (workspace.name === "paperclip" && workspace.private === true && server.name === "@paperclipai/server") { + return { root: workspaceRoot, manifest: resolve(serverRoot, "package.json") }; + } + } return { root: serverRoot, manifest: resolve(serverRoot, "package.json") }; } if ( diff --git a/scripts/acpx-patch-packaging.test.mjs b/scripts/acpx-patch-packaging.test.mjs index 9454ab009b0..d502eaebab8 100644 --- a/scripts/acpx-patch-packaging.test.mjs +++ b/scripts/acpx-patch-packaging.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync, + cpSync, lstatSync, mkdirSync, mkdtempSync, @@ -56,6 +57,33 @@ function esbuildFixture(t) { } const codexTargets = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"]; const codexOptional = Object.fromEntries(codexTargets.map(target => [`@openai/codex-${target}`, `npm:@openai/codex@0.160.0-${target}`])); +const claudeTargets = [...codexTargets, "linux-x64-musl", "linux-arm64-musl"]; +const claudeOptional = Object.fromEntries(claudeTargets.map(target => [`@anthropic-ai/claude-agent-sdk-${target}`, "0.3.286"])); +function claudeFixture(t) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-bundled-claude-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const graph = join(directory, "node_modules"); + const writePackage = (location, metadata) => { + mkdirSync(location, { recursive: true }); + writeFileSync(join(location, "package.json"), JSON.stringify(metadata)); + return location; + }; + const bridge = writePackage(join(graph, "@agentclientprotocol/claude-agent-acp"), { + name: "@agentclientprotocol/claude-agent-acp", version: "0.73.0", dependencies: { "@anthropic-ai/claude-agent-sdk": "0.3.286" }, + }); + writeFileSync(join(bridge, "index.js"), "export {};\n"); + const runtime = writePackage(join(graph, "@anthropic-ai/claude-agent-sdk"), { + name: "@anthropic-ai/claude-agent-sdk", version: "0.3.286", exports: { ".": "./sdk.mjs" }, optionalDependencies: claudeOptional, + }); + writeFileSync(join(runtime, "sdk.mjs"), "// official JavaScript fixture, never executed\n"); + const producer = writePackage(join(graph, "@anthropic-ai/claude-agent-sdk-linux-x64"), { + name: "@anthropic-ai/claude-agent-sdk-linux-x64", version: "0.3.286", os: ["linux"], cpu: ["x64"], + }); + writeFileSync(join(producer, "claude"), "producer native fixture, never executed"); + const profiles = [{ agent: "claude", ...profileData.profiles.claude }]; + const publish = { name: "paperclip-claude-pack-fixture", version: "1.0.0", dependencies: { "@agentclientprotocol/claude-agent-acp": "0.73.0" }, bundleDependencies: ["@agentclientprotocol/claude-agent-acp"] }; + return { directory, graph, bridge, runtime, producer, profiles, publish, writePackage }; +} function codexFixture(t) { const directory = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-bundled-codex-"))); t.after(() => rmSync(directory, { recursive: true, force: true })); @@ -195,7 +223,7 @@ test("published Codex bridge uses its qualified producer closure without consume createBundledInstallManifest(published, serverPackage.bundleDependencies), serverPackage.bundleDependencies, rootPackage, profileData, ); - assert.equal(profiles.length, 1); + assert.deepEqual(profiles.map(profile => profile.agent), ["codex", "claude"]); for (const profile of profiles) { assert.equal(published.dependencies[profile.agentServerPackage], profile.agentServerVersion); assert.ok(published.bundleDependencies.includes(profile.agentServerPackage)); @@ -214,6 +242,58 @@ test("published Codex bridge uses its qualified producer closure without consume assert.throws(() => configureBundledProviderOverrides(conflict, serverPackage.bundleDependencies, rootPackage, profileData), /conflicting producer override/); }); +test("Linux-produced Claude bundles retain the SDK and install native packages for the consumer platform", (t) => { + const { directory, bridge, runtime, producer, profiles, publish } = claudeFixture(t); + const bridgeBefore = readFileSync(join(bridge, "package.json"), "utf8"); + const staged = stageBundledProviderOptionalDependencies(directory, publish, profiles); + assert.deepEqual(staged.optionalDependencies, claudeOptional); + assert.equal(existsSync(producer), false); + assert.equal(JSON.parse(readFileSync(join(runtime, "package.json"), "utf8")).optionalDependencies, undefined); + assert.equal(readFileSync(join(bridge, "package.json"), "utf8"), bridgeBefore); + assert.deepEqual(stageBundledProviderOptionalDependencies(directory, staged, profiles), staged); + writeFileSync(join(directory, "package.json"), JSON.stringify(staged)); + const packs = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--offline", "--json"], { + cwd: directory, encoding: "utf8", timeout: 30_000, + env: { ...process.env, HOME: directory, npm_config_cache: join(directory, "cache"), npm_config_update_notifier: "false" }, + })); + const paths = packs[0].files.map(file => file.path); + assert.ok(paths.some(path => path.endsWith("@anthropic-ai/claude-agent-sdk/sdk.mjs"))); + assert.equal(paths.some(path => /@anthropic-ai\/claude-agent-sdk-(?:linux|darwin|win32)-/.test(path)), false); +}); + +test("Claude bundle validation rejects unqualified declarations before removing native payloads", (t) => { + const { directory, runtime, producer, profiles, publish } = claudeFixture(t); + const file = join(runtime, "package.json"), original = JSON.parse(readFileSync(file, "utf8")); + for (const metadata of [ + { ...original, version: "0.3.285" }, + { ...original, optionalDependencies: { ...claudeOptional, "@anthropic-ai/claude-agent-sdk-darwin-arm64": "latest" } }, + { ...original, optionalDependencies: {} }, + ]) { + writeFileSync(file, JSON.stringify(metadata)); + assert.throws(() => stageBundledProviderOptionalDependencies(directory, publish, profiles), /version mismatch|not qualified/); + assert.equal(existsSync(producer), true); + } +}); + +test("mixed provider bundles validate both runtimes before removing either producer executable", (t) => { + const codex = codexFixture(t), claude = claudeFixture(t); + for (const name of ["@agentclientprotocol/claude-agent-acp", "@anthropic-ai/claude-agent-sdk", "@anthropic-ai/claude-agent-sdk-linux-x64"]) { + cpSync(join(claude.graph, name), join(codex.graph, name), { recursive: true }); + } + const profiles = [...codex.profiles, ...claude.profiles]; + const publish = { ...codex.publish, dependencies: { ...codex.publish.dependencies, ...claude.publish.dependencies }, + bundleDependencies: [...codex.publish.bundleDependencies, ...claude.publish.bundleDependencies] }; + const file = join(codex.graph, "@anthropic-ai/claude-agent-sdk/package.json"); + const metadata = JSON.parse(readFileSync(file, "utf8")); + writeFileSync(file, JSON.stringify({ ...metadata, version: "0.3.285" })); + assert.throws(() => stageBundledProviderOptionalDependencies(codex.directory, publish, profiles), /Claude runtime version mismatch/); + assert.equal(existsSync(codex.producer), true); + assert.deepEqual(JSON.parse(readFileSync(join(codex.runtime, "package.json"), "utf8")).optionalDependencies, codexOptional); + writeFileSync(file, JSON.stringify(metadata)); + const staged = stageBundledProviderOptionalDependencies(codex.directory, publish, profiles); + assert.deepEqual(staged.optionalDependencies, { ...codexOptional, ...claudeOptional }); +}); + test("Codex bundles retain pinned JavaScript and delegate official consumer platform aliases without native payloads", (t) => { const { directory, graph, bridge, runtime, producer, nested, profiles, publish } = codexFixture(t); const before = [bridge, runtime].map(path => readFileSync(join(path, "package.json"), "utf8")); @@ -543,7 +623,7 @@ mkdir -p "$destination/node_modules/.pnpm" set -euo pipefail printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG" [ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ] -node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); } for (const [bridge, runtimes] of Object.entries(pkg.overrides ?? {})) { for (const [name, version] of Object.entries(runtimes)) { const dir = "node_modules/" + name; fs.mkdirSync(dir, { recursive: true }); const native = name === "@openai/codex"; const optionalDependencies = native ? Object.fromEntries(["linux-x64","linux-arm64","darwin-x64","darwin-arm64","win32-x64","win32-arm64"].map(target => [name+"-"+target,"npm:"+name+"@"+version+"-"+target])) : undefined; fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version, optionalDependencies })); } }' +node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); } for (const [bridge, runtimes] of Object.entries(pkg.overrides ?? {})) { for (const [name, version] of Object.entries(runtimes)) { const dir = "node_modules/" + name; fs.mkdirSync(dir, { recursive: true }); const native = name === "@openai/codex"; const targets = ["linux-x64","linux-arm64","darwin-x64","darwin-arm64","win32-x64","win32-arm64"]; const optionalDependencies = native ? Object.fromEntries(targets.map(target => [name+"-"+target,"npm:"+name+"@"+version+"-"+target])) : name === "@anthropic-ai/claude-agent-sdk" ? Object.fromEntries([...targets,"linux-x64-musl","linux-arm64-musl"].map(target => [name+"-"+target,version])) : undefined; fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version, optionalDependencies })); } }' node -e 'const fs=require("node:fs");fs.cpSync(process.env.FAKE_NATIVE_ARTIFACTS+"/@openai/codex-linux-x64","node_modules/@openai/codex-linux-x64",{recursive:true});fs.mkdirSync("node_modules/@openai/codex/vendor",{recursive:true});fs.writeFileSync("node_modules/@openai/codex/vendor/codex","producer fallback fixture");' mkdir -p node_modules/acpx/dist node -e 'const fs=require("node:fs");fs.mkdirSync("node_modules/esbuild",{recursive:true});fs.writeFileSync("node_modules/esbuild/package.json",JSON.stringify({name:"esbuild",version:"0.28.2",scripts:{postinstall:"node install.js"},optionalDependencies:Object.fromEntries(["linux-x64","darwin-arm64","darwin-x64"].map(target=>["@esbuild/"+target,"0.28.2"]))}));fs.mkdirSync("node_modules/@esbuild/linux-x64",{recursive:true});fs.writeFileSync("node_modules/@esbuild/linux-x64/package.json",JSON.stringify({name:"@esbuild/linux-x64",version:"0.28.2",os:["linux"],cpu:["x64"]}));' @@ -603,7 +683,7 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" assert.equal(lstatSync(stagedAcpxDir).isSymbolicLink(), false); assert.equal(existsSync(join(destinationDir, "node_modules/.pnpm")), false); const stagedManifest = JSON.parse(readFileSync(join(destinationDir, "package.json"), "utf8")); - for (const [name, version] of Object.entries(codexOptional)) { + for (const [name, version] of Object.entries({ ...codexOptional, ...claudeOptional })) { assert.equal(stagedManifest.optionalDependencies[name], version); assert.equal(existsSync(join(destinationDir, "node_modules", name)), false); assert.equal(stagedManifest.bundleDependencies.includes(name), false); diff --git a/scripts/prepare-bundled-package.mjs b/scripts/prepare-bundled-package.mjs index c6a4ad5f841..528ece8bfb5 100644 --- a/scripts/prepare-bundled-package.mjs +++ b/scripts/prepare-bundled-package.mjs @@ -80,9 +80,7 @@ export function configureBundledProviderOverrides(installManifest, bundledDepend ...(agent === "claude" ? { "@agentclientprotocol/sdk": "1.4.0", zod: "4.4.3" } : {}), }; result.overrides = { ...result.overrides, [npmSelector]: overrides }; - // Codex delegates its platform aliases below. Claude retains the SDK - // platform dependencies in its ordinary bundled provider closure. - if (agent === "codex") selected.push({ agent, ...profile }); + selected.push({ agent, ...profile }); } return { installManifest: result, profiles: selected }; } @@ -92,88 +90,105 @@ const inside = (root, candidate) => { return value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value); }; -// Bundle the pinned JavaScript closure, never the producer's Codex executable. +// Bundle the pinned JavaScript closure, never the producer's executable. // npm installs the official optional package for the consumer's own platform. export function stageBundledProviderOptionalDependencies(destinationDir, publishManifest, profiles) { const graphRoot = resolve(destinationDir, "node_modules"); if (realpathSync(graphRoot) !== graphRoot) throw new Error("Bundled Codex graph must be a canonical owned directory"); const result = structuredClone(publishManifest), remove = new Set(), optional = {}, normalize = new Map(); - const profile = profiles.find(value => value.agent === "codex"); - if (profiles.length !== 1 || !profile) throw new Error("Bundled provider graph must contain its qualified Codex profile"); - const targets = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"]; - const declarations = Object.fromEntries(targets.map(target => [`${profile.agentRuntimePackage}-${target}`, - `npm:${profile.agentRuntimePackage}@${profile.agentRuntimeVersion}-${target}`])); - const ownedDirectory = directory => { - const stat = lstatSync(directory); - if (!stat.isDirectory() || stat.isSymbolicLink() || !inside(graphRoot, realpathSync(directory))) { - throw new Error("Bundled Codex dependency escapes its producer graph"); - } - }; - const manifestAt = directory => { - const path = resolve(directory, "package.json"), stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256 * 1024) { - throw new Error("Bundled Codex dependency manifest must be a bounded regular file"); - } - return JSON.parse(readFileSync(path, "utf8")); - }; - const bridgeDirectory = resolve(graphRoot, profile.agentServerPackage); - ownedDirectory(bridgeDirectory); - const bridge = manifestAt(bridgeDirectory); - if (bridge.name !== profile.agentServerPackage || bridge.version !== profile.agentServerVersion) throw new Error("Bundled Codex bridge version mismatch"); - const issuer = createRequire(resolve(bridgeDirectory, "package.json")); - const runtimeManifest = realpathSync(issuer.resolve(`${profile.agentRuntimePackage}/package.json`)); - if (!inside(graphRoot, runtimeManifest)) throw new Error("Bundled Codex runtime escapes its producer graph"); - let runtimeCount = 0, packageCount = 0; - const pending = [{ directory: graphRoot, depth: 0 }]; - while (pending.length) { - const { directory, depth } = pending.pop(); - if (depth > 64) throw new Error("Bundled Codex graph exceeds its depth limit"); - const packages = []; - for (const entry of readdirSync(directory)) { - if (entry.startsWith(".")) continue; - const candidate = resolve(directory, entry); ownedDirectory(candidate); - if (entry.startsWith("@")) { - for (const child of readdirSync(candidate)) { - const scoped = resolve(candidate, child); ownedDirectory(scoped); packages.push(scoped); - } - } else packages.push(candidate); - } - for (const packageDirectory of packages) { - if (++packageCount > 20_000) throw new Error("Bundled Codex graph exceeds its package limit"); - const metadata = manifestAt(packageDirectory); - const binding = basename(dirname(packageDirectory)) === "@openai" ? `@openai/${basename(packageDirectory)}` : null; - if (binding?.startsWith("@openai/codex-")) { - const target = binding.slice("@openai/codex-".length), [os, cpu] = target.split("-"); - if (!Object.hasOwn(declarations, binding) || ![binding, profile.agentRuntimePackage].includes(metadata.name) - || metadata.version !== `${profile.agentRuntimeVersion}-${target}` || !metadata.os?.includes(os) || !metadata.cpu?.includes(cpu)) { - throw new Error("Bundled Codex installed platform identity mismatch"); - } - remove.add(packageDirectory); - continue; - } - const nested = resolve(packageDirectory, "node_modules"); - if (lstatExists(nested)) { ownedDirectory(nested); pending.push({ directory: nested, depth: depth + 1 }); } - if (binding !== profile.agentRuntimePackage) continue; - if (metadata.name !== profile.agentRuntimePackage || metadata.version !== profile.agentRuntimeVersion) throw new Error("Bundled Codex runtime version mismatch"); - runtimeCount++; - const delegated = metadata.optionalDependencies === undefined - && Object.entries(declarations).every(([name, specifier]) => result.optionalDependencies?.[name] === specifier); - if (!delegated && (Object.keys(metadata.optionalDependencies ?? {}).length !== targets.length - || targets.some(target => metadata.optionalDependencies?.[`${profile.agentRuntimePackage}-${target}`] !== declarations[`${profile.agentRuntimePackage}-${target}`]))) { - throw new Error("Bundled Codex platform declaration is not qualified"); - } - if (!delegated) { - const normalized = { ...metadata }; delete normalized.optionalDependencies; - normalize.set(resolve(packageDirectory, "package.json"), normalized); - } - const vendor = resolve(packageDirectory, "vendor"); - if (lstatExists(vendor)) { ownedDirectory(vendor); remove.add(vendor); } - } + if (!profiles.length || new Set(profiles.map(profile => profile.agent)).size !== profiles.length) { + throw new Error("Bundled provider graph must contain distinct qualified profiles"); } - if (!runtimeCount) throw new Error("Bundled Codex runtime omitted its platform declarations"); - for (const [name, specifier] of Object.entries(declarations)) { - if (result.optionalDependencies?.[name] !== undefined && result.optionalDependencies[name] !== specifier) throw new Error(`Bundled Codex platform conflicts with published dependency: ${name}`); - optional[name] = specifier; + for (const profile of profiles) { + const expected = profile.agent === "codex" ? ["@agentclientprotocol/codex-acp", "@openai/codex"] + : profile.agent === "claude" ? ["@agentclientprotocol/claude-agent-acp", "@anthropic-ai/claude-agent-sdk"] : null; + if (!expected || profile.agentServerPackage !== expected[0] || profile.agentRuntimePackage !== expected[1]) { + throw new Error("Bundled provider graph contains an unknown provider profile"); + } + const label = profile.agent === "codex" ? "Codex" : "Claude"; + const targets = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64", + ...(profile.agent === "claude" ? ["linux-x64-musl", "linux-arm64-musl"] : [])]; + const declarations = Object.fromEntries(targets.map(target => [`${profile.agentRuntimePackage}-${target}`, + profile.agent === "codex" ? `npm:${profile.agentRuntimePackage}@${profile.agentRuntimeVersion}-${target}` : profile.agentRuntimeVersion])); + const ownedDirectory = directory => { + const stat = lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || !inside(graphRoot, realpathSync(directory))) { + throw new Error(`Bundled ${label} dependency escapes its producer graph`); + } + }; + const manifestAt = directory => { + const path = resolve(directory, "package.json"), stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256 * 1024) { + throw new Error(`Bundled ${label} dependency manifest must be a bounded regular file`); + } + return JSON.parse(readFileSync(path, "utf8")); + }; + const bridgeDirectory = resolve(graphRoot, profile.agentServerPackage); + ownedDirectory(bridgeDirectory); + const bridge = manifestAt(bridgeDirectory); + if (bridge.name !== profile.agentServerPackage || bridge.version !== profile.agentServerVersion) throw new Error(`Bundled ${label} bridge version mismatch`); + const issuer = createRequire(resolve(bridgeDirectory, "package.json")); + let runtimeManifest; + try { runtimeManifest = issuer.resolve(`${profile.agentRuntimePackage}/package.json`); } + catch (error) { + if (error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error; + runtimeManifest = resolve(dirname(issuer.resolve(profile.agentRuntimePackage)), "package.json"); + } + runtimeManifest = realpathSync(runtimeManifest); + if (!inside(graphRoot, runtimeManifest)) throw new Error(`Bundled ${label} runtime escapes its producer graph`); + let runtimeCount = 0, packageCount = 0; + const pending = [{ directory: graphRoot, depth: 0 }]; + while (pending.length) { + const { directory, depth } = pending.pop(); + if (depth > 64) throw new Error(`Bundled ${label} graph exceeds its depth limit`); + const packages = []; + for (const entry of readdirSync(directory)) { + if (entry.startsWith(".")) continue; + const candidate = resolve(directory, entry); ownedDirectory(candidate); + if (entry.startsWith("@")) { + for (const child of readdirSync(candidate)) { + const scoped = resolve(candidate, child); ownedDirectory(scoped); packages.push(scoped); + } + } else packages.push(candidate); + } + for (const packageDirectory of packages) { + if (++packageCount > 20_000) throw new Error(`Bundled ${label} graph exceeds its package limit`); + const metadata = manifestAt(packageDirectory); + const scope = basename(dirname(packageDirectory)); + const binding = scope.startsWith("@") ? `${scope}/${basename(packageDirectory)}` : basename(packageDirectory); + if (binding.startsWith(`${profile.agentRuntimePackage}-`)) { + const target = binding.slice(profile.agentRuntimePackage.length + 1), [os, cpu] = target.split("-"); + if (!Object.hasOwn(declarations, binding) || !(metadata.name === binding || (profile.agent === "codex" && metadata.name === profile.agentRuntimePackage)) + || metadata.version !== (profile.agent === "codex" ? `${profile.agentRuntimeVersion}-${target}` : profile.agentRuntimeVersion) || !metadata.os?.includes(os) || !metadata.cpu?.includes(cpu)) { + throw new Error(`Bundled ${label} installed platform identity mismatch`); + } + remove.add(packageDirectory); + continue; + } + const nested = resolve(packageDirectory, "node_modules"); + if (lstatExists(nested)) { ownedDirectory(nested); pending.push({ directory: nested, depth: depth + 1 }); } + if (binding !== profile.agentRuntimePackage) continue; + if (metadata.name !== profile.agentRuntimePackage || metadata.version !== profile.agentRuntimeVersion) throw new Error(`Bundled ${label} runtime version mismatch`); + runtimeCount++; + const delegated = metadata.optionalDependencies === undefined + && Object.entries(declarations).every(([name, specifier]) => result.optionalDependencies?.[name] === specifier); + if (!delegated && (Object.keys(metadata.optionalDependencies ?? {}).length !== targets.length + || targets.some(target => metadata.optionalDependencies?.[`${profile.agentRuntimePackage}-${target}`] !== declarations[`${profile.agentRuntimePackage}-${target}`]))) { + throw new Error(`Bundled ${label} platform declaration is not qualified`); + } + if (!delegated) { + const normalized = { ...metadata }; delete normalized.optionalDependencies; + normalize.set(resolve(packageDirectory, "package.json"), normalized); + } + const vendor = resolve(packageDirectory, "vendor"); + if (profile.agent === "codex" && lstatExists(vendor)) { ownedDirectory(vendor); remove.add(vendor); } + } + } + if (!runtimeCount) throw new Error(`Bundled ${label} runtime omitted its platform declarations`); + for (const [name, specifier] of Object.entries(declarations)) { + if (result.optionalDependencies?.[name] !== undefined && result.optionalDependencies[name] !== specifier) throw new Error(`Bundled ${label} platform conflicts with published dependency: ${name}`); + optional[name] = specifier; + } } // A bundled wrapper's optional edges make npm treat missing platform slots // as bundled too. Delegate those exact declarations to the unbundled server @@ -183,7 +198,7 @@ export function stageBundledProviderOptionalDependencies(destinationDir, publish for (const directory of remove) rmSync(directory, { recursive: true, force: true }); result.optionalDependencies = { ...result.optionalDependencies, ...optional }; for (const field of ["bundleDependencies", "bundledDependencies"]) { - if (Array.isArray(result[field])) result[field] = result[field].filter(name => !Object.hasOwn(declarations, name)); + if (Array.isArray(result[field])) result[field] = result[field].filter(name => !Object.hasOwn(optional, name)); } delete result.paperclipProviderArtifacts; return result;