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::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, ×tamp_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}