diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index dda7857260..4688c8d813 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -113,6 +113,15 @@ pub(crate) enum RequirementPayload { /// One-line stderr excerpt identifying the parse error. diagnostic: String, }, + /// The CLI's login-status probe failed before it could determine auth. + /// Routes to Agent runtimes so the diagnostic is visible without starting + /// another login flow. + CliProbeFailed { + probe_args: Vec, + setup_copy: String, + /// One-line stderr excerpt identifying the CLI failure. + diagnostic: String, + }, /// Git for Windows is missing; open Agent runtimes for the installation guide. GitBash, } @@ -186,6 +195,17 @@ impl RequirementPayload { config_file, diagnostic ) } + RequirementPayload::CliProbeFailed { + probe_args, + diagnostic, + .. + } => { + let cli = probe_args.first().map(String::as_str).unwrap_or("the CLI"); + format!( + "{} login-status check failed: {} — open Agent runtimes in Settings to diagnose", + cli, diagnostic + ) + } RequirementPayload::GitBash => { "install Git for Windows (open Agent runtimes in Settings to diagnose)".to_string() } @@ -253,10 +273,14 @@ impl SetupPayload { .map(|r| format!("- {}", r.instruction())) .collect(); - let has_doctor_requirement = self + let has_git_bash_requirement = self .requirements .iter() .any(|r| matches!(r, RequirementPayload::GitBash)); + let has_cli_probe_failure = self + .requirements + .iter() + .any(|r| matches!(r, RequirementPayload::CliProbeFailed { .. })); let all_external = self .requirements .iter() @@ -266,7 +290,9 @@ impl SetupPayload { .iter() .any(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); - let footer = if has_doctor_requirement { + let footer = if has_cli_probe_failure { + "Open Agent runtimes in Settings, repair the CLI installation, then re-check and restart the agent.".to_string() + } else if has_git_bash_requirement { "Open Agent runtimes in Settings, install Git for Windows, then re-check and restart the agent.".to_string() } else if all_external { // All requirements are external config files — Edit Agent cannot @@ -815,6 +841,39 @@ mod tests { } } + fn make_cli_probe_failed(cli: &str, diagnostic: &str) -> RequirementPayload { + RequirementPayload::CliProbeFailed { + probe_args: vec![cli.to_string(), "login".to_string(), "status".to_string()], + setup_copy: String::new(), + diagnostic: diagnostic.to_string(), + } + } + + #[test] + fn nudge_body_cli_probe_failed_routes_to_agent_runtimes() { + let payload = SetupPayload { + agent_name: "Codex".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![make_cli_probe_failed( + "codex", + "Error: spawn /opt/codex/bin/codex ENOENT", + )], + }; + let body = payload.nudge_body(); + assert!( + body.contains("login-status check failed"), + "nudge must distinguish a broken probe from logged-out state: {body:?}" + ); + assert!( + body.contains("Open Agent runtimes"), + "nudge must route CLI failures to Agent runtimes: {body:?}" + ); + assert!( + body.contains("repair the CLI installation"), + "nudge footer must provide repair guidance: {body:?}" + ); + } + #[test] fn nudge_body_all_config_invalid_omits_edit_agent_footer() { // An all-CliConfigInvalid requirements list must NOT send users to diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 50206567ad..7930c47ab8 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -919,7 +919,11 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { let mut child = match command.spawn() { Ok(c) => c, - Err(_) => return AuthStatus::Unknown, + Err(error) => { + return AuthStatus::ProbeFailed { + diagnostic: format!("failed to run {}: {error}", binary_path.display()), + } + } }; // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. @@ -991,6 +995,9 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid { diagnostic: stderr_excerpt, }, + cli_probe::ProbeOutcome::ProbeFailed { stderr_excerpt } => AuthStatus::ProbeFailed { + diagnostic: stderr_excerpt, + }, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..d860b9dc9b 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -193,6 +193,16 @@ pub enum Requirement { /// Shown verbatim in the nudge so the user can identify the problem. diagnostic: String, }, + /// The CLI could not execute its login-status probe. This is distinct from + /// a logged-out state because starting another login flow cannot repair it. + CliProbeFailed { + /// Arguments used in the failed probe. + probe_args: Vec, + /// Human-readable hint shown when no structured copy is available. + setup_copy: String, + /// One-line stderr excerpt identifying the CLI failure. + diagnostic: String, + }, /// Git for Windows is missing, so buzz-agent cannot launch buzz-dev-mcp's /// Bash-based shell tool. Doctor owns installation and re-checking. GitBash, diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 4036d9f239..8ee4f57ba0 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -61,6 +61,13 @@ pub(super) fn requirements( diagnostic: stderr_excerpt, }] } + cli_probe::ProbeOutcome::ProbeFailed { stderr_excerpt } => { + vec![Requirement::CliProbeFailed { + probe_args: probe_args.iter().map(|value| value.to_string()).collect(), + setup_copy: setup_copy.to_string(), + diagnostic: stderr_excerpt, + }] + } } } other => vec![missing_requirement(probe_args, setup_copy, other)], diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 8e2f866b7e..7a1869e9b2 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -24,8 +24,7 @@ pub(crate) fn augmented_path() -> Option { pub(crate) enum ProbeOutcome { /// The CLI reported a successful login (exit 0). LoggedIn, - /// The CLI exited non-zero without a config-parse signal — treat as - /// "not authenticated." + /// The CLI explicitly reported that authentication is absent. LoggedOut, /// The CLI exited non-zero and its stderr contains a config-parse error /// (e.g. from `~/.codex/config.toml`). The user needs to fix their @@ -34,6 +33,12 @@ pub(crate) enum ProbeOutcome { /// A trimmed excerpt of the stderr message to surface in the nudge. stderr_excerpt: String, }, + /// The CLI could not complete its auth probe for a reason other than being + /// logged out (for example, a broken npm shim or missing native binary). + ProbeFailed { + /// A trimmed excerpt of the failure to surface in Doctor/onboarding. + stderr_excerpt: String, + }, } /// Signals emitted to stderr by codex (and related CLI tools) when they @@ -47,6 +52,30 @@ pub(crate) enum ProbeOutcome { /// one term. const CONFIG_PARSE_SIGNALS: &[&str] = &["error loading configuration", "unknown variant"]; +/// Signals used by supported CLIs when auth is genuinely absent. Empty stderr +/// also remains `LoggedOut` for compatibility with CLIs that only communicate +/// the state through their exit code. +const LOGGED_OUT_SIGNALS: &[&str] = &[ + "not authenticated", + "not logged in", + "logged out", + "authentication required", + "login required", +]; + +const MAX_DIAGNOSTIC_CHARS: usize = 512; + +fn diagnostic_excerpt(stderr: &str) -> String { + stderr + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(stderr) + .trim() + .chars() + .take(MAX_DIAGNOSTIC_CHARS) + .collect() +} + /// Run the probe at the resolved absolute path so the GUI-PATH gap is /// bypassed. Injects the same augmented PATH used for launched agents so /// script shims with `/usr/bin/env ` shebangs can find runtimes @@ -65,7 +94,9 @@ pub(crate) fn login_probe( match command.output() { Ok(o) if o.status.success() => ProbeOutcome::LoggedIn, Ok(o) => classify_probe_output(&o.stderr, false), - Err(_) => ProbeOutcome::LoggedOut, + Err(error) => ProbeOutcome::ProbeFailed { + stderr_excerpt: format!("failed to run {}: {error}", binary_path.display()), + }, } } @@ -84,18 +115,26 @@ pub(crate) fn classify_probe_output(stderr_bytes: &[u8], exit_success: bool) -> .iter() .all(|sig| stderr_lower.contains(sig)) { - let excerpt = stderr.trim().lines().next().unwrap_or("").to_string(); + let excerpt = diagnostic_excerpt(&stderr); ProbeOutcome::ConfigInvalid { stderr_excerpt: excerpt, } - } else { + } else if stderr.trim().is_empty() + || LOGGED_OUT_SIGNALS + .iter() + .any(|signal| stderr_lower.contains(signal)) + { ProbeOutcome::LoggedOut + } else { + ProbeOutcome::ProbeFailed { + stderr_excerpt: diagnostic_excerpt(&stderr), + } } } #[cfg(test)] mod tests { - use super::{ProbeOutcome, CONFIG_PARSE_SIGNALS}; + use super::{ProbeOutcome, CONFIG_PARSE_SIGNALS, LOGGED_OUT_SIGNALS}; #[cfg(unix)] #[test] @@ -226,19 +265,58 @@ mod tests { assert_eq!( outcome, ProbeOutcome::LoggedOut, - "non-config stderr should produce LoggedOut" + "recognized logged-out stderr should produce LoggedOut" + ); + } + + #[cfg(unix)] + #[test] + fn login_probe_failed_on_unexpected_cli_error() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let script_path = temp.path().join("fake-codex-broken"); + fs::write( + &script_path, + "#!/bin/sh\necho 'Error: spawn /opt/codex/vendor/bin/codex ENOENT' >&2\nexit 1\n", + ) + .expect("write script"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let outcome = super::login_probe( + &script_path, + &["fake-codex-broken", "login", "status"], + None, + ); + assert_eq!( + outcome, + ProbeOutcome::ProbeFailed { + stderr_excerpt: "Error: spawn /opt/codex/vendor/bin/codex ENOENT".to_string(), + }, + "unexpected CLI failures must not be reported as logged out" + ); + } + + #[test] + fn login_probe_failed_when_binary_cannot_start() { + let missing = std::path::Path::new("/definitely/missing/codex"); + let outcome = super::login_probe(missing, &["codex", "login", "status"], None); + assert!( + matches!(outcome, ProbeOutcome::ProbeFailed { .. }), + "spawn failures must remain distinguishable from logged-out state: {outcome:?}" ); } /// Verify that every string in CONFIG_PARSE_SIGNALS is lowercased so the /// case-insensitive match works correctly. #[test] - fn config_parse_signals_are_lowercase() { - for sig in CONFIG_PARSE_SIGNALS { + fn probe_signals_are_lowercase() { + for sig in CONFIG_PARSE_SIGNALS.iter().chain(LOGGED_OUT_SIGNALS) { assert_eq!( *sig, sig.to_lowercase(), - "CONFIG_PARSE_SIGNAL must be lowercase for case-insensitive matching: {sig}" + "probe signal must be lowercase for case-insensitive matching: {sig}" ); } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 77b3a241e0..4aa0193704 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1665,6 +1665,16 @@ pub fn spawn_agent_child( "setup_copy": setup_copy, "diagnostic": diagnostic, }), + Requirement::CliProbeFailed { + probe_args, + setup_copy, + diagnostic, + } => serde_json::json!({ + "surface": "cli_probe_failed", + "probe_args": probe_args, + "setup_copy": setup_copy, + "diagnostic": diagnostic, + }), Requirement::GitBash => serde_json::json!({ "surface": "git_bash", }), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index ba851f8a7d..c5da3d5b41 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -550,13 +550,19 @@ pub enum AcpAvailabilityStatus { pub enum AuthStatus { /// The CLI reported a successful login. LoggedIn, - /// The CLI exited non-zero without a config-parse signal. + /// The CLI explicitly reported that authentication is absent. LoggedOut, /// The CLI exited non-zero and its stderr contains a config-parse error. ConfigInvalid { /// Trimmed excerpt of the stderr message. diagnostic: String, }, + /// The CLI probe itself failed (for example, a broken launcher or missing + /// native executable), so re-authenticating would not resolve the issue. + ProbeFailed { + /// Trimmed excerpt of the CLI failure. + diagnostic: String, + }, /// This runtime does not have a login step (e.g. goose, buzz-agent). NotApplicable, /// Probe was not attempted (runtime unavailable or probe timed out). diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 6111589cef..ce74d6435e 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -252,6 +252,20 @@ function RuntimeStatus({ ); } + if ( + runtime.availability === "available" && + runtime.authStatus.status === "probe_failed" + ) { + return ( + + CLI ERROR + + ); + } + const installLabel = installError ? "RETRY INSTALL" : "INSTALL"; if (runtime.canAutoInstall) { return ( @@ -438,6 +452,16 @@ function getOnboardingAuthMethods( } function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { + if (runtime.authStatus.status === "probe_failed") { + return ( + + ); + } if (runtime.authStatus.status === "config_invalid") { return ( 0 && (runtime.availability !== "available" || runtime.authStatus.status === "logged_out" || - runtime.authStatus.status === "config_invalid"); + runtime.authStatus.status === "config_invalid" || + runtime.authStatus.status === "probe_failed"); const hasActions = runtime.nodeRequired || hasInstructions || authMethods.length > 0; @@ -200,21 +201,25 @@ function RuntimeActions({ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { const label = - runtime.authStatus.status === "config_invalid" - ? "Config error" - : runtime.availability === "adapter_missing" - ? "Adapter needed" - : runtime.availability === "adapter_outdated" - ? "Update needed" - : runtime.availability === "cli_missing" - ? "CLI needed" - : null; + runtime.authStatus.status === "probe_failed" + ? "CLI error" + : runtime.authStatus.status === "config_invalid" + ? "Config error" + : runtime.availability === "adapter_missing" + ? "Adapter needed" + : runtime.availability === "adapter_outdated" + ? "Update needed" + : runtime.availability === "cli_missing" + ? "CLI needed" + : null; if (!label) { return null; } - const isConfigError = runtime.authStatus.status === "config_invalid"; + const isRuntimeError = + runtime.authStatus.status === "config_invalid" || + runtime.authStatus.status === "probe_failed"; return ( <> @@ -224,7 +229,7 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ) : null} + {runtime.authStatus.status === "probe_failed" ? ( +

+ CLI check failed: {runtime.authStatus.diagnostic} +

+ ) : null} + {installSuccess && runtime.availability !== "available" ? (

{runtime.label} installed. Checking for sign-in options... diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 17d31f95ac..3529d70ae1 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -506,6 +506,7 @@ export type AuthStatus = | { status: "logged_in" } | { status: "logged_out" } | { status: "config_invalid"; diagnostic: string } + | { status: "probe_failed"; diagnostic: string } | { status: "not_applicable" } | { status: "unknown" }; diff --git a/desktop/src/shared/lib/configNudge.test.mjs b/desktop/src/shared/lib/configNudge.test.mjs index f616d78298..8b67deee28 100644 --- a/desktop/src/shared/lib/configNudge.test.mjs +++ b/desktop/src/shared/lib/configNudge.test.mjs @@ -102,6 +102,22 @@ test("extractConfigNudge parses cli_login with adapter_outdated availability", ( ); }); +test("extractConfigNudge parses cli_probe_failed requirement", () => { + const payload = { + agent_name: "Codex", + agent_pubkey: CODEX_PUBKEY, + requirements: [ + { + surface: "cli_probe_failed", + probe_args: ["codex", "login", "status"], + setup_copy: "run `codex login`", + diagnostic: "Error: spawn /opt/codex/bin/codex ENOENT", + }, + ], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + test("extractConfigNudge returns null for cli_login without availability", () => { // availability is required — old-format payloads (no availability field) // must not parse so stale nudge JSON from before the Doctor-CTA update diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts index 7893236c28..41b05d63df 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -50,6 +50,14 @@ export type ConfigNudgeRequirement = /** One-line stderr excerpt from the CLI's parse error. */ diagnostic: string; } + | { + /** The CLI failed before authentication state could be determined. */ + surface: "cli_probe_failed"; + probe_args: string[]; + setup_copy: string; + /** One-line stderr excerpt from the failed CLI probe. */ + diagnostic: string; + } | { surface: "git_bash" }; /** @@ -149,6 +157,7 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement { case "git_bash": return true; case "cli_config_invalid": + case "cli_probe_failed": return ( Array.isArray(r.probe_args) && r.probe_args.every((a) => typeof a === "string") && diff --git a/desktop/src/shared/ui/config-nudge-attachment.test.mjs b/desktop/src/shared/ui/config-nudge-attachment.test.mjs index e526be34fe..10fba76a5d 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.test.mjs +++ b/desktop/src/shared/ui/config-nudge-attachment.test.mjs @@ -50,6 +50,21 @@ test("shouldOpenDoctor_gitBashMixedWithEnvKey_routesToDoctor", () => { ); }); +test("shouldOpenDoctor_cliProbeFailure_routesToDoctor", () => { + assert.equal( + shouldOpenDoctor([ + { + surface: "cli_probe_failed", + probe_args: ["codex", "login", "status"], + setup_copy: "run `codex login`", + diagnostic: "Error: spawn codex ENOENT", + }, + ]), + true, + "a broken CLI probe must route to Agent runtimes instead of Edit Agent", + ); +}); + test("shouldOpenDoctor_regularMixedRequirements_routesToEditAgent", () => { assert.equal( shouldOpenDoctor([ diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx index e1294b7dde..415fe15b22 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -35,6 +35,8 @@ function requirementKey( return `cli_login:${req.probe_args.join(",")}:${index}`; case "cli_config_invalid": return `cli_config_invalid:${req.probe_args.join(",")}:${index}`; + case "cli_probe_failed": + return `cli_probe_failed:${req.probe_args.join(",")}:${index}`; case "git_bash": return `git_bash:${index}`; } @@ -53,6 +55,10 @@ function hasGitBashRequirement( return reqs.some((r) => r.surface === "git_bash"); } +function hasCliProbeFailure(reqs: ConfigNudgePayload["requirements"]): boolean { + return reqs.some((r) => r.surface === "cli_probe_failed"); +} + function isAllCliLogin(reqs: ConfigNudgePayload["requirements"]): boolean { return reqs.length > 0 && reqs.every((r) => r.surface === "cli_login"); } @@ -60,7 +66,11 @@ function isAllCliLogin(reqs: ConfigNudgePayload["requirements"]): boolean { export function shouldOpenDoctor( reqs: ConfigNudgePayload["requirements"], ): boolean { - return isAllCliLogin(reqs) || hasGitBashRequirement(reqs); + return ( + isAllCliLogin(reqs) || + hasGitBashRequirement(reqs) || + hasCliProbeFailure(reqs) + ); } /** @@ -381,6 +391,26 @@ function RequirementRow({ ); + case "cli_probe_failed": { + const cli = requirement.probe_args[0] ?? "the CLI"; + return ( +

+ + {cli} login-status check failed:{" "} + + {requirement.diagnostic} + + + +
+ ); + } case "cli_config_invalid": { // Config-invalid rows are purely informational — the user must edit an // external file. No Agent runtimes CTA (Buzz can't repair ~/.codex/config.toml) diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 40ddc64ef6..60cb5cad55 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -155,6 +155,38 @@ test("unknown authentication can be checked again", async ({ page }) => { ); }); +test("failed CLI probe shows its diagnostic instead of starting sign-in", async ({ + page, +}) => { + const diagnostic = "Error: spawn /opt/codex/bin/codex ENOENT"; + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("codex", "available", { + status: "probe_failed", + diagnostic, + }), + ], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + await expect( + page.getByTestId("onboarding-runtime-probe-failed-codex"), + ).toHaveText("CLI ERROR"); + await expect( + page.getByRole("button", { name: "Sign in to Codex" }), + ).toHaveCount(0); + const error = page.getByTestId("onboarding-runtime-auth-error-codex"); + await expect(error).toHaveAttribute( + "aria-label", + `CLI check failed. ${diagnostic}`, + ); +}); + test("auth discovery failure stays actionable without exposing internals", async ({ page, }) => {