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