1use super::{journal, purge, try_exclusive};
6use crate::context::Context;
7use crate::error::{Error, Result};
8use crate::state::{Key, Park, State};
9use crate::{park, state};
10use serde_json::Value;
11
12const AHEAD_SECONDS: i64 = 120;
14
15pub(crate) const DEFAULT_SCOPES: [&str; 6] = [
17 "user:profile",
18 "user:inference",
19 "user:sessions:claude_code",
20 "user:mcp_servers",
21 "user:file_upload",
22 "user:plugins",
23];
24
25#[derive(Debug)]
26#[non_exhaustive]
27pub enum Renewal {
28 Renewed,
29 Refused,
31 Deferred,
33 Failed(Error),
34}
35
36impl Renewal {
37 pub fn code(&self) -> &'static str {
38 match self {
39 Renewal::Renewed => "renewed",
40 Renewal::Refused => "parked_login_refused",
41 Renewal::Deferred => "renewal_deferred",
42 Renewal::Failed(e) => e.code(),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Due {
50 ToBeAsked,
54 ToStayAlive,
57}
58
59impl Due {
60 fn covers(self, held: &Park, now: i64) -> bool {
61 if !held.restorable_at(now) {
62 return false;
63 }
64 let access_lapsed = !held.askable_at(now + AHEAD_SECONDS);
65 match self {
66 Due::ToBeAsked => access_lapsed,
67 Due::ToStayAlive => {
68 access_lapsed
69 || held
70 .refresh_expires_at
71 .is_some_and(|at| at - now < crate::doctor::RENEW_WITHIN)
72 }
73 }
74 }
75}
76
77pub fn renew_parked(ctx: &Context) -> Vec<(Key, Renewal)> {
81 renew_due(ctx, Due::ToBeAsked)
82}
83
84pub fn renew_due(ctx: &Context, due: Due) -> Vec<(Key, Renewal)> {
86 let Some(_exclusive) = try_exclusive(ctx) else {
87 return Vec::new();
88 };
89 if journal::pending(ctx) {
90 return Vec::new();
91 }
92 let Ok(mut state) = state::load(ctx) else {
93 return Vec::new();
94 };
95 let now = ctx.now();
96 let covered = |state: &State| -> Vec<(Key, Park)> {
97 state
98 .accounts
99 .iter()
100 .filter_map(|a| {
101 let held = a.parked.as_ref()?;
102 due.covers(held, now).then(|| (a.key(), held.clone()))
103 })
104 .collect()
105 };
106 let mut to_renew = covered(&state);
107 if !to_renew.is_empty() {
112 let _ = super::drop_live_twins(ctx, &mut state);
113 to_renew = covered(&state);
114 }
115 let asked: Vec<(Key, Park, Result<Asked>)> = std::thread::scope(|scope| {
119 let handles: Vec<_> = to_renew
120 .into_iter()
121 .map(|(key, held)| {
122 let ctx = &*ctx;
123 let handle = scope.spawn({
124 let key = key.clone();
125 let held = held.clone();
126 move || ask(ctx, &key, &held)
127 });
128 (key, held, handle)
129 })
130 .collect();
131 handles
132 .into_iter()
133 .map(|(key, held, handle)| {
134 let answer = handle.join().unwrap_or_else(|_| {
135 Err(Error::RenewalFailed {
136 label: key.typed(),
137 cause: None,
138 detail: "the renewal thread stopped".into(),
139 })
140 });
141 (key, held, answer)
142 })
143 .collect()
144 });
145 let outcomes = asked
146 .into_iter()
147 .map(|(key, held, answer)| {
148 let outcome =
149 apply(ctx, &mut state, &key, &held, answer).unwrap_or_else(Renewal::Failed);
150 (key, outcome)
151 })
152 .collect();
153 purge(ctx, &mut state);
154 outcomes
155}
156
157struct Asked {
159 renewed: Option<Value>,
162 refused: bool,
164}
165
166fn ask(ctx: &Context, key: &Key, held: &Park) -> Result<Asked> {
169 let document = park::load(ctx, key, held)?;
170 park::price(ctx, key.provider, &key.typed(), &held.service, &document)?;
175 let tool = crate::provider::of(key.provider);
176 let credential = crate::provider::Credential::new(key.provider, document);
177 match tool.renew(ctx, &credential) {
178 Ok(fresh) => Ok(Asked {
179 renewed: Some(fresh.raw),
180 refused: false,
181 }),
182 Err(crate::provider::ProviderError::InvalidGrant { .. }) => Ok(Asked {
183 renewed: None,
184 refused: true,
185 }),
186 Err(
188 crate::provider::ProviderError::Network { .. }
189 | crate::provider::ProviderError::RateLimited { .. },
190 ) => Ok(Asked {
191 renewed: None,
192 refused: false,
193 }),
194 Err(e) => Err(Error::RenewalFailed {
195 label: key.typed(),
196 cause: Some(crate::error::Cause::of_provider(&e)),
197 detail: e.to_string(),
198 }),
199 }
200}
201
202pub(super) fn renew_one(
206 ctx: &Context,
207 state: &mut State,
208 key: &Key,
209 held: &Park,
210) -> Result<Option<Park>> {
211 match apply(ctx, state, key, held, ask(ctx, key, held))? {
212 Renewal::Renewed => Ok(state.get(key).and_then(|a| a.parked.clone())),
213 Renewal::Refused => Err(Error::ParkedLoginRefused {
214 tool: key.provider,
215 label: state.typed(key),
216 }),
217 Renewal::Deferred => Ok(None),
218 Renewal::Failed(e) => Err(e),
219 }
220}
221
222fn apply(
224 ctx: &Context,
225 state: &mut State,
226 key: &Key,
227 held: &Park,
228 asked: Result<Asked>,
229) -> Result<Renewal> {
230 let asked = asked?;
231 if asked.refused {
232 state.release(&held.service);
235 state::save(ctx, state)?;
236 return Ok(Renewal::Refused);
237 }
238 let Some(next) = asked.renewed else {
239 return Ok(Renewal::Deferred);
240 };
241 if state.is_foreign(&held.service) {
248 state.used_here(&held.service);
249 let _ = state::save(ctx, state);
250 }
251
252 let uuid = state
255 .get(key)
256 .map(|a| a.account_uuid.clone())
257 .unwrap_or_default();
258 let store = || {
259 park::reserve(ctx, &uuid)
260 .and_then(|service| park::store_at(ctx, key.provider, &service, &next))
261 };
262 let parked = match store().or_else(|_| store()) {
263 Ok(parked) => parked,
264 Err(e) => {
265 state.discard(&held.service);
269 let _ = state::save(ctx, state);
270 return Err(Error::RenewalFailed {
271 label: state.typed(key),
272 cause: None,
274 detail: e.to_string(),
275 });
276 }
277 };
278 crate::fault::point("renew.park_stored");
279 state.discard(&held.service);
282 state.park(key, parked.clone());
283 state::save(ctx, state)?;
288 Ok(Renewal::Renewed)
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::api::Renewed;
295 use crate::api::scripted::{Asked as Question, ScriptedApi, Trouble};
296 use crate::state::Account;
297 use crate::store::memory::{Fault, MemoryHost};
298 use crate::time::FixedClock;
299 use serde_json::json;
300 use std::sync::Arc;
301
302 const NOW: i64 = 1_760_000_000;
303
304 struct Machine {
305 ctx: Context,
306 mem: Arc<MemoryHost>,
307 api: Arc<ScriptedApi>,
308 home: std::path::PathBuf,
309 }
310
311 impl Drop for Machine {
312 fn drop(&mut self) {
313 let _ = std::fs::remove_dir_all(&self.home);
314 }
315 }
316
317 fn machine(name: &str) -> Machine {
319 let home = std::env::temp_dir().join(format!(
320 "pitboard-renew-{name}-{}-{:?}",
321 std::process::id(),
322 std::thread::current().id()
323 ));
324 let _ = std::fs::remove_dir_all(&home);
325 std::fs::create_dir_all(&home).expect("a scratch home");
326 let mem = MemoryHost::new();
327 let api = ScriptedApi::new();
328 let ctx = Context::new(home.clone())
329 .with_pitboard_home(home.clone())
330 .with_memory_stores(Arc::clone(&mem))
331 .with_scripted_api(Arc::clone(&api))
332 .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn crate::time::Clock>);
333 Machine {
334 ctx,
335 mem,
336 api,
337 home,
338 }
339 }
340
341 fn oauth(refresh: &str, access_expires_at: i64) -> Value {
342 json!({
343 "refreshToken": refresh,
344 "accessToken": "a",
345 "expiresAt": access_expires_at * 1000,
346 "refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
347 })
348 }
349
350 fn with_park(m: &Machine, label: &str, refresh: &str, access_expires_at: i64) -> Park {
352 let service = park::reserve(&m.ctx, "acc").expect("a free name");
353 let park = park::store_at(
354 &m.ctx,
355 crate::provider::ProviderId::Claude,
356 &service,
357 &oauth(refresh, access_expires_at),
358 )
359 .expect("parked");
360 let mut state = State::default();
361 state.accounts.push(Account {
362 last_used_at: None,
363 label: label.into(),
364 account_uuid: "acc".into(),
365 email: "me@example.com".into(),
366 detail: state::Detail::Claude {
367 organization_uuid: "org".into(),
368 oauth_account: json!({}),
369 },
370 parked: Some(park.clone()),
371 });
372 state::save(&m.ctx, &state).expect("saved");
373 park
374 }
375
376 fn fresh(refresh: &str) -> Renewed {
377 Renewed {
378 access_token: "new-access".into(),
379 refresh_token: Some(refresh.into()),
380 expires_in: 3600,
381 refresh_token_expires_in: Some(30 * 86_400),
382 scopes: None,
383 at: None,
384 }
385 }
386
387 fn outcome(outcomes: &[(Key, Renewal)], label: &str) -> String {
388 outcomes
389 .iter()
390 .find(|(key, _)| key.label == label)
391 .map(|(_, r)| r.code().to_string())
392 .unwrap_or_else(|| "not attempted".into())
393 }
394
395 #[test]
399 fn a_renewed_expiry_is_measured_from_anthropics_clock_not_this_machines() {
400 let m = machine("anchored");
401 with_park(&m, "work", "old", NOW - 1);
402 let server_now = NOW - 7200;
404 m.api.renews(
405 "old",
406 Renewed {
407 access_token: "new-access".into(),
408 refresh_token: Some("new".into()),
409 expires_in: 3600,
410 refresh_token_expires_in: Some(30 * 86_400),
411 scopes: None,
412 at: Some(server_now),
413 },
414 );
415
416 renew_parked(&m.ctx);
417
418 let park = state::load(&m.ctx)
419 .expect("state")
420 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
421 .expect("account")
422 .parked
423 .clone()
424 .expect("renewed");
425 assert_eq!(
426 park.access_expires_at,
427 Some(server_now + 3600),
428 "an hour after the answer, not an hour after this machine's idea of now"
429 );
430 assert_eq!(park.refresh_expires_at, Some(server_now + 30 * 86_400));
431 }
432
433 #[test]
436 fn an_answer_with_no_clock_of_its_own_falls_back_to_this_machines() {
437 let m = machine("unanchored");
438 with_park(&m, "work", "old", NOW - 1);
439 m.api.renews("old", fresh("new"));
440
441 renew_parked(&m.ctx);
442
443 let park = state::load(&m.ctx)
444 .expect("state")
445 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
446 .expect("account")
447 .parked
448 .clone()
449 .expect("renewed");
450 assert_eq!(park.access_expires_at, Some(NOW + 3600));
451 }
452
453 #[test]
456 fn a_park_that_is_not_due_is_not_asked_about() {
457 let m = machine("not-due");
458 with_park(&m, "work", "r", NOW + 3600);
459
460 let outcomes = renew_parked(&m.ctx);
461
462 assert!(outcomes.is_empty());
463 assert_eq!(m.api.calls(), 0, "nothing was due, so nothing was asked");
464 }
465
466 #[test]
467 fn a_due_park_is_renewed_and_the_spent_copy_is_dropped() {
468 let m = machine("renewed");
469 let before = with_park(&m, "work", "old", NOW - 1);
470 m.api.renews("old", fresh("new"));
471
472 let outcomes = renew_parked(&m.ctx);
473
474 assert_eq!(outcome(&outcomes, "work"), "renewed");
475 assert_eq!(m.api.asked(), vec![Question::Renew("old".into())]);
476 let state = state::load(&m.ctx).expect("state");
477 let now = state
478 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
479 .expect("account")
480 .parked
481 .clone()
482 .expect("park");
483 assert_ne!(now.service, before.service, "a renewal takes a new name");
484 assert_eq!(
485 m.mem.vault().services(),
486 vec![now.service.clone()],
487 "the spent copy is deleted, not left behind"
488 );
489 }
490
491 #[test]
494 fn a_login_anthropic_no_longer_accepts_is_dropped() {
495 let m = machine("refused");
496 with_park(&m, "work", "old", NOW - 1);
497 m.api.renew_trouble("old", Trouble::InvalidGrant);
498
499 let outcomes = renew_parked(&m.ctx);
500
501 assert_eq!(outcome(&outcomes, "work"), "parked_login_refused");
502 let state = state::load(&m.ctx).expect("state");
503 assert!(
504 state
505 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
506 .expect("account")
507 .parked
508 .is_none()
509 );
510 assert!(m.mem.vault().services().is_empty());
511 }
512
513 #[test]
516 fn a_renewal_that_could_not_happen_leaves_the_park_alone() {
517 for trouble in [Trouble::Offline, Trouble::RateLimited] {
518 let m = machine(&format!("deferred-{trouble:?}"));
519 let before = with_park(&m, "work", "old", NOW - 1);
520 m.api.renew_trouble("old", trouble);
521
522 let outcomes = renew_parked(&m.ctx);
523
524 assert_eq!(outcome(&outcomes, "work"), "renewal_deferred");
525 let state = state::load(&m.ctx).expect("state");
526 assert_eq!(
527 state
528 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
529 .expect("account")
530 .parked,
531 Some(before.clone()),
532 "{trouble:?} must not spend or drop anything"
533 );
534 assert_eq!(m.mem.vault().services(), vec![before.service.clone()]);
535 }
536 }
537
538 #[test]
543 fn a_park_that_could_not_be_written_back_is_not_renewed() {
544 let mut m = machine("no-argv");
545 m.ctx = m.ctx.clone().with_argv_fallback(false);
546 let before = with_park(&m, "work", "old", NOW - 1);
547 m.mem.vault().takes_on_stdin(16);
548 m.api.renews("old", fresh("new"));
549
550 let outcomes = renew_parked(&m.ctx);
551
552 assert_eq!(outcome(&outcomes, "work"), "credential_too_large");
553 assert_eq!(
554 m.api.calls(),
555 0,
556 "the service was not asked, so nothing was spent"
557 );
558 let state = state::load(&m.ctx).expect("state");
559 assert_eq!(
560 state
561 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
562 .expect("account")
563 .parked,
564 Some(before.clone())
565 );
566 assert_eq!(m.mem.vault().services(), vec![before.service.clone()]);
567 }
568
569 #[test]
571 fn a_park_past_the_ceiling_is_renewed_where_the_argument_line_is_allowed() {
572 let m = machine("argv");
573 with_park(&m, "work", "old", NOW - 1);
574 m.mem.vault().takes_on_stdin(16);
575 m.api.renews("old", fresh("new"));
576
577 let outcomes = renew_parked(&m.ctx);
578
579 assert_eq!(outcome(&outcomes, "work"), "renewed");
580 }
581
582 #[test]
587 fn a_renewal_whose_answer_cannot_be_stored_drops_the_spent_park() {
588 let m = machine("write-lost");
589 let before = with_park(&m, "work", "old", NOW - 1);
590 m.api.renews("old", fresh("new"));
591 m.mem
592 .vault()
593 .fault_all(Fault::FailWrite("the keychain refused".into()));
594
595 let outcomes = renew_parked(&m.ctx);
596
597 assert_eq!(outcome(&outcomes, "work"), "renewal_failed");
598 let state = state::load(&m.ctx).expect("state");
599 assert!(
600 state
601 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
602 .expect("account")
603 .parked
604 .is_none(),
605 "a park whose refresh token Anthropic has spent must not stay on offer"
606 );
607 assert!(
608 !m.mem.vault().services().contains(&before.service),
609 "the dead copy is deleted in the same run, not left behind unnamed"
610 );
611 assert!(
612 state.discarded.is_empty(),
613 "and nothing is left listed for a later run to retry"
614 );
615 }
616}