codex_cli/auth/
auto_refresh.rs1use 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, ×tamp_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}