Skip to content

Commit e53aeff

Browse files
fix: resolve Windows timeout and false "Auth missing" errors (#3)
Two root causes for the bug reported in issue #3: 1. Claude timeout (10s): The per-provider timeout was 10s, but fetch_cli() spawns multiple subprocesses sequentially (version check + 3 JSON rate-limit probes + limits fallback). On Windows, where process spawning is slower due to cmd.exe overhead and antivirus hooks, the total easily exceeds 10s even though individual commands (like --help in doctor) complete in ~700ms. Increased default timeouts: primary providers to 30s, lightweight CLIs to 15s, API providers to 30s, others to 20s. 2. Codex "Auth missing" despite being authenticated: The credential health checker (check_oauth_json) only looked for access_token/ id_token/refresh_token at the JSON root level. But Codex auth.json nests tokens under {"tokens": {"access_token": ...}}, and Claude .credentials.json nests under {"claudeAiOauth": {"accessToken": ...}}. The doctor command had provider-specific auth checks that handled these formats correctly, but the usage command's auth_warning path used the generic checker, causing false "Auth missing!" warnings. Now check_oauth_json probes multiple JSON shapes: top-level tokens, Codex nested tokens, OPENAI_API_KEY/apiKey fields, and Claude's claudeAiOauth structure. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e99c684 commit e53aeff

2 files changed

Lines changed: 184 additions & 27 deletions

File tree

src/core/credential_health.rs

Lines changed: 163 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -574,36 +574,112 @@ pub fn check_oauth_file(path: &Path) -> CredentialHealth {
574574
}
575575

576576
/// Check OAuth credentials from JSON content.
577+
///
578+
/// Handles multiple credential file formats:
579+
/// - Direct top-level tokens: `{"access_token": "...", "refresh_token": "..."}`
580+
/// - Codex `auth.json` format: `{"tokens": {"access_token": "...", "id_token": "..."}}`
581+
/// or `{"OPENAI_API_KEY": "sk-..."}`
582+
/// - Claude `.credentials.json` format: `{"claudeAiOauth": {"accessToken": "..."}}`
577583
#[must_use]
578584
pub fn check_oauth_json(json: &str) -> CredentialHealth {
579-
#[derive(Deserialize)]
580-
#[allow(clippy::struct_field_names)]
581-
struct OAuthTokens {
582-
#[serde(default)]
583-
access_token: Option<String>,
584-
#[serde(default)]
585-
id_token: Option<String>,
586-
#[serde(default)]
587-
refresh_token: Option<String>,
588-
}
589-
590-
// Try to parse as direct tokens
591-
let tokens: OAuthTokens = match serde_json::from_str(json) {
592-
Ok(t) => t,
585+
// Parse into a generic JSON value first so we can probe multiple shapes
586+
let value: serde_json::Value = match serde_json::from_str(json) {
587+
Ok(v) => v,
593588
Err(e) => return CredentialHealth::CheckFailed(format!("invalid JSON: {e}")),
594589
};
595590

591+
// Strategy 1: Direct top-level tokens (generic OAuth files)
592+
// {"access_token": "...", "id_token": "...", "refresh_token": "..."}
593+
{
594+
let access = value
595+
.get("access_token")
596+
.and_then(|v| v.as_str())
597+
.filter(|s| !s.is_empty());
598+
let id = value
599+
.get("id_token")
600+
.and_then(|v| v.as_str())
601+
.filter(|s| !s.is_empty());
602+
let refresh = value
603+
.get("refresh_token")
604+
.and_then(|v| v.as_str())
605+
.filter(|s| !s.is_empty());
606+
607+
if access.is_some() || id.is_some() {
608+
return check_token_triple(access.or(id), refresh);
609+
}
610+
}
611+
612+
// Strategy 2: Codex auth.json - tokens nested under "tokens" key
613+
// {"tokens": {"access_token": "...", "id_token": "..."}}
614+
if let Some(tokens_obj) = value.get("tokens") {
615+
let access = tokens_obj
616+
.get("access_token")
617+
.and_then(|v| v.as_str())
618+
.filter(|s| !s.is_empty());
619+
let id = tokens_obj
620+
.get("id_token")
621+
.and_then(|v| v.as_str())
622+
.filter(|s| !s.is_empty());
623+
let refresh = tokens_obj
624+
.get("refresh_token")
625+
.and_then(|v| v.as_str())
626+
.filter(|s| !s.is_empty());
627+
628+
if access.is_some() || id.is_some() {
629+
return check_token_triple(access.or(id), refresh);
630+
}
631+
}
632+
633+
// Strategy 3: Codex API key auth
634+
// {"OPENAI_API_KEY": "sk-..."}
635+
if value
636+
.get("OPENAI_API_KEY")
637+
.and_then(|v| v.as_str())
638+
.is_some_and(|s| !s.is_empty())
639+
{
640+
return CredentialHealth::ApiKeyPresent;
641+
}
642+
643+
// Also check lowercase variant used by some configs
644+
if value
645+
.get("apiKey")
646+
.and_then(|v| v.as_str())
647+
.is_some_and(|s| !s.is_empty())
648+
{
649+
return CredentialHealth::ApiKeyPresent;
650+
}
651+
652+
// Strategy 4: Claude .credentials.json - tokens under "claudeAiOauth"
653+
// {"claudeAiOauth": {"accessToken": "...", "refreshToken": "..."}}
654+
if let Some(claude_oauth) = value.get("claudeAiOauth") {
655+
let access = claude_oauth
656+
.get("accessToken")
657+
.and_then(|v| v.as_str())
658+
.filter(|s| !s.is_empty());
659+
let refresh = claude_oauth
660+
.get("refreshToken")
661+
.and_then(|v| v.as_str())
662+
.filter(|s| !s.is_empty());
663+
664+
if access.is_some() {
665+
return check_token_triple(access, refresh);
666+
}
667+
}
668+
669+
// No tokens found in any known format
670+
CredentialHealth::Missing
671+
}
672+
673+
/// Check a set of extracted token strings and return the appropriate health status.
674+
fn check_token_triple(access_or_id: Option<&str>, refresh: Option<&str>) -> CredentialHealth {
596675
let checker = JwtHealthChecker::new();
597676

598-
// Check access/id token (use id_token if access_token not present)
599-
let access_token = tokens.access_token.as_ref().or(tokens.id_token.as_ref());
600-
let access_health = match access_token {
677+
let access_health = match access_or_id {
601678
Some(token) => checker.check(token),
602679
None => return CredentialHealth::Missing,
603680
};
604681

605-
// Check refresh token if present
606-
match tokens.refresh_token.as_ref() {
682+
match refresh {
607683
Some(refresh_token) => {
608684
let refresh_health = checker.check(refresh_token);
609685
CredentialHealth::OAuth(OAuthHealth::with_refresh(access_health, refresh_health))
@@ -1155,6 +1231,74 @@ mod tests {
11551231
assert!(matches!(health, CredentialHealth::CheckFailed(_)));
11561232
}
11571233

1234+
#[test]
1235+
fn check_oauth_json_codex_nested_tokens() {
1236+
// Codex auth.json nests tokens under "tokens" key
1237+
let id_token = make_jwt_with_exp(86400);
1238+
let json = format!(
1239+
r#"{{"tokens":{{"id_token":"{id_token}","access_token":"acc","account_id":"acct_123"}}}}"#
1240+
);
1241+
1242+
let health = check_oauth_json(&json);
1243+
assert!(
1244+
matches!(health, CredentialHealth::OAuth(_)),
1245+
"Expected OAuth health for Codex nested tokens, got {health:?}"
1246+
);
1247+
}
1248+
1249+
#[test]
1250+
fn check_oauth_json_codex_api_key() {
1251+
// Codex auth.json with only OPENAI_API_KEY
1252+
let json = r#"{"OPENAI_API_KEY": "sk-test-key-123"}"#;
1253+
1254+
let health = check_oauth_json(json);
1255+
assert!(
1256+
matches!(health, CredentialHealth::ApiKeyPresent),
1257+
"Expected ApiKeyPresent for Codex API key auth, got {health:?}"
1258+
);
1259+
}
1260+
1261+
#[test]
1262+
fn check_oauth_json_codex_api_key_lowercase() {
1263+
let json = r#"{"apiKey": "sk-test-key-456"}"#;
1264+
1265+
let health = check_oauth_json(json);
1266+
assert!(
1267+
matches!(health, CredentialHealth::ApiKeyPresent),
1268+
"Expected ApiKeyPresent for apiKey field, got {health:?}"
1269+
);
1270+
}
1271+
1272+
#[test]
1273+
fn check_oauth_json_claude_credentials() {
1274+
// Claude .credentials.json format
1275+
let access = make_jwt_with_exp(86400);
1276+
let json = format!(
1277+
r#"{{"claudeAiOauth":{{"accessToken":"{access}","subscriptionType":"team"}}}}"#
1278+
);
1279+
1280+
let health = check_oauth_json(&json);
1281+
assert!(
1282+
matches!(health, CredentialHealth::OAuth(_)),
1283+
"Expected OAuth health for Claude credentials, got {health:?}"
1284+
);
1285+
}
1286+
1287+
#[test]
1288+
fn check_oauth_json_claude_credentials_with_refresh() {
1289+
let access = make_jwt_with_exp(86400);
1290+
let refresh = make_jwt_with_exp(86400 * 30);
1291+
let json = format!(
1292+
r#"{{"claudeAiOauth":{{"accessToken":"{access}","refreshToken":"{refresh}","subscriptionType":"pro"}}}}"#
1293+
);
1294+
1295+
let health = check_oauth_json(&json);
1296+
assert!(
1297+
matches!(health, CredentialHealth::OAuth(_)),
1298+
"Expected OAuth health for Claude credentials with refresh, got {health:?}"
1299+
);
1300+
}
1301+
11581302
// =========================================================================
11591303
// Description and severity tests
11601304
// =========================================================================

src/core/provider.rs

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,30 @@ impl Provider {
151151
}
152152

153153
/// Default timeout for provider fetch operations.
154+
///
155+
/// Windows process spawning is significantly slower than Unix (cmd.exe
156+
/// overhead, antivirus hooks, etc.), so each CLI subprocess invocation
157+
/// can take 1-3s even for trivial commands. The fetch pipeline may
158+
/// invoke several subprocesses sequentially (version check, multiple
159+
/// rate-limit probes), so we use generous timeouts to avoid false
160+
/// "request timeout" errors on slower machines.
154161
#[must_use]
155162
pub const fn default_timeout(self) -> Duration {
156163
match self {
157164
// API/OAuth providers can be a bit slower
158-
Self::Gemini | Self::VertexAI => Duration::from_secs(15),
165+
Self::Gemini | Self::VertexAI => Duration::from_secs(30),
159166
// Local CLIs or lightweight sources
160167
Self::Cursor | Self::Copilot | Self::Kiro | Self::JetBrainsAI | Self::Amp => {
161-
Duration::from_secs(8)
168+
Duration::from_secs(15)
162169
}
163-
// Default for most providers
164-
_ => Duration::from_secs(10),
170+
// Primary providers: CLI fetch tries multiple subprocess
171+
// invocations sequentially (version + JSON probes + fallbacks),
172+
// each needing time to spawn. 30s accommodates slow Windows
173+
// environments where doctor --help succeeds in ~700ms but
174+
// the full fetch pipeline can exceed 10s.
175+
Self::Claude | Self::Codex => Duration::from_secs(30),
176+
// Default for other providers
177+
_ => Duration::from_secs(20),
165178
}
166179
}
167180

@@ -515,9 +528,9 @@ mod tests {
515528

516529
#[test]
517530
fn provider_default_timeout_values() {
518-
assert_eq!(Provider::Claude.default_timeout().as_secs(), 10);
519-
assert_eq!(Provider::Codex.default_timeout().as_secs(), 10);
520-
assert_eq!(Provider::Gemini.default_timeout().as_secs(), 15);
521-
assert_eq!(Provider::Cursor.default_timeout().as_secs(), 8);
531+
assert_eq!(Provider::Claude.default_timeout().as_secs(), 30);
532+
assert_eq!(Provider::Codex.default_timeout().as_secs(), 30);
533+
assert_eq!(Provider::Gemini.default_timeout().as_secs(), 30);
534+
assert_eq!(Provider::Cursor.default_timeout().as_secs(), 15);
522535
}
523536
}

0 commit comments

Comments
 (0)