1use super::{journal, purge, try_exclusive};
6use crate::context::Context;
7use crate::error::{Error, Result};
8use crate::service::Permit;
9use crate::state::{Key, Park, State};
10use crate::{park, state};
11use serde_json::Value;
12
13const AHEAD_SECONDS: i64 = 120;
15
16pub(crate) const DEFAULT_SCOPES: [&str; 6] = [
18 "user:profile",
19 "user:inference",
20 "user:sessions:claude_code",
21 "user:mcp_servers",
22 "user:file_upload",
23 "user:plugins",
24];
25
26#[derive(Debug)]
27#[non_exhaustive]
28pub enum Renewal {
29 Renewed,
30 Refused,
32 Deferred,
34 Failed(Error),
35}
36
37impl Renewal {
38 pub fn code(&self) -> &'static str {
39 match self {
40 Renewal::Renewed => "renewed",
41 Renewal::Refused => "parked_login_refused",
42 Renewal::Deferred => "renewal_deferred",
43 Renewal::Failed(e) => e.code(),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Due {
51 ToBeAsked,
55 ToStayAlive,
58}
59
60impl Due {
61 fn covers(self, held: &Park, now: i64) -> bool {
62 if !held.restorable_at(now) {
63 return false;
64 }
65 let access_lapsed = !held.askable_at(now + AHEAD_SECONDS);
66 match self {
67 Due::ToBeAsked => access_lapsed,
68 Due::ToStayAlive => {
69 access_lapsed
70 || held
71 .refresh_expires_at
72 .is_some_and(|at| crate::doctor::renewal_due(at, now))
73 }
74 }
75 }
76}
77
78pub fn renew_parked(ctx: &Context, permit: Permit) -> Vec<(Key, Renewal)> {
82 renew_due(ctx, permit, Due::ToBeAsked)
83}
84
85pub fn renew_due(ctx: &Context, permit: Permit, due: Due) -> Vec<(Key, Renewal)> {
87 let Some(_exclusive) = try_exclusive(ctx, permit) else {
88 return Vec::new();
89 };
90 if journal::pending(ctx) {
91 return Vec::new();
92 }
93 let Ok(mut state) = state::load(ctx) else {
94 return Vec::new();
95 };
96 let now = ctx.now();
97 let covered = |state: &State| -> Vec<(Key, Park)> {
98 state
99 .accounts
100 .iter()
101 .filter_map(|a| {
102 let held = a.parked.as_ref()?;
103 due.covers(held, now).then(|| (a.key(), held.clone()))
104 })
105 .collect()
106 };
107 let mut to_renew = covered(&state);
108 if !to_renew.is_empty() {
113 let _ = super::drop_live_twins(ctx, permit, &mut state);
114 to_renew = covered(&state);
115 }
116 let asked: Vec<(Key, Park, Result<Asked>)> = std::thread::scope(|scope| {
120 let handles: Vec<_> = to_renew
121 .into_iter()
122 .map(|(key, held)| {
123 let ctx = &*ctx;
124 let handle = scope.spawn({
125 let key = key.clone();
126 let held = held.clone();
127 move || ask(ctx, permit, &key, &held)
128 });
129 (key, held, handle)
130 })
131 .collect();
132 handles
133 .into_iter()
134 .map(|(key, held, handle)| {
135 let answer = handle.join().unwrap_or_else(|_| {
136 Err(Error::RenewalFailed {
137 label: key.typed(),
138 cause: None,
139 detail: "the renewal thread stopped".into(),
140 })
141 });
142 (key, held, answer)
143 })
144 .collect()
145 });
146 let outcomes = asked
147 .into_iter()
148 .map(|(key, held, answer)| {
149 let outcome =
150 apply(ctx, permit, &mut state, &key, &held, answer).unwrap_or_else(Renewal::Failed);
151 (key, outcome)
152 })
153 .collect();
154 purge(ctx, permit, &mut state);
155 outcomes
156}
157
158struct Asked {
160 renewed: Option<Value>,
163 refused: bool,
165}
166
167fn ask(ctx: &Context, permit: Permit, key: &Key, held: &Park) -> Result<Asked> {
170 let document = park::load(ctx, key, held)?;
171 park::price(ctx, key.provider, &key.typed(), &held.service, &document)?;
176 let tool = crate::provider::of(key.provider);
177 let credential = crate::provider::Credential::new(key.provider, document);
178 match tool.renew(ctx, permit, &credential) {
179 Ok(fresh) => Ok(Asked {
180 renewed: Some(fresh.raw),
181 refused: false,
182 }),
183 Err(crate::provider::ProviderError::InvalidGrant { .. }) => Ok(Asked {
184 renewed: None,
185 refused: true,
186 }),
187 Err(
189 crate::provider::ProviderError::Network { .. }
190 | crate::provider::ProviderError::RateLimited { .. },
191 ) => Ok(Asked {
192 renewed: None,
193 refused: false,
194 }),
195 Err(e) => Err(Error::RenewalFailed {
196 label: key.typed(),
197 cause: Some(crate::error::Cause::of_provider(&e)),
198 detail: e.to_string(),
199 }),
200 }
201}
202
203pub(super) fn renew_one(
207 ctx: &Context,
208 permit: Permit,
209 state: &mut State,
210 key: &Key,
211 held: &Park,
212) -> Result<Option<Park>> {
213 match apply(ctx, permit, state, key, held, ask(ctx, permit, key, held))? {
214 Renewal::Renewed => Ok(state.get(key).and_then(|a| a.parked.clone())),
215 Renewal::Refused => Err(Error::ParkedLoginRefused {
216 tool: key.provider,
217 label: state.typed(key),
218 }),
219 Renewal::Deferred => Ok(None),
220 Renewal::Failed(e) => Err(e),
221 }
222}
223
224fn apply(
226 ctx: &Context,
227 permit: Permit,
228 state: &mut State,
229 key: &Key,
230 held: &Park,
231 asked: Result<Asked>,
232) -> Result<Renewal> {
233 let asked = asked?;
234 if asked.refused {
235 state.release(&held.service);
238 state::save(ctx, permit, state)?;
239 return Ok(Renewal::Refused);
240 }
241 let Some(next) = asked.renewed else {
242 return Ok(Renewal::Deferred);
243 };
244 if state.is_foreign(&held.service) {
251 state.used_here(&held.service);
252 let _ = state::save(ctx, permit, state);
253 }
254
255 let id = state.get(key).map(|a| a.id.clone()).unwrap_or_default();
258 let store = || {
259 park::reserve(ctx, permit, &id)
260 .and_then(|service| park::store_at(ctx, permit, 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, permit, 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, permit, 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::host::memory::MemoryHost;
297 use crate::state::Account;
298 use crate::store::memory::Fault;
299 use crate::time::FixedClock;
300 use serde_json::json;
301 use std::sync::Arc;
302
303 const NOW: i64 = 1_760_000_000;
304
305 struct Machine {
306 ctx: Context,
307 mem: Arc<MemoryHost>,
308 api: Arc<ScriptedApi>,
309 home: std::path::PathBuf,
310 }
311
312 impl Drop for Machine {
313 fn drop(&mut self) {
314 let _ = std::fs::remove_dir_all(&self.home);
315 }
316 }
317
318 fn machine(name: &str) -> Machine {
320 let home = std::env::temp_dir().join(format!(
321 "pitboard-renew-{name}-{}-{:?}",
322 std::process::id(),
323 std::thread::current().id()
324 ));
325 let _ = std::fs::remove_dir_all(&home);
326 std::fs::create_dir_all(&home).expect("a scratch home");
327 let mem = MemoryHost::new();
328 let api = ScriptedApi::new();
329 let ctx = Context::new(home.clone())
330 .with_pitboard_home(home.clone())
331 .with_memory_stores(Arc::clone(&mem))
332 .with_scripted_api(Arc::clone(&api))
333 .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn crate::time::Clock>);
334 Machine {
335 ctx,
336 mem,
337 api,
338 home,
339 }
340 }
341
342 fn oauth(refresh: &str, access_expires_at: i64) -> Value {
343 json!({
344 "refreshToken": refresh,
345 "accessToken": "a",
346 "expiresAt": access_expires_at * 1000,
347 "refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
348 })
349 }
350
351 fn with_park(m: &Machine, label: &str, refresh: &str, access_expires_at: i64) -> Park {
353 park_in(&m.ctx, label, refresh, access_expires_at)
354 }
355
356 fn park_in(ctx: &Context, label: &str, refresh: &str, access_expires_at: i64) -> Park {
358 let service = park::reserve(ctx, Permit::for_a_test(), "acc").expect("a free name");
359 let park = park::store_at(
360 ctx,
361 Permit::for_a_test(),
362 crate::provider::ProviderId::Claude,
363 &service,
364 &oauth(refresh, access_expires_at),
365 )
366 .expect("parked");
367 let mut state = State::default();
368 state.accounts.push(Account {
369 last_used_at: None,
370 label: label.into(),
371 id: "acc".into(),
372 account_uuid: "acc".into(),
373 email: "me@example.com".into(),
374 detail: state::Detail::Claude {
375 organization_uuid: "org".into(),
376 oauth_account: json!({}),
377 },
378 parked: Some(park.clone()),
379 });
380 state::save(ctx, Permit::for_a_test(), &state).expect("saved");
381 park
382 }
383
384 fn fresh(refresh: &str) -> Renewed {
385 Renewed {
386 access_token: "new-access".into(),
387 refresh_token: Some(refresh.into()),
388 expires_in: 3600,
389 refresh_token_expires_in: Some(30 * 86_400),
390 scopes: None,
391 at: None,
392 }
393 }
394
395 fn outcome(outcomes: &[(Key, Renewal)], label: &str) -> String {
396 outcomes
397 .iter()
398 .find(|(key, _)| key.label == label)
399 .map(|(_, r)| r.code().to_string())
400 .unwrap_or_else(|| "not attempted".into())
401 }
402
403 #[test]
407 fn a_renewed_expiry_is_measured_from_anthropics_clock_not_this_machines() {
408 let m = machine("anchored");
409 with_park(&m, "work", "old", NOW - 1);
410 let server_now = NOW - 7200;
412 m.api.renews(
413 "old",
414 Renewed {
415 access_token: "new-access".into(),
416 refresh_token: Some("new".into()),
417 expires_in: 3600,
418 refresh_token_expires_in: Some(30 * 86_400),
419 scopes: None,
420 at: Some(server_now),
421 },
422 );
423
424 renew_parked(&m.ctx, Permit::for_a_test());
425
426 let park = state::load(&m.ctx)
427 .expect("state")
428 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
429 .expect("account")
430 .parked
431 .clone()
432 .expect("renewed");
433 assert_eq!(
434 park.access_expires_at,
435 Some(server_now + 3600),
436 "an hour after the answer, not an hour after this machine's idea of now"
437 );
438 assert_eq!(park.refresh_expires_at, Some(server_now + 30 * 86_400));
439 }
440
441 #[test]
444 fn an_answer_with_no_clock_of_its_own_falls_back_to_this_machines() {
445 let m = machine("unanchored");
446 with_park(&m, "work", "old", NOW - 1);
447 m.api.renews("old", fresh("new"));
448
449 renew_parked(&m.ctx, Permit::for_a_test());
450
451 let park = state::load(&m.ctx)
452 .expect("state")
453 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
454 .expect("account")
455 .parked
456 .clone()
457 .expect("renewed");
458 assert_eq!(park.access_expires_at, Some(NOW + 3600));
459 }
460
461 #[test]
464 fn a_park_that_is_not_due_is_not_asked_about() {
465 let m = machine("not-due");
466 with_park(&m, "work", "r", NOW + 3600);
467
468 let outcomes = renew_parked(&m.ctx, Permit::for_a_test());
469
470 assert!(outcomes.is_empty());
471 assert_eq!(m.api.calls(), 0, "nothing was due, so nothing was asked");
472 }
473
474 #[test]
475 fn a_due_park_is_renewed_and_the_spent_copy_is_dropped() {
476 let m = machine("renewed");
477 let before = with_park(&m, "work", "old", NOW - 1);
478 m.api.renews("old", fresh("new"));
479
480 let outcomes = renew_parked(&m.ctx, Permit::for_a_test());
481
482 assert_eq!(outcome(&outcomes, "work"), "renewed");
483 assert_eq!(m.api.asked(), vec![Question::Renew("old".into())]);
484 let state = state::load(&m.ctx).expect("state");
485 let now = state
486 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
487 .expect("account")
488 .parked
489 .clone()
490 .expect("park");
491 assert_ne!(now.service, before.service, "a renewal takes a new name");
492 assert_eq!(
493 m.mem.vault().services(),
494 vec![now.service.clone()],
495 "the spent copy is deleted, not left behind"
496 );
497 }
498
499 #[test]
502 fn a_login_anthropic_no_longer_accepts_is_dropped() {
503 let m = machine("refused");
504 with_park(&m, "work", "old", NOW - 1);
505 m.api.renew_trouble("old", Trouble::InvalidGrant);
506
507 let outcomes = renew_parked(&m.ctx, Permit::for_a_test());
508
509 assert_eq!(outcome(&outcomes, "work"), "parked_login_refused");
510 let state = state::load(&m.ctx).expect("state");
511 assert!(
512 state
513 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
514 .expect("account")
515 .parked
516 .is_none()
517 );
518 assert!(m.mem.vault().services().is_empty());
519 }
520
521 #[test]
524 fn a_renewal_that_could_not_happen_leaves_the_park_alone() {
525 for trouble in [Trouble::Offline, Trouble::RateLimited] {
526 let m = machine(&format!("deferred-{trouble:?}"));
527 let before = with_park(&m, "work", "old", NOW - 1);
528 m.api.renew_trouble("old", trouble);
529
530 let outcomes = renew_parked(&m.ctx, Permit::for_a_test());
531
532 assert_eq!(outcome(&outcomes, "work"), "renewal_deferred");
533 let state = state::load(&m.ctx).expect("state");
534 assert_eq!(
535 state
536 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
537 .expect("account")
538 .parked,
539 Some(before.clone()),
540 "{trouble:?} must not spend or drop anything"
541 );
542 assert_eq!(m.mem.vault().services(), vec![before.service.clone()]);
543 }
544 }
545
546 #[test]
551 fn a_park_that_could_not_be_written_back_is_not_renewed() {
552 let mut m = machine("no-argv");
553 m.ctx = m.ctx.clone().with_argv_fallback(false);
554 let before = with_park(&m, "work", "old", NOW - 1);
555 m.mem.vault().takes_on_stdin(16);
556 m.api.renews("old", fresh("new"));
557
558 let outcomes = renew_parked(&m.ctx, Permit::for_a_test());
559
560 assert_eq!(outcome(&outcomes, "work"), "credential_too_large");
561 assert_eq!(
562 m.api.calls(),
563 0,
564 "the service was not asked, so nothing was spent"
565 );
566 let state = state::load(&m.ctx).expect("state");
567 assert_eq!(
568 state
569 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
570 .expect("account")
571 .parked,
572 Some(before.clone())
573 );
574 assert_eq!(m.mem.vault().services(), vec![before.service.clone()]);
575 }
576
577 #[test]
579 fn a_park_past_the_ceiling_is_renewed_where_the_argument_line_is_allowed() {
580 let m = machine("argv");
581 with_park(&m, "work", "old", NOW - 1);
582 m.mem.vault().takes_on_stdin(16);
583 m.api.renews("old", fresh("new"));
584
585 let outcomes = renew_parked(&m.ctx, Permit::for_a_test());
586
587 assert_eq!(outcome(&outcomes, "work"), "renewed");
588 }
589
590 #[test]
599 #[cfg_attr(
600 windows,
601 ignore = "W25: Task Scheduler, which names the task it starts, and Pitboard's default \
602 home on Windows (W14)"
603 )]
604 fn a_run_the_schedule_started_renews_the_default_home_whatever_pitboard_home_says() {
605 use crate::host::{OS, Os};
606 use crate::service::Pitboard;
607 let job = match OS {
608 Os::MacOs => "com.datlechin.pitboard.renew",
609 Os::Linux => "pitboard-renew.service",
610 Os::Windows => panic!("W25 names the task Task Scheduler starts for the schedule"),
611 };
612 type Told = fn(Context, &str) -> Context;
613 let ways: [(&str, Told); 2] = [
614 ("as the scheduler says", |ctx, job| {
615 ctx.with_scheduled_job(job.into())
616 }),
617 ("by the marker", |ctx, _| ctx.started_by_the_schedule()),
618 ];
619 for (way, scheduled) in ways {
620 let m = machine(&format!("scheduled-{}", way.len()));
621 let default = m.ctx.clone().with_pitboard_home(m.home.join(".pitboard"));
622 let elsewhere = m.ctx.clone().with_pitboard_home(m.home.join("elsewhere"));
623 park_in(&default, "work", "default-old", NOW - 1);
624 park_in(&elsewhere, "other", "elsewhere-old", NOW - 1);
625 m.api.renews("default-old", fresh("default-new"));
626 m.api.renews("elsewhere-old", fresh("elsewhere-new"));
627 let renewed = |outcomes: Vec<(Key, Renewal)>| -> Vec<(String, String)> {
628 outcomes
629 .into_iter()
630 .map(|(key, outcome)| (key.label, outcome.code().to_string()))
631 .collect()
632 };
633
634 let run = Pitboard::new(scheduled(elsewhere.clone(), job))
635 .renew()
636 .expect("a run");
637 assert_eq!(renewed(run), [("work".into(), "renewed".into())], "{way}");
638 assert_eq!(
639 m.api.asked(),
640 [Question::Renew("default-old".into())],
641 "{way}"
642 );
643 assert_eq!(
644 crate::audit::read(&default, 10)
645 .iter()
646 .map(|e| (e.verb.as_str(), e.outcome.as_str()))
647 .collect::<Vec<_>>(),
648 [("renew", "renewed")],
649 "{way}: recorded in the home it renewed"
650 );
651
652 let by_hand = Pitboard::new(elsewhere).renew().expect("a run");
653 assert_eq!(
654 renewed(by_hand),
655 [("other".into(), "renewed".into())],
656 "{way}"
657 );
658 }
659 }
660
661 #[test]
666 fn a_run_the_schedule_started_renews_the_default_home_where_pitboard_home_is_not_full() {
667 use crate::service::Pitboard;
668 for named in ["", "relative"] {
669 let m = machine(&format!("scheduled-not-full-{}", named.len()));
670 let default = m.ctx.clone().with_pitboard_home(m.home.join(".pitboard"));
671 park_in(&default, "work", "default-old", NOW - 1);
672 m.api.renews("default-old", fresh("default-new"));
673 let given = m.ctx.clone().with_pitboard_home(named.into());
674
675 let by_hand = Pitboard::new(given.clone()).renew();
676 assert!(
677 matches!(by_hand, Err(Error::HomeNotAbsolute { .. })),
678 "{named:?}: {by_hand:?}"
679 );
680 assert_eq!(m.api.asked(), [], "{named:?}");
681
682 let run = Pitboard::new(given.started_by_the_schedule())
683 .renew()
684 .expect("a run");
685 assert_eq!(
686 run.iter()
687 .map(|(key, outcome)| (key.label.as_str(), outcome.code()))
688 .collect::<Vec<_>>(),
689 [("work", "renewed")],
690 "{named:?}"
691 );
692 }
693 }
694
695 #[test]
700 #[should_panic(expected = "with this account's own home")]
701 fn a_run_the_schedule_started_in_a_build_for_tests_never_renews_the_real_home() {
702 let own = {
703 let _real = crate::host::user::testing::reaching_the_real_home();
704 crate::host::user::home().expect("this account has a home")
705 };
706 let ctx = Context::new(own)
707 .with_memory_stores(MemoryHost::new())
708 .started_by_the_schedule();
709 let _ = crate::schedule::for_its_run(&ctx);
710 }
711
712 #[test]
717 fn a_renewal_whose_answer_cannot_be_stored_drops_the_spent_park() {
718 let m = machine("write-lost");
719 let before = with_park(&m, "work", "old", NOW - 1);
720 m.api.renews("old", fresh("new"));
721 m.mem
722 .vault()
723 .fault_all(Fault::FailWrite("the keychain refused".into()));
724
725 let outcomes = renew_parked(&m.ctx, Permit::for_a_test());
726
727 assert_eq!(outcome(&outcomes, "work"), "renewal_failed");
728 let state = state::load(&m.ctx).expect("state");
729 assert!(
730 state
731 .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
732 .expect("account")
733 .parked
734 .is_none(),
735 "a park whose refresh token Anthropic has spent must not stay on offer"
736 );
737 assert!(
738 !m.mem.vault().services().contains(&before.service),
739 "the dead copy is deleted in the same run, not left behind unnamed"
740 );
741 assert!(
742 state.discarded.is_empty(),
743 "and nothing is left listed for a later run to retry"
744 );
745 }
746}