Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions crates/buzz-acp/src/setup_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
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,
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
},
}
}

Expand Down
10 changes: 10 additions & 0 deletions desktop/src-tauri/src/managed_agents/readiness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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,
Expand Down
7 changes: 7 additions & 0 deletions desktop/src-tauri/src/managed_agents/readiness/cli_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down
98 changes: 88 additions & 10 deletions desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ pub(crate) fn augmented_path() -> Option<String> {
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
Expand All @@ -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
Expand All @@ -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 <interpreter>` shebangs can find runtimes
Expand All @@ -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()),
},
}
}

Expand All @@ -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]
Expand Down Expand Up @@ -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}"
);
}
}
Expand Down
10 changes: 10 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
Expand Down
8 changes: 7 additions & 1 deletion desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
24 changes: 24 additions & 0 deletions desktop/src/features/onboarding/ui/SetupStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,20 @@ function RuntimeStatus({
);
}

if (
runtime.availability === "available" &&
runtime.authStatus.status === "probe_failed"
) {
return (
<span
className="inline-flex h-5 cursor-default items-center rounded-full bg-destructive/10 px-2.5 font-mono text-badge font-normal uppercase text-destructive"
data-testid={`onboarding-runtime-probe-failed-${runtime.id}`}
>
CLI ERROR
</span>
);
}

const installLabel = installError ? "RETRY INSTALL" : "INSTALL";
if (runtime.canAutoInstall) {
return (
Expand Down Expand Up @@ -438,6 +452,16 @@ function getOnboardingAuthMethods(
}

function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.authStatus.status === "probe_failed") {
return (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail={runtime.authStatus.diagnostic}
label="CLI check failed"
testId={`onboarding-runtime-auth-error-${runtime.id}`}
/>
);
}
if (runtime.authStatus.status === "config_invalid") {
return (
<RuntimeErrorTooltip
Expand Down
Loading