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