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::state::{Key, Park, State};
9use crate::{park, state};
10use serde_json::Value;
11
12/// Renewed this long before its access token expires, so a read just after still answers.
13const AHEAD_SECONDS: i64 = 120;
14
15/// What Claude Code asks for when a login records no scopes of its own.
16pub(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    /// Anthropic refuses the login for good; it has been dropped.
30    Refused,
31    /// Anthropic could not be reached or asked to slow down; tried again next time.
32    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/// Why a parked login is being renewed.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Due {
50    /// What a reading needs: a park whose access token has lapsed cannot be asked about.
51    /// This is the one `status` does, and it is the read path's own requirement rather
52    /// than a job it does on the side.
53    ToBeAsked,
54    /// What keeps a park usable: a refresh token has a finite life, and one that lapses
55    /// costs a browser sign-in. Also covers everything `ToBeAsked` covers.
56    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
77/// Renew every parked login whose access token has expired or is about to. Nothing is done
78/// while another pitboard run holds the lock or a switch waits to be finished: a renewal
79/// replaces the refresh token, and nothing may install the old copy meanwhile.
80pub fn renew_parked(ctx: &Context) -> Vec<(Key, Renewal)> {
81    renew_due(ctx, Due::ToBeAsked)
82}
83
84/// The same, for whichever reason.
85pub 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    // A park that copies the login in use holds the refresh token its tool is about to
108    // present, and renewing it would spend that token under the tool. It is dropped instead,
109    // as the next change would drop it. Looked for only when something is due, because
110    // finding one reads each tool's login.
111    if !to_renew.is_empty() {
112        let _ = super::drop_live_twins(ctx, &mut state);
113        to_renew = covered(&state);
114    }
115    // Each renewal is a round trip that can take as long as the request timeout, so they
116    // are asked together. What comes back is then written one at a time: the state file is
117    // one file, and the order of writes to it is not something to leave to chance.
118    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
157/// What one round trip produced, before anything is written down.
158struct Asked {
159    /// The parked document with fresh tokens already folded in, the way the tool that owns
160    /// it stores its own after renewing, so it reads the same once restored.
161    renewed: Option<Value>,
162    /// The service refuses this login for good.
163    refused: bool,
164}
165
166/// The part of a renewal that talks to Anthropic. Touches no shared state, so several run
167/// at once.
168fn ask(ctx: &Context, key: &Key, held: &Park) -> Result<Asked> {
169    let document = park::load(ctx, key, held)?;
170    // The service spends the old refresh token as it answers, so an answer that could not
171    // be written back would lose the login. Where it could not be, the service is not
172    // asked: a renewed login is this one with new tokens of the same length, so this one's
173    // size answers for it. On macOS with PITBOARD_NO_ARGV, that is every Codex park.
174    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        // Unreachable or asked to slow down: nothing is written and the next run tries.
187        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
202/// Renew one parked login now, for a caller that needs it usable rather than merely
203/// present. Returns the park that replaces it, or `None` when Anthropic could not be
204/// reached or asked for less traffic, which is a reason to stop and not a reason to act.
205pub(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
222/// The part that writes: one at a time, in the order the accounts are listed.
223fn 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        // Refused, not spent: nothing was taken from it. One `repair` gave back is left for
233        // the pitboard that wrote it, which will be refused the same way.
234        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    // A copy `repair` gave back is left for the pitboard that wrote it only while it is
242    // unused, and the service has just spent it. Saved as used before the answer is
243    // written, so a run killed between writing the answer and recording it leaves a spent
244    // copy the next change deletes, rather than one it lets go for a pitboard that would
245    // present a spent token. A save that fails here must not stop the answer being written:
246    // that is the account's only working login now.
247    if state.is_foreign(&held.service) {
248        state.used_here(&held.service);
249        let _ = state::save(ctx, state);
250    }
251
252    // The old refresh token may already be spent, so the answer is written at once, and a
253    // second time under another name if the first write fails.
254    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            // 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, 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, 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    /// A machine with no keychain, no network and a clock that stands still.
318    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    /// One account holding one park, written the way a switch would have written it.
351    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    /// The lifetimes a renewal answers with are relative, so what they are added to decides
396    /// when the login expires. A machine whose clock is wrong must not get an expiry to
397    /// match, or every status renews the park again and rotates the refresh chain on a loop.
398    #[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        // This machine believes it is two hours later than it is.
403        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    /// An answer with no `Date` leaves the local clock as all there is, which is what it
434    /// always was.
435    #[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    /// The budget question, asked of the code rather than of a stopwatch: a park whose
454    /// access token is still good is not a reason to talk to Anthropic at all.
455    #[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    /// The answer that ends a park. The account keeps its label and its email, so the way
492    /// back is one sign-in rather than an enrolment.
493    #[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    /// Being unreachable, or being asked to slow down, must change nothing at all: the park
514    /// that is still there is the one thing standing between the user and a browser.
515    #[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    /// With the argument line forbidden, a login past the keychain's standard input cannot
539    /// be written back, and the service spends the old refresh token as it answers. It is
540    /// not asked: the park stays as it was, and the reason is said. It used to be asked,
541    /// and the login was lost.
542    #[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    /// The same login with the argument line allowed is renewed as usual.
570    #[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    /// The failure this module's comments describe and no test could reach: Anthropic has
583    /// already spent the old refresh token, and the fresh one cannot be written down. The
584    /// copy pitboard holds is dead either way, so it is dropped rather than left to be
585    /// offered as a login that cannot work.
586    #[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}