From 100cd59194985d9a3ca26421258fb5c927fdfc65 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:09:40 +0700 Subject: [PATCH 1/2] fix(refresh): run the refresh CLIs from the temp directory A GUI app starts with its working directory at the home folder, / or System32, and `codex exec .` refuses to run there because it is not a trusted directory or repository. Both CLIs now start from the temp directory so neither runs inside a project or loads its instructions and hooks, and Codex runs with --skip-git-repo-check in a read-only sandbox. The orchestrator takes its spawn step as an argument internally, so its refreshed, timeout and missing-CLI outcomes are now tested with a fake credential source, along with the locator's source order and the Claude credential file signature. --- src/creds/local_fs.rs | 32 ++++++++ src/usage/refresh.rs | 184 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 211 insertions(+), 5 deletions(-) diff --git a/src/creds/local_fs.rs b/src/creds/local_fs.rs index 19d297f..a50a6c1 100644 --- a/src/creds/local_fs.rs +++ b/src/creds/local_fs.rs @@ -106,6 +106,38 @@ mod tests { assert_eq!(token.expires_at_unix_ms, None); } + #[test] + fn signature_changes_when_a_rewrite_changes_the_file_size() { + use crate::creds::CredentialSource; + + let path = std::env::temp_dir().join(format!( + "ccub-local-creds-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + let creds = LocalClaudeCreds { + id: format!("local:{}", path.display()), + path: path.clone(), + }; + assert_eq!(creds.signature(), None, "missing file has no signature"); + + std::fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"a"}}"#).expect("write"); + let before = creds.signature().expect("file exists"); + assert_eq!( + Some(before.clone()), + creds.signature(), + "stable while unchanged" + ); + + std::fs::write(&path, r#"{"claudeAiOauth":{"accessToken":"rotated"}}"#).expect("rewrite"); + let after = creds.signature().expect("file exists"); + let _ = std::fs::remove_file(&path); + assert_ne!(before, after); + } + #[test] fn missing_fields_are_reported() { let err = parse_claude_json(&fixture("claude-credentials-missing-token.json")).unwrap_err(); diff --git a/src/usage/refresh.rs b/src/usage/refresh.rs index b13c871..0aa3e52 100644 --- a/src/usage/refresh.rs +++ b/src/usage/refresh.rs @@ -20,6 +20,17 @@ const EXTRA_HOME_DIRS: &[&str] = &[".local/bin", ".claude/local"]; #[cfg(unix)] const EXTRA_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"]; +/// `codex exec` refuses to run outside a trusted directory or repository, +/// and the refresh runs from the temp directory, so that check is skipped. +/// The read-only sandbox keeps the one-shot prompt from changing any file. +const CODEX_ARGS: &[&str] = &[ + "exec", + "--skip-git-repo-check", + "--sandbox", + "read-only", + ".", +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Outcome { Refreshed, @@ -42,9 +53,19 @@ impl Orchestrator { } pub fn refresh(&self, source: &dyn CredentialSource) -> Outcome { + self.refresh_with(source, spawn_cli) + } + + /// Spawn the refresh CLI through `spawn`, then wait for the credential + /// signature to change. `spawn` returns `false` when no CLI could start. + fn refresh_with( + &self, + source: &dyn CredentialSource, + spawn: impl FnOnce(&RefreshHint) -> bool, + ) -> Outcome { let initial_sig = source.signature(); let hint = source.refresh_hint(); - if !spawn_cli(&hint) { + if !spawn(&hint) { return Outcome::CliMissing; } let start = Instant::now(); @@ -73,7 +94,7 @@ fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static s RefreshHint::LocalClaudeCli => (&["claude.cmd", "claude.exe", "claude"], &["-p", "."]), RefreshHint::LocalCodexCli => ( &["codex.cmd", "codex.ps1", "codex.exe", "codex"], - &["exec", "."], + CODEX_ARGS, ), } } @@ -82,18 +103,22 @@ fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static s fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) { match hint { RefreshHint::LocalClaudeCli => (&["claude"], &["-p", "."]), - RefreshHint::LocalCodexCli => (&["codex"], &["exec", "."]), + RefreshHint::LocalCodexCli => (&["codex"], CODEX_ARGS), } } /// Spawn the first candidate that starts. The child is detached: a reaper -/// thread waits on it so no zombie is left behind. +/// thread waits on it so no zombie is left behind. The child runs from the +/// temp directory rather than the app's own working directory (the home +/// folder, `/` or System32), so the CLI never runs inside a project or +/// loads its instructions and hooks. fn spawn_first(candidates: &[&str], args: &[&str]) -> bool { for name in candidates { let Some(mut cmd) = command_for(name, args) else { continue; }; - cmd.env_remove("CLAUDECODE") + cmd.current_dir(std::env::temp_dir()) + .env_remove("CLAUDECODE") .env_remove("CLAUDE_CODE_ENTRYPOINT") .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -209,6 +234,129 @@ fn find_executable(name: &str, dirs: &[std::path::PathBuf]) -> Option, + } + + fn fake(id: &str, version: u32) -> (FakeSource, Arc) { + let version = Arc::new(AtomicU32::new(version)); + let source = FakeSource { + id: id.to_string(), + version: Arc::clone(&version), + }; + (source, version) + } + + impl CredentialSource for FakeSource { + fn id(&self) -> &str { + &self.id + } + + fn read(&self) -> Result { + Err(CredsError::Unavailable) + } + + fn signature(&self) -> Option { + match self.version.load(Ordering::SeqCst) { + 0 => None, + v => Some(format!("{}|{v}", self.id)), + } + } + + fn refresh_hint(&self) -> RefreshHint { + RefreshHint::LocalCodexCli + } + } + + fn fast_orchestrator() -> Orchestrator { + Orchestrator { + timeout: Duration::from_millis(30), + poll_interval: Duration::from_millis(2), + } + } + + #[test] + fn codex_refresh_skips_the_repository_check_in_a_read_only_sandbox() { + let (_, args) = cli_for(&RefreshHint::LocalCodexCli); + assert_eq!(args.first(), Some(&"exec")); + assert!(args.contains(&"--skip-git-repo-check"), "{args:?}"); + let sandbox = args + .iter() + .position(|a| *a == "--sandbox") + .expect("sandbox flag"); + assert_eq!(args.get(sandbox + 1), Some(&"read-only")); + } + + #[test] + fn signature_change_after_spawn_is_refreshed() { + let (source, version) = fake("fake", 1); + let outcome = fast_orchestrator().refresh_with(&source, |hint| { + assert!(matches!(hint, RefreshHint::LocalCodexCli)); + version.store(2, Ordering::SeqCst); + true + }); + assert_eq!(outcome, Outcome::Refreshed); + } + + #[test] + fn unchanged_signature_times_out() { + let (source, _) = fake("fake", 1); + let outcome = fast_orchestrator().refresh_with(&source, |_| true); + assert_eq!(outcome, Outcome::Timeout); + } + + #[test] + fn failed_spawn_is_cli_missing_without_waiting() { + let (source, _) = fake("fake", 1); + let orchestrator = Orchestrator { + timeout: Duration::from_secs(60), + poll_interval: Duration::from_secs(60), + }; + let start = Instant::now(); + assert_eq!( + orchestrator.refresh_with(&source, |_| false), + Outcome::CliMissing + ); + assert!(start.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn first_available_skips_missing_sources_and_keeps_order() { + let (missing, _) = fake("missing", 0); + let (second, _) = fake("second", 1); + let (third, _) = fake("third", 1); + let locator = Locator::new(vec![Box::new(missing), Box::new(second), Box::new(third)]); + assert_eq!(locator.first_available().map(|s| s.id()), Some("second")); + + let (only_missing, _) = fake("missing", 0); + assert!(Locator::new(vec![Box::new(only_missing)]) + .first_available() + .is_none()); + assert!(Locator::new(Vec::new()).first_available().is_none()); + } + + #[test] + fn first_available_prefers_an_earlier_source_once_it_appears() { + let (first, first_version) = fake("first", 0); + let (second, _) = fake("second", 1); + let locator = Locator::new(vec![Box::new(first), Box::new(second)]); + assert_eq!(locator.first_available().map(|s| s.id()), Some("second")); + first_version.store(1, Ordering::SeqCst); + assert_eq!(locator.first_available().map(|s| s.id()), Some("first")); + } +} + #[cfg(all(test, unix))] mod tests { use std::ffi::OsString; @@ -308,6 +456,32 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn spawned_child_runs_in_the_temp_directory() { + let dir = temp_dir("cwd"); + let script = dir.join("fake-cli"); + let marker = dir.join("cwd"); + std::fs::write( + &script, + format!("#!/bin/sh\npwd -P > '{}'\n", marker.display()), + ) + .expect("write script"); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + assert!(spawn_first(&[script.to_str().expect("utf8 path")], &[])); + // The file exists before `pwd` writes, so wait for its full line. + let read = || std::fs::read_to_string(&marker).unwrap_or_default(); + let deadline = Instant::now() + Duration::from_secs(10); + while !read().ends_with('\n') && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + let written = read(); + let expected = std::env::temp_dir() + .canonicalize() + .expect("temp dir resolves"); + assert_eq!(PathBuf::from(written.trim()), expected); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn child_path_resolves_env_interpreter_from_extra_dirs() { let tools = temp_dir("tools"); From 13810a600400d2c81240262a91abdbe8c572479e Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:09:40 +0700 Subject: [PATCH 2/2] test(usage): cover the provider HTTP paths against a loopback server The Claude and Codex fetches now take their endpoint URLs as arguments, with poll supplying the real ones, so tests can point them at the local one-shot server. The tests pin the current behaviour: usage auth failures stop before the Messages fallback, server errors and unparsable bodies fall back to Messages, partial bodies get their reset times merged, Messages auth failures and missing rate-limit headers are reported, Codex 401 and 500 map to auth-required and bad-response, and the account header is sent only when an account id is set. --- src/usage/anthropic.rs | 160 ++++++++++++++++++++++++++++++++++++++--- src/usage/chatgpt.rs | 143 +++++++++++++++++++++++++++++------- 2 files changed, 268 insertions(+), 35 deletions(-) diff --git a/src/usage/anthropic.rs b/src/usage/anthropic.rs index 718869f..b098601 100644 --- a/src/usage/anthropic.rs +++ b/src/usage/anthropic.rs @@ -44,29 +44,38 @@ impl UsageProvider for ClaudeProvider { if token_is_expired(token.expires_at_unix_ms) { return Err(Error::AuthRequired); } - fetch_with_fallback(http, &token.access_token) + fetch_with_fallback(http, &token.access_token, USAGE_URL, MESSAGES_URL) } } -fn fetch_with_fallback(http: &Client, token: &str) -> Result { +fn fetch_with_fallback( + http: &Client, + token: &str, + usage_url: &str, + messages_url: &str, +) -> Result { // First try the dedicated endpoint. - match try_usage_endpoint(http, token)? { + match try_usage_endpoint(http, token, usage_url)? { Some(windows) if has_reset_times(&windows) => return Ok(windows), Some(partial) => { // Got percentages but no reset times — fill them in from messages. - if let Ok(fallback) = try_messages_endpoint(http, token) { + if let Ok(fallback) = try_messages_endpoint(http, token, messages_url) { return Ok(merge_resets(partial, fallback)); } return Ok(partial); } None => {} } - try_messages_endpoint(http, token) + try_messages_endpoint(http, token, messages_url) } -fn try_usage_endpoint(http: &Client, token: &str) -> Result, Error> { +fn try_usage_endpoint( + http: &Client, + token: &str, + url: &str, +) -> Result, Error> { let resp = match http - .get(USAGE_URL) + .get(url) .header("Authorization", &format!("Bearer {token}")) .header("anthropic-beta", BETA_HEADER) .send() @@ -95,7 +104,7 @@ fn parse_usage_body(body: &[u8]) -> Option { Some(UsageWindows { primary, secondary }) } -fn try_messages_endpoint(http: &Client, token: &str) -> Result { +fn try_messages_endpoint(http: &Client, token: &str, url: &str) -> Result { for model in PROBE_MODELS { let body = serde_json::json!({ "model": model, @@ -103,7 +112,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result Option { Some(UNIX_EPOCH + Duration::from_secs(secs)) @@ -381,4 +392,135 @@ mod tests { assert!(token_is_expired(Some(0))); assert!(!token_is_expired(Some(i64::MAX))); } + + // --- HTTP paths against a loopback server --- + + const RATE_LIMIT_HEADERS: &[(&str, &str)] = &[ + ("anthropic-ratelimit-unified-5h-utilization", "0.25"), + ("anthropic-ratelimit-unified-5h-reset", "1000"), + ("anthropic-ratelimit-unified-7d-utilization", "0.5"), + ("anthropic-ratelimit-unified-7d-reset", "2000"), + ]; + + fn client() -> Client { + Client::without_proxy(USER_AGENT) + } + + fn fetch(usage: &Served, messages: &Served) -> Result { + fetch_with_fallback(&client(), "tok", &usage.base, &messages.base) + } + + fn received(served: &Served) -> String { + served + .request + .recv_timeout(Duration::from_secs(10)) + .expect("server saw the request") + } + + fn assert_not_called(served: &Served) { + assert!( + served + .request + .recv_timeout(Duration::from_millis(300)) + .is_err(), + "endpoint must not be called" + ); + } + + fn messages_with_headers() -> Served { + serve_once("200 OK", RATE_LIMIT_HEADERS, b"{}".to_vec()) + } + + #[test] + fn usage_auth_failures_stop_before_the_messages_fallback() { + for status in ["401 Unauthorized", "403 Forbidden"] { + let usage = serve_once(status, &[], b"{}".to_vec()); + let messages = messages_with_headers(); + let result = fetch(&usage, &messages); + assert!( + matches!(result, Err(Error::AuthRequired)), + "{status}: {result:?}" + ); + let request = received(&usage); + assert!(request.starts_with("GET / HTTP/1.1\r\n"), "{request}"); + let lower = request.to_ascii_lowercase(); + assert!(lower.contains("authorization: bearer tok\r\n"), "{request}"); + assert!( + lower.contains("anthropic-beta: oauth-2025-04-20\r\n"), + "{request}" + ); + assert_not_called(&messages); + } + } + + #[test] + fn usage_server_error_falls_back_to_messages_headers() { + let usage = serve_once("500 Internal Server Error", &[], b"oops".to_vec()); + let messages = messages_with_headers(); + let w = fetch(&usage, &messages).expect("fallback windows"); + assert_eq!(w.primary.utilization, 25.0); + assert_eq!(w.primary.resets_at, at(1000)); + assert_eq!(w.secondary.utilization, 50.0); + assert_eq!(w.secondary.resets_at, at(2000)); + let request = received(&messages); + assert!(request.starts_with("POST / HTTP/1.1\r\n"), "{request}"); + assert!(request + .to_ascii_lowercase() + .contains("anthropic-version: 2023-06-01\r\n")); + assert!(request.contains("\"max_tokens\":1"), "{request}"); + } + + #[test] + fn unparsable_usage_body_falls_back_to_messages_headers() { + let usage = serve_once("200 OK", &[], b"not json".to_vec()); + let messages = messages_with_headers(); + let w = fetch(&usage, &messages).expect("fallback windows"); + assert_eq!(w.primary.utilization, 25.0); + assert_eq!(w.secondary.resets_at, at(2000)); + received(&messages); + } + + #[test] + fn full_usage_body_skips_the_messages_call() { + let usage = serve_once("200 OK", &[], fixture("anthropic-usage-full.json")); + let messages = messages_with_headers(); + let w = fetch(&usage, &messages).expect("usage windows"); + assert_eq!(w.primary.utilization, 42.5); + assert_eq!(w.primary.resets_at, at(1_761_040_800)); + assert_not_called(&messages); + } + + #[test] + fn partial_usage_body_merges_reset_times_from_messages() { + let usage = serve_once("200 OK", &[], fixture("anthropic-usage-partial.json")); + let messages = messages_with_headers(); + let w = fetch(&usage, &messages).expect("merged windows"); + assert_eq!(w.primary.utilization, 7.0, "usage percent is kept"); + assert_eq!(w.primary.resets_at, at(1000), "missing reset is filled in"); + assert_eq!(w.secondary.utilization, 61.0); + assert_eq!( + w.secondary.resets_at, + at(1_709_251_199), + "known reset is kept" + ); + received(&messages); + } + + #[test] + fn messages_auth_failure_is_auth_required() { + let usage = serve_once("503 Service Unavailable", &[], Vec::new()); + let messages = serve_once("401 Unauthorized", RATE_LIMIT_HEADERS, b"{}".to_vec()); + let result = fetch(&usage, &messages); + assert!(matches!(result, Err(Error::AuthRequired)), "{result:?}"); + } + + #[test] + fn messages_without_rate_limit_headers_is_a_bad_response() { + // The second probe model finds the one-shot server gone, which + // counts as a failed probe too. + let usage = serve_once("500 Internal Server Error", &[], Vec::new()); + let messages = serve_once("200 OK", &[], b"{}".to_vec()); + let result = fetch(&usage, &messages); + assert!(matches!(result, Err(Error::BadResponse(_))), "{result:?}"); + } } diff --git a/src/usage/chatgpt.rs b/src/usage/chatgpt.rs index cd744ca..9cb7677 100644 --- a/src/usage/chatgpt.rs +++ b/src/usage/chatgpt.rs @@ -35,35 +35,50 @@ impl UsageProvider for ChatGptProvider { fn poll(&mut self, http: &Client) -> Result { let source = self.locator.first_available().ok_or(Error::NoCredentials)?; let token = source.read()?; - let mut req = http - .get(USAGE_URL) - .header("Authorization", &format!("Bearer {}", token.access_token)) - .header("User-Agent", "codex-cli"); - if let Some(account_id) = token.account_id.as_deref().filter(|s| !s.is_empty()) { - req = req.header("ChatGPT-Account-Id", account_id); - } - let resp = match req.send() { - Ok(r) => r, - Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => { - return Err(Error::AuthRequired); - } - Err(e) => return Err(Error::Network(e)), - }; - if resp.status() == 401 || resp.status() == 403 { + fetch( + http, + USAGE_URL, + &token.access_token, + token.account_id.as_deref(), + ) + } +} + +/// Usage windows from the Codex usage endpoint at `url`. The account header +/// is sent only when `account_id` is set and not empty. +fn fetch( + http: &Client, + url: &str, + token: &str, + account_id: Option<&str>, +) -> Result { + let mut req = http + .get(url) + .header("Authorization", &format!("Bearer {token}")) + .header("User-Agent", "codex-cli"); + if let Some(account_id) = account_id.filter(|s| !s.is_empty()) { + req = req.header("ChatGPT-Account-Id", account_id); + } + let resp = match req.send() { + Ok(r) => r, + Err(crate::net::Error::Status(code)) if code == 401 || code == 403 => { return Err(Error::AuthRequired); } - if !(200..300).contains(&resp.status()) { - return Err(Error::BadResponse(format!( - "Codex usage endpoint returned {}", - resp.status() - ))); - } - let body: Envelope = resp - .json() - .map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?; - envelope_to_windows(body) - .ok_or_else(|| Error::BadResponse("missing rate_limit section".into())) + Err(e) => return Err(Error::Network(e)), + }; + if resp.status() == 401 || resp.status() == 403 { + return Err(Error::AuthRequired); } + if !(200..300).contains(&resp.status()) { + return Err(Error::BadResponse(format!( + "Codex usage endpoint returned {}", + resp.status() + ))); + } + let body: Envelope = resp + .json() + .map_err(|e| Error::BadResponse(format!("JSON parse: {e}")))?; + envelope_to_windows(body).ok_or_else(|| Error::BadResponse("missing rate_limit section".into())) } fn envelope_to_windows(envelope: Envelope) -> Option { @@ -129,6 +144,8 @@ impl FlattenBoxed for Option>> { #[cfg(test)] mod tests { use super::*; + use crate::net::test_server::{serve_once, Served}; + use crate::net::USER_AGENT; fn at(secs: u64) -> Option { Some(UNIX_EPOCH + Duration::from_secs(secs)) @@ -173,4 +190,78 @@ mod tests { assert_eq!(w.primary.resets_at, None); assert_eq!(w.secondary.resets_at, at(0)); } + + // --- HTTP path against a loopback server --- + + fn fixture(name: &str) -> Vec { + let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}")) + } + + fn fetch_from(served: &Served, account_id: Option<&str>) -> Result { + fetch( + &Client::without_proxy(USER_AGENT), + &served.base, + "tok", + account_id, + ) + } + + fn received(served: &Served) -> String { + served + .request + .recv_timeout(Duration::from_secs(10)) + .expect("server saw the request") + .to_ascii_lowercase() + } + + #[test] + fn success_maps_windows_and_sends_codex_headers() { + let served = serve_once("200 OK", &[], fixture("chatgpt-usage-full.json")); + let w = fetch_from(&served, None).expect("windows"); + assert_eq!(w.primary.utilization, 12.0); + assert_eq!(w.secondary.resets_at, at(1_761_508_800)); + let request = received(&served); + assert!( + request.contains("authorization: bearer tok\r\n"), + "{request}" + ); + assert!(request.contains("user-agent: codex-cli\r\n"), "{request}"); + } + + #[test] + fn unauthorized_is_auth_required() { + let served = serve_once("401 Unauthorized", &[], b"{}".to_vec()); + let result = fetch_from(&served, None); + assert!(matches!(result, Err(Error::AuthRequired)), "{result:?}"); + } + + #[test] + fn server_error_is_a_bad_response() { + let served = serve_once("500 Internal Server Error", &[], b"{}".to_vec()); + let result = fetch_from(&served, None); + assert!(matches!(result, Err(Error::BadResponse(_))), "{result:?}"); + } + + #[test] + fn account_header_is_sent_only_with_an_account_id() { + let body = || fixture("chatgpt-usage-full.json"); + let with_id = serve_once("200 OK", &[], body()); + fetch_from(&with_id, Some("acct-123")).expect("windows"); + let request = received(&with_id); + assert!( + request.contains("chatgpt-account-id: acct-123\r\n"), + "{request}" + ); + + for account_id in [None, Some("")] { + let served = serve_once("200 OK", &[], body()); + fetch_from(&served, account_id).expect("windows"); + let request = received(&served); + assert!( + !request.contains("chatgpt-account-id"), + "{account_id:?}: {request}" + ); + } + } }