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::auth::output::{self, AuthAutoRefreshResult, AuthAutoRefreshTargetResult};
7use crate::fs;
8use crate::paths;
9
10pub fn run() -> Result<i32> {
11    run_with_json(false)
12}
13
14pub fn run_with_json(output_json: bool) -> Result<i32> {
15    if !is_configured() {
16        if output_json {
17            output::emit_result(
18                "auth auto-refresh",
19                AuthAutoRefreshResult {
20                    refreshed: 0,
21                    skipped: 0,
22                    failed: 0,
23                    min_age_days: 0,
24                    targets: Vec::new(),
25                },
26            )?;
27        }
28        return Ok(0);
29    }
30
31    let min_days_raw =
32        std::env::var("CODEX_AUTO_REFRESH_MIN_DAYS").unwrap_or_else(|_| "5".to_string());
33    let min_days = match min_days_raw.parse::<i64>() {
34        Ok(value) => value,
35        Err(_) => {
36            if output_json {
37                output::emit_error(
38                    "auth auto-refresh",
39                    "invalid-min-days",
40                    format!(
41                        "codex-auto-refresh: invalid CODEX_AUTO_REFRESH_MIN_DAYS: {}",
42                        min_days_raw
43                    ),
44                    Some(serde_json::json!({
45                        "value": min_days_raw,
46                    })),
47                )?;
48            } else {
49                eprintln!(
50                    "codex-auto-refresh: invalid CODEX_AUTO_REFRESH_MIN_DAYS: {}",
51                    min_days_raw
52                );
53            }
54            return Ok(64);
55        }
56    };
57
58    let min_seconds = min_days.saturating_mul(86_400);
59    let now_epoch = Utc::now().timestamp();
60
61    let auth_file = paths::resolve_auth_file();
62    if auth_file.is_some() {
63        let sync_rc = auth::sync::run_with_json(false)?;
64        if sync_rc != 0 {
65            if output_json {
66                output::emit_error(
67                    "auth auto-refresh",
68                    "sync-failed",
69                    "codex-auto-refresh: failed to sync auth and secrets before refresh",
70                    None,
71                )?;
72            }
73            return Ok(1);
74        }
75    }
76
77    let mut targets = Vec::new();
78    if let Some(auth_file) = auth_file.as_ref() {
79        targets.push(auth_file.clone());
80    }
81    if let Some(secret_dir) = paths::resolve_secret_dir()
82        && let Ok(entries) = std::fs::read_dir(&secret_dir)
83    {
84        for entry in entries.flatten() {
85            let path = entry.path();
86            if path.extension().and_then(|s| s.to_str()) == Some("json") {
87                targets.push(path);
88            }
89        }
90    }
91
92    let mut refreshed: i64 = 0;
93    let mut skipped: i64 = 0;
94    let mut failed: i64 = 0;
95    let mut target_results: Vec<AuthAutoRefreshTargetResult> = Vec::new();
96
97    for target in targets {
98        if !target.is_file() {
99            if auth_file.as_ref().map(|p| p == &target).unwrap_or(false) {
100                skipped += 1;
101                target_results.push(AuthAutoRefreshTargetResult {
102                    target_file: target.display().to_string(),
103                    status: "skipped".to_string(),
104                    reason: Some("auth-file-missing".to_string()),
105                });
106                continue;
107            }
108            if !output_json {
109                eprintln!("codex-auto-refresh: missing file: {}", target.display());
110            }
111            failed += 1;
112            target_results.push(AuthAutoRefreshTargetResult {
113                target_file: target.display().to_string(),
114                status: "failed".to_string(),
115                reason: Some("missing-file".to_string()),
116            });
117            continue;
118        }
119
120        let timestamp_path = timestamp_path(&target)?;
121        match should_refresh(&target, &timestamp_path, now_epoch, min_seconds) {
122            RefreshDecision::Refresh => {
123                let rc = if auth_file.as_ref().map(|p| p == &target).unwrap_or(false) {
124                    if output_json {
125                        auth::refresh::run_silent(&[])?
126                    } else {
127                        auth::refresh::run(&[])?
128                    }
129                } else {
130                    let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("");
131                    if output_json {
132                        auth::refresh::run_silent(&[name.to_string()])?
133                    } else {
134                        auth::refresh::run(&[name.to_string()])?
135                    }
136                };
137                if rc == 0 {
138                    refreshed += 1;
139                    target_results.push(AuthAutoRefreshTargetResult {
140                        target_file: target.display().to_string(),
141                        status: "refreshed".to_string(),
142                        reason: None,
143                    });
144                } else {
145                    failed += 1;
146                    target_results.push(AuthAutoRefreshTargetResult {
147                        target_file: target.display().to_string(),
148                        status: "failed".to_string(),
149                        reason: Some(format!("refresh-exit-{rc}")),
150                    });
151                }
152            }
153            RefreshDecision::Skip => {
154                skipped += 1;
155                target_results.push(AuthAutoRefreshTargetResult {
156                    target_file: target.display().to_string(),
157                    status: "skipped".to_string(),
158                    reason: Some("not-due".to_string()),
159                });
160            }
161            RefreshDecision::WarnFuture => {
162                if !output_json {
163                    eprintln!(
164                        "codex-auto-refresh: warning: future timestamp for {}",
165                        target.display()
166                    );
167                }
168                skipped += 1;
169                target_results.push(AuthAutoRefreshTargetResult {
170                    target_file: target.display().to_string(),
171                    status: "skipped".to_string(),
172                    reason: Some("future-timestamp".to_string()),
173                });
174            }
175        }
176    }
177
178    if output_json {
179        output::emit_result(
180            "auth auto-refresh",
181            AuthAutoRefreshResult {
182                refreshed,
183                skipped,
184                failed,
185                min_age_days: min_days,
186                targets: target_results,
187            },
188        )?;
189    } else {
190        println!(
191            "codex-auto-refresh: refreshed={} skipped={} failed={} (min_age_days={})",
192            refreshed, skipped, failed, min_days
193        );
194    }
195
196    if failed > 0 {
197        return Ok(1);
198    }
199
200    Ok(0)
201}
202
203fn is_configured() -> bool {
204    let mut candidates = Vec::new();
205    if let Some(auth_file) = paths::resolve_auth_file() {
206        candidates.push(auth_file);
207    }
208    if let Some(secret_dir) = paths::resolve_secret_dir()
209        && let Ok(entries) = std::fs::read_dir(&secret_dir)
210    {
211        for entry in entries.flatten() {
212            let path = entry.path();
213            if path.extension().and_then(|s| s.to_str()) == Some("json") {
214                candidates.push(path);
215            }
216        }
217    }
218
219    candidates.iter().any(|path| path.is_file())
220}
221
222enum RefreshDecision {
223    Refresh,
224    Skip,
225    WarnFuture,
226}
227
228fn should_refresh(
229    target: &Path,
230    timestamp_path: &Path,
231    now_epoch: i64,
232    min_seconds: i64,
233) -> RefreshDecision {
234    if let Some(last_epoch) = last_refresh_epoch(target, timestamp_path) {
235        let age = now_epoch - last_epoch;
236        if age < 0 {
237            return RefreshDecision::WarnFuture;
238        }
239        if age >= min_seconds {
240            RefreshDecision::Refresh
241        } else {
242            RefreshDecision::Skip
243        }
244    } else {
245        RefreshDecision::Refresh
246    }
247}
248
249fn last_refresh_epoch(target: &Path, timestamp_path: &Path) -> Option<i64> {
250    if let Ok(content) = std::fs::read_to_string(timestamp_path) {
251        let iso = normalize_iso(&content);
252        if let Some(epoch) = iso_to_epoch(&iso) {
253            return Some(epoch);
254        }
255    }
256
257    let iso = auth::last_refresh_from_auth_file(target).ok().flatten()?;
258    let iso = normalize_iso(&iso);
259    let epoch = iso_to_epoch(&iso)?;
260    let _ = fs::write_timestamp(timestamp_path, Some(&iso));
261    Some(epoch)
262}
263
264fn normalize_iso(raw: &str) -> String {
265    let mut trimmed = raw
266        .split(&['\n', '\r'][..])
267        .next()
268        .unwrap_or("")
269        .to_string();
270    if let Some(dot) = trimmed.find('.')
271        && trimmed.ends_with('Z')
272    {
273        trimmed.truncate(dot);
274        trimmed.push('Z');
275    }
276    trimmed
277}
278
279fn iso_to_epoch(iso: &str) -> Option<i64> {
280    DateTime::parse_from_rfc3339(iso)
281        .ok()
282        .map(|dt| dt.timestamp())
283}
284
285fn timestamp_path(target: &Path) -> Result<PathBuf> {
286    let cache_dir = paths::resolve_secret_cache_dir()
287        .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
288    let name = target
289        .file_name()
290        .and_then(|name| name.to_str())
291        .unwrap_or("auth.json");
292    Ok(cache_dir.join(format!("{name}.timestamp")))
293}