1use anyhow::{Context, Result, anyhow, bail};
27use async_trait::async_trait;
28use serde::Deserialize;
29use std::path::PathBuf;
30use std::sync::Arc;
31
32use crate::openai_chatgpt_oauth::{
33 OpenAIChatGptSession, OpenAIChatGptSessionRefresher, parse_jwt_claims, parse_jwt_exp,
34};
35
36#[derive(Clone, Deserialize)]
41pub(crate) struct CodexAuthDotJson {
42 #[serde(default)]
43 pub auth_mode: Option<String>,
44 #[serde(rename = "OPENAI_API_KEY", default)]
46 pub openai_api_key: Option<String>,
47 #[serde(default)]
48 pub tokens: Option<CodexTokenData>,
49 #[serde(default)]
51 pub last_refresh: Option<String>,
52 #[serde(default)]
55 pub personal_access_token: Option<RedactedPresence>,
56 #[serde(default)]
58 pub bedrock_api_key: Option<RedactedPresence>,
59}
60
61#[derive(Clone)]
65pub(crate) struct RedactedPresence {
66 _present: bool,
67}
68
69impl RedactedPresence {
70 fn is_present(&self) -> bool {
71 self._present
72 }
73}
74
75impl<'de> Deserialize<'de> for RedactedPresence {
76 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
77 where
78 D: serde::Deserializer<'de>,
79 {
80 let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
82 Ok(RedactedPresence { _present: true })
83 }
84}
85
86impl std::fmt::Debug for CodexAuthDotJson {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("CodexAuthDotJson")
89 .field("auth_mode", &self.auth_mode)
90 .field("openai_api_key", &self.openai_api_key.as_ref().map(|_| "<redacted>"))
91 .field("tokens", &self.tokens.as_ref().map(|_| "<redacted>"))
92 .field("last_refresh", &self.last_refresh)
93 .field("personal_access_token", &self.personal_access_token.as_ref().map(|_| "<present>"))
94 .field("bedrock_api_key", &self.bedrock_api_key.as_ref().map(|_| "<present>"))
95 .finish()
96 }
97}
98
99#[derive(Clone, Deserialize)]
102pub(crate) struct CodexTokenData {
103 pub id_token: String,
104 pub access_token: String,
105 pub refresh_token: String,
106 #[serde(default)]
107 pub account_id: Option<String>,
108}
109
110impl std::fmt::Debug for CodexTokenData {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.debug_struct("CodexTokenData")
113 .field("id_token", &"<redacted>")
114 .field("access_token", &"<redacted>")
115 .field("refresh_token", &"<redacted>")
116 .field("account_id", &self.account_id)
117 .finish()
118 }
119}
120
121pub fn codex_home_dir() -> Result<PathBuf> {
123 if let Ok(path) = std::env::var("CODEX_HOME")
124 && !path.is_empty()
125 {
126 return Ok(PathBuf::from(path));
127 }
128 dirs::home_dir()
129 .map(|home| home.join(".codex"))
130 .ok_or_else(|| anyhow!("could not determine home directory for codex auth path"))
131}
132
133pub fn codex_auth_json_path() -> Result<PathBuf> {
135 Ok(codex_home_dir()?.join("auth.json"))
136}
137
138pub fn codex_auth_json_exists() -> bool {
140 codex_auth_json_path().map(|path| path.exists()).unwrap_or(false)
141}
142
143pub(crate) fn read_codex_auth_json() -> Result<CodexAuthDotJson> {
148 let path = codex_auth_json_path()?;
149 let data = std::fs::read(&path).with_context(|| "failed to read codex auth.json")?;
150 serde_json::from_slice::<CodexAuthDotJson>(&data).with_context(|| "failed to parse codex auth.json")
151}
152
153fn try_load_codex_chatgpt_session_once() -> Result<Option<OpenAIChatGptSession>> {
170 let path = codex_auth_json_path()?;
171 let data = match std::fs::read(&path) {
175 Ok(data) => data,
176 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
177 Err(_) => return Err(anyhow!("failed to read codex auth.json")),
178 };
179 let auth = serde_json::from_slice::<CodexAuthDotJson>(&data).with_context(|| "failed to parse codex auth.json")?;
180 if let Some(mode) = auth.auth_mode.as_deref()
182 && !mode.eq_ignore_ascii_case("chatgpt")
183 {
184 return Ok(None);
185 }
186 if auth.auth_mode.is_none() {
190 if auth.personal_access_token.as_ref().is_some_and(|p| p.is_present()) {
191 return Ok(None);
192 }
193 if auth.bedrock_api_key.as_ref().is_some_and(|p| p.is_present()) {
194 return Ok(None);
195 }
196 if auth.openai_api_key.is_some() {
197 return Ok(None);
198 }
199 }
200 let Some(tokens) = &auth.tokens else {
201 return Ok(None);
202 };
203 if tokens.access_token.trim().is_empty() {
205 return Ok(None);
206 }
207 Ok(Some(codex_tokens_to_session(&auth, tokens)))
208}
209
210pub fn try_load_codex_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
224 let mut last_err = None;
225 for attempt in 0..CODEX_REFRESH_MAX_ATTEMPTS {
226 match try_load_codex_chatgpt_session_once() {
227 Ok(result) => return Ok(result),
228 Err(err) => last_err = Some(err),
229 }
230 if attempt + 1 < CODEX_REFRESH_MAX_ATTEMPTS {
232 if let Some(&delay) = CODEX_REFRESH_BACKOFF_MS.get(attempt) {
233 std::thread::sleep(std::time::Duration::from_millis(delay));
234 }
235 }
236 }
237 Err(last_err.unwrap_or_else(|| anyhow!("failed to read codex auth.json after retries")))
238}
239
240fn codex_tokens_to_session(auth: &CodexAuthDotJson, tokens: &CodexTokenData) -> OpenAIChatGptSession {
249 let now = now_secs();
250 let claims = parse_jwt_claims(&tokens.id_token).ok();
252 let expires_at = parse_jwt_exp(&tokens.access_token);
256 OpenAIChatGptSession {
257 openai_api_key: auth.openai_api_key.clone().unwrap_or_default(),
260 id_token: tokens.id_token.clone(),
261 access_token: tokens.access_token.clone(),
262 refresh_token: String::new(),
265 account_id: tokens
266 .account_id
267 .clone()
268 .or_else(|| claims.as_ref().and_then(|c| c.account_id.clone())),
269 email: claims.as_ref().and_then(|c| c.email.clone()),
270 plan: claims.as_ref().and_then(|c| c.plan.clone()),
271 obtained_at: now,
274 refreshed_at: now,
275 expires_at,
276 }
277}
278
279pub(crate) fn is_session_expired(session: &OpenAIChatGptSession) -> bool {
284 let Some(expires_at) = session.expires_at else {
285 return false;
286 };
287 now_secs().saturating_add(60) >= expires_at
288}
289
290pub struct CodexAuthJsonRefresher;
304
305const CODEX_REFRESH_MAX_ATTEMPTS: usize = 4;
306const CODEX_REFRESH_BACKOFF_MS: &[u64] = &[10, 30, 100];
307
308#[async_trait]
309impl OpenAIChatGptSessionRefresher for CodexAuthJsonRefresher {
310 async fn refresh_session(&self, current: &OpenAIChatGptSession) -> Result<OpenAIChatGptSession> {
311 let mut last_err = None;
312 for attempt in 0..CODEX_REFRESH_MAX_ATTEMPTS {
313 match try_load_codex_chatgpt_session_once() {
317 Ok(Some(session)) => {
318 if is_session_expired(&session) {
323 if session.access_token == current.access_token {
324 bail!(
325 "Codex's auth.json contains an expired access token that has not been refreshed. \
326 Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
327 );
328 }
329 bail!(
330 "Codex's auth.json contains an expired replacement access token. \
331 Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
332 );
333 }
334 return Ok(session);
335 }
336 Ok(None) => {
337 bail!(
339 "Codex auth.json no longer contains ChatGPT tokens. \
340 Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
341 );
342 }
343 Err(err) => {
344 last_err = Some(err);
346 if attempt + 1 < CODEX_REFRESH_MAX_ATTEMPTS {
347 let delay = CODEX_REFRESH_BACKOFF_MS.get(attempt).copied().unwrap_or(100);
348 tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
349 }
350 }
351 }
352 }
353 Err(last_err.unwrap_or_else(|| anyhow!("failed to read codex auth.json after retries")))
355 }
356}
357
358pub fn codex_auth_json_refresher() -> Arc<dyn OpenAIChatGptSessionRefresher> {
361 Arc::new(CodexAuthJsonRefresher)
362}
363
364fn now_secs() -> u64 {
365 std::time::SystemTime::now()
366 .duration_since(std::time::UNIX_EPOCH)
367 .map(|duration| duration.as_secs())
368 .unwrap_or(0)
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use serial_test::serial;
375
376 #[test]
377 fn parse_codex_auth_json_with_tokens() {
378 let json = r#"{
379 "OPENAI_API_KEY": "sk-derived-key",
380 "tokens": {
381 "id_token": "header.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjLTEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
382 "access_token": "oauth-access",
383 "refresh_token": "oauth-refresh",
384 "account_id": "acc-123"
385 },
386 "last_refresh": "2025-01-15T12:00:00Z"
387 }"#;
388 let auth: CodexAuthDotJson = serde_json::from_str(json).expect("parse");
389 assert_eq!(auth.openai_api_key.as_deref(), Some("sk-derived-key"));
390 let tokens = auth.tokens.expect("tokens present");
391 assert_eq!(tokens.access_token, "oauth-access");
392 assert_eq!(tokens.refresh_token, "oauth-refresh");
393 assert_eq!(tokens.account_id.as_deref(), Some("acc-123"));
394 }
395
396 #[test]
397 fn parse_codex_auth_json_without_tokens() {
398 let json = r#"{"OPENAI_API_KEY": "sk-key"}"#;
399 let auth: CodexAuthDotJson = serde_json::from_str(json).expect("parse");
400 assert!(auth.tokens.is_none());
401 }
402
403 #[test]
404 fn codex_tokens_to_session_maps_fields() {
405 let auth = CodexAuthDotJson {
406 auth_mode: Some("chatgpt".to_string()),
407 openai_api_key: Some("sk-derived".to_string()),
408 tokens: Some(CodexTokenData {
409 id_token: "header.eyJlbWFpbCI6InVzZXJAdGVzdC5jb20ifQ.sig".to_string(),
410 access_token: "access-tok".to_string(),
411 refresh_token: "refresh-tok".to_string(),
412 account_id: Some("acc-456".to_string()),
413 }),
414 last_refresh: None,
415 personal_access_token: None,
416 bedrock_api_key: None,
417 };
418 let tokens = auth.tokens.clone().unwrap();
419 let session = codex_tokens_to_session(&auth, &tokens);
420 assert_eq!(session.openai_api_key, "sk-derived");
421 assert_eq!(session.access_token, "access-tok");
422 assert_eq!(session.refresh_token, "");
425 assert_eq!(session.account_id.as_deref(), Some("acc-456"));
426 assert_eq!(session.email.as_deref(), Some("user@test.com"));
428 }
429
430 #[test]
431 fn codex_tokens_to_session_falls_back_to_empty_api_key() {
432 let auth = CodexAuthDotJson {
433 auth_mode: None,
434 openai_api_key: None,
435 tokens: Some(CodexTokenData {
436 id_token: "header.e30.sig".to_string(),
437 access_token: "access-tok".to_string(),
438 refresh_token: "refresh-tok".to_string(),
439 account_id: None,
440 }),
441 last_refresh: None,
442 personal_access_token: None,
443 bedrock_api_key: None,
444 };
445 let tokens = auth.tokens.clone().unwrap();
446 let session = codex_tokens_to_session(&auth, &tokens);
447 assert!(session.openai_api_key.is_empty());
448 assert!(session.account_id.is_none());
449 }
450
451 #[tokio::test]
452 #[serial]
453 async fn codex_refresher_fails_when_no_auth_file() {
454 let temp = tempfile::tempdir().expect("create temp dir");
456 let prev = std::env::var("CODEX_HOME").ok();
457 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
458 let refresher = CodexAuthJsonRefresher;
459 let session = OpenAIChatGptSession {
460 openai_api_key: String::new(),
461 id_token: String::new(),
462 access_token: String::new(),
463 refresh_token: String::new(),
464 account_id: None,
465 email: None,
466 plan: None,
467 obtained_at: 0,
468 refreshed_at: 0,
469 expires_at: None,
470 };
471 let result = refresher.refresh_session(&session).await;
472 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
474 assert!(result.is_err());
475 assert!(result.unwrap_err().to_string().contains("no longer contains ChatGPT tokens"));
476 }
477
478 #[test]
479 #[serial]
480 fn codex_apikey_mode_is_rejected_as_chatgpt_fallback() {
481 let temp = tempfile::tempdir().expect("create temp dir");
483 let prev = std::env::var("CODEX_HOME").ok();
484 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
485 let auth_json = r#"{
486 "auth_mode": "apikey",
487 "OPENAI_API_KEY": "sk-test",
488 "tokens": {
489 "id_token": "header.e30.sig",
490 "access_token": "oauth-access",
491 "refresh_token": "oauth-refresh"
492 }
493 }"#;
494 std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
495 let session = try_load_codex_chatgpt_session();
496 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
497 assert!(session.is_ok(), "parsing should not error");
498 assert!(session.unwrap().is_none(), "apikey mode should not produce a ChatGPT session");
499 }
500
501 #[test]
502 #[serial]
503 fn codex_blank_access_token_is_rejected() {
504 let temp = tempfile::tempdir().expect("create temp dir");
505 let prev = std::env::var("CODEX_HOME").ok();
506 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
507 let auth_json = r#"{
508 "auth_mode": "chatgpt",
509 "tokens": {
510 "id_token": "header.e30.sig",
511 "access_token": " ",
512 "refresh_token": "oauth-refresh"
513 }
514 }"#;
515 std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
516 let session = try_load_codex_chatgpt_session();
517 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
518 assert!(session.is_ok());
519 assert!(session.unwrap().is_none(), "blank access_token should not produce a session");
520 }
521
522 #[test]
523 #[serial]
524 fn codex_chatgpt_mode_with_access_token_is_accepted() {
525 let temp = tempfile::tempdir().expect("create temp dir");
526 let prev = std::env::var("CODEX_HOME").ok();
527 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
528 let auth_json = r#"{
530 "auth_mode": "chatgpt",
531 "tokens": {
532 "id_token": "header.e30.sig",
533 "access_token": "oauth-access-token",
534 "refresh_token": "oauth-refresh"
535 }
536 }"#;
537 std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
538 let session = try_load_codex_chatgpt_session();
539 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
540 let session = session.expect("parse ok").expect("session should be present");
541 assert_eq!(session.access_token, "oauth-access-token");
542 assert!(session.openai_api_key.is_empty(), "no derived API key in this fixture");
543 }
544
545 #[test]
546 #[serial]
547 fn codex_no_auth_mode_with_api_key_is_rejected() {
548 let temp = tempfile::tempdir().expect("create temp dir");
552 let prev = std::env::var("CODEX_HOME").ok();
553 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
554 let auth_json = r#"{
555 "OPENAI_API_KEY": "",
556 "tokens": {
557 "id_token": "header.e30.sig",
558 "access_token": "oauth-access",
559 "refresh_token": "oauth-refresh"
560 }
561 }"#;
562 std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
563 let session = try_load_codex_chatgpt_session();
564 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
565 assert!(session.is_ok());
566 assert!(
567 session.unwrap().is_none(),
568 "missing auth_mode + present (even empty) API key should be API-key mode, not ChatGPT"
569 );
570 }
571
572 #[test]
573 #[serial]
574 fn codex_no_auth_mode_no_api_key_defaults_to_chatgpt() {
575 let temp = tempfile::tempdir().expect("create temp dir");
577 let prev = std::env::var("CODEX_HOME").ok();
578 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
579 let auth_json = r#"{
580 "tokens": {
581 "id_token": "header.e30.sig",
582 "access_token": "oauth-access",
583 "refresh_token": "oauth-refresh"
584 }
585 }"#;
586 std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
587 let session = try_load_codex_chatgpt_session();
588 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
589 assert!(session.is_ok());
590 assert!(session.unwrap().is_some(), "missing auth_mode + no API key + tokens should default to ChatGPT");
591 }
592
593 #[test]
596 #[serial]
597 fn codex_pat_presence_suppresses_chatgpt_fallback() {
598 let temp = tempfile::tempdir().expect("create temp dir");
602 let prev = std::env::var("CODEX_HOME").ok();
603 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
604 let auth_json = r#"{
605 "personal_access_token": "pat-secret-value",
606 "tokens": {
607 "id_token": "header.e30.sig",
608 "access_token": "oauth-access",
609 "refresh_token": "oauth-refresh"
610 }
611 }"#;
612 std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
613 let session = try_load_codex_chatgpt_session();
614 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
615 assert!(session.is_ok());
616 assert!(session.unwrap().is_none(), "PAT presence (even with tokens) should suppress ChatGPT fallback");
617 }
618
619 #[test]
620 #[serial]
621 fn codex_bedrock_presence_suppresses_chatgpt_fallback() {
622 let temp = tempfile::tempdir().expect("create temp dir");
625 let prev = std::env::var("CODEX_HOME").ok();
626 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
627 let auth_json = r#"{
628 "bedrock_api_key": {"api_key": "bedrock-secret", "region": "us-east-1"},
629 "tokens": {
630 "id_token": "header.e30.sig",
631 "access_token": "oauth-access",
632 "refresh_token": "oauth-refresh"
633 }
634 }"#;
635 std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
636 let session = try_load_codex_chatgpt_session();
637 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
638 assert!(session.is_ok());
639 assert!(session.unwrap().is_none(), "Bedrock presence (even with tokens) should suppress ChatGPT fallback");
640 }
641
642 #[test]
643 #[serial]
644 fn codex_explicit_chatgpt_mode_overrides_pat_presence() {
645 let temp = tempfile::tempdir().expect("create temp dir");
648 let prev = std::env::var("CODEX_HOME").ok();
649 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
650 let auth_json = r#"{
651 "auth_mode": "chatgpt",
652 "personal_access_token": "pat-should-be-ignored",
653 "tokens": {
654 "id_token": "header.e30.sig",
655 "access_token": "oauth-access",
656 "refresh_token": "oauth-refresh"
657 }
658 }"#;
659 std::fs::write(temp.path().join("auth.json"), auth_json).expect("write auth.json");
660 let session = try_load_codex_chatgpt_session();
661 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
662 let session = session.expect("parse ok").expect("session should be present");
663 assert_eq!(session.access_token, "oauth-access");
664 }
665
666 #[test]
667 fn codex_auth_json_debug_never_leaks_token_values() {
668 let auth = CodexAuthDotJson {
670 auth_mode: Some("chatgpt".to_string()),
671 openai_api_key: Some("sk-secret-key".to_string()),
672 tokens: Some(CodexTokenData {
673 id_token: "id-secret".to_string(),
674 access_token: "access-secret".to_string(),
675 refresh_token: "refresh-secret".to_string(),
676 account_id: Some("acc-123".to_string()),
677 }),
678 last_refresh: None,
679 personal_access_token: Some(RedactedPresence { _present: true }),
680 bedrock_api_key: Some(RedactedPresence { _present: true }),
681 };
682 let debug_str = format!("{auth:?}");
683 assert!(!debug_str.contains("sk-secret-key"), "api key leaked: {debug_str}");
684 assert!(!debug_str.contains("id-secret"), "id_token leaked: {debug_str}");
685 assert!(!debug_str.contains("access-secret"), "access_token leaked: {debug_str}");
686 assert!(!debug_str.contains("refresh-secret"), "refresh_token leaked: {debug_str}");
687 assert!(debug_str.contains("chatgpt"), "auth_mode should be visible: {debug_str}");
689 assert!(!debug_str.contains("acc-123"), "account_id inside tokens should be redacted: {debug_str}");
691 }
692
693 fn make_jwt_with_exp(exp: u64) -> String {
696 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
697 let payload = format!(r#"{{"exp":{exp}}}"#);
698 let encoded = URL_SAFE_NO_PAD.encode(payload.as_bytes());
699 format!("header.{encoded}.sig")
700 }
701
702 fn make_jwt_without_exp() -> String {
703 "opaque-access-token-not-a-jwt".to_string()
705 }
706
707 fn write_codex_auth_json(dir: &std::path::Path, json: &str) {
708 std::fs::write(dir.join("auth.json"), json).expect("write auth.json");
709 }
710
711 fn codex_auth_json_with_access_token(access_token: &str) -> String {
712 format!(
713 r#"{{"auth_mode":"chatgpt","tokens":{{"id_token":"header.e30.sig","access_token":"{access_token}","refresh_token":"oauth-refresh"}}}}"#
714 )
715 }
716
717 #[test]
720 #[serial]
721 fn initial_load_returns_ok_none_when_file_absent() {
722 let temp = tempfile::tempdir().expect("create temp dir");
724 let prev = std::env::var("CODEX_HOME").ok();
725 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
726 let result = try_load_codex_chatgpt_session();
727 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
728 assert!(result.is_ok(), "missing file should be Ok(None), not Err");
729 assert!(result.unwrap().is_none());
730 }
731
732 #[test]
733 #[serial]
734 fn initial_load_retries_partial_write_then_succeeds() {
735 let temp = tempfile::tempdir().expect("create temp dir");
739 let prev = std::env::var("CODEX_HOME").ok();
740 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
741 write_codex_auth_json(temp.path(), r#"{"auth_mode":"chatg"#);
743 let path = temp.path().join("auth.json");
745 drop(std::thread::spawn(move || {
746 std::thread::sleep(std::time::Duration::from_millis(15));
747 std::fs::write(&path, codex_auth_json_with_access_token("oauth-access")).expect("complete the file");
748 }));
749 let result = try_load_codex_chatgpt_session();
750 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
751 let session = result.expect("should succeed after retry");
752 assert!(session.is_some(), "retry should absorb the partial write and return the session");
753 assert_eq!(session.unwrap().access_token, "oauth-access");
754 }
755
756 #[test]
757 #[serial]
758 fn initial_load_returns_path_neutral_error_after_retries_exhausted() {
759 let temp = tempfile::tempdir().expect("create temp dir");
762 let prev = std::env::var("CODEX_HOME").ok();
763 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
764 write_codex_auth_json(temp.path(), "this is not json at all {{{{");
765 let result = try_load_codex_chatgpt_session();
766 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
767 let err = result.expect_err("permanently invalid JSON should error");
768 let msg = err.to_string();
769 assert!(msg.contains("failed to parse codex auth.json"), "error should mention parse failure: {msg}");
770 assert!(!msg.contains(temp.path().to_str().unwrap()), "error must not leak absolute path: {msg}");
772 }
773
774 #[tokio::test]
777 #[serial]
778 async fn refresher_rejects_changed_but_expired_access_token() {
779 let temp = tempfile::tempdir().expect("create temp dir");
780 let prev = std::env::var("CODEX_HOME").ok();
781 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
782 let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
784 let current = OpenAIChatGptSession {
785 openai_api_key: String::new(),
786 id_token: String::new(),
787 access_token: expired_jwt.clone(),
788 refresh_token: String::new(),
789 account_id: None,
790 email: None,
791 plan: None,
792 obtained_at: 0,
793 refreshed_at: 0,
794 expires_at: Some(now_secs().saturating_sub(3600)),
795 };
796 let new_expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(1800));
798 write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&new_expired_jwt));
799 let refresher = CodexAuthJsonRefresher;
800 let result = refresher.refresh_session(¤t).await;
801 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
802 let err = result.expect_err("expired replacement should be rejected");
803 assert!(
804 err.to_string().contains("expired replacement access token"),
805 "should mention expired replacement: {err}"
806 );
807 }
808
809 #[tokio::test]
810 #[serial]
811 async fn refresher_accepts_changed_valid_access_token() {
812 let temp = tempfile::tempdir().expect("create temp dir");
813 let prev = std::env::var("CODEX_HOME").ok();
814 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
815 let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
817 let current = OpenAIChatGptSession {
818 openai_api_key: String::new(),
819 id_token: String::new(),
820 access_token: expired_jwt,
821 refresh_token: String::new(),
822 account_id: None,
823 email: None,
824 plan: None,
825 obtained_at: 0,
826 refreshed_at: 0,
827 expires_at: Some(now_secs().saturating_sub(3600)),
828 };
829 let valid_jwt = make_jwt_with_exp(now_secs().saturating_add(3600));
831 write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&valid_jwt));
832 let refresher = CodexAuthJsonRefresher;
833 let result = refresher.refresh_session(¤t).await;
834 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
835 let session = result.expect("valid replacement should be accepted");
836 assert!(!session.access_token.is_empty(), "should return the refreshed session");
837 }
838
839 #[tokio::test]
840 #[serial]
841 async fn refresher_rejects_unchanged_expired_access_token() {
842 let temp = tempfile::tempdir().expect("create temp dir");
843 let prev = std::env::var("CODEX_HOME").ok();
844 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
845 let expired_jwt = make_jwt_with_exp(now_secs().saturating_sub(3600));
846 let current = OpenAIChatGptSession {
847 openai_api_key: String::new(),
848 id_token: String::new(),
849 access_token: expired_jwt.clone(),
850 refresh_token: String::new(),
851 account_id: None,
852 email: None,
853 plan: None,
854 obtained_at: 0,
855 refreshed_at: 0,
856 expires_at: Some(now_secs().saturating_sub(3600)),
857 };
858 write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&expired_jwt));
860 let refresher = CodexAuthJsonRefresher;
861 let result = refresher.refresh_session(¤t).await;
862 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
863 let err = result.expect_err("unchanged expired token should be rejected");
864 assert!(err.to_string().contains("has not been refreshed"), "should mention Codex hasn't refreshed: {err}");
865 }
866
867 #[test]
870 #[serial]
871 fn unknown_expiry_session_is_treated_as_valid() {
872 let temp = tempfile::tempdir().expect("create temp dir");
875 let prev = std::env::var("CODEX_HOME").ok();
876 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
877 let opaque_token = make_jwt_without_exp();
878 write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&opaque_token));
879 let session = try_load_codex_chatgpt_session();
880 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
881 let session = session.expect("parse ok").expect("session present");
882 assert!(session.expires_at.is_none(), "opaque token should have no expiry");
883 assert!(!is_session_expired(&session), "unknown expiry should be treated as valid (not expired)");
884 }
885
886 #[tokio::test]
887 #[serial]
888 async fn refresher_accepts_unknown_expiry_token() {
889 let temp = tempfile::tempdir().expect("create temp dir");
892 let prev = std::env::var("CODEX_HOME").ok();
893 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
894 let opaque_token = make_jwt_without_exp();
895 write_codex_auth_json(temp.path(), &codex_auth_json_with_access_token(&opaque_token));
896 let current = OpenAIChatGptSession {
897 openai_api_key: String::new(),
898 id_token: String::new(),
899 access_token: "previous-opaque".to_string(),
900 refresh_token: String::new(),
901 account_id: None,
902 email: None,
903 plan: None,
904 obtained_at: 0,
905 refreshed_at: 0,
906 expires_at: None,
907 };
908 let refresher = CodexAuthJsonRefresher;
909 let result = refresher.refresh_session(¤t).await;
910 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
911 let session = result.expect("unknown-expiry token should be accepted");
912 assert_eq!(session.access_token, opaque_token);
913 }
914
915 #[tokio::test]
918 #[serial]
919 async fn refresher_uses_single_attempt_primitive() {
920 let temp = tempfile::tempdir().expect("create temp dir");
926 let prev = std::env::var("CODEX_HOME").ok();
927 vtcode_commons::env_lock::set_var("CODEX_HOME", temp.path());
928 write_codex_auth_json(temp.path(), "invalid json {{{{");
929 let current = OpenAIChatGptSession {
930 openai_api_key: String::new(),
931 id_token: String::new(),
932 access_token: "old".to_string(),
933 refresh_token: String::new(),
934 account_id: None,
935 email: None,
936 plan: None,
937 obtained_at: 0,
938 refreshed_at: 0,
939 expires_at: None,
940 };
941 let refresher = CodexAuthJsonRefresher;
942 let start = std::time::Instant::now();
943 let result = refresher.refresh_session(¤t).await;
944 let elapsed = start.elapsed();
945 vtcode_commons::env_lock::lock().restore_var("CODEX_HOME", prev.as_deref());
946 assert!(result.is_err(), "invalid file should error");
947 assert!(
952 elapsed < std::time::Duration::from_millis(200),
953 "refresher should not multiply retry delays (took {elapsed:?})"
954 );
955 }
956}