diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 6fc8fa4f267..ae811577a16 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1457,6 +1457,19 @@ Claude, which can reject unavailable models. Package/version verification is independent of model selection. Environment tests verify runtime installation; 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. +Changing the model or signing in again cannot repair a missing runtime package. + ACPX Claude supports Linux x64 and macOS ARM64/x64 with pinned SDK executables. On macOS the launcher uses private verified module/executable snapshots instead of Linux `/proc` descriptors. Dependency isolation, process ownership, and 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 331f6ed17f3..ff99355038f 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 @@ -844,6 +844,7 @@ fn stderr_diagnostic_categories(value: &str) -> BTreeSet<&'static str> { fn response_error_classification(error: &ResponseError) -> &'static str { match error.code.as_str() { + "ACPX_RUNTIME_PACKAGE_MISSING" => return "runtime_package_missing", "ACP_MODEL_UNSUPPORTED" => return "requested_model_unsupported", "AGENT_STARTUP_FAILED" => return "agent_startup_failed", "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE" => return "agent_startup_unverified_module", @@ -1038,6 +1039,7 @@ mod tests { #[test] fn candidate_auth_diagnostics_use_only_closed_codes_and_never_provider_text() { for (code, expected) in [ + ("ACPX_RUNTIME_PACKAGE_MISSING", "runtime_package_missing"), ("ACPX_TOOL_CALL_STALE", "provider_tool_call_retired"), ("ACPX_TOOL_CALL_STALE_EXTRA", "unclassified"), ("COPILOT_AUTH_REQUIRED", "authentication_required"), diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts index 12c80c49221..0b6a280bdb3 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts @@ -186,3 +186,9 @@ describe("ACPX sidecar input sequencing", () => { ); }); }); + +it("preserves a missing runtime package category across wrapped bootstrap errors", () => { + const error = Object.assign(new Error("private resolver stack"), { code: "ACPX_RUNTIME_PACKAGE_MISSING" }); + expect(acpxSidecarErrorCode(new Error("bootstrap failed", { cause: error }))).toBe("ACPX_RUNTIME_PACKAGE_MISSING"); + expect(acpxSidecarErrorCode(acpxBootstrapBlockedError(error, "session.read")!)).toBe("ACPX_RUNTIME_PACKAGE_MISSING"); +}); diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts index 6aeee58ca14..e9f4c645028 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts @@ -25,6 +25,7 @@ export function acpxBootstrapBlockedError( return failure ? new Error( `ACPX provider bootstrap failed before ${command}: ${failure.message}`, + { cause: failure }, ) : null; } @@ -130,6 +131,7 @@ const GENERIC_ACPX_OUTPUT_CODES = new Set([ ]); const STABLE_ACPX_SIDECAR_CODES = new Set([ + "ACPX_RUNTIME_PACKAGE_MISSING", "COPILOT_POLICY_VIOLATION", "COPILOT_DETACHED_WORK_UNSUPPORTED", "ACP_MODEL_UNSUPPORTED", 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 089aeec06bc..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,77 @@ 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", + ), { code: "MODULE_NOT_FOUND" }); + const error = await verifyQualifiedAcpxInstallation( + resolveQualifiedAcpxProfile("claude", "claude-opus-5-5"), + () => { throw missingPackage; }, + ).catch(error => error); + expect(error).toMatchObject({ code: "ACPX_RUNTIME_PACKAGE_MISSING" }); + expect(error.message).toContain("@agentclientprotocol/claude-agent-acp"); + expect(error.message).toContain("Reinstall or rebuild"); + expect(error.message).not.toContain("/srv/private"); + expect(error.message).not.toContain("Require stack"); + }); + it.each([false, true])("admits only the declared qualified Codex platform in its npm-hoisted slot (published platform declaration: %s)", async (publishedDeclaration) => { const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-codex-npm-hoist-"))); temporaryDirectories.push(parent); diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 05fd89fdc47..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, @@ -645,7 +655,22 @@ export async function verifyQualifiedAcpxInstallation( if (builtin && (profile.agentServerPackage !== "builtin:grok-acp" || profile.agentServerVersion !== "1" || profile.agentRuntimePackage !== "native:grok" || profile.agentRuntimeVersion !== "1.0.13")) { throw new Error("Grok builtin profile identity mismatch"); } - const serverPackageJsonPath = builtin ? null : await realpath(resolvePackageJson(profile.agentServerPackage)); + let serverPackageJsonPath: string | null = null; + if (!builtin) { + try { + serverPackageJsonPath = await realpath(resolvePackageJson(profile.agentServerPackage)); + } catch (error) { + if ((profile.agent === "claude" || profile.agent === "codex") && + error instanceof Error && "code" in error && + (error.code === "MODULE_NOT_FOUND" || error.code === "ERR_MODULE_NOT_FOUND")) { + throw Object.assign(new Error( + `The ACPX ${profile.agent} runtime is incomplete: required package ${profile.agentServerPackage} is missing. ` + + "Reinstall or rebuild Paperclip with its bundled provider dependencies, restart the server, then test the agent's runtime before retrying.", + ), { code: "ACPX_RUNTIME_PACKAGE_MISSING" }); + } + throw error; + } + } const serverPackage: AcpxPackageMetadata = builtin ? { version: "1", bin: "launcher.cjs", type: "commonjs" } : await readPackageJson(serverPackageJsonPath!, profile.agentServerPackage); @@ -1019,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 fb7086946ef..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 })); @@ -176,13 +204,26 @@ test("published packages preserve the patched ACPX runtime", () => { assert.equal(cliEsbuildConfig.external.includes("acpx"), false); }); +test("published Claude bridge pins its qualified SDK and provider dependency closure", () => { + const published = materializePublishManifest(serverPackage); + const { installManifest } = configureBundledProviderOverrides( + createBundledInstallManifest(published, serverPackage.bundleDependencies), + serverPackage.bundleDependencies, rootPackage, profileData, + ); + assert.deepEqual(installManifest.overrides["@agentclientprotocol/claude-agent-acp@0.73.0"], { + "@anthropic-ai/claude-agent-sdk": "0.3.286", + "@agentclientprotocol/sdk": "1.4.0", + zod: "4.4.3", + }); +}); + test("published Codex bridge uses its qualified producer closure without consumer overrides", () => { const published = materializePublishManifest(serverPackage); const { installManifest, profiles } = configureBundledProviderOverrides( 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)); @@ -201,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")); @@ -530,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"]}));' @@ -590,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 cdc03387905..528ece8bfb5 100644 --- a/scripts/prepare-bundled-package.mjs +++ b/scripts/prepare-bundled-package.mjs @@ -51,13 +51,14 @@ export function createBundledInstallManifest(publishManifest, bundledDependencie } // npm consumers cannot inherit the workspace's pnpm overrides or patches. -// Materialize the already-qualified Codex bridge closure in the tarball, +// Materialize the already-qualified provider bridge closures in the tarball, // with overrides confined to the temporary, scripts-disabled producer graph. export function configureBundledProviderOverrides(installManifest, bundledDependencies, rootPackage, profileData) { const result = structuredClone(installManifest); const selected = []; for (const [agent, serverPackage, runtimePackage] of [ ["codex", "@agentclientprotocol/codex-acp", "@openai/codex"], + ["claude", "@agentclientprotocol/claude-agent-acp", "@anthropic-ai/claude-agent-sdk"], ]) { if (!bundledDependencies.includes(serverPackage)) continue; const profile = profileData?.profiles?.[agent]; @@ -75,7 +76,9 @@ export function configureBundledProviderOverrides(installManifest, bundledDepend if (result.overrides?.[npmSelector] !== undefined) { throw new Error(`Bundled ${agent} runtime has a conflicting producer override`); } - const overrides = { [runtimePackage]: profile.agentRuntimeVersion }; + const overrides = { [runtimePackage]: profile.agentRuntimeVersion, + ...(agent === "claude" ? { "@agentclientprotocol/sdk": "1.4.0", zod: "4.4.3" } : {}), + }; result.overrides = { ...result.overrides, [npmSelector]: overrides }; selected.push({ agent, ...profile }); } @@ -87,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 @@ -178,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; diff --git a/server/package.json b/server/package.json index ae6ce5ab0b9..657e961e729 100644 --- a/server/package.json +++ b/server/package.json @@ -44,6 +44,7 @@ "typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "0.73.0", "@agentclientprotocol/codex-acp": "1.6.2", "@ai-sdk/amazon-bedrock": "5.0.113", "@ai-sdk/anthropic": "4.0.78", @@ -113,6 +114,7 @@ "zod": "^4.4.3" }, "bundleDependencies": [ + "@agentclientprotocol/claude-agent-acp", "@agentclientprotocol/codex-acp", "@chat-adapter/discord", "@chat-adapter/github", diff --git a/server/scripts/verify-runner-vendor-dependencies.mjs b/server/scripts/verify-runner-vendor-dependencies.mjs index 26bf6f3918d..78d855d021b 100644 --- a/server/scripts/verify-runner-vendor-dependencies.mjs +++ b/server/scripts/verify-runner-vendor-dependencies.mjs @@ -100,6 +100,21 @@ export async function findRunnerExternalPackages(entryPoints) { return externalPackageNames; } +// Provider entrypoints are resolved with createRequire from profile metadata, +// so esbuild cannot see them. These two bridges ship in the server package; +// other ACP agents use separately provisioned provider packs. +export async function findRunnerRuntimePackages(entryPoints, profileData) { + const packages = await findRunnerExternalPackages(entryPoints); + for (const agent of ["claude", "codex"]) { + const profile = profileData?.profiles?.[agent]; + if (profileData?.schema !== "paperclip.acpx-profiles.v1" || !profile?.agentServerPackage) { + throw new Error(`paperclip-runner vendor check: missing ${agent} provider profile.`); + } + packages.add(profile.agentServerPackage); + } + return packages; +} + function readDependencyNames(packageJsonPath) { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); return new Map(Object.entries(packageJson.dependencies ?? {})); @@ -125,7 +140,8 @@ function explainMissingDependencies(missing, runnerDependencyNames) { async function main() { const entryPoints = ENTRY_POINT_NAMES.map((name) => resolve(runnerDist, name)); - const externalPackageNames = await findRunnerExternalPackages(entryPoints); + const profileData = JSON.parse(readFileSync(resolve(runnerRoot, "acpx-profiles.json"), "utf8")); + const externalPackageNames = await findRunnerRuntimePackages(entryPoints, profileData); const serverDependencyNames = readDependencyNames(resolve(serverRoot, "package.json")); const missing = findMissingVendorDependencies( diff --git a/server/scripts/verify-runner-vendor-dependencies.test.mjs b/server/scripts/verify-runner-vendor-dependencies.test.mjs index 07bbbcef3bf..3ffca3e2cf5 100644 --- a/server/scripts/verify-runner-vendor-dependencies.test.mjs +++ b/server/scripts/verify-runner-vendor-dependencies.test.mjs @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { findMissingVendorDependencies, findRunnerExternalPackages, + findRunnerRuntimePackages, } from "./verify-runner-vendor-dependencies.mjs"; describe("findMissingVendorDependencies", () => { @@ -87,6 +88,15 @@ describe("findRunnerExternalPackages", () => { expect(externalPackageNames).toEqual(new Set(["smol-toml"])); }); + it("checks dynamically resolved bundled providers even without a JavaScript import", async () => { + const entryPoints = writeFixture(); + const profiles = JSON.parse(readFileSync(new URL("../../packages/paperclip-runner/acpx-profiles.json", import.meta.url), "utf8")); + const packages = await findRunnerRuntimePackages(entryPoints, profiles); + expect(findMissingVendorDependencies(packages, new Set(["smol-toml", "@agentclientprotocol/codex-acp"]))) + .toEqual(["@agentclientprotocol/claude-agent-acp"]); + expect(packages.has("pi-acp")).toBe(false); + }); + it("throws a clear, actionable error when an entry point is missing", async () => { fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-runner-vendor-fixture-")); const missingEntryPoint = join(fixtureDir, "index.js"); @@ -96,3 +106,12 @@ describe("findRunnerExternalPackages", () => { ); }); }); + +// Claude is resolved through createRequire at runtime, outside esbuild's graph. +it("ships the qualified Claude ACP server with the vendored runner", () => { + const server = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + const profiles = JSON.parse(readFileSync(new URL("../../packages/paperclip-runner/acpx-profiles.json", import.meta.url), "utf8")); + const claude = profiles.profiles.claude; + expect(server.dependencies[claude.agentServerPackage]).toBe(claude.agentServerVersion); + expect(server.bundleDependencies).toContain(claude.agentServerPackage); +}); diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index d86b0144217..b59f7ffc878 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -118,6 +118,13 @@ describe("native ACPX environment checks", () => { })]); }); + it("identifies an incomplete installation and gives the operator a repair", async () => { + probeInstallation.mockRejectedValue(Object.assign(new Error("The Claude ACP runtime package is missing. Reinstall or rebuild Paperclip."), { code: "ACPX_RUNTIME_PACKAGE_MISSING" })); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context); + expect(result.status).toBe("fail"); + expect(result.checks[0]).toMatchObject({ code: "acpx_runtime_package_missing", message: expect.stringContaining("Reinstall or rebuild") }); + }); + it("requires a successful installed runtime probe", async () => { const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context); expect(result.status).toBe("pass"); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 54b72aa3bd5..1754f81e3df 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -447,7 +447,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = { } catch (error) { return { adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(), - checks: [{ code: "acpx_runtime_unavailable", level: "error" as const, message: error instanceof Error ? error.message : "ACPX Claude runtime could not be verified." }], + checks: [{ + code: error instanceof Error && "code" in error && error.code === "ACPX_RUNTIME_PACKAGE_MISSING" + ? "acpx_runtime_package_missing" : "acpx_runtime_unavailable", + level: "error" as const, + message: error instanceof Error ? error.message : "The ACPX runtime could not be verified.", + }], }; } } diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 375cbf61cc7..a78296fc189 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -13156,3 +13156,10 @@ it.each([ ), ).toBe("native_event_replay_conflict"); }); + +it("keeps an ACPX missing runtime package failure actionable and stops automatic retries", () => { + const failure = new Error('PRP command session.open failed: {"result":{"code":"command_execution_failed","message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=runtime_package_missing)"},"status":"failed"}'); + const code = nativeSessionFailureSourceCode(failure); + expect(code).toBe("native_provider_runtime_missing"); + expect(nativeSessionFailureDisposition(1, new Date(), code)).toEqual({ phase: "terminal_failure", failureCode: code, nextAttemptAt: null }); +}); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 9a823b4e95f..92501cfa942 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -6198,6 +6198,7 @@ export function nativeSessionFailureDisposition( sourceFailureCode?: ReturnType, ) { const permanentFailure = + sourceFailureCode === "native_provider_runtime_missing" || sourceFailureCode === "native_provider_model_rejected" || sourceFailureCode === "native_provider_approval_required" || sourceFailureCode === "native_event_replay_conflict" || @@ -6271,6 +6272,7 @@ export function nativeSessionFailureSourceCode( | "native_runner_process_exited" | "planning_mode_unsupported" | "native_event_replay_conflict" + | "native_provider_runtime_missing" | "native_provider_model_rejected" | "native_current_wake_comments_unread" | "native_current_wake_comments_changed_after_read" @@ -6293,6 +6295,8 @@ export function nativeSessionFailureSourceCode( if (error instanceof NativeSessionCleanupQuarantinedError) return "native_session_cleanup_quarantined"; const message = error instanceof Error ? error.message : String(error); + if (/classification=runtime_package_missing\)/.test(message)) + return "native_provider_runtime_missing"; if (/native_provider_model_rejected/i.test(message)) return "native_provider_model_rejected"; if (/native_adopted_runner_authentication_timeout/i.test(message)) { diff --git a/ui/src/components/RuntimeTestCard.test.tsx b/ui/src/components/RuntimeTestCard.test.tsx new file mode 100644 index 00000000000..b7ca0538ece --- /dev/null +++ b/ui/src/components/RuntimeTestCard.test.tsx @@ -0,0 +1,23 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vitest"; +import { RuntimeTestCard } from "./RuntimeTestCard"; + +it("shows the installation repair directly on a missing runtime test", () => { + const container = document.createElement("div"); + const root = createRoot(container); + const message = "The ACPX claude runtime is incomplete: required package @agentclientprotocol/claude-agent-acp is missing. Reinstall or rebuild Paperclip with its bundled provider dependencies, restart the server, then test the agent's runtime before retrying."; + try { + flushSync(() => root.render( {}} result={{ + adapterType: "paperclip_runner", status: "fail", testedAt: "2026-10-11T11:19:14Z", + checks: [{ code: "acpx_runtime_package_missing", level: "error", message }], + }} />)); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Runtime missing"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain(message); + expect(container.textContent).not.toContain("Check your model and provider connection"); + } finally { + flushSync(() => root.unmount()); + } +}); diff --git a/ui/src/components/RuntimeTestCard.tsx b/ui/src/components/RuntimeTestCard.tsx index 24398642b14..66c4b033d9d 100644 --- a/ui/src/components/RuntimeTestCard.tsx +++ b/ui/src/components/RuntimeTestCard.tsx @@ -61,7 +61,12 @@ export function RuntimeTestCard({ disabled?: boolean; variant?: "connection" | "prerequisites"; }) { - const content = (variant === "prerequisites" ? prerequisiteCopy : copy)[state]; + const missingRuntime = state === "fail" + ? result?.checks.find(check => check.code === "acpx_runtime_package_missing") + : undefined; + const content = missingRuntime + ? { ...copy.fail, title: "Runtime missing", description: missingRuntime.message } + : (variant === "prerequisites" ? prerequisiteCopy : copy)[state]; const Icon = state === "running" ? Loader2 diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index b05b403b431..d7d255d6d82 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -4658,3 +4658,16 @@ describe("TaskChatThread composer execution controls", () => { expect(container.querySelector('[data-testid="task-chat-composer-stop"]')).toBeNull(); }); }); + +it("explains a missing provider runtime with an installation repair", () => { + render( {}} linkedRuns={[{ + runId: "missing-runtime", runtimeMode: "native", status: "failed", errorCode: "native_provider_runtime_missing", + agentId: "agent-1", agentName: "Content Strategist", adapterType: "paperclip_runner", + createdAt: "2026-10-11T11:19:08Z", startedAt: "2026-10-11T11:19:08Z", finishedAt: "2026-10-11T11:19:14Z", + }]} />); + expect(container.textContent).toContain("Runtime missing"); + const toggle = container.querySelector('[data-testid="task-chat-collapsible-marker"] button[aria-expanded]')!; + flushSync(() => toggle.click()); + expect(container.textContent).toContain("Reinstall or rebuild Paperclip"); + expect(container.textContent).toContain("test the agent's runtime"); +}); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index bf497109f90..c1425f56e79 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1646,7 +1646,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? "native_runner_timed_out" : "native_runner_process_exited"); const label = - code === "native_provider_model_rejected" && source.status === "failed" + code === "native_provider_runtime_missing" && source.status === "failed" + ? "Runtime missing" + : code === "native_provider_model_rejected" && source.status === "failed" ? "Model unavailable" : code === "native_provider_approval_required" && source.status === "failed" ? "Approval required" @@ -1667,6 +1669,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? `The run was cancelled${responseBoundary}.` : source.status === "interrupted" ? `The run was interrupted${responseBoundary}.` + : code === "native_provider_runtime_missing" + ? "The selected provider runtime is missing a required package. Reinstall or rebuild Paperclip with its bundled provider dependencies, restart the server, then test the agent's runtime before retrying." : code === "native_provider_approval_required" ? "This operation requires approval, but this runner has no interactive approval handler. Review the operation and update the agent's permission setting before retrying." : code === "native_provider_model_rejected"