Skip to main content

codex_cli/auth/
auto_refresh.rs

1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use std::path::{Path, PathBuf};
4
5use crate::auth;
6use crate::fs;
7use crate::paths;
8
9pub fn run() -> Result<i32> {
10    if !is_configured() {
11        return Ok(0);
12    }
13
14    let min_days_raw =
15        std::env::var("CODEX_AUTO_REFRESH_MIN_DAYS").unwrap_or_else(|_| "5".to_string());
16    let min_days = match min_days_raw.parse::<i64>() {
17        Ok(value) => value,
18        Err(_) => {
19            eprintln!(
20                "codex-auto-refresh: invalid CODEX_AUTO_REFRESH_MIN_DAYS: {}",
21                min_days_raw
22            );
23            return Ok(64);
24        }
25    };
26
27    let min_seconds = min_days.saturating_mul(86_400);
28    let now_epoch = Utc::now().timestamp();
29
30    let auth_file = paths::resolve_auth_file();
31    if auth_file.is_some() {
32        let sync_rc = auth::sync::run()?;
33        if sync_rc != 0 {
34            return Ok(1);
35        }
36    }
37
38    let mut targets = Vec::new();
39    if let Some(auth_file) = auth_file.as_ref() {
40        targets.push(auth_file.clone());
41    }
42    if let Some(secret_dir) = paths::resolve_secret_dir()
43        && let Ok(entries) = std::fs::read_dir(&secret_dir)
44    {
45        for entry in entries.flatten() {
46            let path = entry.path();
47            if path.extension().and_then(|s| s.to_str()) == Some("json") {
48                targets.push(path);
49            }
50        }
51    }
52
53    let mut refreshed = 0;
54    let mut skipped = 0;
55    let mut failed = 0;
56
57    for target in targets {
58        if !target.is_file() {
59            if auth_file.as_ref().map(|p| p == &target).unwrap_or(false) {
60                skipped += 1;
61                continue;
62            }
63            eprintln!("codex-auto-refresh: missing file: {}", target.display());
64            failed += 1;
65            continue;
66        }
67
68        let timestamp_path = timestamp_path(&target)?;
69        match should_refresh(&target, &timestamp_path, now_epoch, min_seconds) {
70            RefreshDecision::Refresh => {
71                let rc = if auth_file.as_ref().map(|p| p == &target).unwrap_or(false) {
72                    auth::refresh::run(&[])?
73                } else {
74                    let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("");
75                    auth::refresh::run(&[name.to_string()])?
76                };
77                if rc == 0 {
78                    refreshed += 1;
79                } else {
80                    failed += 1;
81                }
82            }
83            RefreshDecision::Skip => {
84                skipped += 1;
85            }
86            RefreshDecision::WarnFuture => {
87                eprintln!(
88                    "codex-auto-refresh: warning: future timestamp for {}",
89                    target.display()
90                );
91                skipped += 1;
92            }
93        }
94    }
95
96    println!(
97        "codex-auto-refresh: refreshed={} skipped={} failed={} (min_age_days={})",
98        refreshed, skipped, failed, min_days
99    );
100
101    if failed > 0 {
102        return Ok(1);
103    }
104
105    Ok(0)
106}
107
108fn is_configured() -> bool {
109    let mut candidates = Vec::new();
110    if let Some(auth_file) = paths::resolve_auth_file() {
111        candidates.push(auth_file);
112    }
113    if let Some(secret_dir) = paths::resolve_secret_dir()
114        && let Ok(entries) = std::fs::read_dir(&secret_dir)
115    {
116        for entry in entries.flatten() {
117            let path = entry.path();
118            if path.extension().and_then(|s| s.to_str()) == Some("json") {
119                candidates.push(path);
120            }
121        }
122    }
123
124    candidates.iter().any(|path| path.is_file())
125}
126
127enum RefreshDecision {
128    Refresh,
129    Skip,
130    WarnFuture,
131}
132
133fn should_refresh(
134    target: &Path,
135    timestamp_path: &Path,
136    now_epoch: i64,
137    min_seconds: i64,
138) -> RefreshDecision {
139    if let Some(last_epoch) = last_refresh_epoch(target, timestamp_path) {
140        let age = now_epoch - last_epoch;
141        if age < 0 {
142            return RefreshDecision::WarnFuture;
143        }
144        if age >= min_seconds {
145            RefreshDecision::Refresh
146        } else {
147            RefreshDecision::Skip
148        }
149    } else {
150        RefreshDecision::Refresh
151    }
152}
153
154fn last_refresh_epoch(target: &Path, timestamp_path: &Path) -> Option<i64> {
155    if let Ok(content) = std::fs::read_to_string(timestamp_path) {
156        let iso = normalize_iso(&content);
157        if let Some(epoch) = iso_to_epoch(&iso) {
158            return Some(epoch);
159        }
160    }
161
162    let iso = auth::last_refresh_from_auth_file(target).ok().flatten()?;
163    let iso = normalize_iso(&iso);
164    let epoch = iso_to_epoch(&iso)?;
165    let _ = fs::write_timestamp(timestamp_path, Some(&iso));
166    Some(epoch)
167}
168
169fn normalize_iso(raw: &str) -> String {
170    let mut trimmed = raw
171        .split(&['\n', '\r'][..])
172        .next()
173        .unwrap_or("")
174        .to_string();
175    if let Some(dot) = trimmed.find('.')
176        && trimmed.ends_with('Z')
177    {
178        trimmed.truncate(dot);
179        trimmed.push('Z');
180    }
181    trimmed
182}
183
184fn iso_to_epoch(iso: &str) -> Option<i64> {
185    DateTime::parse_from_rfc3339(iso)
186        .ok()
187        .map(|dt| dt.timestamp())
188}
189
190fn timestamp_path(target: &Path) -> Result<PathBuf> {
191    let cache_dir = paths::resolve_secret_cache_dir()
192        .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
193    let name = target
194        .file_name()
195        .and_then(|name| name.to_str())
196        .unwrap_or("auth.json");
197    Ok(cache_dir.join(format!("{name}.timestamp")))
198}