use crate::claude_auth::ClaudeAuthMode;
use crate::login::LoginConfig;
use crate::model_catalog::{
CatalogAcceptance, classify_catalog_acceptance, fetch_provider_catalog,
};
use crate::subscription::{SubscriptionProvider, all_subscription_readers};
#[must_use]
pub fn login_mode_report(login: &LoginConfig) -> Vec<String> {
let uses_external = login.command != "claude";
let selected = if login.args.iter().any(|argument| argument == "setup-token") {
ClaudeAuthMode::SetupToken
} else {
ClaudeAuthMode::Full
};
let mut lines = Vec::new();
for mode in [ClaudeAuthMode::Full, ClaudeAuthMode::SetupToken] {
let marker = if mode == selected { " (default)" } else { "" };
let availability = if uses_external {
let resolved = resolve_in_path(&login.command);
resolved.map_or_else(
|| {
format!(
"UNAVAILABLE — LOGIN_CLI_COMMAND `{}` is not in PATH",
login.command
)
},
|path| format!("via {}", path.display()),
)
} else {
"available (in-process OAuth)".to_string()
};
lines.push(format!(
"login_mode {:<12}: {availability}{marker}; scopes: {}",
mode.name(),
mode.scopes()
));
}
lines
}
#[must_use]
pub fn forwarded_header_report() -> Vec<String> {
let mut report = vec![format!(
"{:<23}: {}",
"upstream_headers",
crate::proxy::forwarded_client_headers().join(", ")
)];
report.push(format!(
"{:<23}: preserved from signed client",
"upstream_user_agent"
));
report.push(format!(
"{:<23}: {}",
"upstream_dropped",
concat!(
"Router authentication/internal headers, cookies, authority, ",
"hop-by-hop transport fields, framing, and accept-encoding"
)
));
report
}
fn resolve_in_path(command: &str) -> Option<std::path::PathBuf> {
let candidate = std::path::Path::new(command);
if candidate.is_absolute() {
return candidate.is_file().then(|| candidate.to_path_buf());
}
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|directory| directory.join(command))
.find(|path| path.is_file())
})
}
const fn credential_status(was_expired: bool, still_expired: bool, rejected: bool) -> &'static str {
match (was_expired, still_expired, rejected) {
(_, true, true) => "found, token EXPIRED and REJECTED",
(_, true, false) => "found, token EXPIRED on disk but ACCEPTED upstream",
(true, false, true) => "found, token REJECTED after refresh",
(false, _, true) => "found, token REJECTED",
(true, false, false) => "found, token OK (refreshed durably)",
(false, false, false) => "found, token OK",
}
}
fn credential_location(
provider: SubscriptionProvider,
origin: crate::platform_keychain::Origin,
path: &std::path::Path,
) -> String {
match origin {
crate::platform_keychain::Origin::Keychain => {
crate::platform_keychain::service_name(provider).map_or_else(
|| String::from("platform keychain"),
|service| format!("keychain {service:?}"),
)
}
crate::platform_keychain::Origin::File => path.display().to_string(),
crate::platform_keychain::Origin::AdoptedFile => {
format!("{} (adopted vendor credential)", path.display())
}
crate::platform_keychain::Origin::ExternalFile => {
format!("{} (external refresh owner)", path.display())
}
}
}
pub async fn subscription_catalog_diagnostics(
active_provider: SubscriptionProvider,
claude_home: &str,
user_home: &str,
data_dir: Option<&std::path::Path>,
) -> bool {
subscription_catalog_diagnostics_with_token_url(
active_provider,
claude_home,
user_home,
data_dir,
None,
)
.await
}
async fn subscription_catalog_diagnostics_with_token_url(
_active_provider: SubscriptionProvider,
claude_home: &str,
user_home: &str,
data_dir: Option<&std::path::Path>,
token_url: Option<(SubscriptionProvider, &str)>,
) -> bool {
let readers = all_subscription_readers(claude_home, user_home);
let client = reqwest::Client::new();
let token_cache = data_dir.map_or_else(
|| {
let cache = crate::refresh::TokenCache::new();
cache.register_readers(crate::credential_recovery_store::PRIMARY_ACCOUNT, &readers);
cache
},
|data_dir| crate::refresh::TokenCache::registered_for(&readers, data_dir),
);
let now_ms = chrono::Utc::now().timestamp_millis();
let mut catalog_error = false;
for reader in readers {
let provider = reader.provider();
let label = format!("{provider} subscription");
let path = reader
.discover_credential_path()
.unwrap_or_else(|| reader.home().to_path_buf());
let origin = reader
.read_token_from()
.map_or(crate::platform_keychain::Origin::File, |(_, origin)| origin);
let disk_token = match token_cache
.load_authoritative(provider, crate::credential_recovery_store::PRIMARY_ACCOUNT)
.await
{
Ok(Some(token)) => token,
Ok(None) => {
println!("{label:<23}: {} (MISSING)", reader.home().display());
continue;
}
Err(_) => {
println!("{label:<23}: {provider} credential store (found, NO TOKEN)");
println!(
"{:<23}: ERROR (credential is unreadable)",
format!("{provider} catalog")
);
catalog_error = true;
continue;
}
};
let was_expired = disk_token.is_expired(now_ms);
let refreshed = match token_url {
Some((override_provider, url)) if override_provider == provider => {
token_cache
.get_fresh_registered_at(
&client,
url,
provider,
crate::credential_recovery_store::PRIMARY_ACCOUNT,
now_ms,
)
.await
}
_ => {
token_cache
.get_fresh_registered(
&client,
provider,
crate::credential_recovery_store::PRIMARY_ACCOUNT,
now_ms,
)
.await
}
};
let Ok(token) = refreshed else {
let location = credential_location(provider, origin, &path);
let detail = token_cache
.last_refresh_error_for(provider, crate::credential_recovery_store::PRIMARY_ACCOUNT)
.unwrap_or_else(|| "refresh failed before the credential could be checked".into());
println!(
"{label:<23}: {location} (found, refresh FAILED, store: {})",
origin.label()
);
println!("{:<23}: ERROR ({detail})", format!("{provider} refresh"));
println!(
"{:<23}: ERROR (credential refresh failed before catalog probe)",
format!("{provider} catalog")
);
catalog_error = true;
continue;
};
if let Some(detail) = token_cache
.last_refresh_error_for(provider, crate::credential_recovery_store::PRIMARY_ACCOUNT)
{
let location = credential_location(provider, origin, &path);
println!(
"{label:<23}: {location} (found, refresh FAILED, store: {})",
origin.label()
);
println!("{:<23}: ERROR ({detail})", format!("{provider} refresh"));
println!(
"{:<23}: ERROR (credential refresh failed before catalog probe)",
format!("{provider} catalog")
);
catalog_error = true;
continue;
}
let still_expired = token.is_expired(now_ms);
let catalog = fetch_provider_catalog(&client, provider, &token, None).await;
let acceptance = classify_catalog_acceptance(&catalog);
let rejected = matches!(
acceptance,
CatalogAcceptance::MissingSubscription | CatalogAcceptance::CredentialRejected
);
let status = credential_status(was_expired, still_expired, rejected);
let location = credential_location(provider, origin, &path);
println!(
"{label:<23}: {location} ({status}, store: {})",
origin.label()
);
match (acceptance, catalog) {
(CatalogAcceptance::Accepted, Ok(models)) => println!(
"{:<23}: OK ({} live model(s))",
format!("{provider} catalog"),
models.len()
),
(CatalogAcceptance::MissingSubscription, Ok(_)) => {
println!(
"{:<23}: ERROR (no live models; active subscription was not proven)",
format!("{provider} catalog")
);
catalog_error = true;
}
(_, Err(error)) => {
println!("{:<23}: ERROR ({error})", format!("{provider} catalog"));
catalog_error = true;
}
_ => unreachable!("catalog classification matches its result"),
}
}
catalog_error
}
pub async fn subscription_catalog_diagnostics_in(
active_provider: SubscriptionProvider,
claude_home: &str,
user_home: &str,
data_dir: &std::path::Path,
) -> bool {
subscription_catalog_diagnostics(active_provider, claude_home, user_home, Some(data_dir)).await
}
#[cfg(test)]
mod tests {
use super::*;
async fn one_successful_qwen_catalog() -> (
String,
std::sync::Arc<std::sync::atomic::AtomicUsize>,
tokio::task::JoinHandle<()>,
) {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("loopback listener");
let base = format!(
"http://{}",
listener.local_addr().expect("listener address")
);
let requests = std::sync::Arc::new(AtomicUsize::new(0));
let observed = std::sync::Arc::clone(&requests);
let server = tokio::spawn(async move {
if let Ok(Ok((mut socket, _))) =
tokio::time::timeout(std::time::Duration::from_millis(500), listener.accept()).await
{
let mut buffer = [0_u8; 4096];
let _ = socket.read(&mut buffer).await;
observed.fetch_add(1, Ordering::SeqCst);
let body = r#"{"data":[{"id":"synthetic-model"}]}"#;
socket
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await
.expect("catalog response");
}
});
(base, requests, server)
}
#[cfg(unix)]
async fn one_successful_qwen_refresh_that_blocks_both_writes(
credential_home: std::path::PathBuf,
recovery_path: std::path::PathBuf,
) -> (
String,
std::sync::Arc<std::sync::atomic::AtomicUsize>,
tokio::task::JoinHandle<()>,
) {
use std::os::unix::fs::PermissionsExt as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("loopback listener");
let url = format!(
"http://{}",
listener.local_addr().expect("listener address")
);
let requests = std::sync::Arc::new(AtomicUsize::new(0));
let observed = std::sync::Arc::clone(&requests);
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("refresh request");
let mut buffer = [0_u8; 4096];
let _ = socket.read(&mut buffer).await;
observed.fetch_add(1, Ordering::SeqCst);
std::fs::set_permissions(&credential_home, std::fs::Permissions::from_mode(0o500))
.expect("make primary unwritable");
std::fs::create_dir(&recovery_path).expect("block recovery file replacement");
let body = r#"{"access_token":"rotated-access","refresh_token":"rotated-refresh","expires_in":3600}"#;
socket
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await
.expect("refresh response");
});
(url, requests, server)
}
fn config(command: &str, args: &[&str]) -> LoginConfig {
LoginConfig {
command: command.to_string(),
args: args.iter().map(|value| (*value).to_string()).collect(),
..LoginConfig::default()
}
}
#[tokio::test]
async fn refresh_storage_failure_is_a_diagnostic_failure_before_catalog_probe() {
use std::sync::atomic::Ordering;
let root = tempfile::tempdir().expect("temp root");
let user_home = root.path().join("user");
let qwen_home = user_home.join(".qwen");
std::fs::create_dir_all(&qwen_home).expect("qwen home");
let (resource_url, catalog_requests, server) = one_successful_qwen_catalog().await;
std::fs::write(
qwen_home.join("oauth_creds.json"),
serde_json::to_vec(&serde_json::json!({
"access_token": "expired-access",
"refresh_token": "refresh-link",
"expiry_date": 1,
"resource_url": resource_url,
"vendor_field": "preserve-me"
}))
.expect("serialize credential"),
)
.expect("seed qwen credential");
let blocked_data_dir = root.path().join("not-a-directory");
std::fs::write(&blocked_data_dir, b"occupied").expect("block recovery directory");
let failed = subscription_catalog_diagnostics_in(
SubscriptionProvider::Qwen,
root.path().join("claude").to_str().expect("claude home"),
user_home.to_str().expect("user home"),
&blocked_data_dir,
)
.await;
server.await.expect("catalog server task");
assert!(failed, "refresh durability failure was reported healthy");
assert_eq!(
catalog_requests.load(Ordering::SeqCst),
0,
"a catalog success must not hide an unsafe refresh"
);
}
#[cfg(unix)]
#[tokio::test]
async fn successful_rotation_with_dual_persistence_failure_never_probes_catalog() {
use std::os::unix::fs::PermissionsExt as _;
use std::sync::atomic::Ordering;
let root = tempfile::tempdir().expect("temp root");
let user_home = root.path().join("user");
let qwen_home = user_home.join(".qwen");
let data_dir = root.path().join("router-data");
std::fs::create_dir_all(&qwen_home).expect("qwen home");
let (resource_url, catalog_requests, catalog_server) = one_successful_qwen_catalog().await;
std::fs::write(
qwen_home.join("oauth_creds.json"),
serde_json::to_vec(&serde_json::json!({
"access_token": "expired-access",
"refresh_token": "refresh-link",
"expiry_date": 1,
"resource_url": resource_url
}))
.expect("serialize credential"),
)
.expect("seed qwen credential");
let recovery_path = crate::credential_recovery_store::credential_lock_path(
&data_dir,
SubscriptionProvider::Qwen,
crate::credential_recovery_store::PRIMARY_ACCOUNT,
)
.with_extension("json");
let (token_url, refresh_requests, refresh_server) =
one_successful_qwen_refresh_that_blocks_both_writes(qwen_home.clone(), recovery_path)
.await;
let failed = subscription_catalog_diagnostics_with_token_url(
SubscriptionProvider::Qwen,
root.path().join("claude").to_str().expect("claude home"),
user_home.to_str().expect("user home"),
Some(&data_dir),
Some((SubscriptionProvider::Qwen, token_url.as_str())),
)
.await;
refresh_server.await.expect("refresh server task");
catalog_server.await.expect("catalog server task");
std::fs::set_permissions(&qwen_home, std::fs::Permissions::from_mode(0o700))
.expect("restore credential permissions");
assert!(failed, "lost rotation was reported healthy");
assert_eq!(refresh_requests.load(Ordering::SeqCst), 1);
assert_eq!(
catalog_requests.load(Ordering::SeqCst),
0,
"the old access token must not hide a failed refresh transaction"
);
}
#[tokio::test]
async fn an_authoritative_unexpired_credential_is_probed_and_reported_healthy() {
use std::sync::atomic::Ordering;
let root = tempfile::tempdir().expect("temp root");
let user_home = root.path().join("user");
let qwen_home = user_home.join(".qwen");
std::fs::create_dir_all(&qwen_home).expect("qwen home");
let (resource_url, catalog_requests, server) = one_successful_qwen_catalog().await;
std::fs::write(
qwen_home.join("oauth_creds.json"),
serde_json::to_vec(&serde_json::json!({
"access_token": "current-access",
"refresh_token": "current-refresh",
"expiry_date": 9_999_999_999_999_i64,
"resource_url": resource_url
}))
.expect("serialize credential"),
)
.expect("seed qwen credential");
let failed = subscription_catalog_diagnostics_in(
SubscriptionProvider::Qwen,
root.path().join("claude").to_str().expect("claude home"),
user_home.to_str().expect("user home"),
&root.path().join("router-data"),
)
.await;
server.await.expect("catalog server task");
assert!(!failed, "a successful authoritative catalog probe failed");
assert_eq!(catalog_requests.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn the_legacy_optional_data_directory_path_still_probes_credentials() {
use std::sync::atomic::Ordering;
let root = tempfile::tempdir().expect("temp root");
let user_home = root.path().join("user");
let qwen_home = user_home.join(".qwen");
std::fs::create_dir_all(&qwen_home).expect("qwen home");
let (resource_url, catalog_requests, server) = one_successful_qwen_catalog().await;
std::fs::write(
qwen_home.join("oauth_creds.json"),
serde_json::to_vec(&serde_json::json!({
"access_token": "legacy-access",
"expiry_date": 9_999_999_999_999_i64,
"resource_url": resource_url
}))
.expect("serialize credential"),
)
.expect("seed qwen credential");
let failed = subscription_catalog_diagnostics(
SubscriptionProvider::Qwen,
root.path().join("claude").to_str().expect("claude home"),
user_home.to_str().expect("user home"),
None,
)
.await;
server.await.expect("catalog server task");
assert!(!failed, "the compatibility wrapper failed a live probe");
assert_eq!(catalog_requests.load(Ordering::SeqCst), 1);
}
#[test]
fn both_native_modes_are_reported_available() {
let report = login_mode_report(&config("claude", &[])).join("\n");
assert!(report.contains("login_mode full"), "{report}");
assert!(report.contains("login_mode setup-token"), "{report}");
assert_eq!(
report.matches("available (in-process OAuth)").count(),
2,
"{report}"
);
assert!(report.contains("user:inference"), "{report}");
assert!(report.contains("org:create_api_key"), "{report}");
}
#[test]
fn the_configured_mode_is_marked_as_the_default() {
let full = login_mode_report(&config("claude", &[]));
assert!(full[0].contains("(default)"), "{full:?}");
assert!(!full[1].contains("(default)"), "{full:?}");
let narrow = login_mode_report(&config("claude", &["setup-token"]));
assert!(!narrow[0].contains("(default)"), "{narrow:?}");
assert!(narrow[1].contains("(default)"), "{narrow:?}");
}
#[test]
fn a_missing_external_command_is_reported_unavailable() {
let report = login_mode_report(&config("definitely-not-on-path-98765", &[])).join("\n");
assert!(report.contains("UNAVAILABLE"), "{report}");
assert!(report.contains("definitely-not-on-path-98765"), "{report}");
}
#[test]
fn an_absolute_command_path_is_probed_directly() {
let existing = std::env::current_exe().expect("test binary path");
let report = login_mode_report(&config(&existing.to_string_lossy(), &[])).join("\n");
assert!(report.contains("via "), "{report}");
assert!(!report.contains("UNAVAILABLE"), "{report}");
let missing = login_mode_report(&config("/nonexistent/router/login-cli", &[])).join("\n");
assert!(missing.contains("UNAVAILABLE"), "{missing}");
}
#[test]
fn resolve_in_path_finds_a_real_executable() {
assert!(
resolve_in_path("sh").is_some() || cfg!(windows),
"sh should be resolvable"
);
assert!(resolve_in_path("definitely-not-on-path-98765").is_none());
}
#[test]
fn a_keychain_credential_is_described_by_its_entry() {
let location = credential_location(
SubscriptionProvider::Claude,
crate::platform_keychain::Origin::Keychain,
std::path::Path::new("/Users/someone/.claude/.credentials.json"),
);
if cfg!(target_os = "macos") {
assert_eq!(location, "keychain \"Claude Code-credentials\"");
} else {
assert_eq!(location, "platform keychain");
}
assert!(
!location.contains(".credentials.json"),
"a keychain credential must not be reported as a file: {location}"
);
}
#[test]
fn a_storeless_provider_reports_a_generic_store() {
let location = credential_location(
SubscriptionProvider::Gemini,
crate::platform_keychain::Origin::Keychain,
std::path::Path::new("/home/someone/.gemini/oauth_creds.json"),
);
assert_eq!(location, "platform keychain");
}
#[test]
fn a_file_credential_is_described_by_its_path() {
let location = credential_location(
SubscriptionProvider::Codex,
crate::platform_keychain::Origin::File,
std::path::Path::new("/home/someone/.codex/auth.json"),
);
assert_eq!(location, "/home/someone/.codex/auth.json");
}
#[test]
fn a_refreshed_credential_reads_as_ok() {
assert_eq!(
credential_status(true, false, false),
"found, token OK (refreshed durably)"
);
assert_eq!(credential_status(false, false, false), "found, token OK");
}
#[test]
fn an_expired_and_refused_credential_says_both() {
let status = credential_status(true, true, true);
assert!(status.contains("EXPIRED"), "{status}");
assert!(status.contains("REJECTED"), "{status}");
}
#[test]
fn an_expired_but_accepted_credential_is_not_condemned() {
let status = credential_status(true, true, false);
assert!(status.contains("ACCEPTED upstream"), "{status}");
assert!(!status.contains("REJECTED"), "{status}");
}
#[test]
fn a_refusal_names_whether_a_refresh_preceded_it() {
assert_eq!(
credential_status(true, false, true),
"found, token REJECTED after refresh"
);
assert_eq!(
credential_status(false, false, true),
"found, token REJECTED"
);
}
#[test]
fn the_report_names_what_is_forwarded_and_what_is_not() {
let report = forwarded_header_report().join("\n");
for forwarded in crate::proxy::forwarded_client_headers() {
assert!(
report.contains(forwarded),
"the report must name {forwarded}: {report}"
);
}
assert!(report.contains("preserved from signed client"), "{report}");
for dropped in ["Router authentication", "cookies", "hop-by-hop", "framing"] {
assert!(
report.contains(dropped),
"the report must say {dropped} is dropped: {report}"
);
}
}
}