pitboard-core 0.4.0

The engine behind pitboard: parking and restoring Claude Code and Codex logins. Serves pitboard's own front ends.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
//! Keeping parked logins alive, so their usage can be asked and they do not lapse. A parked
//! login is held by pitboard alone, so renewing it puts no second holder on its refresh
//! chain. The login signed in is Claude Code's, and is never renewed here.

use super::{journal, purge, try_exclusive};
use crate::context::Context;
use crate::error::{Error, Result};
use crate::state::{Key, Park, State};
use crate::{park, state};
use serde_json::Value;

/// Renewed this long before its access token expires, so a read just after still answers.
const AHEAD_SECONDS: i64 = 120;

/// What Claude Code asks for when a login records no scopes of its own.
pub(crate) const DEFAULT_SCOPES: [&str; 6] = [
    "user:profile",
    "user:inference",
    "user:sessions:claude_code",
    "user:mcp_servers",
    "user:file_upload",
    "user:plugins",
];

#[derive(Debug)]
#[non_exhaustive]
pub enum Renewal {
    Renewed,
    /// Anthropic refuses the login for good; it has been dropped.
    Refused,
    /// Anthropic could not be reached or asked to slow down; tried again next time.
    Deferred,
    Failed(Error),
}

impl Renewal {
    pub fn code(&self) -> &'static str {
        match self {
            Renewal::Renewed => "renewed",
            Renewal::Refused => "parked_login_refused",
            Renewal::Deferred => "renewal_deferred",
            Renewal::Failed(e) => e.code(),
        }
    }
}

/// Why a parked login is being renewed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Due {
    /// What a reading needs: a park whose access token has lapsed cannot be asked about.
    /// This is the one `status` does, and it is the read path's own requirement rather
    /// than a job it does on the side.
    ToBeAsked,
    /// What keeps a park usable: a refresh token has a finite life, and one that lapses
    /// costs a browser sign-in. Also covers everything `ToBeAsked` covers.
    ToStayAlive,
}

impl Due {
    fn covers(self, held: &Park, now: i64) -> bool {
        if !held.restorable_at(now) {
            return false;
        }
        let access_lapsed = !held.askable_at(now + AHEAD_SECONDS);
        match self {
            Due::ToBeAsked => access_lapsed,
            Due::ToStayAlive => {
                access_lapsed
                    || held
                        .refresh_expires_at
                        .is_some_and(|at| at - now < crate::doctor::RENEW_WITHIN)
            }
        }
    }
}

/// Renew every parked login whose access token has expired or is about to. Nothing is done
/// while another pitboard run holds the lock or a switch waits to be finished: a renewal
/// replaces the refresh token, and nothing may install the old copy meanwhile.
pub fn renew_parked(ctx: &Context) -> Vec<(Key, Renewal)> {
    renew_due(ctx, Due::ToBeAsked)
}

/// The same, for whichever reason.
pub fn renew_due(ctx: &Context, due: Due) -> Vec<(Key, Renewal)> {
    let Some(_exclusive) = try_exclusive(ctx) else {
        return Vec::new();
    };
    if journal::pending(ctx) {
        return Vec::new();
    }
    let Ok(mut state) = state::load(ctx) else {
        return Vec::new();
    };
    let now = ctx.now();
    let covered = |state: &State| -> Vec<(Key, Park)> {
        state
            .accounts
            .iter()
            .filter_map(|a| {
                let held = a.parked.as_ref()?;
                due.covers(held, now).then(|| (a.key(), held.clone()))
            })
            .collect()
    };
    let mut to_renew = covered(&state);
    // A park that copies the login in use holds the refresh token its tool is about to
    // present, and renewing it would spend that token under the tool. It is dropped instead,
    // as the next change would drop it. Looked for only when something is due, because
    // finding one reads each tool's login.
    if !to_renew.is_empty() {
        let _ = super::drop_live_twins(ctx, &mut state);
        to_renew = covered(&state);
    }
    // Each renewal is a round trip that can take as long as the request timeout, so they
    // are asked together. What comes back is then written one at a time: the state file is
    // one file, and the order of writes to it is not something to leave to chance.
    let asked: Vec<(Key, Park, Result<Asked>)> = std::thread::scope(|scope| {
        let handles: Vec<_> = to_renew
            .into_iter()
            .map(|(key, held)| {
                let ctx = &*ctx;
                let handle = scope.spawn({
                    let key = key.clone();
                    let held = held.clone();
                    move || ask(ctx, &key, &held)
                });
                (key, held, handle)
            })
            .collect();
        handles
            .into_iter()
            .map(|(key, held, handle)| {
                let answer = handle.join().unwrap_or_else(|_| {
                    Err(Error::RenewalFailed {
                        label: key.typed(),
                        cause: None,
                        detail: "the renewal thread stopped".into(),
                    })
                });
                (key, held, answer)
            })
            .collect()
    });
    let outcomes = asked
        .into_iter()
        .map(|(key, held, answer)| {
            let outcome =
                apply(ctx, &mut state, &key, &held, answer).unwrap_or_else(Renewal::Failed);
            (key, outcome)
        })
        .collect();
    purge(ctx, &mut state);
    outcomes
}

/// What one round trip produced, before anything is written down.
struct Asked {
    /// The parked document with fresh tokens already folded in, the way the tool that owns
    /// it stores its own after renewing, so it reads the same once restored.
    renewed: Option<Value>,
    /// The service refuses this login for good.
    refused: bool,
}

/// The part of a renewal that talks to Anthropic. Touches no shared state, so several run
/// at once.
fn ask(ctx: &Context, key: &Key, held: &Park) -> Result<Asked> {
    let document = park::load(ctx, key, held)?;
    let tool = crate::provider::of(key.provider);
    let credential = crate::provider::Credential::new(key.provider, document);
    match tool.renew(ctx, &credential) {
        Ok(fresh) => Ok(Asked {
            renewed: Some(fresh.raw),
            refused: false,
        }),
        Err(crate::provider::ProviderError::InvalidGrant { .. }) => Ok(Asked {
            renewed: None,
            refused: true,
        }),
        // Unreachable or asked to slow down: nothing is written and the next run tries.
        Err(
            crate::provider::ProviderError::Network { .. }
            | crate::provider::ProviderError::RateLimited { .. },
        ) => Ok(Asked {
            renewed: None,
            refused: false,
        }),
        Err(e) => Err(Error::RenewalFailed {
            label: key.typed(),
            cause: Some(crate::error::Cause::of_provider(&e)),
            detail: e.to_string(),
        }),
    }
}

/// Renew one parked login now, for a caller that needs it usable rather than merely
/// present. Returns the park that replaces it, or `None` when Anthropic could not be
/// reached or asked for less traffic, which is a reason to stop and not a reason to act.
pub(super) fn renew_one(
    ctx: &Context,
    state: &mut State,
    key: &Key,
    held: &Park,
) -> Result<Option<Park>> {
    match apply(ctx, state, key, held, ask(ctx, key, held))? {
        Renewal::Renewed => Ok(state.get(key).and_then(|a| a.parked.clone())),
        Renewal::Refused => Err(Error::ParkedLoginRefused {
            tool: key.provider,
            label: state.typed(key),
        }),
        Renewal::Deferred => Ok(None),
        Renewal::Failed(e) => Err(e),
    }
}

/// The part that writes: one at a time, in the order the accounts are listed.
fn apply(
    ctx: &Context,
    state: &mut State,
    key: &Key,
    held: &Park,
    asked: Result<Asked>,
) -> Result<Renewal> {
    let asked = asked?;
    if asked.refused {
        // Refused, not spent: nothing was taken from it. One `repair` gave back is left for
        // the pitboard that wrote it, which will be refused the same way.
        state.release(&held.service);
        state::save(ctx, state)?;
        return Ok(Renewal::Refused);
    }
    let Some(next) = asked.renewed else {
        return Ok(Renewal::Deferred);
    };
    // A copy `repair` gave back is left for the pitboard that wrote it only while it is
    // unused, and the service has just spent it. Saved as used before the answer is
    // written, so a run killed between writing the answer and recording it leaves a spent
    // copy the next change deletes, rather than one it lets go for a pitboard that would
    // present a spent token. A save that fails here must not stop the answer being written:
    // that is the account's only working login now.
    if state.is_foreign(&held.service) {
        state.used_here(&held.service);
        let _ = state::save(ctx, state);
    }

    // The old refresh token may already be spent, so the answer is written at once, and a
    // second time under another name if the first write fails.
    let uuid = state
        .get(key)
        .map(|a| a.account_uuid.clone())
        .unwrap_or_default();
    let store = || {
        park::reserve(ctx, &uuid)
            .and_then(|service| park::store_at(ctx, key.provider, &service, &next))
    };
    let parked = match store().or_else(|_| store()) {
        Ok(parked) => parked,
        Err(e) => {
            // Anthropic has already spent the old refresh token, so the copy pitboard holds
            // is dead whatever happens next. Dropping it now means status stops offering a
            // login that cannot work and says to sign in again instead.
            state.discard(&held.service);
            let _ = state::save(ctx, state);
            return Err(Error::RenewalFailed {
                label: state.typed(key),
                // The service answered; it is this machine that could not keep the answer.
                cause: None,
                detail: e.to_string(),
            });
        }
    };
    crate::fault::point("renew.park_stored");
    // The renewal spent the copy it replaces, whoever wrote it, so that one is discarded
    // rather than merely replaced.
    state.discard(&held.service);
    state.park(key, parked.clone());
    // A save that fails leaves the fresh copy where it is. Its name is on pitboard's own
    // list of names it wrote, so the next command gives it back to the account in place of
    // the spent one. Deleting it here, as this once did, threw away the only login the
    // account had left: the service had already spent the one the record still names.
    state::save(ctx, state)?;
    Ok(Renewal::Renewed)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::api::Renewed;
    use crate::api::scripted::{Asked as Question, ScriptedApi, Trouble};
    use crate::state::Account;
    use crate::store::memory::{Fault, MemoryHost};
    use crate::time::FixedClock;
    use serde_json::json;
    use std::sync::Arc;

    const NOW: i64 = 1_760_000_000;

    struct Machine {
        ctx: Context,
        mem: Arc<MemoryHost>,
        api: Arc<ScriptedApi>,
        home: std::path::PathBuf,
    }

    impl Drop for Machine {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.home);
        }
    }

    /// A machine with no keychain, no network and a clock that stands still.
    fn machine(name: &str) -> Machine {
        let home = std::env::temp_dir().join(format!(
            "pitboard-renew-{name}-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        let _ = std::fs::remove_dir_all(&home);
        std::fs::create_dir_all(&home).expect("a scratch home");
        let mem = MemoryHost::new();
        let api = ScriptedApi::new();
        let ctx = Context::new(home.clone())
            .with_pitboard_home(home.clone())
            .with_memory_stores(Arc::clone(&mem))
            .with_scripted_api(Arc::clone(&api))
            .with_clock(Arc::new(FixedClock::at(NOW)) as Arc<dyn crate::time::Clock>);
        Machine {
            ctx,
            mem,
            api,
            home,
        }
    }

    fn oauth(refresh: &str, access_expires_at: i64) -> Value {
        json!({
            "refreshToken": refresh,
            "accessToken": "a",
            "expiresAt": access_expires_at * 1000,
            "refreshTokenExpiresAt": (NOW + 30 * 86_400) * 1000
        })
    }

    /// One account holding one park, written the way a switch would have written it.
    fn with_park(m: &Machine, label: &str, refresh: &str, access_expires_at: i64) -> Park {
        let service = park::reserve(&m.ctx, "acc").expect("a free name");
        let park = park::store_at(
            &m.ctx,
            crate::provider::ProviderId::Claude,
            &service,
            &oauth(refresh, access_expires_at),
        )
        .expect("parked");
        let mut state = State::default();
        state.accounts.push(Account {
            last_used_at: None,
            label: label.into(),
            account_uuid: "acc".into(),
            email: "me@example.com".into(),
            detail: state::Detail::Claude {
                organization_uuid: "org".into(),
                oauth_account: json!({}),
            },
            parked: Some(park.clone()),
        });
        state::save(&m.ctx, &state).expect("saved");
        park
    }

    fn fresh(refresh: &str) -> Renewed {
        Renewed {
            access_token: "new-access".into(),
            refresh_token: Some(refresh.into()),
            expires_in: 3600,
            refresh_token_expires_in: Some(30 * 86_400),
            scopes: None,
            at: None,
        }
    }

    fn outcome(outcomes: &[(Key, Renewal)], label: &str) -> String {
        outcomes
            .iter()
            .find(|(key, _)| key.label == label)
            .map(|(_, r)| r.code().to_string())
            .unwrap_or_else(|| "not attempted".into())
    }

    /// The lifetimes a renewal answers with are relative, so what they are added to decides
    /// when the login expires. A machine whose clock is wrong must not get an expiry to
    /// match, or every status renews the park again and rotates the refresh chain on a loop.
    #[test]
    fn a_renewed_expiry_is_measured_from_anthropics_clock_not_this_machines() {
        let m = machine("anchored");
        with_park(&m, "work", "old", NOW - 1);
        // This machine believes it is two hours later than it is.
        let server_now = NOW - 7200;
        m.api.renews(
            "old",
            Renewed {
                access_token: "new-access".into(),
                refresh_token: Some("new".into()),
                expires_in: 3600,
                refresh_token_expires_in: Some(30 * 86_400),
                scopes: None,
                at: Some(server_now),
            },
        );

        renew_parked(&m.ctx);

        let park = state::load(&m.ctx)
            .expect("state")
            .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
            .expect("account")
            .parked
            .clone()
            .expect("renewed");
        assert_eq!(
            park.access_expires_at,
            Some(server_now + 3600),
            "an hour after the answer, not an hour after this machine's idea of now"
        );
        assert_eq!(park.refresh_expires_at, Some(server_now + 30 * 86_400));
    }

    /// An answer with no `Date` leaves the local clock as all there is, which is what it
    /// always was.
    #[test]
    fn an_answer_with_no_clock_of_its_own_falls_back_to_this_machines() {
        let m = machine("unanchored");
        with_park(&m, "work", "old", NOW - 1);
        m.api.renews("old", fresh("new"));

        renew_parked(&m.ctx);

        let park = state::load(&m.ctx)
            .expect("state")
            .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
            .expect("account")
            .parked
            .clone()
            .expect("renewed");
        assert_eq!(park.access_expires_at, Some(NOW + 3600));
    }

    /// The budget question, asked of the code rather than of a stopwatch: a park whose
    /// access token is still good is not a reason to talk to Anthropic at all.
    #[test]
    fn a_park_that_is_not_due_is_not_asked_about() {
        let m = machine("not-due");
        with_park(&m, "work", "r", NOW + 3600);

        let outcomes = renew_parked(&m.ctx);

        assert!(outcomes.is_empty());
        assert_eq!(m.api.calls(), 0, "nothing was due, so nothing was asked");
    }

    #[test]
    fn a_due_park_is_renewed_and_the_spent_copy_is_dropped() {
        let m = machine("renewed");
        let before = with_park(&m, "work", "old", NOW - 1);
        m.api.renews("old", fresh("new"));

        let outcomes = renew_parked(&m.ctx);

        assert_eq!(outcome(&outcomes, "work"), "renewed");
        assert_eq!(m.api.asked(), vec![Question::Renew("old".into())]);
        let state = state::load(&m.ctx).expect("state");
        let now = state
            .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
            .expect("account")
            .parked
            .clone()
            .expect("park");
        assert_ne!(now.service, before.service, "a renewal takes a new name");
        assert_eq!(
            m.mem.vault().services(),
            vec![now.service.clone()],
            "the spent copy is deleted, not left behind"
        );
    }

    /// The answer that ends a park. The account keeps its label and its email, so the way
    /// back is one sign-in rather than an enrolment.
    #[test]
    fn a_login_anthropic_no_longer_accepts_is_dropped() {
        let m = machine("refused");
        with_park(&m, "work", "old", NOW - 1);
        m.api.renew_trouble("old", Trouble::InvalidGrant);

        let outcomes = renew_parked(&m.ctx);

        assert_eq!(outcome(&outcomes, "work"), "parked_login_refused");
        let state = state::load(&m.ctx).expect("state");
        assert!(
            state
                .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
                .expect("account")
                .parked
                .is_none()
        );
        assert!(m.mem.vault().services().is_empty());
    }

    /// Being unreachable, or being asked to slow down, must change nothing at all: the park
    /// that is still there is the one thing standing between the user and a browser.
    #[test]
    fn a_renewal_that_could_not_happen_leaves_the_park_alone() {
        for trouble in [Trouble::Offline, Trouble::RateLimited] {
            let m = machine(&format!("deferred-{trouble:?}"));
            let before = with_park(&m, "work", "old", NOW - 1);
            m.api.renew_trouble("old", trouble);

            let outcomes = renew_parked(&m.ctx);

            assert_eq!(outcome(&outcomes, "work"), "renewal_deferred");
            let state = state::load(&m.ctx).expect("state");
            assert_eq!(
                state
                    .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
                    .expect("account")
                    .parked,
                Some(before.clone()),
                "{trouble:?} must not spend or drop anything"
            );
            assert_eq!(m.mem.vault().services(), vec![before.service.clone()]);
        }
    }

    /// The failure this module's comments describe and no test could reach: Anthropic has
    /// already spent the old refresh token, and the fresh one cannot be written down. The
    /// copy pitboard holds is dead either way, so it is dropped rather than left to be
    /// offered as a login that cannot work.
    #[test]
    fn a_renewal_whose_answer_cannot_be_stored_drops_the_spent_park() {
        let m = machine("write-lost");
        let before = with_park(&m, "work", "old", NOW - 1);
        m.api.renews("old", fresh("new"));
        m.mem
            .vault()
            .fault_all(Fault::FailWrite("the keychain refused".into()));

        let outcomes = renew_parked(&m.ctx);

        assert_eq!(outcome(&outcomes, "work"), "renewal_failed");
        let state = state::load(&m.ctx).expect("state");
        assert!(
            state
                .get(&Key::new(crate::provider::ProviderId::Claude, "work"))
                .expect("account")
                .parked
                .is_none(),
            "a park whose refresh token Anthropic has spent must not stay on offer"
        );
        assert!(
            !m.mem.vault().services().contains(&before.service),
            "the dead copy is deleted in the same run, not left behind unnamed"
        );
        assert!(
            state.discarded.is_empty(),
            "and nothing is left listed for a later run to retry"
        );
    }
}