diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 41c08b14af4..7ebc5207ae1 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -1247,8 +1247,10 @@ This is trusted controller timing policy, outside the immutable Pi wrapper/profi closure; it does not change profile 13, runtime 1.0.0, or the explicit model. The credential-free closed-startup regression requires the real -`session_ensure_failed` rejection within 60 seconds, with no prompt, no provider -identity and confirmed cleanup. Its child watchdog is 80 seconds (20 seconds for -cleanup after admission), inside a 90-second test timeout. Failed evidence stays +`provider_authentication` rejection within 60 seconds, with no prompt, no provider +identity and confirmed cleanup. Pi reports the missing credential with ACP's +authentication-required error, so the rejection names that typed category instead +of the generic `session_ensure_failed`. Its child watchdog is 80 seconds (20 +seconds for cleanup after admission), inside a 90-second test timeout. Failed evidence stays in its reported temporary directory. These margins do not extend production command deadlines or qualify a target platform without a fresh run. diff --git a/doc/run-log-events.md b/doc/run-log-events.md index ed4dbc3a3f4..33648bb8ef0 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -100,8 +100,18 @@ adapters extract these fields; arbitrary exception objects, request bodies, headers, environment variables, and stacks are excluded. Unknown categories retain their explanation. The cause stays in company-scoped run records and the run log; it is not added to first-party Telemetry, Observability attributes, or Sentry -failure reports. The reportable run error keeps its existing generic text. Startup -receipts retain it across controller restart before any provider turn exists. +failure reports. The reportable run error keeps its existing generic text, except +that an ACPX startup rejection keeps Runner's closed rejection text and its +classification. Startup receipts retain the cause across controller restart before +any provider turn exists. + +Every ACPX session ensure rejection carries a startup cause. When the provider +returned no ACP error, the cause keeps the rejection's own redacted message with +category `unknown`. A provider can type the failure with ACP's +authentication-required code, or with a known error type or `errorKind` in the ACP +error data. The cause then uses that category, and the rejection classification is +`provider_`, such as `provider_quota_exhausted`, instead of +`session_ensure_failed`. Completed OpenCode builtin `bash` parts preserve the numeric process exit from `state.metadata.exit` in `tool.execution.completed.payload.exitCode`. The tool 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 b22e7c9126b..c2e0bbee6a6 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 @@ -14,6 +14,7 @@ use crate::local_runner::LocalRunnerError; use crate::process_supervisor::{ BoundedLogBuffer, ProcessOutput, SupervisedProcess, VerifiedProcessLaunch, }; +use crate::provider_failure::validated_provider_failure; use crate::stable_identity::{is_stable_id, DURABLE_STABLE_ID_CHARS, SHORT_STABLE_ID_CHARS}; pub const ACPX_SIDECAR_MAX_FRAME_BYTES: usize = 1024 * 1024; @@ -908,6 +909,24 @@ fn stderr_diagnostic_categories(value: &str) -> BTreeSet<&'static str> { categories } +// A typed ensure rejection names its provider-failure category, so a limit at +// session start is distinguishable from other startup failures. Only the +// schema-validated enum crosses here, never the cause's text. +fn typed_provider_classification(error: &ResponseError) -> Option<&'static str> { + let failure = validated_provider_failure(error.provider_failure.clone())?; + Some(match failure.get("category")?.as_str()? { + "rate_limit" => "provider_rate_limit", + "quota_exhausted" => "provider_quota_exhausted", + "authentication" => "provider_authentication", + "permission" => "provider_permission", + "model_unavailable" => "provider_model_unavailable", + "invalid_request" => "provider_invalid_request", + "overloaded" => "provider_overloaded", + "timeout" => "provider_timeout", + _ => return None, + }) +} + fn response_error_classification(error: &ResponseError) -> &'static str { match error.code.as_str() { "GROK_EFFORT_ACKNOWLEDGEMENT_MISSING" => return "grok_effort_acknowledgement_missing", @@ -955,7 +974,9 @@ fn response_error_classification(error: &ResponseError) -> &'static str { "SESSION_CONFIG_OPTION_REPLAY_FAILED" => return "session_config_option_replay_failed", "CLAUDE_ACP_SESSION_CREATE_TIMEOUT" => return "claude_session_create_timeout", "ACPX_SESSION_HANDSHAKE_TIMEOUT" => return "session_handshake_timeout", - "ACPX_SESSION_ENSURE_FAILED" => return "session_ensure_failed", + "ACPX_SESSION_ENSURE_FAILED" => { + return typed_provider_classification(error).unwrap_or("session_ensure_failed") + } "ACPX_SESSION_ENSURE_TYPE_ERROR" => return "session_ensure_type_error", "ACPX_SESSION_ENSURE_NON_ERROR" => return "session_ensure_non_error", "ACP_SESSION_INIT_FAILED" => return "acp_session_init_failed", @@ -1188,6 +1209,55 @@ mod tests { } } + #[test] + fn session_ensure_names_only_a_typed_provider_category() { + let ensure = |provider_failure: Option| ResponseError { + code: "ACPX_SESSION_ENSURE_FAILED".to_owned(), + message: "private-token-canary".to_owned(), + retryable: false, + provider_failure, + }; + let cause = |category: &str| { + json!({ "schema": "paperclip.provider-failure.v1", "provider": "anthropic", "phase": "startup", + "category": category, "message": "private-token-canary", "retryability": "unknown" }) + }; + for (category, expected) in [ + ("quota_exhausted", "provider_quota_exhausted"), + ("rate_limit", "provider_rate_limit"), + ("authentication", "provider_authentication"), + ("unknown", "session_ensure_failed"), + ] { + assert_eq!( + response_error_classification(&ensure(Some(cause(category)))), + expected + ); + } + assert_eq!( + response_error_classification(&ensure(None)), + "session_ensure_failed" + ); + // A cause outside the schema cannot select a classification. + let mut invented = cause("quota_exhausted"); + invented["category"] = json!("private-token-canary"); + assert_eq!( + response_error_classification(&ensure(Some(invented))), + "session_ensure_failed" + ); + let mut extra = cause("quota_exhausted"); + extra["headers"] = json!({ "authorization": "private-token-canary" }); + assert_eq!( + response_error_classification(&ensure(Some(extra))), + "session_ensure_failed" + ); + // Other closed codes keep their own classification. + let mut admission = ensure(Some(cause("quota_exhausted"))); + admission.code = "ACPX_ADMISSION_CREDENTIAL".to_owned(); + assert_eq!( + response_error_classification(&admission), + "admission_credential" + ); + } + #[test] fn classifies_only_allowlisted_internal_sidecar_failures() { let error = |code: &str, message: &str| ResponseError { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs index c5329db13e5..97c5f60a800 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs @@ -256,17 +256,21 @@ fn run() -> Result<(), Box> { write_json(&mut stdout, &success(id + 1, command, &request))?; } "remote-error" => { + let mut error = json!({ + "code": request["params"]["errorCode"].as_str().unwrap_or("Q7Z9"), + "message": "violet-circuit-4821", + "retryable": false, + }); + if let Some(cause) = request["params"].get("providerFailure") { + error["providerFailure"] = cause.clone(); + } write_json( &mut stdout, &json!({ "protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, "id": id, "ok": false, - "error": { - "code": request["params"]["errorCode"].as_str().unwrap_or("Q7Z9"), - "message": "violet-circuit-4821", - "retryable": false, - }, + "error": error, }), )?; } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs index 9ee17452633..f6264141dd1 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs @@ -247,6 +247,35 @@ fn preserves_closed_admission_categories_over_the_sidecar_pipe() { transport.shutdown().unwrap(); } +#[test] +fn session_ensure_rejection_carries_its_provider_cause_outside_the_error_text() { + let mut transport = transport("remote-error", Duration::from_secs(1)); + let cause = |category: &str| { + json!({ "schema": "paperclip.provider-failure.v1", "provider": "anthropic", "phase": "startup", + "category": category, "message": "You've hit your limit [REDACTED] amber-cause-5512", + "retryability": "requires_action" }) + }; + for (category, classification) in [ + ("quota_exhausted", "provider_quota_exhausted"), + ("unknown", "session_ensure_failed"), + ] { + let error = transport + .request( + GeneratedAcpxSidecarCommand::SessionOpen, + json!({ "errorCode": "ACPX_SESSION_ENSURE_FAILED", "providerFailure": cause(category) }), + ) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains(&format!( + "retryable=false, classification={classification})" + ))); + assert!(!message.contains("amber-cause-5512")); + assert!(!message.contains("violet-circuit-4821")); + assert_eq!(error.provider_failure(), Some(cause(category))); + } + transport.shutdown().unwrap(); +} + #[test] fn redacts_sidecar_stderr_when_the_process_exits() { let mut transport = transport("exit-secret", Duration::from_secs(1)); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index ddbe54291b5..0df913dbe3d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -49,6 +49,56 @@ describe("Codex ACPX runtime adapter", () => { }); expect(runtime.startTurn).not.toHaveBeenCalled(); }); + it("keeps a redacted explanation for an ensure rejection that carries no ACP error", async () => { + const runtime = fakeRuntime(); + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "claude" }; + options.launchEnvironment = { ANTHROPIC_API_KEY: "fixture-anthropic-secret" }; + const rejection = new Error("Claude Code exited during startup for fixture-anthropic-secret (sk-ant-api03-fixtureprivate)"); + vi.mocked(runtime.ensureSession).mockRejectedValue(rejection); + const failure = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), + createRuntime: () => runtime }).catch((error: unknown) => error); + expect(failure).toBe(rejection); + expect(failure).toMatchObject({ code: "ACPX_SESSION_ENSURE_FAILED", providerFailure: { provider: "anthropic", + phase: "startup", category: "unknown", message: "Claude Code exited during startup for [REDACTED] ([REDACTED])" } }); + expect(JSON.stringify((failure as { providerFailure: unknown }).providerFailure)).not.toMatch(/fixture-anthropic-secret|fixtureprivate/); + }); + it("classifies an ACP authentication-required ensure rejection", async () => { + const runtime = fakeRuntime(); + const message = "Authentication required: This integration does not support using claude.ai subscriptions."; + vi.mocked(runtime.ensureSession).mockRejectedValue(Object.assign(new Error(message), { code: -32000 })); + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "claude" }; + await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), + createRuntime: () => runtime })).rejects.toMatchObject({ code: "ACPX_SESSION_ENSURE_FAILED", + providerFailure: { category: "authentication", message, phase: "startup", retryability: "requires_action" } }); + }); + it("attaches a provider cause to a non-error ensure rejection", async () => { + const runtime = fakeRuntime(); + vi.mocked(runtime.ensureSession).mockRejectedValue({ code: -32603, message: "Internal error", data: { errorKind: "billing_error" } }); + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "claude" }; + await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), + createRuntime: () => runtime })).rejects.toMatchObject({ code: "ACPX_SESSION_ENSURE_NON_ERROR", + providerFailure: { category: "quota_exhausted", providerCode: "billing_error", phase: "startup" } }); + }); + it("does not report Runner cancellation as a provider explanation", async () => { + const runtime = fakeRuntime(); + const controller = new AbortController(); + const reason = new Error("runtime admission cancelled"); + const rejection = new Error("ACP connection closed"); + vi.mocked(runtime.ensureSession).mockImplementation(async () => { + controller.abort(reason); + throw rejection; + }); + const options = openOptions(fakeCommand()); + options.signal = controller.signal; + await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), + createRuntime: () => runtime })).rejects.toBe(reason); + await vi.waitFor(() => expect(rejection).toHaveProperty("code", "ACPX_SESSION_ENSURE_FAILED")); + expect(rejection).not.toHaveProperty("providerFailure"); + expect(reason).not.toHaveProperty("providerFailure"); + }); it.each([false, true])("acknowledges configured Claude effort before admission (restoring=%s)", async restoring => { const runtime = fakeRuntime(); const child = fakeChild(); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 30cb7421fc0..580bf58453d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -5,6 +5,7 @@ import { resolveGrokDisableWebSearch } from "./grok-web-search.js"; import { resolveGrokEffort, createGrokEffortAdmission } from "./grok-effort.js"; import { resolveClaudeEffort, createClaudeEffortAdmission } from "./claude-effort.js"; import { acpxProviderFailure } from "./provider-failure.js"; +import type { ProviderFailure } from "../../contracts/provider-failure.generated.js"; import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js"; import { usesPreconfiguredHome } from "../preconfigured-home.js"; import type { ChildProcess } from "node:child_process"; @@ -600,11 +601,12 @@ export async function openQualifiedAcpxRuntime( return ensuredHandle; }) : ensuredSession) .catch((error: unknown) => { - const providerFailure = acpxProviderFailure(error, options.profile.agent, "startup", options.launchEnvironment); - if (providerFailure && error instanceof Error) { - Object.defineProperty(error, "providerFailure", { value: providerFailure, enumerable: false }); - } - throw classifySessionEnsureFailure(error); + // The closed code below cannot say why the provider session did not + // start, so every ensure rejection keeps its redacted explanation. An + // abort reason is Runner cancellation, not a provider explanation. + const providerFailure = acpxProviderFailure(error, options.profile.agent, "startup", + options.launchEnvironment, options.signal?.aborted !== true); + throw classifySessionEnsureFailure(error, providerFailure); }); let lateCleanup: Promise | null = null; try { @@ -706,20 +708,21 @@ export async function openQualifiedAcpxRuntime( /** Backward-compatible name retained for existing Codex-only consumers. */ export const openCodexAcpxRuntime = openQualifiedAcpxRuntime; -function classifySessionEnsureFailure(error: unknown): Error { - if (error instanceof Error) { - const details = error as Error & Record; - if (typeof details.code !== "string" || details.code.length === 0) { - details.code = - error instanceof TypeError - ? "ACPX_SESSION_ENSURE_TYPE_ERROR" - : "ACPX_SESSION_ENSURE_FAILED"; - } - return error; +function classifySessionEnsureFailure(error: unknown, providerFailure: ProviderFailure | null): Error { + const failure = error instanceof Error ? error + : Object.assign(new Error("ACPX session ensure rejected a non-error"), { code: "ACPX_SESSION_ENSURE_NON_ERROR" }); + const details = failure as Error & Record; + if (typeof details.code !== "string" || details.code.length === 0) { + details.code = + failure instanceof TypeError + ? "ACPX_SESSION_ENSURE_TYPE_ERROR" + : "ACPX_SESSION_ENSURE_FAILED"; } - return Object.assign(new Error("ACPX session ensure rejected a non-error"), { - code: "ACPX_SESSION_ENSURE_NON_ERROR", - }); + // Keep a cause that an inner layer already attached. + if (providerFailure && !Object.hasOwn(failure, "providerFailure")) { + Object.defineProperty(failure, "providerFailure", { value: providerFailure, enumerable: false }); + } + return failure; } function raceRuntimeHandshakeWithAbort( diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts b/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts index e18e978fd25..2962c9c826c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts @@ -17,27 +17,41 @@ function acpError(value: unknown, depth = 0): Record | null { return null; } +// Provider error types, plus the `errorKind` values Claude ACP attaches to +// ACP error data so clients need not match message text. +const CATEGORIES = new Map([ + ["authentication_error", "authentication"], ["permission_error", "permission"], ["overloaded_error", "overloaded"], + ["rate_limit_error", "rate_limit"], ["billing_error", "quota_exhausted"], ["invalid_request_error", "invalid_request"], + ["insufficient_quota", "quota_exhausted"], ["model_not_found", "model_unavailable"], + ["authentication_failed", "authentication"], ["oauth_org_not_allowed", "authentication"], + ["account_on_hold", "quota_exhausted"], ["rate_limit", "rate_limit"], ["overloaded", "overloaded"], + ["invalid_request", "invalid_request"], +]); +// ACP reserves this JSON-RPC code for "Authentication required". +const ACP_AUTH_REQUIRED = -32000; + +/** + * `ownMessage` keeps an error's own message when it carries no ACP error. Use + * it only where every rejection belongs to the provider: a failed terminal + * result or a provider session ensure. + */ export function acpxProviderFailure(error: unknown, agent: string, phase: ProviderFailure["phase"], - environment: NodeJS.ProcessEnv = {}, terminal = false): ProviderFailure | null { + environment: NodeJS.ProcessEnv = {}, ownMessage = false): ProviderFailure | null { const acp = acpError(error); const data = record(acp?.data); const detail = record(data.error); - const fallback = terminal ? record(error) : {}; + const fallback = ownMessage ? record(error) : {}; const message = typeof detail.message === "string" ? detail.message : typeof data.details === "string" ? data.details : typeof acp?.message === "string" ? acp.message : fallback.message; if (typeof message !== "string") return null; const status = data.status ?? data.statusCode ?? detail.status ?? (agent === "claude" ? Number(/^API error: (\d{3})\b/.exec(message)?.[1]) : undefined); - const code = detail.type ?? detail.code ?? data.errorType; - const categories: Record = { - authentication_error: "authentication", permission_error: "permission", overloaded_error: "overloaded", - rate_limit_error: "rate_limit", billing_error: "quota_exhausted", invalid_request_error: "invalid_request", - insufficient_quota: "quota_exhausted", model_not_found: "model_unavailable", - }; + const code = detail.type ?? detail.code ?? data.errorType ?? data.errorKind; + const category = (typeof code === "string" ? CATEGORIES.get(code) : undefined) + ?? (acp?.code === ACP_AUTH_REQUIRED ? "authentication" : undefined); return createProviderFailure({ provider: agent === "claude" ? "anthropic" : agent === "codex" ? "openai" : agent, - phase, message, httpStatus: status, providerCode: code, requestId: data.request_id, - category: typeof code === "string" ? categories[code] : undefined, environment }); + phase, message, httpStatus: status, providerCode: code, requestId: data.request_id, category, environment }); } export function acpxTerminalResult(result: unknown, agent: string, environment: NodeJS.ProcessEnv = {}): Record { diff --git a/packages/paperclip-runner/src/drivers/provider-failure.test.ts b/packages/paperclip-runner/src/drivers/provider-failure.test.ts index 34ee587df5c..cda14560019 100644 --- a/packages/paperclip-runner/src/drivers/provider-failure.test.ts +++ b/packages/paperclip-runner/src/drivers/provider-failure.test.ts @@ -22,6 +22,40 @@ describe("provider failure boundary", () => { expect(acpxProviderFailure(new Error("internal runtime failure"), "claude", "startup")).toBeNull(); }); + it("classifies typed ACP errors and leaves untyped ones unknown", () => { + const rejected = (code: number, message: string, data?: unknown) => + Object.assign(new Error(message), { code, ...(data === undefined ? {} : { data }) }); + // Claude ACP attaches the SDK error kind to ACP error data. + expect(acpxProviderFailure(rejected(-32603, "Internal error", { errorKind: "billing_error" }), "claude", "startup")) + .toMatchObject({ category: "quota_exhausted", providerCode: "billing_error", message: "Internal error", retryability: "requires_action" }); + expect(acpxProviderFailure(rejected(-32603, "Internal error", { errorKind: "rate_limit" }), "claude", "startup")) + .toMatchObject({ category: "rate_limit", providerCode: "rate_limit" }); + // A JSON provider body becomes ACP error data on the agent side. + expect(acpxProviderFailure(rejected(-32603, "Internal error", + { type: "error", error: { type: "rate_limit_error", message: "Usage limit reached." } }), "claude", "startup")) + .toMatchObject({ category: "rate_limit", providerCode: "rate_limit_error", message: "Usage limit reached." }); + // ACP's reserved authentication-required code, as Pi and Claude raise it. + const auth = "Authentication required: Bind the configured OpenRouter API credential to this Paperclip runtime."; + expect(acpxProviderFailure(rejected(-32000, auth, { authMethods: [{ id: "openrouter" }] }), "pi", "startup")) + .toEqual({ schema: "paperclip.provider-failure.v1", provider: "pi", phase: "startup", category: "authentication", + message: auth, retryability: "requires_action" }); + for (const data of [{ details: "Query closed before response received" }, { errorKind: "server_error" }, { errorKind: "constructor" }]) { + expect(acpxProviderFailure(rejected(-32603, "Internal error", data), "claude", "startup")) + .toMatchObject({ category: "unknown", retryability: "unknown" }); + } + }); + + it("keeps a plain error's own redacted message only when asked", () => { + const secret = "fixture-launch-secret"; + const error = new Error(`Claude Code initialization failed for ${secret} with sk-ant-api03-fixtureprivatecredential`); + expect(acpxProviderFailure(error, "claude", "startup", { ANTHROPIC_API_KEY: secret })).toBeNull(); + const failure = acpxProviderFailure(error, "claude", "startup", { ANTHROPIC_API_KEY: secret }, true); + expect(failure).toMatchObject({ provider: "anthropic", phase: "startup", category: "unknown", + message: "Claude Code initialization failed for [REDACTED] with [REDACTED]" }); + expect(JSON.stringify(failure)).not.toMatch(/fixture-launch-secret|fixtureprivatecredential/); + expect(acpxProviderFailure(new Error(""), "claude", "startup", {}, true)).toBeNull(); + }); + it("keeps the ACPX terminal cause when the raw result exceeds its payload limit", () => { const terminal = acpxTerminalResult({ status: "failed", error: { ...acp, data: { ...acp.data, responseBody: "private-body".repeat(7000) } } }, "claude"); 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 63628e9caed..da352598f0c 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -9909,6 +9909,24 @@ it.each(["RPC failed: Private provider explanation.", "failed to start ACPX prov expect(failure).toHaveProperty("runnerCommandResult.result.message", message); expect((failure as Error).message).not.toContain(providerFailure.message); expect(JSON.stringify(failure)).not.toContain(providerFailure.message); + // Runner composes the ACPX rejection from closed codes, so its classification stays reportable. + if (message.startsWith("failed to start ACPX provider")) expect((failure as Error).message).toContain(message); + else expect((failure as Error).message).toContain('"message":"Provider request failed"'); +}); + +it("reports a generic failure when an ACPX rejection does not match Runner's closed text", async () => { + const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "anthropic", phase: "startup", + category: "unknown", message: "Private provider explanation.", retryability: "unknown" }; + const message = "failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=session_ensure_failed) Private provider explanation."; + let failure: unknown; + try { + await waitForRunnerCommand({ type: "session.open", deadline: Date.now() + 1000, abortOnClose: true, + isClosed: () => false, throwIfFailed: () => {}, + command: () => ({ status: "failed", result: { result: { code: "command_execution_failed", message, providerFailure } } }), + runnerHasExited: async () => false, failureCode: () => "startup_failed" }); + } catch (error) { failure = error; } + expect((failure as Error).message).toContain('"message":"Provider request failed"'); + expect((failure as Error).message).not.toContain("Private provider explanation."); }); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 2aba43026b2..d2de7e622be 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -8114,6 +8114,14 @@ export interface RunnerCommandFailure extends Error { readonly runnerCommandResult: unknown; } +// Runner composes this whole ACPX rejection from closed codes; provider text +// travels only in providerFailure. Keep its classification in the reported error. +function closedAcpxRejection(message: unknown): string | null { + return typeof message === "string" + && /^failed to start ACPX provider: ACPX sidecar command [a-z.]+ was rejected \(retryable=(?:true|false), classification=[a-z0-9_]+\)$/.test(message) + ? message : null; +} + /** Shared polling path keeps startup cancellation separate from owned cleanup commands. */ export async function waitForRunnerCommand(input: { type: string; @@ -8135,7 +8143,8 @@ export async function waitForRunnerCommand(input: { if (command !== undefined && command.status !== "pending") { const detail = record(record(command.result).result); const reportedResult = readProviderFailure(detail.providerFailure) - ? { ...record(command.result), result: { code: detail.code, message: "Provider request failed" } } : command.result; + ? { ...record(command.result), result: { code: detail.code, + message: closedAcpxRejection(detail.message) ?? "Provider request failed" } } : command.result; const error = new Error(`PRP command ${input.type} ${command.status}: ${JSON.stringify(reportedResult, (key, value) => key === "providerFailure" ? undefined : value)}`) as RunnerCommandFailure; Object.defineProperty(error, "runnerCommandResult", { value: structuredClone(command.result), enumerable: false }); diff --git a/packages/paperclip-runner/test/pi-closed-startup.test.mjs b/packages/paperclip-runner/test/pi-closed-startup.test.mjs index cb57a2a6b9c..df9b209e382 100644 --- a/packages/paperclip-runner/test/pi-closed-startup.test.mjs +++ b/packages/paperclip-runner/test/pi-closed-startup.test.mjs @@ -55,10 +55,11 @@ if (process.argv[2] === "--pi-no-key-probe") { const report = JSON.parse(await readFile(join(root, "report.json"), "utf8")); assert.equal(report.closeError, null, report.closeError); assert.match(report.error, /session\.open failed/); - assert.match(report.commandFailure?.result?.message ?? "", /retryable=false, classification=session_ensure_failed/); + assert.match(report.commandFailure?.result?.message ?? "", /retryable=false, classification=provider_authentication/); assert.equal(report.commandFailure?.result?.providerFailure?.schema, "paperclip.provider-failure.v1"); assert.equal(report.commandFailure.result.providerFailure.provider, "pi"); assert.equal(report.commandFailure.result.providerFailure.phase, "startup"); + assert.equal(report.commandFailure.result.providerFailure.category, "authentication"); assert.doesNotMatch(report.error, /timed out/); assert.ok(report.settledMs < 60_000, `session.open took ${report.settledMs} ms`); assert.ok(report.evidence.some(item => item.runnerExited === true && item.runnerExitCode === 0)); diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index a0091e50bc2..4aa18710d11 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -86,10 +86,11 @@ try { settledMs = Math.round(performance.now() - started); await bundle.transport.close(); assert.match(failure ?? '', /session\.open failed/); - assert.match(commandFailure?.result?.message ?? '', /retryable=false, classification=session_ensure_failed/); + assert.match(commandFailure?.result?.message ?? '', /retryable=false, classification=provider_authentication/); assert.equal(commandFailure?.result?.providerFailure?.schema, 'paperclip.provider-failure.v1'); assert.equal(commandFailure.result.providerFailure.provider, 'pi'); assert.equal(commandFailure.result.providerFailure.phase, 'startup'); + assert.equal(commandFailure.result.providerFailure.category, 'authentication'); assert.doesNotMatch(failure, /timed out/); assert.ok(settledMs < 60_000, `Pi admission took ${settledMs} ms`); assert.ok(evidence.some(item => item.runnerExited === true && item.runnerExitCode === 0));