fix(runner): bundle the Claude ACP runtime and explain missing packages

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-11 14:43:49 -05:00
1 parent bfe7c6357d
commit 2d81789c14
19 files changed
+180 -9

No files matched your search

+8
View File
@@ -1457,6 +1457,14 @@ 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.
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
@@ -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"),
@@ -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");
});
@@ -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",
@@ -54,6 +54,21 @@ afterEach(async () => {
});
describe("ACPX installation integrity", () => {
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);
@@ -645,7 +645,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);
+13
View File
@@ -176,6 +176,19 @@ 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(
+8 -3
View File
@@ -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,9 +76,13 @@ 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 });
// 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 });
}
return { installManifest: result, profiles: selected };
}
+2
View File
@@ -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",
@@ -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(
@@ -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);
});
+7
View File
@@ -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");
+6 -1
View File
@@ -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.",
}],
};
}
}
@@ -13150,3 +13150,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 });
});
@@ -6197,6 +6197,7 @@ export function nativeSessionFailureDisposition(
sourceFailureCode?: ReturnType<typeof nativeSessionFailureSourceCode>,
) {
const permanentFailure =
sourceFailureCode === "native_provider_runtime_missing" ||
sourceFailureCode === "native_provider_model_rejected" ||
sourceFailureCode === "native_provider_approval_required" ||
sourceFailureCode === "native_event_replay_conflict" ||
@@ -6270,6 +6271,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"
@@ -6292,6 +6294,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)) {
@@ -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(<RuntimeTestCard state="fail" onTest={() => {}} 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());
}
});
+6 -1
View File
@@ -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
+13
View File
@@ -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(<TaskChatThread comments={[]} onAdd={async () => {}} 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<HTMLButtonElement>('[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");
});
+5 -1
View File
@@ -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"