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