Skip to main content

pitboard_core/
service.rs

1//! Pitboard's operations, each run the way every front end must run it: a change settles any
2//! interrupted switch first and is recorded in the audit log, and what went wrong on the way
3//! is reported alongside the result, whether or not the change then succeeds.
4
5use crate::context::Context;
6use crate::doctor::{self, Diagnosis};
7use crate::error::{Error, Result};
8use crate::holder::{self, capitalised};
9use crate::host::Elevation;
10use crate::provider::ProviderId;
11use crate::state::{self, Account, Key};
12use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
13use crate::{audit, readings, schedule, status, statusline};
14use std::fmt;
15
16/// Proof that this process may change something on this machine, which only the one gate
17/// every change passes makes ([`Pitboard::permit`]).
18///
19/// Everything that changes anything takes one as an argument: the durable write of a file,
20/// every private file or directory made and every one removed, a write to or a delete from
21/// any store of logins, the keychain among them, a change to the system's scheduler, a
22/// tool's sign-in started, and the token exchange that renews a parked login. So a change
23/// that did not ask the gate does not compile, and nothing rotates a refresh token it could
24/// not then write down.
25#[derive(Debug, Clone, Copy)]
26pub struct Permit {
27    _only_the_gate_makes_one: (),
28}
29
30#[cfg(test)]
31impl Permit {
32    /// One for a unit test of what takes it, below the gate. A test of the gate itself, or
33    /// of a change through [`Pitboard`], asks the gate.
34    pub(crate) fn for_a_test() -> Permit {
35        Permit {
36            _only_the_gate_makes_one: (),
37        }
38    }
39}
40
41/// The one gate every change passes: whether this process may change anything here, as the
42/// host says. A process that runs as root or under sudo changes nothing, and nor does one
43/// the host cannot place: a file it wrote would be root's, and a keychain item might be,
44/// where the person's own runs might never read, replace or remove it again.
45///
46/// Nor does one whose environment names a home that is empty or relative
47/// ([`crate::home::check_absolute`]): what it wrote would land under whichever folder it
48/// was run from. Asked after elevation, so `sudo` is what a run under it is told first.
49///
50/// Before either, a build that may do nothing at all changes nothing: a Windows build of a
51/// release made before Pitboard for Windows is released ([`crate::release`]). So no writer
52/// and no token exchange runs there, even for a caller that forgot to ask.
53pub(crate) fn gate(ctx: &Context) -> Result<Permit> {
54    crate::release::check()?;
55    match ctx.host().elevation(ctx) {
56        Elevation::Normal => {}
57        Elevation::Elevated { why } => return Err(Error::Elevated { why: Some(why) }),
58        Elevation::Unknown => return Err(Error::Elevated { why: None }),
59    }
60    crate::home::check_absolute(ctx)?;
61    Ok(Permit {
62        _only_the_gate_makes_one: (),
63    })
64}
65
66/// Something to know about that did not stop the operation.
67#[derive(Debug)]
68#[non_exhaustive]
69pub enum Warning {
70    /// An earlier switch had been interrupted; this run found what it did and recorded it.
71    Recovered(Recovered),
72    /// The login moved, but what the tool caches about who is signed in still names the
73    /// previous account.
74    ConfigNotUpdated(Error),
75    /// Parked logins no longer in use that could not be deleted yet.
76    ParksPendingRemoval(usize),
77    /// The service refuses a parked login for good, so it was dropped.
78    ParkedLoginRefused {
79        tool: ProviderId,
80        label: String,
81    },
82    RenewalFailed(Error),
83    /// The tool's write lock stopped being Pitboard's while a change was under way.
84    LockCompromised {
85        tool: ProviderId,
86    },
87    /// The environment authenticates the tool some other way, so the login Pitboard moved
88    /// is not the one a session will use.
89    AuthOverridden {
90        tool: ProviderId,
91        names: Vec<String>,
92    },
93    /// The tool keeps another login in a file behind the store in use, which a session that
94    /// cannot read that store signs in with, and which no switch reaches.
95    FallbackLogin {
96        tool: ProviderId,
97        path: std::path::PathBuf,
98    },
99    /// The login was too large for `security`'s stdin, so it went on the argument line.
100    WrittenOnTheCommandLine {
101        tool: ProviderId,
102        bytes: usize,
103        limit: usize,
104    },
105    /// Sessions of a tool that never follows a switch on its own were running when it
106    /// happened, and go on using the account they started with until they are restarted.
107    /// `holding` is what was running, by kind, never empty.
108    SessionsStillRunning {
109        from: String,
110        holding: Vec<crate::holder::Holding>,
111    },
112    /// A sign-in put a new login in use in place of the old one of the same account, and
113    /// sessions of a tool that never reads its login again were running with the old one.
114    SessionsKeepTheOldLogin {
115        label: String,
116        holding: Vec<crate::holder::Holding>,
117    },
118    /// A switch of a tool that never follows one on its own, where the process list could
119    /// not be read: nobody can say whether anything of the tool started before it is still
120    /// running, and still using `from`. Not the same as nothing running, which says nothing.
121    SessionsUnknown {
122        tool: ProviderId,
123        from: String,
124    },
125    /// A sign-in that put a new login of `label` in use, of a tool that never reads its login
126    /// again, where the process list could not be read: nobody can say whether anything of
127    /// the tool is still running with the old login.
128    SessionsUnknownAfterSignIn {
129        tool: ProviderId,
130        label: String,
131    },
132    /// A sign-in to the account Pitboard last recorded in use was parked rather than put in
133    /// use, because nobody could say whose login the tool has in use, for `why`.
134    SignInParkedNotInUse {
135        tool: ProviderId,
136        label: String,
137        why: String,
138    },
139    /// An interrupted switch is waiting that the next change cannot finish, and every change
140    /// stops at it until it can be finished or is given up on. The refusal that change would
141    /// make, said by a read so that nobody has to make a change to find out.
142    SwitchStuck(Error),
143    /// This process may change nothing, because it runs as root or under sudo, or nobody
144    /// could tell whether it does, so a read answers from what Pitboard last measured: it
145    /// renews nothing, asks nobody and writes nothing. `why` is how it runs, as the host
146    /// said it, and `None` where the host could not say.
147    ReadOnly {
148        why: Option<&'static str>,
149    },
150}
151
152impl Warning {
153    /// Stable, for a program to branch on.
154    pub fn code(&self) -> &'static str {
155        match self {
156            Warning::Recovered(r) => r.code(),
157            Warning::LockCompromised { .. } => "lock_compromised",
158            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
159                e.code()
160            }
161            Warning::ParksPendingRemoval(_) => "parks_pending_removal",
162            Warning::ParkedLoginRefused { .. } => "parked_login_refused",
163            Warning::AuthOverridden { .. } => "auth_overridden",
164            Warning::FallbackLogin { .. } => "fallback_login",
165            Warning::WrittenOnTheCommandLine { .. } => "written_on_the_command_line",
166            Warning::SessionsStillRunning { .. } => "sessions_still_running",
167            Warning::SessionsKeepTheOldLogin { .. } => "sessions_keep_old_login",
168            Warning::SessionsUnknown { .. } | Warning::SessionsUnknownAfterSignIn { .. } => {
169                "sessions_unknown"
170            }
171            Warning::SignInParkedNotInUse { .. } => "sign_in_parked_not_in_use",
172            Warning::ReadOnly { .. } => "read_only",
173        }
174    }
175}
176
177impl fmt::Display for Warning {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match self {
180            Warning::Recovered(r) => write!(f, "{r}"),
181            Warning::LockCompromised { tool } => write!(
182                f,
183                "{} reclaimed the credential write lock while this change was under way, so \
184                 it may have written the login at the same time. Pitboard read the slot back \
185                 and the change stood, but check with `pitboard` that the right account is \
186                 signed in.",
187                tool.name()
188            ),
189            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
190                write!(f, "{e}")
191            }
192            Warning::ParksPendingRemoval(count) => write!(
193                f,
194                "{count} parked login(s) no longer in use could not be removed yet; Pitboard \
195                 tries again on its next change"
196            ),
197            Warning::ParkedLoginRefused { tool, label } => write!(
198                f,
199                "{} no longer accepts the parked login for `{label}`. Run `pitboard enroll \
200                 {label} --sign-in` to sign in to it again.",
201                tool.service()
202            ),
203            Warning::WrittenOnTheCommandLine { tool, bytes, limit } => {
204                write!(
205                    f,
206                    "this login needs {bytes} bytes and `security` reads {limit} from stdin, \
207                     so it was written on the argument line, where a process running as you \
208                     could have read it while the call lasted."
209                )?;
210                if *tool == ProviderId::Claude {
211                    write!(
212                        f,
213                        " Claude Code writes this same login the same way whenever it \
214                         refreshes the token."
215                    )?;
216                }
217                Ok(())
218            }
219            Warning::AuthOverridden { tool, names } => write!(
220                f,
221                "{} is set, so {} signs in with it and not with the login Pitboard moved. \
222                 Unset it for the switch to take effect.",
223                names.join(" and "),
224                tool.name()
225            ),
226            Warning::FallbackLogin { tool, path } => write!(
227                f,
228                "{} holds another {} login, which a session that cannot read the keychain, \
229                 such as one started over SSH, signs in with. No switch reaches it; `pitboard \
230                 doctor` says what to do.",
231                path.display(),
232                tool.name()
233            ),
234            Warning::SessionsStillRunning { from, holding } => write!(
235                f,
236                "{} started before this switch {} still running and still using `{from}`. {} \
237                 Do not sign out in {}: signing out there revokes `{from}`'s login, which \
238                 Pitboard has just parked.",
239                capitalised(&holder::described(holding)),
240                if holder::plural(holding) { "are" } else { "is" },
241                holder::remedies(holding, "to use the new account"),
242                if holder::plural(holding) {
243                    "any of them"
244                } else {
245                    "it"
246                },
247            ),
248            Warning::SessionsKeepTheOldLogin { label, holding } => write!(
249                f,
250                "{} started before this sign-in {} still running and still using `{label}`'s \
251                 old login. {} Otherwise one of them can put the old login back in place of \
252                 the new one when it refreshes its token.",
253                capitalised(&holder::described(holding)),
254                if holder::plural(holding) { "are" } else { "is" },
255                holder::remedies(holding, "to use the new one"),
256            ),
257            Warning::SessionsUnknown { tool, from } => write!(
258                f,
259                "Pitboard could not tell whether {} sessions started before this switch are \
260                 still running, because it could not read the list of processes. Do not sign \
261                 out in one that is: signing out there revokes `{from}`'s login, which \
262                 Pitboard has just parked.",
263                tool.name()
264            ),
265            Warning::SessionsUnknownAfterSignIn { tool, label } => write!(
266                f,
267                "Pitboard could not tell whether {} sessions started before this sign-in are \
268                 still running, because it could not read the list of processes. Quit any \
269                 that are still using `{label}`'s old login and start them again. Otherwise \
270                 one of them can put the old login back in place of the new one when it \
271                 refreshes its token.",
272                tool.name()
273            ),
274            Warning::SignInParkedNotInUse { tool, label, why } => write!(
275                f,
276                "{} goes on with the login it has: Pitboard could not tell whose it is \
277                 ({why}), so it parked the new login for `{label}` rather than write over \
278                 that one. If that login no longer works, run `{}` and sign in to `{label}` \
279                 there.",
280                tool.name(),
281                tool.login_command()
282            ),
283            Warning::ReadOnly { why } => write!(
284                f,
285                "{}, so it changes nothing: these are the numbers it last measured, and it \
286                 renews no parked login and asks nobody. Run it as yourself to ask again.",
287                match why {
288                    Some(why) => format!("Pitboard runs {why}"),
289                    None => "Pitboard cannot tell whether it runs as root or with sudo".into(),
290                }
291            ),
292        }
293    }
294}
295
296#[derive(Debug)]
297pub struct Done<T> {
298    pub value: T,
299    pub warnings: Vec<Warning>,
300}
301
302/// A change that failed, with what was found on the way: recovering an interrupted switch
303/// is reported even when the change that followed it fails.
304#[derive(Debug)]
305pub struct Failed {
306    pub error: Error,
307    pub warnings: Vec<Warning>,
308}
309
310pub type Changing<T> = std::result::Result<Done<T>, Failed>;
311
312pub struct Pitboard {
313    ctx: Context,
314}
315
316impl Pitboard {
317    pub fn new(ctx: Context) -> Pitboard {
318        Pitboard { ctx }
319    }
320
321    /// Whether this process may change anything here, as the one gate every change passes
322    /// says, with the proof it hands out where it may. Every change below asks it first,
323    /// before it reads, locks or records anything, so a change refused here leaves the
324    /// Pitboard directory, the stores of logins and the scheduler exactly as they were, the
325    /// audit log included. A front end asks it too before it writes a file of its own
326    /// through [`crate::app::write_file`], or before it asks a question whose answer could
327    /// only be refused.
328    pub fn permit(&self) -> Result<Permit> {
329        gate(&self.ctx)
330    }
331
332    /// Whether every home the environment names is a full path, as the gate and every read
333    /// of the accounts ask. A front end asks it before anything else that reads under one,
334    /// such as the audit log, the schedule's file or the status line's session records, so
335    /// a home that is empty or relative is refused there too, never read as the folder the
336    /// front end runs in.
337    pub fn check_homes(&self) -> Result<()> {
338        crate::home::check_absolute(&self.ctx)
339    }
340
341    /// The gate's answer as the refusal of a change, which has found nothing on the way.
342    fn permitted(&self) -> std::result::Result<Permit, Failed> {
343        self.permit().map_err(|error| Failed {
344            error,
345            warnings: Vec::new(),
346        })
347    }
348
349    /// Who is signed in and what every account has left. Parked logins whose access has
350    /// lapsed are renewed first, so every account is asked live.
351    ///
352    /// `fresh` asks Anthropic about every account whatever was asked recently. Ordinarily
353    /// false: a number is only asked for again once the tightest limit it describes could
354    /// have moved by a percentage point, which collapses several front ends on one machine
355    /// to one request per account per few minutes.
356    ///
357    /// An interrupted switch the next change cannot finish is said as that change would
358    /// refuse over it, and nothing is done about it: every change stops at it until it can
359    /// be finished or is given up on, and a front end that waited for a change to be
360    /// refused could not offer to give up on it until then. One the next change finishes by
361    /// itself is not said. Nobody has anything to do about it: the rows already say who is
362    /// signed in from each tool's login rather than from Pitboard's record, and the change
363    /// that finishes it says what it found.
364    ///
365    /// Where this process may change nothing, it answers what [`status_offline`] answers,
366    /// with a `read_only` warning that says why: renewing a parked login rotates its refresh
367    /// token, a live read records what it measured, and neither may happen then.
368    ///
369    /// [`status_offline`]: Pitboard::status_offline
370    pub fn status(&self, fresh: bool) -> Result<Done<status::Report>> {
371        let permit = match self.permit() {
372            Ok(permit) => permit,
373            Err(Error::Elevated { why }) => {
374                let mut read = self.status_offline()?;
375                read.warnings.push(Warning::ReadOnly { why });
376                return Ok(read);
377            }
378            Err(other) => return Err(other),
379        };
380        let renewed = switch::renew_parked(&self.ctx, permit);
381        // Unreadable is not the same as empty: reporting it as empty would say the enrolled
382        // logins are gone.
383        let state = state::load(&self.ctx)?;
384        // Alongside the report rather than before it. Where only the service can say whose
385        // the live login is, both ask it, and asked one after the other, a service that did
386        // not answer held the read for its timeout twice.
387        let (stuck, report) = std::thread::scope(|scope| {
388            let stuck = scope.spawn(|| switch::stuck(&self.ctx, &state, switch::Asking::Service));
389            let report = status::gather(&self.ctx, permit, &state, fresh);
390            (stuck.join().ok().flatten(), report)
391        });
392        let mut warnings: Vec<Warning> = stuck.map(Warning::SwitchStuck).into_iter().collect();
393        for (key, outcome) in renewed {
394            audit::record(&self.ctx, permit, "renew", &key.typed(), outcome.code());
395            match outcome {
396                Renewal::Refused => warnings.push(Warning::ParkedLoginRefused {
397                    tool: key.provider,
398                    label: state.typed(&key),
399                }),
400                Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
401                Renewal::Renewed | Renewal::Deferred => {}
402            }
403        }
404        Ok(Done {
405            value: report,
406            warnings,
407        })
408    }
409
410    pub fn doctor(&self) -> Diagnosis {
411        doctor::run(&self.ctx)
412    }
413
414    /// The same report without asking anyone: the last numbers Pitboard measured, and who
415    /// each tool's own files say is signed in. Nothing is renewed and nothing is asked, so
416    /// it answers at once wherever there is no network.
417    ///
418    /// An interrupted switch the next change cannot finish is said here too, as [`status`]
419    /// says it, wherever that can be told without a request: a Claude Code login renewed
420    /// since the switch stopped is one only Anthropic can say whose it is, and then nothing
421    /// is said. Telling reads the tool's login and the copy the switch parked, which on
422    /// macOS are keychain items, and only while a switch is waiting: otherwise one look at
423    /// whether its record is there is the whole of it.
424    ///
425    /// [`status`]: Pitboard::status
426    pub fn status_offline(&self) -> Result<Done<status::Report>> {
427        let state = state::load(&self.ctx)?;
428        let warnings = switch::stuck(&self.ctx, &state, switch::Asking::Nobody)
429            .map(Warning::SwitchStuck)
430            .into_iter()
431            .collect();
432        Ok(Done {
433            value: status::gather_offline(&self.ctx, &state),
434            warnings,
435        })
436    }
437
438    /// The status line for Claude Code's session JSON. Reads only files, and writes only
439    /// Pitboard's own: what the session passed, for its next run to compare with, and the
440    /// usage readings, which keep what moved since its last run where it is newer. Where
441    /// this process may change nothing it writes neither, and draws the line from the files
442    /// as they are.
443    pub fn statusline(&self, session: &str) -> statusline::StatusLine {
444        statusline::read(&self.ctx, self.permit().ok(), session)
445    }
446
447    /// The enrolled account under `typed`, if any, read without taking the lock.
448    pub fn account(&self, typed: &str) -> Option<Account> {
449        let state = state::load(&self.ctx).ok()?;
450        crate::label::resolve(&state, typed).ok().cloned()
451    }
452
453    pub fn switch_to(&self, typed: &str) -> Changing<Outcome> {
454        let permit = self.permitted()?;
455        let key = self.named(permit, "use", typed)?;
456        self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
457            switch::switch(settled, &key)
458        })
459    }
460
461    /// Switches Claude Code by itself where a limit of the account in use has reached
462    /// `threshold` and another account has room, as [`crate::autoswitch`] says. For a front
463    /// end somebody asked to do that: the app with its setting on, or `pitboard watch`.
464    ///
465    /// Looks first from files alone, so a look that finds nothing to do takes no lock, asks
466    /// nobody and records nothing. A switch is decided again under the lock, and is recorded
467    /// in the audit log as `auto-switch`, with what it came to; nothing else here is.
468    pub fn auto_switch(
469        &self,
470        threshold: crate::autoswitch::Threshold,
471    ) -> Changing<crate::autoswitch::Auto> {
472        let permit = self.permitted()?;
473        let state = state::load(&self.ctx).map_err(|error| Failed {
474            error,
475            warnings: Vec::new(),
476        })?;
477        let plan = match crate::autoswitch::look(&self.ctx, &state, threshold) {
478            crate::autoswitch::Next::Say(auto) => {
479                return Ok(Done {
480                    value: auto,
481                    warnings: Vec::new(),
482                });
483            }
484            crate::autoswitch::Next::Switch(plan) => plan,
485        };
486        let subject = state.typed(&plan.to);
487        self.changing(
488            permit,
489            "auto-switch",
490            &subject,
491            Some(ProviderId::Claude),
492            |settled| switch::automatically(settled, &plan, threshold),
493        )
494    }
495
496    /// Which account somebody meant, as the key the engine looks accounts up by.
497    ///
498    /// Resolving here rather than deeper down means every command takes `codex/work` and
499    /// a bare `work` on the same terms, and the one place that decides what an ambiguous
500    /// bare label does is the one place that knows every provider's accounts.
501    fn named(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
502        let state = state::load(&self.ctx).map_err(|error| Failed {
503            error,
504            warnings: Vec::new(),
505        })?;
506        crate::label::resolve(&state, typed)
507            .map(Account::key)
508            .map_err(|error| self.refused(permit, verb, typed, error.code(), error))
509    }
510
511    /// `typed` may name a tool, as in `claude/work`. A bare name means the default tool.
512    pub fn enroll_current(&self, typed: &str) -> Changing<Enrolled> {
513        let permit = self.permitted()?;
514        let key = self.chosen(permit, "enroll", typed)?;
515        self.changing(
516            permit,
517            "enroll",
518            &key.typed(),
519            Some(key.provider),
520            |settled| switch::enroll(settled, &key, None),
521        )
522    }
523
524    /// Which tool a new account is for, and what it is called there.
525    ///
526    /// Split here rather than deeper down so nothing below ever sees a name with a tool
527    /// still stuck to the front of it, which would enrol an account literally called
528    /// `claude/work`.
529    fn chosen(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
530        self.enrolling(typed)
531            .map_err(|error| self.refused(permit, verb, typed, "label_unusable", error))
532    }
533
534    /// A change refused over the name it was given, which happens before it settles.
535    ///
536    /// A mistyped name takes no lock and writes nothing but its line in the audit log. But
537    /// every change settles an interrupted switch first, and one refused here would leave
538    /// that switch for whatever runs next and say nothing about it. So where a switch was
539    /// interrupted, this settles for the tool that switch was of, which a custom OAuth
540    /// endpoint allows or refuses exactly as it would a change to that tool, and reports what
541    /// it found beside the refusal. Only as far as it can: a recovery that cannot finish is
542    /// the next change's to report, and what this one reports is why it was refused.
543    fn refused(
544        &self,
545        permit: Permit,
546        verb: &str,
547        subject: &str,
548        code: &str,
549        error: Error,
550    ) -> Failed {
551        let recovered = if switch::interrupted(&self.ctx) {
552            switch::settle(&self.ctx, permit, switch::interrupted_tool(&self.ctx))
553                .ok()
554                .and_then(|(_, recovered)| recovered)
555        } else {
556            None
557        };
558        let mut warnings = Vec::new();
559        if let Some(r) = recovered {
560            audit::record(&self.ctx, permit, "recover", &r.to, r.code());
561            warnings.push(Warning::Recovered(r));
562        }
563        audit::record(&self.ctx, permit, verb, subject, code);
564        Failed { error, warnings }
565    }
566
567    /// The account `pitboard enroll <typed>` is about: the one already enrolled under that
568    /// exact name, or a new one of the tool the name says.
569    ///
570    /// A label written by 0.1.x could contain a slash, which a new name cannot, and signing
571    /// in to such an account again is exactly what every message about a lapsed park tells
572    /// somebody to do. So an existing account is found by its whole name first.
573    fn enrolling(&self, typed: &str) -> Result<Key> {
574        if typed.contains(crate::label::SEPARATOR)
575            && let Ok(state) = state::load(&self.ctx)
576            && let Some(existing) = state.accounts.iter().find(|a| a.label == typed)
577        {
578            return Ok(existing.key());
579        }
580        crate::label::choose(typed)
581            .map(|chosen| Key::new(chosen.provider, chosen.label))
582            .map_err(Error::Usage)
583    }
584
585    /// The enrolled account `pitboard enroll <typed>` would sign in to again, if it names
586    /// one, for saying whose login to sign in with before a browser opens.
587    pub fn account_to_enroll(&self, typed: &str) -> Option<Account> {
588        let key = self.enrolling(typed).ok()?;
589        state::load(&self.ctx).ok()?.get(&key).cloned()
590    }
591
592    /// What to type to name the account `pitboard enroll <typed>` is about, on this
593    /// machine: bare where that names it alone, qualified where another tool shares it.
594    pub fn name_to_type(&self, typed: &str) -> String {
595        let Ok(key) = self.enrolling(typed) else {
596            return typed.to_string();
597        };
598        state::load(&self.ctx).map_or_else(|_| key.typed(), |state| state.typed(&key))
599    }
600
601    /// The tool's own sign-in in a private directory, for the tool `typed` names. It takes
602    /// no lock but its own, so a person taking their time in a browser never holds up a
603    /// switch.
604    pub fn sign_in(&self, typed: &str) -> std::result::Result<SignIn, Failed> {
605        let permit = self.permitted()?;
606        let key = self.signing_in(permit, typed)?;
607        switch::sign_in(&self.ctx, permit, key.provider)
608            .map_err(|error| self.not_started(permit, typed, error))
609    }
610
611    /// The same sign-in with its output piped, for a front end that has no terminal to
612    /// hand over. The caller shows what the tool says and can type a code back.
613    pub fn sign_in_watched(
614        &self,
615        typed: &str,
616    ) -> std::result::Result<switch::WatchedSignIn, Failed> {
617        let permit = self.permitted()?;
618        let key = self.signing_in(permit, typed)?;
619        switch::sign_in_watched_as(&self.ctx, permit, &key)
620            .map_err(|error| self.not_started(permit, typed, error))
621    }
622
623    /// Which account a sign-in is for, once everything that could refuse it has been asked.
624    ///
625    /// A name no account could have is refused the way every change refuses one, settling
626    /// an interrupted switch on the way; anything else refused here is recorded and nothing
627    /// more, since nothing was about to change.
628    fn signing_in(&self, permit: Permit, typed: &str) -> std::result::Result<Key, Failed> {
629        let key = self.chosen(permit, "enroll", typed)?;
630        self.ready_to_sign_in(key.provider)
631            .map_err(|error| self.not_started(permit, typed, error))?;
632        Ok(key)
633    }
634
635    /// A sign-in that did not start, or did not finish, for a reason other than its name.
636    fn not_started(&self, permit: Permit, typed: &str, error: Error) -> Failed {
637        audit::record(&self.ctx, permit, "enroll", typed, error.code());
638        Failed {
639            error,
640            warnings: Vec::new(),
641        }
642    }
643
644    /// Checked before a sign-in starts, so a person does not sign in through a browser
645    /// only to be told the state file belongs to another machine, that the tool is not
646    /// installed, or that the account could never be switched to afterwards.
647    fn ready_to_sign_in(&self, tool: crate::provider::ProviderId) -> Result<()> {
648        if tool == crate::provider::ProviderId::Claude && self.ctx.custom_oauth() {
649            return Err(Error::CustomOauthEndpoint);
650        }
651        state::load(&self.ctx)?;
652        let driver = crate::provider::of(tool);
653        // An account signed in here is one to switch to later, which needs a live store
654        // Pitboard can write. Asked now rather than after a browser round trip.
655        switch::live_store(&self.ctx, tool)?;
656        // A private sign-in works by pointing the tool's own login at a scratch directory
657        // through its home variable. Where that does not really isolate it, running one
658        // would write over the login somebody is using, and there is no override: forcing
659        // past "this could touch your live login" is what the rule exists to prevent.
660        if let crate::provider::Isolation::NotIsolated { reason } =
661            driver.private_signin_isolation(&self.ctx)
662        {
663            return Err(Error::SignInNotIsolated { reason });
664        }
665        if driver.program(&self.ctx).is_none() {
666            return Err(Error::ProgramMissing {
667                tool,
668                program: self.ctx.program_for(tool).display().to_string(),
669            });
670        }
671        Ok(())
672    }
673
674    pub fn enroll_signed_in(&self, typed: &str, login: SignIn) -> Changing<Enrolled> {
675        let permit = self.permitted()?;
676        let key = self.chosen(permit, "enroll", typed)?;
677        self.changing(
678            permit,
679            "enroll",
680            &key.typed(),
681            Some(key.provider),
682            |settled| switch::enroll(settled, &key, Some(login)),
683        )
684    }
685
686    /// Returns the account's email.
687    pub fn forget(&self, typed: &str) -> Changing<String> {
688        let permit = self.permitted()?;
689        let key = self.named(permit, "forget", typed)?;
690        self.changing(
691            permit,
692            "forget",
693            &key.typed(),
694            Some(key.provider),
695            |settled| switch::forget(settled, &key),
696        )
697    }
698
699    /// Throws away a record of an interrupted switch that cannot be finished, keeping
700    /// every login it names. The way out when recovery cannot reach Anthropic.
701    pub fn abandon_recovery(&self) -> Result<Option<switch::Abandoned>> {
702        let permit = self.permit()?;
703        let outcome = switch::abandon(&self.ctx, permit);
704        audit::record(
705            &self.ctx,
706            permit,
707            "abandon",
708            "",
709            match &outcome {
710                Ok(_) => "ok",
711                Err(e) => e.code(),
712            },
713        );
714        outcome
715    }
716
717    /// Renew every parked login that is due, and nothing else. No switch, no usage, and
718    /// no request but the token exchange. This is what the schedule runs.
719    ///
720    /// Refused where this process may change nothing, as every change is, so a run that
721    /// renewed nothing for that reason says so rather than reading as one where nothing was
722    /// due.
723    ///
724    /// A run the schedule started renews the default home, whatever `PITBOARD_HOME` says
725    /// ([`schedule::for_its_run`]): the schedule is that home's alone.
726    pub fn renew(&self) -> Result<Vec<(Key, Renewal)>> {
727        let ctx = schedule::for_its_run(&self.ctx);
728        let permit = gate(&ctx)?;
729        let outcomes = switch::renew_due(&ctx, permit, switch::Due::ToStayAlive);
730        for (key, outcome) in &outcomes {
731            audit::record(&ctx, permit, "renew", &key.typed(), outcome.code());
732        }
733        Ok(outcomes)
734    }
735
736    /// Whether anything is keeping parked logins alive on this machine without somebody
737    /// running a command.
738    pub fn schedule(&self) -> schedule::Installed {
739        schedule::status(&self.ctx)
740    }
741
742    /// Whether the schedule renews this Pitboard's own parked logins: whether its home is
743    /// the default home, `~/.pitboard`, compared as a path. The schedule renews that home
744    /// alone, and `schedule_install` is refused from any other.
745    pub fn schedule_renews_this_home(&self) -> bool {
746        schedule::serves(&self.ctx)
747    }
748
749    /// Ask the platform's own scheduler to run `renew` daily. Opt-in, and stays opt-in.
750    /// Refused with `schedule_not_default_home` where this is not the default home
751    /// ([`Pitboard::schedule_renews_this_home`]).
752    pub fn schedule_install(&self) -> Result<std::path::PathBuf> {
753        schedule::install(&self.ctx, self.permit()?)
754    }
755
756    /// Take it away. `false` when there was nothing installed.
757    pub fn schedule_uninstall(&self) -> Result<bool> {
758        schedule::uninstall(&self.ctx, self.permit()?)
759    }
760
761    /// Point a schedule an app up to 0.3.0 wrote, which runs the app itself, at the command
762    /// line this context names. `true` when it did; nothing changes otherwise.
763    pub fn schedule_repair(&self) -> Result<bool> {
764        schedule::repair(&self.ctx, self.permit()?)
765    }
766
767    /// Take over a Pitboard directory another machine wrote: keep the accounts, drop the
768    /// logins that came with them. `None` when the directory was already this machine's.
769    ///
770    /// The one change that does not settle first, because a stamp from elsewhere is what
771    /// stops settling. Everything after it settles normally.
772    pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
773        switch::adopt(&self.ctx, self.permit()?)
774    }
775
776    /// Ask the credential store what parked logins are on this machine, and give back or
777    /// delete every one Pitboard's own records do not name. Ordinarily there is nothing to
778    /// do: every change resolves the names it wrote down. This is for a machine whose state
779    /// file was lost or restored from a backup, where the store is the only record left.
780    pub fn repair(&self) -> Changing<switch::Reclaimed> {
781        let permit = self.permitted()?;
782        self.changing(permit, "repair", "", None, |settled| {
783            switch::repair(settled).map(|r| (r, Vec::new()))
784        })
785    }
786
787    /// What is running `which`'s tool with a login in memory that a switch would leave it
788    /// on, by kind: for a front end to say so, or to offer to quit an app, before switching.
789    /// Empty where nothing is, where the tool follows a switch by itself, or where nobody
790    /// could tell. Reads the process list and nothing else.
791    pub fn holding(&self, which: ProviderId) -> Vec<holder::Holding> {
792        match switch::still_holding(&self.ctx, which) {
793            switch::StillHolding::These(holding) => holding,
794            switch::StillHolding::Nothing | switch::StillHolding::Unknown => Vec::new(),
795        }
796    }
797
798    /// When Pitboard's account index last changed, for a front end that wants to know
799    /// whether another one has done something without asking Anthropic about it.
800    pub fn changed_at(&self) -> i64 {
801        state::changed_at(&self.ctx)
802    }
803
804    /// When Pitboard's usage readings last changed, in epoch milliseconds, for a front end
805    /// that shows them to follow what the others record without asking anyone.
806    pub fn readings_changed_at(&self) -> i64 {
807        readings::changed_at(&self.ctx)
808    }
809
810    /// What the app keeps in `file` in Pitboard's directory, or `None` where it keeps nothing
811    /// there. One that is there and cannot be read is an error.
812    pub fn app_file(&self, file: crate::app::AppFile) -> std::io::Result<Option<String>> {
813        crate::app::read_app_file(&self.ctx, file)
814    }
815
816    /// Keeps `body` in the app's `file` in Pitboard's directory, private and whole, as the
817    /// core writes its own files.
818    pub fn keep_app_file(&self, file: crate::app::AppFile, body: &str) -> Result<()> {
819        crate::app::write_app_file(&self.ctx, self.permit()?, file, body)
820    }
821
822    /// The changes Pitboard has made, newest last.
823    pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
824        audit::read(&self.ctx, limit)
825    }
826
827    /// Takes away the daily renewal schedule, deletes every parked login this Pitboard
828    /// wrote, and removes Pitboard's own directory. Each tool's login is left alone: whoever
829    /// is signed in stays signed in.
830    pub fn uninstall(&self) -> Changing<switch::Removed> {
831        let permit = self.permitted()?;
832        self.changing(permit, "uninstall", "", None, |settled| {
833            switch::uninstall(settled).map(|r| (r, Vec::new()))
834        })
835    }
836
837    /// Returns the account's email.
838    /// `from` may be qualified; `to` is a plain name, and stays inside whichever provider
839    /// the account already belongs to. Renaming cannot move an account between tools.
840    pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
841        let permit = self.permitted()?;
842        let from = self.named(permit, "rename", from)?;
843        let chosen = self.chosen(permit, "rename", to)?;
844        // Only a prefix somebody actually typed can disagree: a bare new name stays inside
845        // the account's own tool whatever tool a bare name would mean for a new account.
846        if to.contains(crate::label::SEPARATOR) && chosen.provider != from.provider {
847            let error = Error::Usage(format!(
848                "`{from}` is a {} account, and a rename cannot move it to {}. Sign in to that \
849                 tool and enrol the account there instead.",
850                from.provider, chosen.provider
851            ));
852            return Err(self.refused(permit, "rename", &from.typed(), error.code(), error));
853        }
854        let to = chosen.label;
855        self.changing(
856            permit,
857            "rename",
858            &format!("{from} -> {to}"),
859            Some(from.provider),
860            |settled| switch::rename(settled, &from, &to).map(|email| (email, Vec::new())),
861        )
862    }
863
864    /// Settles, runs the change, and records it in the audit log.
865    ///
866    /// `tool` is the tool whose login the change is about, where it is about one: its own
867    /// ways of being signed in by something else are what is worth warning about, and a
868    /// refusal that is one tool's business does not stop a change to another's.
869    fn changing<T: Audited>(
870        &self,
871        permit: Permit,
872        verb: &str,
873        subject: &str,
874        tool: Option<ProviderId>,
875        run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
876    ) -> Changing<T> {
877        let (settled, recovered) = switch::settle(&self.ctx, permit, tool).map_err(|error| {
878            audit::record(&self.ctx, permit, verb, subject, error.code());
879            Failed {
880                error,
881                warnings: Vec::new(),
882            }
883        })?;
884        let mut warnings = Vec::new();
885        // Read from files as well as from this process's environment, so the app, which
886        // has no shell environment at all, gets the same answer as the command line.
887        if let Some(tool) = tool {
888            let names = crate::provider::of(tool).overridden_by(&self.ctx);
889            if !names.is_empty() {
890                warnings.push(Warning::AuthOverridden { tool, names });
891            }
892            if let Some(path) = crate::provider::of(tool).fallback_login(&self.ctx) {
893                warnings.push(Warning::FallbackLogin { tool, path });
894            }
895        }
896        if let Some(r) = recovered {
897            audit::record(&self.ctx, permit, "recover", &r.to, r.code());
898            warnings.push(Warning::Recovered(r));
899        }
900        match run(settled) {
901            Ok((value, more)) => {
902                if value.recorded() {
903                    audit::record(&self.ctx, permit, verb, subject, value.audit_code());
904                }
905                warnings.extend(more);
906                Ok(Done { value, warnings })
907            }
908            Err(mut error) => {
909                audit::record(&self.ctx, permit, verb, subject, error.code());
910                warnings.extend(error.take_warnings());
911                Err(Failed { error, warnings })
912            }
913        }
914    }
915}
916
917/// How a successful change is written in the audit log.
918trait Audited {
919    fn audit_code(&self) -> &'static str {
920        "ok"
921    }
922
923    /// Whether it changed anything worth a line. A change that found, once it held the lock,
924    /// that there was nothing to do, did nothing.
925    fn recorded(&self) -> bool {
926        true
927    }
928}
929
930impl Audited for crate::autoswitch::Auto {
931    fn recorded(&self) -> bool {
932        matches!(self, crate::autoswitch::Auto::Switched { .. })
933    }
934}
935
936impl Audited for Outcome {
937    fn audit_code(&self) -> &'static str {
938        match self {
939            Outcome::Switched { .. } => "ok",
940            Outcome::AlreadyActive { .. } => "already_active",
941        }
942    }
943}
944
945impl Audited for switch::Reclaimed {}
946impl Audited for Enrolled {}
947impl Audited for String {}
948
949impl Audited for switch::Removed {
950    fn audit_code(&self) -> &'static str {
951        if self.pending > 0 {
952            "parks_pending_removal"
953        } else {
954            "ok"
955        }
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use crate::switch::harness::{Machine, codex_machine, hold, machine};
963    use std::collections::BTreeMap;
964    use std::sync::Arc;
965
966    type Make = fn(&str) -> Machine;
967
968    /// A name is refused the same way whichever tool it is for.
969    const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
970
971    type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
972
973    /// Every way a change is refused over the name it was given, with the verb the audit
974    /// log records it under, the code it is refused with and the one the log records: a
975    /// name nobody enrolled, a name no account could have, whether the account signed in
976    /// now is being enrolled or one is being signed in to, and a new name that would move
977    /// an account to another tool.
978    const REFUSALS: [(&str, &str, &str, &str, Refuse); 4] = [
979        (
980            "use",
981            "use",
982            "account_unknown",
983            "account_unknown",
984            |p, _| p.switch_to("nobody").err(),
985        ),
986        ("enroll", "enroll", "usage", "label_unusable", |p, _| {
987            p.enroll_current("codx/work").err()
988        }),
989        ("sign-in", "enroll", "usage", "label_unusable", |p, _| {
990            p.sign_in("codx/work").err()
991        }),
992        ("rename", "rename", "usage", "usage", |p, tool| {
993            let other = if tool == ProviderId::Claude {
994                ProviderId::Codex
995            } else {
996                ProviderId::Claude
997            };
998            p.rename(
999                &Key::new(tool, "here").qualified(),
1000                &Key::new(other, "moved").qualified(),
1001            )
1002            .err()
1003        }),
1004    ];
1005
1006    /// A switch from `here` to `there` killed after parking `here` and before installing
1007    /// `there`, so its record is all that says it happened.
1008    fn interrupted(make: Make, name: &str) -> Machine {
1009        let m = make(name);
1010        let settled = switch::settle(&m.ctx, Permit::for_a_test(), None)
1011            .expect("nothing to recover yet")
1012            .0;
1013        let died = crate::fault::killing("switch.park_recorded", || {
1014            switch::switch(settled, &m.key("there"))
1015        });
1016        assert_eq!(died.unwrap_err(), "switch.park_recorded");
1017        assert!(switch::interrupted(&m.ctx));
1018        m
1019    }
1020
1021    /// The same switch, which the next change cannot finish: nothing is signed in to the
1022    /// tool any more, so nothing says which side of the switch won. Told apart without
1023    /// asking anyone, for either tool.
1024    fn stuck(make: Make, name: &str) -> Machine {
1025        let m = interrupted(make, name);
1026        m.fault_live(crate::store::memory::Fault::Vanish);
1027        m
1028    }
1029
1030    /// The switch interrupted on a Claude Code machine, which the next change cannot finish
1031    /// either, though only Anthropic could tell: Claude Code has since renewed the login, so
1032    /// it matches neither side the record kept, and its session has expired, so Anthropic
1033    /// will not say whose it is. An access token the scripted service does not know is one
1034    /// it refuses.
1035    fn stuck_unless_asked(name: &str) -> Machine {
1036        let m = interrupted(machine, name);
1037        m.sign_in(&crate::switch::harness::document("renewed-since"));
1038        m
1039    }
1040
1041    /// The warnings a read gives, by code and words.
1042    fn said(read: &Done<status::Report>) -> Vec<(&'static str, String)> {
1043        read.warnings
1044            .iter()
1045            .map(|w| (w.code(), w.to_string()))
1046            .collect()
1047    }
1048
1049    /// Everything a read must leave as it found it: Pitboard's files but the records a
1050    /// live read keeps by design (what it measured and when to ask again), every parked
1051    /// login and the live one.
1052    type Untouched = (
1053        BTreeMap<String, Vec<u8>>,
1054        Vec<(String, Option<String>)>,
1055        Option<serde_json::Value>,
1056    );
1057
1058    fn untouched(m: &Machine) -> Untouched {
1059        let kept_by_a_read = ["usage.json", "usage.lock", "asking.json"];
1060        let mut files = files(m);
1061        files.retain(|path, _| !kept_by_a_read.iter().any(|kept| path.ends_with(kept)));
1062        let vault = m.mem.vault();
1063        let parked = vault
1064            .services()
1065            .into_iter()
1066            .map(|service| {
1067                let held = vault.peek(&service);
1068                (service, held)
1069            })
1070            .collect();
1071        (files, parked, m.live())
1072    }
1073
1074    /// A switch says when Claude Code keeps another login in its fallback file, which a
1075    /// session that cannot read the keychain signs in with and no switch reaches. Codex has
1076    /// no store behind the one in use, so nothing is said of it.
1077    #[test]
1078    fn a_switch_says_a_login_is_left_where_it_does_not_reach() {
1079        let m = machine("switch-fallback-login");
1080        let left = crate::provider::claude::live::credential_file(&m.ctx);
1081        m.mem.file_at(left.clone()).plant(
1082            &crate::provider::claude::paths::live_service(&m.ctx),
1083            &crate::switch::harness::document("left-by-a-sign-in").to_string(),
1084        );
1085        let switched = Pitboard::new(m.ctx.clone())
1086            .switch_to("there")
1087            .expect("a switch");
1088        let said: Vec<String> = switched
1089            .warnings
1090            .iter()
1091            .filter(|w| w.code() == "fallback_login")
1092            .map(ToString::to_string)
1093            .collect();
1094        assert_eq!(said.len(), 1, "{:?}", switched.warnings);
1095        assert!(
1096            said[0].starts_with(&left.display().to_string()),
1097            "{}",
1098            said[0]
1099        );
1100
1101        for (tool, make) in MACHINES {
1102            let m = make(&format!("switch-nothing-left-{tool}"));
1103            let switched = Pitboard::new(m.ctx.clone())
1104                .switch_to(&m.key("there").typed())
1105                .expect("a switch");
1106            assert!(
1107                switched
1108                    .warnings
1109                    .iter()
1110                    .all(|w| w.code() != "fallback_login"),
1111                "{tool}: {:?}",
1112                switched.warnings
1113            );
1114        }
1115    }
1116
1117    /// A switch nothing can finish was said only by a change refused over it. The app looks
1118    /// for it in what a read says, so it never offered to give up on one, and `pitboard
1119    /// status` never said it at all. A read says it now, in the words of the refusal, and
1120    /// changes nothing: the record is kept for whatever finishes it or gives up on it.
1121    #[test]
1122    fn a_read_says_an_interrupted_switch_it_cannot_finish_is_waiting() {
1123        for (tool, make) in MACHINES {
1124            let m = stuck(make, &format!("read-stuck-{tool}"));
1125            let pitboard = Pitboard::new(m.ctx.clone());
1126            let before = untouched(&m);
1127
1128            let read = pitboard
1129                .status(false)
1130                .expect("a read is not refused over it");
1131
1132            assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1133            assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1134            let refused = pitboard
1135                .switch_to(&m.key("there").typed())
1136                .expect_err("every change stops at it");
1137            assert_eq!(refused.error.code(), "recovery_undetermined", "{tool}");
1138            assert_eq!(
1139                said(&read),
1140                [("recovery_undetermined", refused.error.to_string())],
1141                "{tool}: said in the refusal's own words"
1142            );
1143        }
1144    }
1145
1146    /// Where only the service can say whose the live login is, a read asks it, as the next
1147    /// change would.
1148    #[test]
1149    fn a_read_asks_whose_the_login_is_where_only_the_service_can_say() {
1150        let m = stuck_unless_asked("read-stuck-asked");
1151        let pitboard = Pitboard::new(m.ctx.clone());
1152        let before = untouched(&m);
1153
1154        let read = pitboard.status(false).expect("a read");
1155
1156        assert_eq!(untouched(&m), before, "a read changes nothing");
1157        let refused = pitboard.switch_to("there").expect_err("it stops at it");
1158        assert_eq!(
1159            said(&read),
1160            [("recovery_undetermined", refused.error.to_string())]
1161        );
1162        assert!(
1163            refused.error.to_string().contains("has expired"),
1164            "{}",
1165            refused.error
1166        );
1167    }
1168
1169    /// A switch the next change finishes by itself is not one anybody has to do anything
1170    /// about. The rows already say who is signed in, from the tool's login and not from
1171    /// Pitboard's record, and the change that finishes it says what it found.
1172    #[test]
1173    fn a_read_says_nothing_of_a_switch_the_next_change_finishes() {
1174        for (tool, make) in MACHINES {
1175            let m = interrupted(make, &format!("read-settles-{tool}"));
1176            let before = untouched(&m);
1177
1178            let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
1179
1180            assert_eq!(said(&read), [], "{tool}");
1181            assert_eq!(untouched(&m), before, "{tool}: a read finishes nothing");
1182            assert!(switch::interrupted(&m.ctx), "{tool}");
1183        }
1184    }
1185
1186    /// The read never says a switch is waiting that the next change would not stop at for
1187    /// that reason, and never offers to give up on one that giving up cannot reach. Under a
1188    /// custom Claude Code endpoint every change is refused over the endpoint before it comes
1189    /// to a Claude Code switch, and giving up on one is refused too. A Codex switch is
1190    /// settled under one as anywhere else, and given up on as anywhere else: giving up
1191    /// refused it, so the read offered a way out that could not be taken.
1192    #[test]
1193    fn a_read_says_a_switch_is_waiting_only_where_a_change_would_stop_at_it() {
1194        for (tool, make) in MACHINES {
1195            let m = stuck(make, &format!("read-stuck-custom-{tool}"));
1196            let mut ctx = m.ctx.clone();
1197            ctx.custom_oauth = true;
1198            let pitboard = Pitboard::new(ctx);
1199
1200            let read = pitboard.status(false).expect("a read");
1201            let known = pitboard.status_offline().expect("a read");
1202            let refused = pitboard
1203                .switch_to(&m.key("there").typed())
1204                .expect_err("refused");
1205
1206            let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
1207            let expected: &[&str] = match m.which {
1208                ProviderId::Claude => &[],
1209                ProviderId::Codex => &["recovery_undetermined"],
1210            };
1211            assert_eq!(codes, expected, "{tool}: {}", refused.error);
1212            let codes: Vec<&str> = known.warnings.iter().map(Warning::code).collect();
1213            assert_eq!(codes, expected, "{tool}: the read that asks nobody too");
1214            assert_eq!(
1215                refused.error.code() == "recovery_undetermined",
1216                !expected.is_empty(),
1217                "{tool}"
1218            );
1219
1220            let gave_up = pitboard.abandon_recovery();
1221            match m.which {
1222                ProviderId::Claude => assert_eq!(
1223                    gave_up.expect_err("refused").code(),
1224                    "custom_oauth_endpoint",
1225                    "{tool}"
1226                ),
1227                ProviderId::Codex => {
1228                    assert!(gave_up.expect("given up").is_some(), "{tool}");
1229                    assert!(!switch::interrupted(&m.ctx), "{tool}: nothing waits");
1230                }
1231            }
1232        }
1233    }
1234
1235    /// Every read says it, the one that asks nobody too: what an app reads each time the
1236    /// account index moves, and what `pitboard status --offline` prints. It sends no request,
1237    /// so it says it wherever the record and the logins tell it without one, in the words of
1238    /// the refusal, and changes nothing. Where only Anthropic could say whose the login is,
1239    /// it does not say what it cannot know.
1240    #[test]
1241    fn a_read_that_asks_nobody_says_a_switch_is_waiting_wherever_it_can_tell() {
1242        for (tool, make) in MACHINES {
1243            let m = stuck(make, &format!("known-stuck-{tool}"));
1244            let pitboard = Pitboard::new(m.ctx.clone());
1245            let (before, asked) = (untouched(&m), m.api.calls());
1246
1247            let read = pitboard.status_offline().expect("a read");
1248
1249            assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1250            assert_eq!(m.api.calls(), asked, "{tool}: and asks nobody");
1251            assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1252            let refused = pitboard
1253                .switch_to(&m.key("there").typed())
1254                .expect_err("every change stops at it");
1255            assert_eq!(
1256                said(&read),
1257                [("recovery_undetermined", refused.error.to_string())],
1258                "{tool}"
1259            );
1260        }
1261
1262        let m = stuck_unless_asked("known-stuck-unasked");
1263        let asked = m.api.calls();
1264        let read = Pitboard::new(m.ctx.clone())
1265            .status_offline()
1266            .expect("a read");
1267        assert_eq!(m.api.calls(), asked, "asks nobody");
1268        assert_eq!(said(&read), [], "only Anthropic could tell");
1269    }
1270
1271    /// A tool whose login names its own account is identified from the login, which asks
1272    /// nobody, so a read that sends no request can still tell whether the next change
1273    /// finishes its switch. Here Codex has renewed its login since the switch stopped, so the
1274    /// login matches neither side the record kept, and the copy the switch parked cannot be
1275    /// read. `doctor` and the read that asks nobody said nothing of it, as though only OpenAI
1276    /// could say whose the login is.
1277    #[test]
1278    fn whose_a_codex_login_is_is_told_without_asking_anyone() {
1279        let m = interrupted(codex_machine, "codex-renewed-since");
1280        m.sign_in(&crate::switch::harness::codex_login(
1281            "here",
1282            "renewed-since",
1283        ));
1284        m.mem.vault().fault(
1285            &reserved(&m),
1286            crate::store::memory::Fault::Unreadable("locked".into()),
1287        );
1288        let pitboard = Pitboard::new(m.ctx.clone());
1289        let asked = m.api.calls();
1290
1291        let read = pitboard.status_offline().expect("a read");
1292        let checks = pitboard.doctor().checks;
1293
1294        assert_eq!(m.api.calls(), asked, "asks nobody");
1295        let refused = pitboard
1296            .switch_to("codex/there")
1297            .expect_err("it stops at it");
1298        assert_eq!(refused.error.code(), "recovery_undetermined");
1299        assert_eq!(
1300            said(&read),
1301            [("recovery_undetermined", refused.error.to_string())]
1302        );
1303        let check = checks
1304            .iter()
1305            .find(|c| c.code == "interrupted_switch")
1306            .expect("said");
1307        assert_eq!(check.detail, refused.error.to_string());
1308    }
1309
1310    /// A Codex store that `/etc/codex/requirements.toml` pins is refused before a sign-in
1311    /// starts, naming that file, as it is for a switch: no line of the person's own changes
1312    /// it, and the `-c` Pitboard's sign-ins give is under it, so the login would be kept where
1313    /// Pitboard cannot read it back. Pitboard used to read only `$CODEX_HOME/config.toml`, and
1314    /// started the sign-in.
1315    #[test]
1316    fn a_codex_store_a_requirement_pins_is_refused_before_a_sign_in() {
1317        let m = codex_machine("codex-pinned");
1318        // Named where nothing is, so a sign-in that got past the refusal would find no
1319        // program to run rather than this machine's own `codex`.
1320        let ctx = m.ctx.clone().with_codex_program("/nowhere/codex".into());
1321        m.mem.administers(
1322            "/etc/codex/requirements.toml",
1323            "cli_auth_credentials_store = \"keyring\"\n",
1324        );
1325        let pitboard = Pitboard::new(ctx);
1326        for refused in [
1327            pitboard.sign_in("codex/new").err().expect("refused"),
1328            pitboard
1329                .sign_in_watched("codex/new")
1330                .err()
1331                .expect("refused"),
1332        ] {
1333            assert_eq!(refused.error.code(), "live_store_unsupported");
1334            let said = refused.error.to_string();
1335            assert!(
1336                said.contains("pinned to `keyring` by /etc/codex/requirements.toml"),
1337                "{said}"
1338            );
1339            assert!(
1340                said.contains("No line in your own config.toml can change it"),
1341                "{said}"
1342            );
1343        }
1344        let switched = pitboard.switch_to("codex/there").expect_err("refused too");
1345        assert_eq!(switched.error.code(), "live_store_unsupported");
1346        let file = m
1347            .mem
1348            .file_at(crate::provider::codex::paths::auth_file(&m.ctx));
1349        let kept = crate::store::RawStore::read(&file, "auth.json")
1350            .expect("readable")
1351            .expect("there");
1352        let kept: serde_json::Value = serde_json::from_str(&kept).expect("JSON");
1353        assert_eq!(
1354            kept["tokens"]["refresh_token"], "here-refresh",
1355            "the auth.json Codex does not use is left as it was"
1356        );
1357    }
1358
1359    /// The park the interrupted switch reserved, as its record names it.
1360    fn reserved(m: &Machine) -> String {
1361        let raw = std::fs::read_to_string(crate::home::dir(&m.ctx).join("journal.json"))
1362            .expect("a record");
1363        serde_json::from_str::<serde_json::Value>(&raw).expect("a record is JSON")["park_service"]
1364            .as_str()
1365            .expect("a park")
1366            .to_owned()
1367    }
1368
1369    /// `doctor` sends no request, and says what a read says wherever it can tell without
1370    /// one: the refusal, and the way out. Where only the service could tell, it says what
1371    /// it always said, that a switch did not finish.
1372    #[test]
1373    fn doctor_says_a_switch_is_stuck_where_it_can_tell_without_asking() {
1374        for (tool, make) in MACHINES {
1375            let m = stuck(make, &format!("doctor-stuck-{tool}"));
1376            let pitboard = Pitboard::new(m.ctx.clone());
1377            let before = untouched(&m);
1378
1379            let checks = pitboard.doctor().checks;
1380
1381            assert_eq!(untouched(&m), before, "{tool}: doctor changes nothing");
1382            let refused = pitboard
1383                .switch_to(&m.key("there").typed())
1384                .expect_err("refused");
1385            let check = checks
1386                .iter()
1387                .find(|c| c.code == "interrupted_switch")
1388                .expect("said");
1389            assert_eq!(check.level, doctor::Level::Warn, "{tool}");
1390            assert_eq!(check.detail, refused.error.to_string(), "{tool}");
1391            assert!(check.advice.contains("pitboard abandon"), "{tool}");
1392        }
1393
1394        let m = stuck_unless_asked("doctor-stuck-unasked");
1395        let asked = m.api.calls();
1396        let checks = Pitboard::new(m.ctx.clone()).doctor().checks;
1397        assert_eq!(m.api.calls(), asked, "doctor asks nobody");
1398        let check = checks
1399            .iter()
1400            .find(|c| c.code == "interrupted_switch")
1401            .expect("said");
1402        assert_eq!(check.detail, "a switch did not finish");
1403    }
1404
1405    /// Every file in Pitboard's own directory but the audit log.
1406    fn files(m: &Machine) -> BTreeMap<String, Vec<u8>> {
1407        std::fs::read_dir(crate::home::dir(&m.ctx))
1408            .expect("a Pitboard home")
1409            .map(|entry| entry.expect("an entry").path())
1410            .filter(|path| path.file_name() != Some("audit.log".as_ref()))
1411            .map(|path| {
1412                let body = std::fs::read(&path).unwrap_or_default();
1413                (path.display().to_string(), body)
1414            })
1415            .collect()
1416    }
1417
1418    /// The last two lines of the audit log, as verb and outcome.
1419    fn last_audited(m: &Machine) -> Vec<(String, String)> {
1420        audit::read(&m.ctx, 2)
1421            .into_iter()
1422            .map(|entry| (entry.verb, entry.outcome))
1423            .collect()
1424    }
1425
1426    /// Every other change settles an interrupted switch before anything else, and one
1427    /// refused over its name did not, so the switch stayed unrecovered and the refusal was
1428    /// all anybody was told. It is settled now, recorded the way any recovery is, and
1429    /// reported beside the refusal.
1430    #[test]
1431    fn a_change_refused_over_its_name_still_recovers_an_interrupted_switch() {
1432        for (tool, make) in MACHINES {
1433            for (change, verb, code, audited, refuse) in REFUSALS {
1434                let at = format!("{tool}, {change}");
1435                let m = interrupted(make, &format!("refused-{tool}-{change}"));
1436
1437                let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
1438                    .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
1439
1440                assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
1441                let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1442                assert_eq!(said, ["interrupted_switch_undone"], "{at}");
1443                assert!(!switch::interrupted(&m.ctx), "{at}: the record is resolved");
1444                hold(&m, &at);
1445                assert_eq!(
1446                    last_audited(&m),
1447                    [
1448                        (
1449                            "recover".to_string(),
1450                            "interrupted_switch_undone".to_string()
1451                        ),
1452                        (verb.to_string(), audited.to_string()),
1453                    ],
1454                    "{at}"
1455                );
1456            }
1457        }
1458    }
1459
1460    /// A mistyped name with nothing to recover takes no lock and writes nothing but its
1461    /// line in the audit log.
1462    #[test]
1463    fn a_change_refused_over_its_name_with_nothing_interrupted_changes_nothing() {
1464        for (tool, make) in MACHINES {
1465            for (change, verb, code, audited, refuse) in REFUSALS {
1466                let at = format!("{tool}, {change}");
1467                let m = make(&format!("refused-quietly-{tool}-{change}"));
1468                let (before, parked, live) = (files(&m), m.mem.vault().services(), m.live());
1469
1470                let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
1471                    .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
1472
1473                assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
1474                assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
1475                assert_eq!(files(&m), before, "{at}: not even the lock file is made");
1476                assert_eq!(m.mem.vault().services(), parked, "{at}");
1477                assert_eq!(m.live(), live, "{at}");
1478                assert_eq!(
1479                    last_audited(&m).last(),
1480                    Some(&(verb.to_string(), audited.to_string())),
1481                    "{at}"
1482                );
1483            }
1484        }
1485    }
1486
1487    /// A new login that did not hold after it was written is parked rather than lost, and
1488    /// parking a login too big for `security`'s standard input puts it on the argument line.
1489    /// The change then fails, and that is still said beside the failure.
1490    #[test]
1491    fn a_new_login_parked_after_it_did_not_hold_says_how_it_was_parked() {
1492        for (tool, make) in MACHINES {
1493            let m = make(&format!("not-installed-said-{tool}"));
1494            m.mem.vault().takes_on_stdin(64);
1495            let login = crate::switch::harness::signed_in(&m, "here", "here-refresh-2");
1496            m.fault_live(crate::store::memory::Fault::DeletedAfterWrite);
1497
1498            let failed = Pitboard::new(m.ctx.clone())
1499                .enroll_signed_in(&m.key("here").typed(), login)
1500                .expect_err("it did not hold");
1501
1502            assert_eq!(failed.error.code(), "sign_in_not_installed", "{tool}");
1503            let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1504            assert_eq!(said, ["written_on_the_command_line"], "{tool}");
1505        }
1506    }
1507
1508    /// The recovery settles for the tool whose switch was interrupted, not for no tool in
1509    /// particular, so a custom Claude Code endpoint stops exactly what it stops for any
1510    /// change: the recovery of a Claude Code switch, and not of a Codex one. What it stops
1511    /// is left for a later run, and the refusal is reported as it always was.
1512    #[test]
1513    fn a_custom_claude_endpoint_stops_only_the_recovery_of_a_claude_code_switch() {
1514        let codex = interrupted(codex_machine, "refused-custom-codex");
1515        let mut ctx = codex.ctx.clone();
1516        ctx.custom_oauth = true;
1517        let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
1518        assert_eq!(failed.error.code(), "account_unknown");
1519        let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1520        assert_eq!(said, ["interrupted_switch_undone"]);
1521        assert!(!switch::interrupted(&codex.ctx));
1522
1523        let claude = interrupted(machine, "refused-custom-claude");
1524        let mut ctx = claude.ctx.clone();
1525        ctx.custom_oauth = true;
1526        let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
1527        assert_eq!(
1528            failed.error.code(),
1529            "account_unknown",
1530            "the refusal, not the recovery that could not run"
1531        );
1532        assert!(failed.warnings.is_empty(), "{:?}", failed.warnings);
1533        assert!(
1534            switch::interrupted(&claude.ctx),
1535            "the record is kept for a run that can finish it"
1536        );
1537    }
1538
1539    /// Every way the host can say this process may change nothing: as root, under sudo,
1540    /// and with rights nobody could tell.
1541    const REFUSED_AS: [Elevation; 3] = [
1542        Elevation::Elevated { why: "as root" },
1543        Elevation::Elevated { why: "with sudo" },
1544        Elevation::Unknown,
1545    ];
1546
1547    /// What one change came to, as a change refused at the gate is reported: its error,
1548    /// and the warnings found on the way, of which there must be none.
1549    fn failed<T>(outcome: Result<T>) -> Option<Failed> {
1550        outcome.err().map(|error| Failed {
1551            error,
1552            warnings: Vec::new(),
1553        })
1554    }
1555
1556    type Change = fn(&Pitboard, &Machine) -> Option<Failed>;
1557
1558    /// Every change a front end can ask for, by its name. `enroll_signed_in` is asked apart,
1559    /// since it takes a sign-in made before.
1560    const CHANGES: [(&str, Change); 15] = [
1561        ("switch_to", |p, m| {
1562            p.switch_to(&m.key("there").typed()).err()
1563        }),
1564        ("enroll_current", |p, m| {
1565            p.enroll_current(&m.key("here").typed()).err()
1566        }),
1567        ("sign_in", |p, m| p.sign_in(&m.key("new").typed()).err()),
1568        ("sign_in_watched", |p, m| {
1569            p.sign_in_watched(&m.key("new").typed()).err()
1570        }),
1571        ("forget", |p, m| p.forget(&m.key("there").typed()).err()),
1572        ("rename", |p, m| {
1573            p.rename(&m.key("there").typed(), "elsewhere").err()
1574        }),
1575        ("abandon_recovery", |p, _| failed(p.abandon_recovery())),
1576        ("renew", |p, _| failed(p.renew())),
1577        ("schedule_install", |p, _| failed(p.schedule_install())),
1578        ("schedule_uninstall", |p, _| failed(p.schedule_uninstall())),
1579        ("schedule_repair", |p, _| failed(p.schedule_repair())),
1580        ("adopt", |p, _| failed(p.adopt())),
1581        ("repair", |p, _| p.repair().err()),
1582        ("keep_app_file", |p, _| {
1583            failed(p.keep_app_file(crate::app::AppFile::Preferences, "{}"))
1584        }),
1585        ("uninstall", |p, _| p.uninstall().err()),
1586    ];
1587
1588    /// Everything on a machine a change could leave different: every directory and file
1589    /// under its home, the audit log among them and the scheduler's files with them, every
1590    /// item in its keychain, every login parked and the one in use. The private directory
1591    /// a sign-in made before is not counted: dropping that sign-in takes it away again.
1592    type Everything = (
1593        BTreeMap<String, Option<Vec<u8>>>,
1594        Vec<(String, Option<String>)>,
1595        Vec<(String, Option<String>)>,
1596        Option<serde_json::Value>,
1597    );
1598
1599    fn everything(m: &Machine) -> Everything {
1600        fn walk(dir: &std::path::Path, into: &mut BTreeMap<String, Option<Vec<u8>>>) {
1601            for entry in std::fs::read_dir(dir).expect("a directory to read") {
1602                let path = entry.expect("an entry").path();
1603                if path.ends_with(".pitboard/signin") {
1604                    continue;
1605                }
1606                if path.is_dir() {
1607                    into.insert(path.display().to_string(), None);
1608                    walk(&path, into);
1609                } else {
1610                    let body = std::fs::read(&path).expect("a file to read");
1611                    into.insert(path.display().to_string(), Some(body));
1612                }
1613            }
1614        }
1615        let mut files = BTreeMap::new();
1616        walk(&m.ctx_home(), &mut files);
1617        let items = |store: &crate::store::memory::MemoryStore| {
1618            store
1619                .services()
1620                .into_iter()
1621                .map(|service| {
1622                    let held = store.peek(&service);
1623                    (service, held)
1624                })
1625                .collect()
1626        };
1627        (files, items(m.mem.live()), items(m.mem.vault()), m.live())
1628    }
1629
1630    /// Running as root or under sudo, Pitboard changed things as root: a state file, a
1631    /// schedule or a lock it made was root's, and a park might be, where the person's own
1632    /// runs might never read or replace it again. Every change is refused now, at one gate, before it
1633    /// reads, locks or records anything, and leaves the machine byte for byte as it was,
1634    /// the audit log included. The machine has an interrupted switch waiting, which every
1635    /// change that settles would otherwise finish, and asks nobody.
1636    #[test]
1637    fn every_change_is_refused_where_pitboard_runs_as_root_or_with_sudo_and_changes_nothing() {
1638        for (tool, make) in MACHINES {
1639            for elevation in REFUSED_AS {
1640                for (change, run) in CHANGES {
1641                    let at = format!("{tool}, {change}, {elevation:?}");
1642                    let m = interrupted(make, &format!("gate-{tool}-{change}"));
1643                    m.mem.runs_with(elevation);
1644                    let (before, asked) = (everything(&m), m.api.calls());
1645
1646                    let failed = run(&Pitboard::new(m.ctx.clone()), &m)
1647                        .unwrap_or_else(|| panic!("{at}: refused"));
1648
1649                    assert_eq!(failed.error.code(), "elevated", "{at}: {}", failed.error);
1650                    assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
1651                    assert!(everything(&m) == before, "{at}: nothing changes");
1652                    assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1653                }
1654
1655                let at = format!("{tool}, enroll_signed_in, {elevation:?}");
1656                let m = interrupted(make, &format!("gate-{tool}-enroll-signed-in"));
1657                let login = crate::switch::harness::signed_in(&m, "new", "new-refresh");
1658                m.mem.runs_with(elevation);
1659                let before = everything(&m);
1660                let failed = Pitboard::new(m.ctx.clone())
1661                    .enroll_signed_in(&m.key("new").typed(), login)
1662                    .expect_err("refused");
1663                assert_eq!(failed.error.code(), "elevated", "{at}");
1664                assert!(everything(&m) == before, "{at}: nothing changes");
1665            }
1666        }
1667    }
1668
1669    /// The gate's refusal says what to do, the same for root and for sudo, and says it
1670    /// apart where nobody could tell.
1671    #[test]
1672    fn the_gate_says_why_it_refuses_and_lets_a_person_through() {
1673        let m = machine("gate-words");
1674        let pitboard = Pitboard::new(m.ctx.clone());
1675        assert!(pitboard.permit().is_ok(), "as the person");
1676        for elevation in REFUSED_AS {
1677            m.mem.runs_with(elevation);
1678            let refused = pitboard.permit().expect_err("refused");
1679            assert_eq!(refused.code(), "elevated");
1680            assert_eq!(refused.exit_code(), 1);
1681            assert_eq!(
1682                refused.to_string(),
1683                match elevation {
1684                    Elevation::Unknown => {
1685                        "Pitboard changes nothing when it cannot tell whether it runs as root \
1686                         or with sudo. Run it as yourself."
1687                    }
1688                    _ => {
1689                        "Pitboard changes nothing when it runs as root or with sudo. Run it \
1690                         as yourself."
1691                    }
1692                },
1693            );
1694        }
1695    }
1696
1697    /// An empty or relative `HOME` made every path under it lead into the folder Pitboard
1698    /// was run from: its own files where `PITBOARD_HOME` was not set, and the scheduler's
1699    /// and each tool's default folder whatever was. The gate refuses it now, and so does
1700    /// every read of the accounts, with `home_not_absolute`, and nothing changes. Asked of
1701    /// the gate and the reads first, which change nothing whatever they answer.
1702    #[test]
1703    fn every_change_and_read_is_refused_where_the_home_is_not_a_full_path() {
1704        for home in ["", "relative"] {
1705            for (tool, make) in MACHINES {
1706                let at = format!("{tool}, HOME={home:?}");
1707                let m = make(&format!("unplaced-{tool}-{}", home.len()));
1708                let mut ctx = m.ctx.clone();
1709                ctx.home = std::path::PathBuf::from(home);
1710                let pitboard = Pitboard::new(ctx);
1711                let refused = |error: Error| {
1712                    assert_eq!(error.code(), "home_not_absolute", "{at}: {error}");
1713                    assert!(error.to_string().starts_with("HOME is "), "{at}: {error}");
1714                };
1715
1716                refused(pitboard.permit().expect_err("the gate refuses"));
1717                refused(pitboard.status_offline().err().expect("refused"));
1718                refused(pitboard.status(false).err().expect("refused"));
1719                let before = (everything(&m), m.api.calls());
1720                for (change, run) in CHANGES {
1721                    let failed = run(&pitboard, &m).unwrap_or_else(|| panic!("{at}: {change}"));
1722                    refused(failed.error);
1723                    assert!(failed.warnings.is_empty(), "{at}, {change}");
1724                }
1725                assert!(
1726                    (everything(&m), m.api.calls()) == before,
1727                    "{at}: nothing changes and nobody is asked"
1728                );
1729            }
1730        }
1731    }
1732
1733    /// What a read says, account by account: enough to tell a live read from one of what
1734    /// was last measured.
1735    type Read = Vec<(
1736        ProviderId,
1737        Option<String>,
1738        bool,
1739        Option<crate::usage::Snapshot>,
1740        Option<status::Stale>,
1741    )>;
1742
1743    fn rows(report: &status::Report) -> Read {
1744        report
1745            .rows
1746            .iter()
1747            .map(|row| {
1748                (
1749                    row.provider,
1750                    row.label.clone(),
1751                    row.signed_in,
1752                    row.usage.clone(),
1753                    row.stale,
1754                )
1755            })
1756            .collect()
1757    }
1758
1759    /// A read renewed every parked login whose access had lapsed, asked each service about
1760    /// every account and wrote what it measured down, as root too. Where this process may
1761    /// change nothing it answers what the read that asks nobody answers, and says why: it
1762    /// renews nothing, asks nobody and writes nothing. A park whose access has lapsed is
1763    /// there to be renewed, so a read that renewed would show it.
1764    #[test]
1765    fn a_read_where_pitboard_may_change_nothing_asks_nobody_and_says_why() {
1766        for (tool, make) in MACHINES {
1767            for elevation in REFUSED_AS {
1768                let at = format!("{tool}, {elevation:?}");
1769                let m = make(&format!("gate-read-{tool}"));
1770                let later = m
1771                    .ctx
1772                    .clone()
1773                    .with_clock(Arc::new(crate::time::FixedClock::at(
1774                        crate::switch::harness::NOW + 11 * 86_400,
1775                    )));
1776                let pitboard = Pitboard::new(later);
1777                m.mem.runs_with(elevation);
1778                let (before, asked) = (everything(&m), m.api.calls());
1779
1780                let read = pitboard.status(false).expect("a read");
1781                let known = pitboard.status_offline().expect("a read");
1782
1783                assert!(everything(&m) == before, "{at}: nothing is written");
1784                assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1785                assert_eq!(rows(&read.value), rows(&known.value), "{at}");
1786                let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
1787                assert_eq!(codes.last(), Some(&"read_only"), "{at}");
1788                assert_eq!(read.warnings.len(), known.warnings.len() + 1, "{at}");
1789                let said = read.warnings.last().expect("said").to_string();
1790                match elevation {
1791                    Elevation::Elevated { why } => assert!(
1792                        said.starts_with(&format!("Pitboard runs {why}, so it changes nothing")),
1793                        "{at}: {said}"
1794                    ),
1795                    _ => assert!(said.starts_with("Pitboard cannot tell"), "{at}: {said}"),
1796                }
1797
1798                m.mem.runs_with(Elevation::Normal);
1799                let live = pitboard.status(false).expect("a read");
1800                assert!(m.api.calls() > asked, "{at}: as the person, it asks");
1801                assert!(
1802                    !live.warnings.iter().any(|w| w.code() == "read_only"),
1803                    "{at}"
1804                );
1805            }
1806        }
1807    }
1808
1809    /// The status line kept what each session passed it and the readings it moved, as root
1810    /// too. Where this process may change nothing it draws the same line from the files as
1811    /// they are, and writes neither.
1812    #[test]
1813    fn the_status_line_where_pitboard_may_change_nothing_draws_the_same_line_and_writes_nothing() {
1814        let session = serde_json::json!({
1815            "session_id": "a-session",
1816            "rate_limits": {
1817                "five_hour": {"used_percentage": 40.0, "resets_at": crate::switch::harness::NOW + 3_600},
1818                "seven_day": {"used_percentage": 20.0, "resets_at": crate::switch::harness::NOW + 86_400},
1819            },
1820        })
1821        .to_string();
1822        for elevation in REFUSED_AS {
1823            let m = machine("gate-statusline");
1824            let pitboard = Pitboard::new(m.ctx.clone());
1825            m.mem.runs_with(elevation);
1826            let before = everything(&m);
1827
1828            let refused = pitboard.statusline(&session);
1829
1830            assert!(
1831                everything(&m) == before,
1832                "{elevation:?}: nothing is written"
1833            );
1834            m.mem.runs_with(Elevation::Normal);
1835            assert_eq!(pitboard.statusline(&session), refused, "{elevation:?}");
1836            assert!(
1837                crate::home::dir(&m.ctx).join("sessions.json").exists(),
1838                "{elevation:?}: as the person, the session is kept"
1839            );
1840        }
1841    }
1842
1843    /// `renew` returned an empty list when it renewed nothing, whatever the reason, so a
1844    /// run that could not renew read as one where nothing was due. Refused, it says so, and
1845    /// asks nobody, with a park that is due to be renewed.
1846    #[test]
1847    fn a_renewal_where_pitboard_may_change_nothing_is_refused_and_asks_nobody() {
1848        for (tool, make) in MACHINES {
1849            for elevation in REFUSED_AS {
1850                let at = format!("{tool}, {elevation:?}");
1851                let m = make(&format!("gate-renew-{tool}"));
1852                crate::switch::harness::renews(&m, "there-refresh", "there-refresh-2");
1853                let later = m
1854                    .ctx
1855                    .clone()
1856                    .with_clock(Arc::new(crate::time::FixedClock::at(
1857                        crate::switch::harness::NOW + 11 * 86_400,
1858                    )));
1859                let pitboard = Pitboard::new(later);
1860                m.mem.runs_with(elevation);
1861                let (before, asked) = (everything(&m), m.api.calls());
1862
1863                let refused = pitboard.renew().expect_err("refused");
1864
1865                assert_eq!(refused.code(), "elevated", "{at}");
1866                assert!(everything(&m) == before, "{at}: nothing changes");
1867                assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1868
1869                m.mem.runs_with(Elevation::Normal);
1870                let renewed = pitboard.renew().expect("as the person, it renews");
1871                let codes: Vec<&str> = renewed.iter().map(|(_, r)| r.code()).collect();
1872                assert_eq!(codes, ["renewed"], "{at}");
1873            }
1874        }
1875    }
1876
1877    /// `doctor` says why every change is refused, first, as a check that fails, and says
1878    /// nothing of it where Pitboard runs as the person.
1879    #[test]
1880    fn doctor_says_why_pitboard_changes_nothing() {
1881        let m = machine("gate-doctor");
1882        let pitboard = Pitboard::new(m.ctx.clone());
1883        assert!(
1884            !pitboard
1885                .doctor()
1886                .checks
1887                .iter()
1888                .any(|c| c.code == "elevated"),
1889            "as the person"
1890        );
1891        for elevation in REFUSED_AS {
1892            m.mem.runs_with(elevation);
1893            let checks = pitboard.doctor().checks;
1894            let check = checks.first().expect("checks");
1895            assert_eq!(check.code, "elevated", "{elevation:?}");
1896            assert_eq!(check.level, doctor::Level::Fail, "{elevation:?}");
1897            assert_eq!(
1898                check.detail,
1899                match elevation {
1900                    Elevation::Elevated { why } => format!("Pitboard runs {why}"),
1901                    _ => "Pitboard cannot tell whether it runs as root or with sudo".into(),
1902                }
1903            );
1904            assert!(
1905                check.advice.ends_with("Run it as yourself."),
1906                "{}",
1907                check.advice
1908            );
1909        }
1910    }
1911}