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 0652b50923..033422533c 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 @@ -281,7 +281,7 @@ impl AcpxSidecarTransport { "ACPX sidecar command {} was rejected (retryable={}, classification={})", command.as_str(), error.retryable, - response_error_classification(&error.message), + response_error_classification(&error), ), ))); } @@ -597,8 +597,30 @@ fn redact_diagnostic(value: &str) -> String { } } -fn response_error_classification(message: &str) -> &'static str { - match message { +fn response_error_classification(error: &ResponseError) -> &'static str { + match error.code.as_str() { + "ACP_MODEL_UNSUPPORTED" => return "requested_model_unsupported", + "AGENT_STARTUP_FAILED" => return "agent_startup_failed", + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE" => return "agent_startup_unverified_module", + "AGENT_STARTUP_FAILED.MODULE_NOT_FOUND" => return "agent_startup_module_not_found", + "AGENT_STARTUP_FAILED.PERMISSION_DENIED" => return "agent_startup_permission_denied", + "AGENT_STARTUP_FAILED.FILE_NOT_FOUND" => return "agent_startup_file_not_found", + "AGENT_STARTUP_FAILED.SYNTAX_ERROR" => return "agent_startup_syntax_error", + "AGENT_STARTUP_FAILED.INVALID_ARGUMENT" => return "agent_startup_invalid_argument", + "AGENT_STARTUP_FAILED.NO_STDERR" => return "agent_startup_no_stderr", + "AGENT_STARTUP_FAILED.SIGNAL" => return "agent_startup_signal", + "AGENT_STARTUP_FAILED.EXIT_NONZERO" => return "agent_startup_exit_nonzero", + "AGENT_STARTUP_FAILED.OTHER" => return "agent_startup_other", + "AGENT_DISCONNECTED" => return "agent_disconnected", + "AUTH_REQUIRED" => return "authentication_required", + "SESSION_RESUME_REQUIRED" => return "session_resume_required", + "SESSION_MODE_REPLAY_FAILED" => return "session_mode_replay_failed", + "SESSION_MODEL_REPLAY_FAILED" => return "session_model_replay_failed", + "SESSION_CONFIG_OPTION_REPLAY_FAILED" => return "session_config_option_replay_failed", + "CLAUDE_ACP_SESSION_CREATE_TIMEOUT" => return "claude_session_create_timeout", + _ => {} + } + match error.message.as_str() { "ACPX session handshake exceeded its admission deadline" => "session_handshake_timeout", "ACPX provider lifetime guardian exited before ownership transfer" => { "provider_guardian_exit" @@ -606,6 +628,20 @@ fn response_error_classification(message: &str) -> &'static str { "ACPX provider lifetime guardian ownership timed out" => "provider_guardian_timeout", "ACPX session handshake and runtime cleanup failed" => "session_handshake_cleanup_failed", "ACPX runtime initialization and cleanup failed" => "runtime_initialization_cleanup_failed", + _ if error + .message + .starts_with("ACP agent exited before initialize completed") => + { + "agent_startup_failed" + } + _ if error.message.starts_with("Failed to spawn agent command:") => "agent_spawn_failed", + _ if error + .message + .starts_with("ACP agent disconnected during request") => + { + "agent_disconnected" + } + _ if error.message.starts_with("Authentication required") => "authentication_required", _ => "unclassified", } } @@ -658,12 +694,31 @@ mod tests { #[test] fn classifies_only_allowlisted_internal_sidecar_failures() { + let error = |code: &str, message: &str| ResponseError { + code: code.to_owned(), + message: message.to_owned(), + retryable: false, + }; assert_eq!( - response_error_classification("ACPX session handshake exceeded its admission deadline"), + response_error_classification(&error( + "acpx_sidecar_command_failed", + "ACPX session handshake exceeded its admission deadline", + )), "session_handshake_timeout" ); assert_eq!( - response_error_classification("violet-circuit-4821"), + response_error_classification(&error("ACP_MODEL_UNSUPPORTED", "violet-circuit-4821",)), + "requested_model_unsupported" + ); + assert_eq!( + response_error_classification(&error( + "acpx_sidecar_command_failed", + "ACP agent exited before initialize completed (exit=1, signal=null): violet-circuit-4821", + )), + "agent_startup_failed" + ); + assert_eq!( + response_error_classification(&error("VIOLET_CIRCUIT", "violet-circuit-4821")), "unclassified" ); } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index b54eaa74b9..41f1685cb0 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -15,8 +15,8 @@ use crate::durable::QualifiedLaunchArtifact; use crate::durable::{redact_text, OpenCodeLaunchProfile}; use crate::local_runner::LocalRunnerError; use crate::process_supervisor::{ - BoundedLogBuffer, ProcessOutput, SupervisedProcess, VerifiedProcessArgument, - VerifiedProcessLaunch, + is_node_interpreter, BoundedLogBuffer, ProcessOutput, SupervisedProcess, + VerifiedProcessArgument, VerifiedProcessLaunch, }; use crate::provider_bridge::{AuthorizedTool, DurableReplayFilter, ToolResult}; use crate::provider_events::normalized_codex_terminal_event_type; @@ -1994,8 +1994,16 @@ fn verified_opencode_launch( .map_err(|error| LocalRunnerError::invalid(error.to_string()))?; let executable = verify_launch_artifact(&profile.executable, "OpenCode provider executable") .map_err(|error| LocalRunnerError::invalid(error.to_string()))?; + let proxy = if is_node_interpreter(&profile.command.path) { + VerifiedProcessArgument::CommonJsArtifact(proxy) + } else { + // Qualified test and alternate proxy commands own their ordinary + // argv contract. Only Node understands the runner-owned CommonJS + // descriptor loader flags. + VerifiedProcessArgument::Artifact(proxy) + }; let args = vec![ - VerifiedProcessArgument::CommonJsArtifact(proxy), + proxy, VerifiedProcessArgument::Literal(TRUSTED_OPENCODE_EXECUTABLE_ARG.to_owned()), VerifiedProcessArgument::ExecutableArtifact(executable), ]; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index 42b744ed91..4ea6d727ae 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -29,6 +29,12 @@ const PROCESS_OUTPUT_QUEUE_CAPACITY: usize = 256; const VERIFIED_RUNTIME_EXECUTABLE_ENV: &str = "PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE"; const VERIFIED_COMMONJS_ARTIFACT_LOADER: &str = r#"const fs=require("node:fs");const Module=require("node:module");const filename=process.argv[1];const source=fs.readFileSync(filename,"utf8").replace(/^#![^\r\n]*(?:\r?\n|$)/,"");const artifact=new Module(filename);artifact.filename=filename;artifact.paths=[];artifact._compile(source,filename);"#; +pub(crate) fn is_node_interpreter(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| matches!(name, "node" | "nodejs" | "node.exe")) +} + #[derive(Clone, Debug)] pub struct VerifiedProcessArtifact { display_path: PathBuf, diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 11885ffcb3..a9c4474e64 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -54,6 +54,7 @@ import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js"; import type { RunnerToolCall } from "../drivers/runner-tool-bridge.js"; import { acpxBootstrapBlockedError, + acpxSidecarErrorCode, enqueueAcpxSidecarInput, recordAcpxBootstrapFailure, } from "./acpx-sidecar-input.js"; @@ -182,15 +183,19 @@ async function receiveLine(line: string): Promise { } catch (error) { const normalized = error instanceof Error ? error : new Error(String(error)); + const normalizedRecord = record(normalized); bootstrapFailure = recordAcpxBootstrapFailure( bootstrapFailure, request.command, normalized, ); response(request.id, false, undefined, { - code: safeCode(record(normalized).code, "acpx_sidecar_command_failed"), + code: safeCode( + acpxSidecarErrorCode(normalized), + "acpx_sidecar_command_failed", + ), message: safeMessage(normalized), - retryable: record(normalized).retryable === true, + retryable: normalizedRecord.retryable === true, }); } } 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 c9f2f613f6..d07389fee1 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { acpxBootstrapBlockedError, + acpxSidecarErrorCode, enqueueAcpxSidecarInput, recordAcpxBootstrapFailure, } from "./acpx-sidecar-input.js"; @@ -112,4 +113,33 @@ describe("ACPX sidecar input sequencing", () => { ).toBeNull(); expect(acpxBootstrapBlockedError(null, "turn.start")).toBeNull(); }); + + it("preserves stable ACPX error identities without copying startup stderr", () => { + const missingModule = Object.assign(new Error("provider exited"), { + detailCode: "AGENT_STARTUP_FAILED", + stderrSummary: + "Error [ERR_MODULE_NOT_FOUND]: violet-circuit-4821 was not found", + exitCode: 1, + }); + const opaqueExit = Object.assign(new Error("provider exited"), { + detailCode: "AGENT_STARTUP_FAILED", + stderrSummary: "violet-circuit-4821", + exitCode: 1, + }); + const model = Object.assign(new Error("model rejected"), { + code: "ACP_MODEL_UNSUPPORTED", + detailCode: "AGENT_STARTUP_FAILED", + }); + + expect(acpxSidecarErrorCode(missingModule)).toBe( + "AGENT_STARTUP_FAILED.MODULE_NOT_FOUND", + ); + expect(acpxSidecarErrorCode(opaqueExit)).toBe( + "AGENT_STARTUP_FAILED.EXIT_NONZERO", + ); + expect(acpxSidecarErrorCode(opaqueExit)).not.toContain( + "violet-circuit-4821", + ); + expect(acpxSidecarErrorCode(model)).toBe("ACP_MODEL_UNSUPPORTED"); + }); }); diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts index f6f4a116c6..8e20380525 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts @@ -28,3 +28,52 @@ export function acpxBootstrapBlockedError( ) : null; } + +/** + * Preserve only stable ACPX/provider error identities across the sidecar + * boundary. Startup stderr can contain credentials or provider output, so it + * contributes a closed category and is never copied into the code itself. + */ +export function acpxSidecarErrorCode(error: Error): string { + const details = error as Error & Record; + const code = + typeof details.code === "string" + ? details.code + : typeof details.detailCode === "string" + ? details.detailCode + : "acpx_sidecar_command_failed"; + if (code !== "AGENT_STARTUP_FAILED") return code; + + const stderr = + typeof details.stderrSummary === "string" ? details.stderrSummary : ""; + if (/ERR_ACPX_UNVERIFIED_MODULE/.test(stderr)) { + return "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE"; + } + if (/ERR_MODULE_NOT_FOUND|Cannot find (?:module|package)/i.test(stderr)) { + return "AGENT_STARTUP_FAILED.MODULE_NOT_FOUND"; + } + if (/\bEACCES\b|permission denied/i.test(stderr)) { + return "AGENT_STARTUP_FAILED.PERMISSION_DENIED"; + } + if (/\bENOENT\b|no such file or directory/i.test(stderr)) { + return "AGENT_STARTUP_FAILED.FILE_NOT_FOUND"; + } + if (/SyntaxError|unexpected token/i.test(stderr)) { + return "AGENT_STARTUP_FAILED.SYNTAX_ERROR"; + } + if (/ERR_INVALID_ARG|invalid argument/i.test(stderr)) { + return "AGENT_STARTUP_FAILED.INVALID_ARGUMENT"; + } + if (!stderr.trim()) return "AGENT_STARTUP_FAILED.NO_STDERR"; + if (typeof details.signal === "string" && details.signal) { + return "AGENT_STARTUP_FAILED.SIGNAL"; + } + if ( + typeof details.exitCode === "number" && + Number.isInteger(details.exitCode) && + details.exitCode !== 0 + ) { + return "AGENT_STARTUP_FAILED.EXIT_NONZERO"; + } + return "AGENT_STARTUP_FAILED.OTHER"; +}