Skip to content
Closed
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
12 changes: 12 additions & 0 deletions cmux-tui/crates/acpmux/src/claude_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,18 @@ pub fn is_sign_in_failure(message: &str) -> bool {
|| m.contains("authentication_failed")
}

/// Whether `message` says a credential expired: an OAuth or access token, an API key, a login
/// or a credential, with "expired" (or "expiry", "expires"). One matcher for every place that
/// tells an expired sign-in from a missing one (a failed auth check, a failed turn), so the
/// two never drift. A bare "session expired" says nothing about the sign-in.
pub fn is_expired_credential(message: &str) -> bool {
let m = message.to_lowercase();
m.contains("expir")
&& ["oauth", "token", "credential", "api key", "authentication", "login", "logged in"]
.iter()
.any(|word| m.contains(word))
}

/// The explanation for a Claude agent's sign-in failure, or None when this
/// session is not a Claude agent acpmux started (or the text says another
/// thing). `original` is Claude's own text, kept at the end.
Expand Down
35 changes: 31 additions & 4 deletions cmux-tui/crates/acpmux/src/harness_health/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const LATEST_TTL_MS: u64 = (6 * 3600 - 600) * 1000;
pub enum Auth {
SignedIn,
SignedOut,
Expired,
Unknown,
}

Expand Down Expand Up @@ -296,6 +297,12 @@ pub fn keychain_sign_in(id: &str, profile: &crate::config::HarnessProfile) -> Op
.then_some("Codex's `cli_auth_credentials_store` does not keep its login in a file")
}

/// A failed auth check is expired only when its own output says a credential expired
/// (`claude_auth::is_expired_credential`, the matcher a failed turn uses too).
fn auth_failure(output: &str) -> Auth {
if crate::claude_auth::is_expired_credential(output) { Auth::Expired } else { Auth::SignedOut }
}

static SIGN_IN: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"(?i)\b(not (logged|signed) in|log ?in|sign ?in|unauthenticated|authentication required|auth required|unauthorized)\b")
.expect("sign-in pattern") // crash-allow: constant pattern
Expand Down Expand Up @@ -480,15 +487,25 @@ fn local(s: Subject, home: &Path) -> HarnessHealth {
let (auth, auth_detail) = match &s.probe_error {
_ if !s.check.is_empty() && found.is_some() => {
match probe::auth_check(&s.check, &s.env, &s.family) {
Ok(true) => (Auth::SignedIn, Some(format!("`{}` passed", s.check.join(" ")))),
Ok(false) => (Auth::SignedOut, Some(format!("`{}` failed", s.check.join(" ")))),
Ok((true, _)) => (Auth::SignedIn, Some(format!("`{}` passed", s.check.join(" ")))),
Ok((false, output)) => {
let auth = auth_failure(&output);
let detail = if auth == Auth::Expired {
"credential expired".to_owned()
} else {
format!("`{}` failed", s.check.join(" "))
};
(auth, Some(detail))
}
Err(e) => (Auth::Unknown, Some(e)),
}
}
_ if builtin.as_ref().is_some_and(|(auth, _)| *auth != Auth::SignedOut) => {
builtin.unwrap_or((Auth::Unknown, None))
}
Some(e) if SIGN_IN.is_match(e) => (Auth::SignedOut, Some(e.clone())),
Some(e) if SIGN_IN.is_match(e) || auth_failure(e) == Auth::Expired => {
(auth_failure(e), Some(e.clone()))
}
_ => builtin.unwrap_or((Auth::Unknown, None)),
};
HarnessHealth {
Expand All @@ -514,10 +531,20 @@ fn local(s: Subject, home: &Path) -> HarnessHealth {

#[cfg(test)]
mod tests {
use super::codex_login_in_file;
use super::{Auth, auth_failure, codex_login_in_file};
use serde_json::json;
use std::collections::BTreeMap;

#[test]
fn expired_login_probe_is_not_just_signed_out() {
assert_eq!(auth_failure("OAuth token has expired"), Auth::Expired);
assert_eq!(auth_failure("Not logged in"), Auth::SignedOut);
assert_eq!(auth_failure("HTTP 503"), Auth::SignedOut);
assert_eq!(auth_failure("Your refresh token has expired; run codex login"), Auth::Expired);
assert_eq!(auth_failure("the MCP session expired"), Auth::SignedOut);
assert_eq!(serde_json::to_value(Auth::Expired).unwrap(), json!("expired"));
}

fn home_with(n: usize, config: Option<&str>) -> (std::path::PathBuf, BTreeMap<String, String>) {
let dir = std::env::temp_dir().join(format!("amx-codex-store-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Expand Down
4 changes: 2 additions & 2 deletions cmux-tui/crates/acpmux/src/harness_health/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ pub fn auth_check(
argv: &[String],
env: &BTreeMap<String, String>,
family: &str,
) -> Result<bool, String> {
run_for(argv, env, TIMEOUT, Some(family)).map(|(ok, _)| ok)
) -> Result<(bool, String), String> {
run_for(argv, env, TIMEOUT, Some(family))
}

/// `codex login status` with the harness's `env`: Some(true) signed in (exit
Expand Down
4 changes: 2 additions & 2 deletions cmux-tui/crates/cmux-tui/src/cli/setup/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ fn report(
.and_then(docs_url)
.or_else(|| shipped(&row.id).and_then(|(_, meta)| docs_url(&meta)));
install(&name, &row.program, docs)
} else if row.auth == Auth::SignedOut {
} else if matches!(row.auth, Auth::SignedOut | Auth::Expired) {
// `cmux harness login` knows only a configured harness; the
// profile's own login command signs in one that is not yet.
let command = match (configured, &row.login) {
Expand Down Expand Up @@ -263,7 +263,7 @@ fn report(
"version": row.version,
"signed_in": match row.auth {
Auth::SignedIn => Some(true),
Auth::SignedOut => Some(false),
Auth::SignedOut | Auth::Expired => Some(false),
Auth::Unknown => None,
},
"auth_detail": row.auth_detail,
Expand Down
Loading