Skip to main content

pitboard_core/switch/
renew.rs

1//! Keeping parked logins alive, so their usage can be asked and they do not lapse. A parked
2//! login is held by Pitboard alone, so renewing it puts no second holder on its refresh
3//! chain. The login signed in is Claude Code's, and is never renewed here.
4
5use 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
13/// Renewed this long before its access token expires, so a read just after still answers.
14const AHEAD_SECONDS: i64 = 120;
15
16/// What Claude Code asks for when a login records no scopes of its own.
17pub(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    /// Anthropic refuses the login for good; it has been dropped.
31    Refused,
32    /// Anthropic could not be reached or asked to slow down; tried again next time.
33    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/// Why a parked login is being renewed.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Due {
51    /// What a reading needs: a park whose access token has lapsed cannot be asked about.
52    /// This is the one `status` does, and it is the read path's own requirement rather
53    /// than a job it does on the side.
54    ToBeAsked,
55    /// What keeps a park usable: a refresh token has a finite life, and one that lapses
56    /// costs a browser sign-in. Also covers everything `ToBeAsked` covers.
57    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
78/// Renew every parked login whose access token has expired or is about to. Nothing is done
79/// while another Pitboard run holds the lock or a switch waits to be finished: a renewal
80/// replaces the refresh token, and nothing may install the old copy meanwhile.
81pub fn renew_parked(ctx: &Context, permit: Permit) -> Vec<(Key, Renewal)> {
82    renew_due(ctx, permit, Due::ToBeAsked)
83}
84
85/// The same, for whichever reason.
86pub 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    // A park that copies the login in use holds the refresh token its tool is about to
109    // present, and renewing it would spend that token under the tool. It is dropped instead,
110    // as the next change would drop it. Looked for only when something is due, because
111    // finding one reads each tool's login.
112    if !to_renew.is_empty() {
113        let _ = super::drop_live_twins(ctx, permit, &mut state);
114        to_renew = covered(&state);
115    }
116    // Each renewal is a round trip that can take as long as the request timeout, so they
117    // are asked together. What comes back is then written one at a time: the state file is
118    // one file, and the order of writes to it is not something to leave to chance.
119    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
158/// What one round trip produced, before anything is written down.
159struct Asked {
160    /// The parked document with fresh tokens already folded in, the way the tool that owns
161    /// it stores its own after renewing, so it reads the same once restored.
162    renewed: Option<Value>,
163    /// The service refuses this login for good.
164    refused: bool,
165}
166
167/// The part of a renewal that talks to Anthropic. Touches no shared state, so several run
168/// at once.
169fn ask(ctx: &Context, permit: Permit, key: &Key, held: &Park) -> Result<Asked> {
170    let document = park::load(ctx, key, held)?;
171    // The service spends the old refresh token as it answers, so an answer that could not
172    // be written back would lose the login. Where it could not be, the service is not
173    // asked: a renewed login is this one with new tokens of the same length, so this one's
174    // size answers for it. On macOS with PITBOARD_NO_ARGV, that is every Codex park.
175    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        // Unreachable or asked to slow down: nothing is written and the next run tries.
188        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
203/// Renew one parked login now, for a caller that needs it usable rather than merely
204/// present. Returns the park that replaces it, or `None` when Anthropic could not be
205/// reached or asked for less traffic, which is a reason to stop and not a reason to act.
206pub(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
224/// The part that writes: one at a time, in the order the accounts are listed.
225fn 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        // Refused, not spent: nothing was taken from it. One `repair` gave back is left for
236        // the Pitboard that wrote it, which will be refused the same way.
237        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    // A copy `repair` gave back is left for the Pitboard that wrote it only while it is
245    // unused, and the service has just spent it. Saved as used before the answer is
246    // written, so a run killed between writing the answer and recording it leaves a spent
247    // copy the next change deletes, rather than one it lets go for a Pitboard that would
248    // present a spent token. A save that fails here must not stop the answer being written:
249    // that is the account's only working login now.
250    if state.is_foreign(&held.service) {
251        state.used_here(&held.service);
252        let _ = state::save(ctx, permit, state);
253    }
254
255    // The old refresh token may already be spent, so the answer is written at once, and a
256    // second time under another name if the first write fails.
257    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            // Anthropic has already spent the old refresh token, so the copy Pitboard holds
266            // is dead whatever happens next. Dropping it now means status stops offering a
267            // login that cannot work and says to sign in again instead.
268            state.discard(&held.service);
269            let _ = state::save(ctx, permit, state);
270            return Err(Error::RenewalFailed {
271                label: state.typed(key),
272                // The service answered; it is this machine that could not keep the answer.
273                cause: None,
274                detail: e.to_string(),
275            });
276        }
277    };
278    crate::fault::point("renew.park_stored");
279    // The renewal spent the copy it replaces, whoever wrote it, so that one is discarded
280    // rather than merely replaced.
281    state.discard(&held.service);
282    state.park(key, parked.clone());
283    // A save that fails leaves the fresh copy where it is. Its name is on Pitboard's own
284    // list of names it wrote, so the next command gives it back to the account in place of
285    // the spent one. Deleting it here, as this once did, threw away the only login the
286    // account had left: the service had already spent the one the record still names.
287    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    /// A machine with no keychain, no network and a clock that stands still.
319    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    /// One account holding one park, written the way a switch would have written it.
352    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    /// The same, in the Pitboard directory `ctx` names.
357    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    /// The lifetimes a renewal answers with are relative, so what they are added to decides
404    /// when the login expires. A machine whose clock is wrong must not get an expiry to
405    /// match, or every status renews the park again and rotates the refresh chain on a loop.
406    #[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        // This machine believes it is two hours later than it is.
411        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    /// An answer with no `Date` leaves the local clock as all there is, which is what it
442    /// always was.
443    #[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    /// The budget question, asked of the code rather than of a stopwatch: a park whose
462    /// access token is still good is not a reason to talk to Anthropic at all.
463    #[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    /// The answer that ends a park. The account keeps its label and its email, so the way
500    /// back is one sign-in rather than an enrolment.
501    #[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    /// Being unreachable, or being asked to slow down, must change nothing at all: the park
522    /// that is still there is the one thing standing between the user and a browser.
523    #[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    /// With the argument line forbidden, a login past the keychain's standard input cannot
547    /// be written back, and the service spends the old refresh token as it answers. It is
548    /// not asked: the park stays as it was, and the reason is said. It used to be asked,
549    /// and the login was lost.
550    #[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    /// The same login with the argument line allowed is renewed as usual.
578    #[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    /// The schedule belongs to the default home, and `launchctl setenv` or a systemd user
591    /// environment can still give its job a `PITBOARD_HOME`. A run the schedule started
592    /// renews the default home's parked logins and no other's, and a run by hand renews the
593    /// ones in the home `PITBOARD_HOME` names. It renewed that home on every run, and on
594    /// Linux nothing told a run of the schedule apart. Told both ways it is told: by the
595    /// job the scheduler says started this process, which a test says here as launchd's
596    /// `XPC_SERVICE_NAME` or systemd's unit would name it, and by the marker the systemd
597    /// unit runs Pitboard with.
598    #[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    /// A run of the schedule takes no notice of a `PITBOARD_HOME` that is empty or relative
662    /// either, which every other run refuses: it renews the default home, and the gate it
663    /// asks is asked of that home. By hand, the same `PITBOARD_HOME` is refused with
664    /// `home_not_absolute`, having asked nobody.
665    #[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    /// A build for tests that runs as the schedule with this account's own home is stopped
696    /// before it reads anything, since it would renew the real Pitboard directory: a test
697    /// gives a run of the schedule a home of its own. Nothing here reads or writes the home;
698    /// the context is all that is made.
699    #[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    /// The failure this module's comments describe and no test could reach: Anthropic has
713    /// already spent the old refresh token, and the fresh one cannot be written down. The
714    /// copy Pitboard holds is dead either way, so it is dropped rather than left to be
715    /// offered as a login that cannot work.
716    #[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}