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::autoswitch::{Auto, Look, Threshold};
6use crate::context::Context;
7use crate::doctor::{self, Diagnosis};
8use crate::error::{Error, Result};
9use crate::holder::{self, capitalised};
10use crate::host::{Elevation, Floor};
11use crate::in_use::{self, Doubt, InUse, Known};
12use crate::provider::{Held, ProviderId};
13use crate::state::{self, Account, Key, new_id};
14use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
15use crate::{audit, readings, schedule, status, statusline};
16use std::fmt;
17
18/// Proof that this process may change something on this machine, which only the one gate
19/// every change passes makes ([`Pitboard::permit`]).
20///
21/// Everything that changes anything takes one as an argument: the durable write of a file,
22/// every private file or directory made and every one removed, a write to or a delete from
23/// any store of logins, the keychain among them, a change to the system's scheduler, a
24/// tool's sign-in started, and the token exchange that renews a parked login. So a change
25/// that did not ask the gate does not compile, and nothing rotates a refresh token it could
26/// not then write down.
27#[derive(Debug, Clone, Copy)]
28pub struct Permit {
29    _only_the_gate_makes_one: (),
30}
31
32#[cfg(test)]
33impl Permit {
34    /// One for a unit test of what takes it, below the gate. A test of the gate itself, or
35    /// of a change through [`Pitboard`], asks the gate.
36    pub(crate) fn for_a_test() -> Permit {
37        Permit {
38            _only_the_gate_makes_one: (),
39        }
40    }
41}
42
43/// The one gate every change passes: whether this process may change anything here, as the
44/// host says. A process that runs as root or under sudo, or elevated on Windows, changes
45/// nothing, and nor does one the host cannot place: a file it wrote would be root's or the
46/// administrators', and a keychain item might be, where the person's own runs might never
47/// read, replace or remove it again.
48///
49/// Nor does one whose environment names a home that is empty or relative
50/// ([`crate::home::check_absolute`]): what it wrote would land under whichever folder it
51/// was run from. Asked after elevation, so `sudo` is what a run under it is told first.
52///
53/// Before those, a build that may do nothing at all changes nothing: a Windows build of a
54/// release made before Pitboard for Windows is released ([`crate::release`]). So no writer
55/// and no token exchange runs there, even for a caller that forgot to ask. Then a Windows
56/// older than 11 24H2 ([`Floor`]): asked before elevation, which cannot change it.
57pub(crate) fn gate(ctx: &Context) -> Result<Permit> {
58    crate::release::check()?;
59    match ctx.host().floor() {
60        Floor::Met => {}
61        Floor::Below { build } => return Err(Error::SystemTooOld { build: Some(build) }),
62        Floor::Unknown => return Err(Error::SystemTooOld { build: None }),
63    }
64    match ctx.host().elevation(ctx) {
65        Elevation::Normal => {}
66        Elevation::Elevated { why } => return Err(Error::Elevated { why: Some(why) }),
67        Elevation::Unknown => return Err(Error::Elevated { why: None }),
68    }
69    crate::home::check_absolute(ctx)?;
70    Ok(Permit {
71        _only_the_gate_makes_one: (),
72    })
73}
74
75/// Something to know about that did not stop the operation.
76#[derive(Debug)]
77#[non_exhaustive]
78pub enum Warning {
79    /// An earlier switch had been interrupted; this run found what it did and recorded it.
80    Recovered(Recovered),
81    /// The login moved, but what the tool caches about who is signed in still names the
82    /// previous account.
83    ConfigNotUpdated(Error),
84    /// Parked logins no longer in use that could not be deleted yet.
85    ParksPendingRemoval(usize),
86    /// The service refuses a parked login for good, so it was dropped.
87    ParkedLoginRefused {
88        tool: ProviderId,
89        label: String,
90    },
91    RenewalFailed(Error),
92    /// The tool's write lock, or its refresh lock, stopped being Pitboard's while a change was
93    /// under way.
94    LockCompromised {
95        tool: ProviderId,
96    },
97    /// The environment authenticates the tool some other way, so the login Pitboard moved
98    /// is not the one a session will use.
99    AuthOverridden {
100        tool: ProviderId,
101        names: Vec<String>,
102    },
103    /// A file of the tool's sits behind the store in use. While it is there a session
104    /// already running keeps its account after a switch until its login is next renewed.
105    /// Where it holds a login, a session that cannot read that store signs in with it, and
106    /// no switch reaches it.
107    FallbackLogin {
108        tool: ProviderId,
109        path: std::path::PathBuf,
110        held: Held,
111    },
112    /// The login was too large for `security`'s stdin, so it went on the argument line.
113    WrittenOnTheCommandLine {
114        tool: ProviderId,
115        bytes: usize,
116        limit: usize,
117    },
118    /// Sessions of a tool that never follows a switch on its own were running when it
119    /// happened, and go on using the account they started with until they are restarted.
120    /// `holding` is what was running, by kind, never empty.
121    SessionsStillRunning {
122        from: String,
123        holding: Vec<crate::holder::Holding>,
124    },
125    /// A sign-in put a new login in use in place of the old one of the same account, and
126    /// sessions of a tool that never reads its login again were running with the old one.
127    SessionsKeepTheOldLogin {
128        label: String,
129        holding: Vec<crate::holder::Holding>,
130    },
131    /// A switch of a tool that never follows one on its own, where the process list could
132    /// not be read: nobody can say whether anything of the tool started before it is still
133    /// running, and still using `from`. Not the same as nothing running, which says nothing.
134    SessionsUnknown {
135        tool: ProviderId,
136        from: String,
137    },
138    /// A sign-in that put a new login of `label` in use, of a tool that never reads its login
139    /// again, where the process list could not be read: nobody can say whether anything of
140    /// the tool is still running with the old login.
141    SessionsUnknownAfterSignIn {
142        tool: ProviderId,
143        label: String,
144    },
145    /// A sign-in to the account Pitboard last recorded in use was parked rather than put in
146    /// use, because nobody could say whose login the tool has in use, for `why`.
147    SignInParkedNotInUse {
148        tool: ProviderId,
149        label: String,
150        why: String,
151    },
152    /// An interrupted switch is waiting that the next change cannot finish, and every change
153    /// stops at it until it can be finished or is given up on. The refusal that change would
154    /// make, said by a read so that nobody has to make a change to find out.
155    SwitchStuck(Error),
156    /// A sign-in outside Pitboard replaced the only login of `label`, and Pitboard holds none
157    /// for it: said by every read until it is signed in again, put in use again or forgotten.
158    /// `now` is whose login the tool has stored instead, as last recorded, which every switch
159    /// changes. `id` is the account's, which stays while the words do not.
160    LoginReplaced {
161        tool: ProviderId,
162        id: String,
163        label: String,
164        now: Stored,
165    },
166    /// The tool's own record names `named` since its service last named the login stored,
167    /// `last`'s, as a sign-in leaves it and as another process starting on another login
168    /// can: another login may be stored. Said by a read that could not ask whose it is.
169    /// `asked` is false for a record brought forward from an earlier Pitboard, whose `last`
170    /// is the account it last switched to, which the service never named, and whose own
171    /// record names another account.
172    InUseUnconfirmed {
173        tool: ProviderId,
174        last: Stored,
175        named: Stored,
176        asked: bool,
177    },
178    /// The tool's own record names `config`, and the login stored is `in_use`'s, as its
179    /// service said: what `/status` in Claude Code shows is not the account in use. Using
180    /// `in_use` writes it there again.
181    ConfigNamesAnother {
182        tool: ProviderId,
183        config: Stored,
184        in_use: Stored,
185    },
186    /// This process may change nothing, because it runs as root or under sudo, or elevated on
187    /// Windows, or nobody could tell whether it does, so a read answers from what Pitboard
188    /// last measured: it renews nothing, asks nobody and writes nothing. `why` is how it runs,
189    /// as the host said it, and `None` where the host could not say.
190    ReadOnly {
191        why: Option<&'static str>,
192    },
193    ReadOnlyBelowTheFloor {
194        build: Option<u32>,
195    },
196}
197
198impl Warning {
199    fn read_only(refused: &Error) -> Option<Warning> {
200        match *refused {
201            Error::Elevated { why } => Some(Warning::ReadOnly { why }),
202            Error::SystemTooOld { build } => Some(Warning::ReadOnlyBelowTheFloor { build }),
203            _ => None,
204        }
205    }
206
207    /// Stable, for a program to branch on.
208    pub fn code(&self) -> &'static str {
209        match self {
210            Warning::Recovered(r) => r.code(),
211            Warning::LockCompromised { .. } => "lock_compromised",
212            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
213                e.code()
214            }
215            Warning::ParksPendingRemoval(_) => "parks_pending_removal",
216            Warning::ParkedLoginRefused { .. } => "parked_login_refused",
217            Warning::AuthOverridden { .. } => "auth_overridden",
218            Warning::FallbackLogin { .. } => "fallback_login",
219            Warning::WrittenOnTheCommandLine { .. } => "written_on_the_command_line",
220            Warning::SessionsStillRunning { .. } => "sessions_still_running",
221            Warning::SessionsKeepTheOldLogin { .. } => "sessions_keep_old_login",
222            Warning::SessionsUnknown { .. } | Warning::SessionsUnknownAfterSignIn { .. } => {
223                "sessions_unknown"
224            }
225            Warning::SignInParkedNotInUse { .. } => "sign_in_parked_not_in_use",
226            Warning::LoginReplaced { .. } => "login_replaced",
227            Warning::InUseUnconfirmed { .. } => "in_use_unconfirmed",
228            Warning::ConfigNamesAnother { .. } => "config_names_another",
229            Warning::ReadOnly { .. } | Warning::ReadOnlyBelowTheFloor { .. } => "read_only",
230        }
231    }
232}
233
234impl fmt::Display for Warning {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        match self {
237            Warning::Recovered(r) => write!(f, "{r}"),
238            Warning::LockCompromised { tool } => write!(
239                f,
240                "{} reclaimed a credential lock while this change was under way, so it may \
241                 have written or renewed a login at the same time. Pitboard read the slot back \
242                 and the change stood, but check with `pitboard` that the right account is \
243                 signed in. A login Pitboard parked meanwhile may need signing in to again \
244                 when it is next used.",
245                tool.name()
246            ),
247            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
248                write!(f, "{e}")
249            }
250            Warning::ParksPendingRemoval(count) => write!(
251                f,
252                "{count} parked login(s) no longer in use could not be removed yet; Pitboard \
253                 tries again on its next change"
254            ),
255            Warning::ParkedLoginRefused { tool, label } => write!(
256                f,
257                "{} no longer accepts the parked login for `{label}`. Run `pitboard enroll \
258                 {label} --sign-in` to sign in to it again.",
259                tool.service()
260            ),
261            Warning::WrittenOnTheCommandLine { tool, bytes, limit } => {
262                write!(
263                    f,
264                    "this login needs {bytes} bytes and `security` reads {limit} from stdin, \
265                     so it was written on the argument line, where a process running as you \
266                     could have read it while the call lasted."
267                )?;
268                if *tool == ProviderId::Claude {
269                    write!(
270                        f,
271                        " Claude Code writes this same login the same way whenever it \
272                         refreshes the token."
273                    )?;
274                }
275                Ok(())
276            }
277            Warning::AuthOverridden { tool, names } => write!(
278                f,
279                "{} is set, so {} signs in with it and not with the login Pitboard moved. \
280                 Unset it for the switch to take effect.",
281                names.join(" and "),
282                tool.name()
283            ),
284            Warning::FallbackLogin {
285                tool,
286                path,
287                held: Held::Login,
288            } => write!(
289                f,
290                "{path} holds another {tool} login, which a session that cannot read the \
291                 keychain, such as one started over SSH, signs in with. No switch reaches it. \
292                 While it is there, {tool} sessions already running at a switch {running}. \
293                 `pitboard stow` keeps that login for its account, where Pitboard holds no \
294                 other it can switch to, then deletes the file.",
295                path = path.display(),
296                tool = tool.name(),
297                running = crate::words::kept_until_renewed(),
298            ),
299            Warning::FallbackLogin {
300                tool,
301                path,
302                held: Held::NoLogin,
303            } => write!(
304                f,
305                "{path} is there with no {tool} login in it. While it is, {tool} sessions \
306                 already running at a switch {running}. `pitboard stow` deletes it, which lets \
307                 them follow a switch.",
308                path = path.display(),
309                tool = tool.name(),
310                running = crate::words::kept_until_renewed(),
311            ),
312            Warning::FallbackLogin {
313                tool,
314                path,
315                held: Held::Unreadable,
316            } => write!(
317                f,
318                "{path} is there, and Pitboard could not read it. While it is, {tool} sessions \
319                 already running at a switch {running}. `pitboard doctor` says why, and what to \
320                 do.",
321                path = path.display(),
322                tool = tool.name(),
323                running = crate::words::kept_until_renewed(),
324            ),
325            Warning::SessionsStillRunning { from, holding } => write!(
326                f,
327                "{} started before this switch {} still running and still using `{from}`. {} \
328                 Do not sign out in {}: signing out there revokes `{from}`'s login, which \
329                 Pitboard has just parked.",
330                capitalised(&holder::described(holding)),
331                if holder::plural(holding) { "are" } else { "is" },
332                holder::remedies(holding, "to use the new account"),
333                if holder::plural(holding) {
334                    "any of them"
335                } else {
336                    "it"
337                },
338            ),
339            Warning::SessionsKeepTheOldLogin { label, holding } => write!(
340                f,
341                "{} started before this sign-in {} still running and still using `{label}`'s \
342                 old login. {} Otherwise one of them can put the old login back in place of \
343                 the new one when it refreshes its token.",
344                capitalised(&holder::described(holding)),
345                if holder::plural(holding) { "are" } else { "is" },
346                holder::remedies(holding, "to use the new one"),
347            ),
348            Warning::SessionsUnknown { tool, from } => write!(
349                f,
350                "Pitboard could not tell whether {} sessions started before this switch are \
351                 still running, because it could not read the list of processes. Do not sign \
352                 out in one that is: signing out there revokes `{from}`'s login, which \
353                 Pitboard has just parked.",
354                tool.name()
355            ),
356            Warning::SessionsUnknownAfterSignIn { tool, label } => write!(
357                f,
358                "Pitboard could not tell whether {} sessions started before this sign-in are \
359                 still running, because it could not read the list of processes. Quit any \
360                 that are still using `{label}`'s old login and start them again. Otherwise \
361                 one of them can put the old login back in place of the new one when it \
362                 refreshes its token.",
363                tool.name()
364            ),
365            Warning::SignInParkedNotInUse { tool, label, why } => write!(
366                f,
367                "{} goes on with the login it has: Pitboard could not tell whose it is \
368                 ({why}), so it parked the new login for `{label}` rather than write over \
369                 that one. If that login no longer works, run `{}` and sign in to `{label}` \
370                 there.",
371                tool.name(),
372                tool.login_command()
373            ),
374            Warning::LoginReplaced {
375                tool, label, now, ..
376            } => {
377                write!(
378                    f,
379                    "`{label}`'s login was replaced by a sign-in outside Pitboard"
380                )?;
381                let tool = tool.name();
382                match now {
383                    Stored::Account(now) => write!(f, ": {tool} now has `{now}`'s login stored,")?,
384                    Stored::Unenrolled(email) => {
385                        write!(f, ": {tool} now has {email}'s login stored,")?;
386                    }
387                    Stored::Nothing => write!(f, ": {tool} now has no login stored,")?,
388                    Stored::Unknown => write!(f, ",")?,
389                }
390                write!(
391                    f,
392                    " and Pitboard holds no login for `{label}`. Run `pitboard enroll {label} \
393                     --sign-in` to sign in to it again."
394                )
395            }
396            Warning::InUseUnconfirmed {
397                tool,
398                last,
399                named,
400                asked: false,
401            } => write!(
402                f,
403                "{name}'s config names {named}, and Pitboard has not asked {service} whose login \
404                 {name} has stored since Pitboard was updated, so another login may be stored. \
405                 The account it last switched to is {last}. `pitboard status` asks.",
406                name = tool.name(),
407                named = named.said("no account"),
408                service = tool.service(),
409                last = last.said("none"),
410            ),
411            Warning::InUseUnconfirmed {
412                tool, last, named, ..
413            } => {
414                write!(
415                    f,
416                    "{name}'s config has named {named} since Pitboard last asked {service} whose \
417                     login {name} has stored, so another login may be stored.",
418                    name = tool.name(),
419                    named = named.said("no account"),
420                    service = tool.service(),
421                )?;
422                match last {
423                    Stored::Account(_) | Stored::Unenrolled(_) => {
424                        write!(f, " The one stored then was {}'s.", last.said(""))?;
425                    }
426                    Stored::Nothing => write!(f, " None was stored then.")?,
427                    Stored::Unknown => {}
428                }
429                write!(f, " `pitboard status` asks again.")
430            }
431            Warning::ConfigNamesAnother {
432                tool,
433                config,
434                in_use,
435            } => {
436                let config = config.said("no account");
437                write!(
438                    f,
439                    "{name}'s config names {config}, and the login {name} has stored is {}'s, as \
440                     {} said. `/status` in {name} shows {config}.",
441                    in_use.said("nobody"),
442                    tool.service(),
443                    name = tool.name(),
444                )?;
445                write!(f, " {}", in_use.how_to_name())
446            }
447            Warning::ReadOnly { why } => {
448                read_only(f, &crate::words::elevated(crate::host::OS, *why))
449            }
450            Warning::ReadOnlyBelowTheFloor { build } => {
451                read_only(f, &crate::words::too_old(*build))
452            }
453        }
454    }
455}
456
457/// An account as a warning or a doctor check names it: whose login a tool has stored, as
458/// Pitboard last recorded it, or whom the tool's own record names.
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub enum Stored {
461    /// An enrolled account's, by the name to type for it.
462    Account(String),
463    /// An account nobody enrolled, by its email.
464    Unenrolled(String),
465    /// None.
466    Nothing,
467    /// Nothing is recorded of the tool's store here.
468    Unknown,
469}
470
471impl Stored {
472    /// `owner`'s account of `tool`, or none.
473    pub(crate) fn of(
474        state: &state::State,
475        tool: ProviderId,
476        owner: Option<&crate::api::Owner>,
477    ) -> Stored {
478        match owner {
479            None => Stored::Nothing,
480            Some(owner) => state.account_of(tool, owner).map_or_else(
481                || Stored::Unenrolled(owner.email.clone()),
482                |account| Stored::Account(state.typed(&account.key())),
483            ),
484        }
485    }
486
487    /// How a sentence names it: the name to type, in backticks, the email, or `nobody`.
488    pub(crate) fn said(&self, nobody: &str) -> String {
489        match self {
490            Stored::Account(name) => format!("`{name}`"),
491            Stored::Unenrolled(email) => email.clone(),
492            Stored::Nothing | Stored::Unknown => nobody.to_string(),
493        }
494    }
495
496    /// The sentence that says what writes this account, whose login the tool has stored, into
497    /// the tool's own record again: `pitboard use` where it is enrolled, and otherwise the
498    /// next switch.
499    pub(crate) fn how_to_name(&self) -> String {
500        match self {
501            Stored::Account(name) => {
502                format!("`pitboard use {name}` writes `{name}` into the config.")
503            }
504            _ => "A switch writes the account it puts in use into the config.".into(),
505        }
506    }
507}
508
509/// Each account whose only login a sign-in outside Pitboard replaced, from Pitboard's own
510/// record alone.
511fn replaced(state: &state::State) -> Vec<Warning> {
512    state
513        .accounts
514        .iter()
515        .filter(|account| account.replaced_at.is_some() && account.parked.is_none())
516        .map(|account| {
517            let tool = account.provider();
518            let now = state.in_use(tool).map_or(Stored::Unknown, |record| {
519                Stored::of(state, tool, record.owner.as_ref())
520            });
521            Warning::LoginReplaced {
522                tool,
523                id: account.id.clone(),
524                label: state.typed(&account.key()),
525                now,
526            }
527        })
528        .collect()
529}
530
531/// What `known` says of a tool whose own record is apart from its login: that the record
532/// moved since its service last named the login stored, or names another account than one
533/// the service never named, or that it names another account than that login's.
534fn in_doubt(state: &state::State, tool: ProviderId, known: &Known) -> Option<Warning> {
535    let named = Stored::of(state, tool, known.own_record.as_ref());
536    let unconfirmed = |asked| {
537        let last = known.last.as_ref().map_or(Stored::Unknown, |record| {
538            Stored::of(state, tool, record.owner.as_ref())
539        });
540        Some(Warning::InUseUnconfirmed {
541            tool,
542            last,
543            named: named.clone(),
544            asked,
545        })
546    };
547    match known.doubt {
548        Some(Doubt::NamedMoved) => return unconfirmed(true),
549        Some(Doubt::NeverEstablished) => {
550            let owner = known.owner()?;
551            let config = known.own_record.as_ref()?;
552            return if new_id(tool, config) == new_id(tool, owner) {
553                None
554            } else {
555                unconfirmed(false)
556            };
557        }
558        Some(Doubt::LoginUnreadable) | None => {}
559    }
560    known
561        .named_another(tool)
562        .map(|_| Warning::ConfigNamesAnother {
563            tool,
564            config: named,
565            in_use: Stored::of(state, tool, known.owner()),
566        })
567}
568
569fn read_only(f: &mut fmt::Formatter<'_>, said: &crate::words::ChangesNothing) -> fmt::Result {
570    write!(
571        f,
572        "{}, so it changes nothing: these are the numbers it last measured, and it renews no \
573         parked login and asks nobody. {}",
574        said.because, said.to_ask_again
575    )
576}
577
578#[derive(Debug)]
579pub struct Done<T> {
580    pub value: T,
581    pub warnings: Vec<Warning>,
582}
583
584/// A change that failed, with what was found on the way: recovering an interrupted switch
585/// is reported even when the change that followed it fails.
586#[derive(Debug)]
587pub struct Failed {
588    pub error: Error,
589    pub warnings: Vec<Warning>,
590}
591
592pub type Changing<T> = std::result::Result<Done<T>, Failed>;
593
594pub struct Pitboard {
595    ctx: Context,
596}
597
598impl Pitboard {
599    pub fn new(ctx: Context) -> Pitboard {
600        Pitboard { ctx }
601    }
602
603    /// Whether this process may change anything here, as the one gate every change passes
604    /// says, with the proof it hands out where it may. Every change below asks it first,
605    /// before it reads, locks or records anything, so a change refused here leaves the
606    /// Pitboard directory, the stores of logins and the scheduler exactly as they were, the
607    /// audit log included. A front end asks it too before it writes a file of its own
608    /// through [`crate::app::write_file`], or before it asks a question whose answer could
609    /// only be refused.
610    pub fn permit(&self) -> Result<Permit> {
611        gate(&self.ctx)
612    }
613
614    /// Whether every home the environment names is a full path, as the gate and every read
615    /// of the accounts ask. A front end asks it before anything else that reads under one,
616    /// such as the audit log, the schedule's file or the status line's session records, so
617    /// a home that is empty or relative is refused there too, never read as the folder the
618    /// front end runs in.
619    pub fn check_homes(&self) -> Result<()> {
620        crate::home::check_absolute(&self.ctx)
621    }
622
623    /// The gate's answer as the refusal of a change, which has found nothing on the way.
624    fn permitted(&self) -> std::result::Result<Permit, Failed> {
625        self.permit().map_err(|error| Failed {
626            error,
627            warnings: Vec::new(),
628        })
629    }
630
631    /// Who is signed in and what every account has left. Parked logins whose access has
632    /// lapsed are renewed first, so every account is asked live, and so is a tool's stored
633    /// login where only its service can tell whose it is
634    /// ([`switch::identify::told_by_a_read`]).
635    ///
636    /// `fresh` asks Anthropic about every account whatever was asked recently. Ordinarily
637    /// false: a number is only asked for again once the tightest limit it describes could
638    /// have moved by a percentage point, which collapses several front ends on one machine
639    /// to one request per account per few minutes.
640    ///
641    /// An interrupted switch the next change cannot finish is said as that change would
642    /// refuse over it, and nothing is done about it: every change stops at it until it can
643    /// be finished or is given up on, and a front end that waited for a change to be
644    /// refused could not offer to give up on it until then. One the next change finishes by
645    /// itself is not said. Nobody has anything to do about it: the rows already say who is
646    /// signed in from each tool's login rather than from Pitboard's record, and the change
647    /// that finishes it says what it found.
648    ///
649    /// A file behind a tool's store is said as a change says it, since while it is there
650    /// every switch reaches running sessions only at their login's next renewal, and a read
651    /// is where somebody looks before they switch.
652    ///
653    /// Whose login each tool has stored is recorded where the read found it changed, as
654    /// [`switch::identify::record_read`] says, before the report is made, so a sign-in outside
655    /// Pitboard that replaced the only login of an account is said from that read on. Where
656    /// Claude Code's config names another account than that login's, that is said too, since
657    /// `/status` in Claude Code shows the config's.
658    ///
659    /// Where this process may change nothing, it answers what [`status_offline`] answers,
660    /// with a `read_only` warning that says why: renewing a parked login rotates its refresh
661    /// token, a live read records what it measured, and neither may happen then.
662    ///
663    /// [`status_offline`]: Pitboard::status_offline
664    pub fn status(&self, fresh: bool) -> Result<Done<status::Report>> {
665        let permit = match self.permit() {
666            Ok(permit) => permit,
667            Err(refused) => {
668                let Some(warning) = Warning::read_only(&refused) else {
669                    return Err(refused);
670                };
671                let mut read = self.status_offline()?;
672                read.warnings.push(warning);
673                return Ok(read);
674            }
675        };
676        let renewed = switch::renew_parked(&self.ctx, permit);
677        // Unreadable is not the same as empty: reporting it as empty would say the enrolled
678        // logins are gone.
679        let state = state::load(&self.ctx)?;
680        // Alongside the answers rather than before them. Where only the service can say
681        // whose the live login is, both ask it, and asked one after the other, a service that
682        // did not answer held the read for its timeout twice.
683        let (stuck, answers) = std::thread::scope(|scope| {
684            let stuck = scope.spawn(|| switch::stuck(&self.ctx, &state, switch::Asking::Service));
685            let answers = status::ask(&self.ctx, permit, &state, fresh);
686            (stuck.join().ok().flatten(), answers)
687        });
688        switch::identify::record_read(&self.ctx, permit, &answers.found, &answers.plans);
689        let found = answers.found.clone();
690        // Again, so the rows and what stands are of whose login each tool has stored as it
691        // was just recorded.
692        let state = state::load(&self.ctx)?;
693        let report = status::report(&self.ctx, permit, &state, answers);
694        let mut warnings: Vec<Warning> = stuck.map(Warning::SwitchStuck).into_iter().collect();
695        for (key, outcome) in renewed {
696            audit::record(&self.ctx, permit, "renew", &key.typed(), outcome.code());
697            match outcome {
698                Renewal::Refused => warnings.push(Warning::ParkedLoginRefused {
699                    tool: key.provider,
700                    label: state.typed(&key),
701                }),
702                Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
703                Renewal::Renewed | Renewal::Deferred => {}
704            }
705        }
706        warnings.extend(
707            ProviderId::ALL
708                .iter()
709                .filter_map(|&tool| self.left_behind(tool)),
710        );
711        warnings.extend(self.standing(&state, &found));
712        Ok(Done {
713            value: report,
714            warnings,
715        })
716    }
717
718    /// What a read says for as long as it stands: each account whose only login a sign-in
719    /// outside Pitboard replaced, and, of a tool that keeps a record of its own apart from its
720    /// login, that the record has moved since its service last named the login stored, or
721    /// that it names another account than that login's.
722    ///
723    /// `found` is whose login each tool's store holds as this read just learned it, which a
724    /// read that asks nobody has none of. For every other tool, what Pitboard recorded
725    /// stands, so a read that could not ask says what one that asks nobody says.
726    fn standing(&self, state: &state::State, found: &[(ProviderId, InUse)]) -> Vec<Warning> {
727        let doubts = ProviderId::ALL
728            .iter()
729            .filter(|&&tool| !crate::provider::of(tool).identifies_by_itself())
730            .filter_map(|&tool| {
731                let known = match found.iter().find(|(which, _)| *which == tool) {
732                    Some((_, found)) => in_use::judged(&self.ctx, tool, Some(found.clone())),
733                    None => in_use::known(&self.ctx, state, tool),
734                };
735                in_doubt(state, tool, &known)
736            });
737        replaced(state).into_iter().chain(doubts).collect()
738    }
739
740    pub fn doctor(&self) -> Diagnosis {
741        doctor::run(&self.ctx)
742    }
743
744    /// The same report without asking anyone: the last numbers Pitboard measured, and whose
745    /// login each tool has stored as its service last said. Nothing is renewed and nothing is
746    /// asked, so it answers at once wherever there is no network. Where Claude Code's config
747    /// has named another account since, as a sign-in leaves it, that is said, and a read that
748    /// asks settles it.
749    ///
750    /// An interrupted switch the next change cannot finish is said here too, as [`status`]
751    /// says it, wherever that can be told without a request: a Claude Code login renewed
752    /// since the switch stopped is one only Anthropic can say whose it is, and then nothing
753    /// is said. Telling reads the tool's login and the copy the switch parked, which on
754    /// macOS are keychain items, and only while a switch is waiting: otherwise one look at
755    /// whether its record is there is the whole of it. So nothing is said of a file behind
756    /// the keychain, which only a read of the keychain can tell. What Pitboard's own record
757    /// holds is said as [`status`] says it: an account whose only login was replaced outside
758    /// Pitboard, and Claude Code's config naming another account than the one in use.
759    ///
760    /// [`status`]: Pitboard::status
761    pub fn status_offline(&self) -> Result<Done<status::Report>> {
762        let state = state::load(&self.ctx)?;
763        let warnings = switch::stuck(&self.ctx, &state, switch::Asking::Nobody)
764            .map(Warning::SwitchStuck)
765            .into_iter()
766            .chain(self.standing(&state, &[]))
767            .collect();
768        Ok(Done {
769            value: status::gather_offline(&self.ctx, &state),
770            warnings,
771        })
772    }
773
774    /// The status line for Claude Code's session JSON. Reads only files, and writes only
775    /// Pitboard's own: what the session passed, for its next run to compare with, and the
776    /// usage readings, which take what moved since its last run for the account its windows
777    /// prove it is on, where it moves a limit Anthropic gave that account. Where this process
778    /// may change nothing it writes neither, and draws the line from the files as they are.
779    pub fn statusline(&self, session: &str) -> statusline::StatusLine {
780        statusline::read(&self.ctx, self.permit().ok(), session)
781    }
782
783    /// The enrolled account under `typed`, if any, read without taking the lock.
784    pub fn account(&self, typed: &str) -> Option<Account> {
785        let state = state::load(&self.ctx).ok()?;
786        crate::label::resolve(&state, typed).ok().cloned()
787    }
788
789    pub fn switch_to(&self, typed: &str) -> Changing<Outcome> {
790        let permit = self.permitted()?;
791        let key = self.named(permit, "use", typed)?;
792        self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
793            switch::switch(settled, &key)
794        })
795    }
796
797    /// What [`switch_to`] does for the account in use, for a front end's button drawn while
798    /// `typed` was: recorded as `use`, and refused, moving nothing, where another account's
799    /// login is stored by then.
800    ///
801    /// [`switch_to`]: Pitboard::switch_to
802    pub fn update_config(&self, typed: &str) -> Changing<Outcome> {
803        let permit = self.permitted()?;
804        let key = self.named(permit, "use", typed)?;
805        self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
806            switch::update_config(settled, &key)
807        })
808    }
809
810    /// Switches Claude Code by itself where a limit of the account in use has reached
811    /// `threshold` and another account has room, as [`crate::autoswitch`] says. For a front
812    /// end somebody asked to do that: the app with its setting on, or `pitboard watch`.
813    ///
814    /// Looks first from files alone ([`auto_look`]), so a look that finds nothing to do takes
815    /// no lock, asks nobody and records nothing. Under the lock whose login Claude Code has
816    /// stored is told, and the switch decided once, as [`switch::automatically`] says, which
817    /// records in the audit log what it came to. A settle that failed first is recorded as
818    /// `auto-switch` of `claude`.
819    ///
820    /// [`auto_look`]: Pitboard::auto_look
821    pub fn auto_switch(&self, threshold: Threshold) -> Changing<Auto> {
822        let permit = self.permitted()?;
823        let looked = self.auto_look(threshold).map_err(|error| Failed {
824            error,
825            warnings: Vec::new(),
826        })?;
827        if let Look::Stands(value) = looked {
828            return Ok(Done {
829                value: *value,
830                warnings: Vec::new(),
831            });
832        }
833        let tool = ProviderId::Claude;
834        let (settled, mut warnings) = self.settled_for(permit, "auto-switch", "", Some(tool))?;
835        match switch::automatically(settled, threshold) {
836            Ok((value, more)) => {
837                warnings.extend(more);
838                Ok(Done { value, warnings })
839            }
840            Err(mut error) => {
841                warnings.extend(error.take_warnings());
842                Err(Failed { error, warnings })
843            }
844        }
845    }
846
847    /// What switching Claude Code by itself would come to now, from files alone: what
848    /// stands, or that only a decision under the lock can say, which [`auto_switch`] makes.
849    /// Waits on no lock, reads no keychain and asks nobody, so a front end may look as often
850    /// as it likes. While another run is in the middle of a switch, only the decision can
851    /// say, which waits for that run.
852    ///
853    /// [`auto_switch`]: Pitboard::auto_switch
854    pub fn auto_look(&self, threshold: Threshold) -> Result<Look> {
855        let state = state::load(&self.ctx)?;
856        Ok(crate::autoswitch::look(&self.ctx, &state, threshold))
857    }
858
859    /// Which account somebody meant, as the key the engine looks accounts up by.
860    ///
861    /// Resolving here rather than deeper down means every command takes `codex/work` and
862    /// a bare `work` on the same terms, and the one place that decides what an ambiguous
863    /// bare label does is the one place that knows every provider's accounts.
864    fn named(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
865        let state = state::load(&self.ctx).map_err(|error| Failed {
866            error,
867            warnings: Vec::new(),
868        })?;
869        crate::label::resolve(&state, typed)
870            .map(Account::key)
871            .map_err(|error| self.refused(permit, verb, typed, error.code(), error))
872    }
873
874    /// `typed` may name a tool, as in `claude/work`. A bare name means the default tool.
875    pub fn enroll_current(&self, typed: &str) -> Changing<Enrolled> {
876        let permit = self.permitted()?;
877        let key = self.chosen(permit, "enroll", typed)?;
878        self.changing(
879            permit,
880            "enroll",
881            &key.typed(),
882            Some(key.provider),
883            |settled| switch::enroll(settled, &key, None),
884        )
885    }
886
887    /// Which tool a new account is for, and what it is called there.
888    ///
889    /// Split here rather than deeper down so nothing below ever sees a name with a tool
890    /// still stuck to the front of it, which would enrol an account literally called
891    /// `claude/work`.
892    fn chosen(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
893        self.enrolling(typed)
894            .map_err(|error| self.refused(permit, verb, typed, "label_unusable", error))
895    }
896
897    /// A change refused over the name it was given, which happens before it settles.
898    ///
899    /// A mistyped name takes no lock and writes nothing but its line in the audit log. But
900    /// every change settles an interrupted switch first, and one refused here would leave
901    /// that switch for whatever runs next and say nothing about it. So where a switch was
902    /// interrupted, this settles for the tool that switch was of, which a custom OAuth
903    /// endpoint allows or refuses exactly as it would a change to that tool, and reports what
904    /// it found beside the refusal. Only as far as it can: a recovery that cannot finish is
905    /// the next change's to report, and what this one reports is why it was refused.
906    fn refused(
907        &self,
908        permit: Permit,
909        verb: &str,
910        subject: &str,
911        code: &str,
912        error: Error,
913    ) -> Failed {
914        let recovered = if switch::interrupted(&self.ctx) {
915            switch::settle(&self.ctx, permit, switch::interrupted_tool(&self.ctx))
916                .ok()
917                .and_then(|(_, recovered)| recovered)
918        } else {
919            None
920        };
921        let mut warnings = Vec::new();
922        if let Some(r) = recovered {
923            audit::record(&self.ctx, permit, "recover", &r.to, r.code());
924            warnings.push(Warning::Recovered(r));
925        }
926        audit::record(&self.ctx, permit, verb, subject, code);
927        Failed { error, warnings }
928    }
929
930    /// The account `pitboard enroll <typed>` is about: the one already enrolled under that
931    /// exact name, or a new one of the tool the name says.
932    ///
933    /// A label written by 0.1.x could contain a slash, which a new name cannot, and signing
934    /// in to such an account again is exactly what every message about a lapsed park tells
935    /// somebody to do. So an existing account is found by its whole name first.
936    fn enrolling(&self, typed: &str) -> Result<Key> {
937        if typed.contains(crate::label::SEPARATOR)
938            && let Ok(state) = state::load(&self.ctx)
939            && let Some(existing) = state.accounts.iter().find(|a| a.label == typed)
940        {
941            return Ok(existing.key());
942        }
943        crate::label::choose(typed)
944            .map(|chosen| Key::new(chosen.provider, chosen.label))
945            .map_err(Error::Usage)
946    }
947
948    /// The enrolled account `pitboard enroll <typed>` would sign in to again, if it names
949    /// one, for saying whose login to sign in with before a browser opens.
950    pub fn account_to_enroll(&self, typed: &str) -> Option<Account> {
951        let key = self.enrolling(typed).ok()?;
952        state::load(&self.ctx).ok()?.get(&key).cloned()
953    }
954
955    /// What to type to name the account `pitboard enroll <typed>` is about, on this
956    /// machine: bare where that names it alone, qualified where another tool shares it.
957    pub fn name_to_type(&self, typed: &str) -> String {
958        let Ok(key) = self.enrolling(typed) else {
959            return typed.to_string();
960        };
961        state::load(&self.ctx).map_or_else(|_| key.typed(), |state| state.typed(&key))
962    }
963
964    /// The tool's own sign-in in a private directory, for the tool `typed` names. It takes
965    /// no lock but its own, so a person taking their time in a browser never holds up a
966    /// switch.
967    pub fn sign_in(&self, typed: &str) -> std::result::Result<SignIn, Failed> {
968        let permit = self.permitted()?;
969        let key = self.signing_in(permit, typed)?;
970        switch::sign_in(&self.ctx, permit, key.provider)
971            .map_err(|error| self.not_started(permit, typed, error))
972    }
973
974    /// The same sign-in with its output piped, for a front end that has no terminal to
975    /// hand over. The caller shows what the tool says and can type a code back.
976    pub fn sign_in_watched(
977        &self,
978        typed: &str,
979    ) -> std::result::Result<switch::WatchedSignIn, Failed> {
980        let permit = self.permitted()?;
981        let key = self.signing_in(permit, typed)?;
982        switch::sign_in_watched_as(&self.ctx, permit, &key)
983            .map_err(|error| self.not_started(permit, typed, error))
984    }
985
986    /// Which account a sign-in is for, once everything that could refuse it has been asked.
987    ///
988    /// A name no account could have is refused the way every change refuses one, settling
989    /// an interrupted switch on the way; anything else refused here is recorded and nothing
990    /// more, since nothing was about to change.
991    fn signing_in(&self, permit: Permit, typed: &str) -> std::result::Result<Key, Failed> {
992        let key = self.chosen(permit, "enroll", typed)?;
993        self.ready_to_sign_in(key.provider)
994            .map_err(|error| self.not_started(permit, typed, error))?;
995        Ok(key)
996    }
997
998    /// A sign-in that did not start, or did not finish, for a reason other than its name.
999    fn not_started(&self, permit: Permit, typed: &str, error: Error) -> Failed {
1000        audit::record(&self.ctx, permit, "enroll", typed, error.code());
1001        Failed {
1002            error,
1003            warnings: Vec::new(),
1004        }
1005    }
1006
1007    /// Checked before a sign-in starts, so a person does not sign in through a browser
1008    /// only to be told the state file belongs to another machine, that the tool is not
1009    /// installed, or that the account could never be switched to afterwards.
1010    fn ready_to_sign_in(&self, tool: crate::provider::ProviderId) -> Result<()> {
1011        if tool == crate::provider::ProviderId::Claude && self.ctx.custom_oauth() {
1012            return Err(Error::CustomOauthEndpoint);
1013        }
1014        state::load(&self.ctx)?;
1015        let driver = crate::provider::of(tool);
1016        // An account signed in here is one to switch to later, which needs a live store
1017        // Pitboard can write. Asked now rather than after a browser round trip.
1018        switch::live_store(&self.ctx, tool)?;
1019        // A private sign-in works by pointing the tool's own login at a scratch directory
1020        // through its home variable. Where that does not really isolate it, running one
1021        // would write over the login somebody is using, and there is no override: forcing
1022        // past "this could touch your live login" is what the rule exists to prevent.
1023        if let crate::provider::Isolation::NotIsolated { reason } =
1024            driver.private_signin_isolation(&self.ctx)
1025        {
1026            return Err(Error::SignInNotIsolated { reason });
1027        }
1028        if driver.program(&self.ctx).is_none() {
1029            return Err(Error::ProgramMissing {
1030                tool,
1031                program: self.ctx.program_for(tool).display().to_string(),
1032            });
1033        }
1034        Ok(())
1035    }
1036
1037    pub fn enroll_signed_in(&self, typed: &str, login: SignIn) -> Changing<Enrolled> {
1038        let permit = self.permitted()?;
1039        let key = self.chosen(permit, "enroll", typed)?;
1040        self.changing(
1041            permit,
1042            "enroll",
1043            &key.typed(),
1044            Some(key.provider),
1045            |settled| switch::enroll(settled, &key, Some(login)),
1046        )
1047    }
1048
1049    /// Returns the account's email.
1050    pub fn forget(&self, typed: &str) -> Changing<String> {
1051        let permit = self.permitted()?;
1052        let key = self.named(permit, "forget", typed)?;
1053        self.changing(
1054            permit,
1055            "forget",
1056            &key.typed(),
1057            Some(key.provider),
1058            |settled| switch::forget(settled, &key),
1059        )
1060    }
1061
1062    /// Throws away a record of an interrupted switch that cannot be finished, keeping
1063    /// every login it names. The way out when recovery cannot reach Anthropic.
1064    pub fn abandon_recovery(&self) -> Result<Option<switch::Abandoned>> {
1065        let permit = self.permit()?;
1066        let outcome = switch::abandon(&self.ctx, permit);
1067        audit::record(
1068            &self.ctx,
1069            permit,
1070            "abandon",
1071            "",
1072            match &outcome {
1073                Ok(_) => "ok",
1074                Err(e) => e.code(),
1075            },
1076        );
1077        outcome
1078    }
1079
1080    /// Renew every parked login that is due, and nothing else. No switch, no usage, and
1081    /// no request but the token exchange. This is what the schedule runs.
1082    ///
1083    /// Refused where this process may change nothing, as every change is, so a run that
1084    /// renewed nothing for that reason says so rather than reading as one where nothing was
1085    /// due.
1086    ///
1087    /// A run the schedule started renews the default home, whatever `PITBOARD_HOME` says
1088    /// ([`schedule::for_its_run`]): the schedule is that home's alone.
1089    pub fn renew(&self) -> Result<Vec<(Key, Renewal)>> {
1090        let ctx = schedule::for_its_run(&self.ctx);
1091        let permit = gate(&ctx)?;
1092        let outcomes = switch::renew_due(&ctx, permit, switch::Due::ToStayAlive);
1093        for (key, outcome) in &outcomes {
1094            audit::record(&ctx, permit, "renew", &key.typed(), outcome.code());
1095        }
1096        Ok(outcomes)
1097    }
1098
1099    /// Whether anything is keeping parked logins alive on this machine without somebody
1100    /// running a command.
1101    pub fn schedule(&self) -> schedule::Installed {
1102        schedule::status(&self.ctx)
1103    }
1104
1105    /// Whether the schedule renews this Pitboard's own parked logins: whether its home is
1106    /// the default home, `~/.pitboard`, compared as a path. The schedule renews that home
1107    /// alone, and `schedule_install` is refused from any other.
1108    pub fn schedule_renews_this_home(&self) -> bool {
1109        schedule::serves(&self.ctx)
1110    }
1111
1112    /// Ask the platform's own scheduler to run `renew` daily. Opt-in, and stays opt-in.
1113    /// Refused with `schedule_not_default_home` where this is not the default home
1114    /// ([`Pitboard::schedule_renews_this_home`]).
1115    pub fn schedule_install(&self) -> Result<std::path::PathBuf> {
1116        schedule::install(&self.ctx, self.permit()?)
1117    }
1118
1119    /// Take it away. `false` when there was nothing installed.
1120    pub fn schedule_uninstall(&self) -> Result<bool> {
1121        schedule::uninstall(&self.ctx, self.permit()?)
1122    }
1123
1124    /// Point a schedule an app up to 0.3.0 wrote, which runs the app itself, at the command
1125    /// line this context names. `true` when it did; nothing changes otherwise.
1126    pub fn schedule_repair(&self) -> Result<bool> {
1127        schedule::repair(&self.ctx, self.permit()?)
1128    }
1129
1130    /// Take over a Pitboard directory another machine wrote: keep the accounts, drop the
1131    /// logins that came with them. `None` when the directory was already this machine's.
1132    ///
1133    /// The one change that does not settle first, because a stamp from elsewhere is what
1134    /// stops settling. Everything after it settles normally.
1135    pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
1136        switch::adopt(&self.ctx, self.permit()?)
1137    }
1138
1139    /// Ask the credential store what parked logins are on this machine, and give back or
1140    /// delete every one Pitboard's own records do not name. Ordinarily there is nothing to
1141    /// do: every change resolves the names it wrote down. This is for a machine whose state
1142    /// file was lost or restored from a backup, where the store is the only record left.
1143    pub fn repair(&self) -> Changing<switch::Reclaimed> {
1144        let permit = self.permitted()?;
1145        self.changing(permit, "repair", "", None, |settled| {
1146            switch::repair(settled).map(|r| (r, Vec::new()))
1147        })
1148    }
1149
1150    /// What is running `which`'s tool with a login in memory that a switch would leave it
1151    /// on, by kind: for a front end to say so, or to offer to quit an app, before switching.
1152    /// Empty where nothing is, where the tool follows a switch by itself, or where nobody
1153    /// could tell. Reads the process list and nothing else.
1154    pub fn holding(&self, which: ProviderId) -> Vec<holder::Holding> {
1155        match switch::still_holding(&self.ctx, which) {
1156            switch::StillHolding::These(holding) => holding,
1157            switch::StillHolding::Nothing | switch::StillHolding::Unknown => Vec::new(),
1158        }
1159    }
1160
1161    /// When Pitboard's account index last changed, in epoch milliseconds, for a front end
1162    /// that wants to know whether another one has done something without asking Anthropic
1163    /// about it.
1164    pub fn changed_at(&self) -> i64 {
1165        state::changed_at(&self.ctx)
1166    }
1167
1168    /// When Pitboard's usage readings last changed, in epoch milliseconds, for a front end
1169    /// that shows them to follow what the others record without asking anyone.
1170    pub fn readings_changed_at(&self) -> i64 {
1171        readings::changed_at(&self.ctx)
1172    }
1173
1174    /// What the app keeps in `file` in Pitboard's directory, or `None` where it keeps nothing
1175    /// there. One that is there and cannot be read is an error.
1176    pub fn app_file(&self, file: crate::app::AppFile) -> std::io::Result<Option<String>> {
1177        crate::app::read_app_file(&self.ctx, file)
1178    }
1179
1180    /// Keeps `body` in the app's `file` in Pitboard's directory, private and whole, as the
1181    /// core writes its own files.
1182    pub fn keep_app_file(&self, file: crate::app::AppFile, body: &str) -> Result<()> {
1183        crate::app::write_app_file(&self.ctx, self.permit()?, file, body)
1184    }
1185
1186    /// The changes Pitboard has made, newest last.
1187    pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
1188        audit::read(&self.ctx, limit)
1189    }
1190
1191    /// Takes away the daily renewal schedule, deletes every parked login this Pitboard
1192    /// wrote, and removes Pitboard's own directory. Each tool's login is left alone: whoever
1193    /// is signed in stays signed in.
1194    pub fn uninstall(&self) -> Changing<switch::Removed> {
1195        let permit = self.permitted()?;
1196        self.changing(permit, "uninstall", "", None, |settled| {
1197            switch::uninstall(settled).map(|r| (r, Vec::new()))
1198        })
1199    }
1200
1201    /// Returns the account's email.
1202    /// `from` may be qualified; `to` is a plain name, and stays inside whichever provider
1203    /// the account already belongs to. Renaming cannot move an account between tools.
1204    pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
1205        let permit = self.permitted()?;
1206        let from = self.named(permit, "rename", from)?;
1207        let chosen = self.chosen(permit, "rename", to)?;
1208        // Only a prefix somebody actually typed can disagree: a bare new name stays inside
1209        // the account's own tool whatever tool a bare name would mean for a new account.
1210        if to.contains(crate::label::SEPARATOR) && chosen.provider != from.provider {
1211            let error = Error::Usage(format!(
1212                "`{from}` is a {} account, and a rename cannot move it to {}. Sign in to that \
1213                 tool and enrol the account there instead.",
1214                from.provider, chosen.provider
1215            ));
1216            return Err(self.refused(permit, "rename", &from.typed(), error.code(), error));
1217        }
1218        let to = chosen.label;
1219        self.changing(
1220            permit,
1221            "rename",
1222            &format!("{from} -> {to}"),
1223            Some(from.provider),
1224            |settled| switch::rename(settled, &from, &to).map(|email| (email, Vec::new())),
1225        )
1226    }
1227
1228    /// Each Claude Code account, whether Pitboard warms it up, and within which span of each
1229    /// day, from Pitboard's files alone: no lock, and nobody asked.
1230    pub fn warm_overview(&self) -> Result<crate::warm::Overview> {
1231        let state = state::load(&self.ctx)?;
1232        let setting = crate::warm::load(&self.ctx)?;
1233        Ok(crate::warm::overview(&self.ctx, &state, &setting))
1234    }
1235
1236    /// Warms up the account `typed` names now, whatever the span of the day and the attempts
1237    /// already made today, where its five-hour period is not running and nothing holds it back:
1238    /// one short request through Claude Code's own headless mode, with that account's access
1239    /// token. Recorded in the activity log as `warm-up` of the account.
1240    pub fn warm_now(&self, typed: &str) -> Changing<switch::warm::WarmUp> {
1241        let permit = self.permitted()?;
1242        let key = self.named(permit, "warm-up", typed)?;
1243        let settled = self.settled_to_warm(permit, "warm-up", &key.typed())?;
1244        self.warming(settled, switch::warm::Target::Account(key))
1245    }
1246
1247    /// Warms up the first account turned on that is due, as [`crate::warm`] says, for a front
1248    /// end somebody asked to: the app, or `pitboard watch`. [`Pitboard::warm_look`] says
1249    /// whether one may be, waiting on no lock.
1250    pub fn warm_up(&self) -> Changing<switch::warm::WarmUp> {
1251        let permit = self.permitted()?;
1252        let looked = self.warm_look().map_err(|error| Failed {
1253            error,
1254            warnings: Vec::new(),
1255        })?;
1256        if let crate::duty::Look::Stands(overview) = looked {
1257            return Ok(Done {
1258                value: switch::warm::WarmUp::Stands(overview),
1259                warnings: Vec::new(),
1260            });
1261        }
1262        let settled = self.settled_to_warm(permit, "warm-up", "")?;
1263        self.warming(settled, switch::warm::Target::Due)
1264    }
1265
1266    /// What warming up would come to now, from files alone: what stands, with where each
1267    /// account turned on stands, or that only [`Pitboard::warm_up`] can say. Waits on no lock,
1268    /// reads no keychain and asks nobody, so a front end may look as often as it likes.
1269    pub fn warm_look(&self) -> Result<crate::duty::Look<crate::warm::Overview>> {
1270        let state = state::load(&self.ctx)?;
1271        crate::warm::look(&self.ctx, &state)
1272    }
1273
1274    /// Settled for a warm-up, with an interrupted switch settling finished. Without what a
1275    /// change of Claude Code's login warns of: a warm-up's run is given nothing of this
1276    /// process's environment and no file behind the keychain, and what would make it sign in
1277    /// another way stops it outright.
1278    fn settled_to_warm(
1279        &self,
1280        permit: Permit,
1281        verb: &str,
1282        subject: &str,
1283    ) -> std::result::Result<(Settled, Vec<Warning>), Failed> {
1284        let (settled, recovered) = self.settled(permit, verb, subject, Some(ProviderId::Claude))?;
1285        Ok((settled, recovered.into_iter().collect()))
1286    }
1287
1288    fn warming(
1289        &self,
1290        (settled, mut warnings): (Settled, Vec<Warning>),
1291        target: switch::warm::Target,
1292    ) -> Changing<switch::warm::WarmUp> {
1293        match switch::warm::warm_up(settled, target) {
1294            Ok((value, more)) => {
1295                warnings.extend(more);
1296                Ok(Done { value, warnings })
1297            }
1298            Err(mut error) => {
1299                warnings.extend(error.take_warnings());
1300                Err(Failed { error, warnings })
1301            }
1302        }
1303    }
1304
1305    /// Warms up the account `typed` names, or stops, recorded in the activity log as
1306    /// `warm-on` or `warm-off` of that account. Only a Claude Code account is warmed up
1307    /// (`warm_up_unsupported`); any may be turned off. Whether that changed anything.
1308    pub fn warm_turn(&self, typed: &str, on: bool) -> Changing<bool> {
1309        let verb = if on { "warm-on" } else { "warm-off" };
1310        let permit = self.permitted()?;
1311        let key = self.named(permit, verb, typed)?;
1312        let subject = key.typed();
1313        let settled = self.settled_to_warm(permit, verb, &subject)?;
1314        self.change(permit, verb, &subject, settled, |settled| {
1315            switch::warm::turn(settled, &key, on).map(|changed| (changed, Vec::new()))
1316        })
1317    }
1318
1319    /// Warms up within `range` of each day, recorded in the activity log as `warm-range` of
1320    /// the span.
1321    pub fn warm_range(&self, range: crate::warm::Range) -> Changing<crate::warm::Range> {
1322        let permit = self.permitted()?;
1323        let subject = format!(
1324            "{}-{}",
1325            crate::warm::clock(range.from()),
1326            crate::warm::clock(range.to())
1327        );
1328        let settled = self.settled_to_warm(permit, "warm-range", &subject)?;
1329        self.change(permit, "warm-range", &subject, settled, |settled| {
1330            switch::warm::range(settled, range).map(|range| (range, Vec::new()))
1331        })
1332    }
1333
1334    /// The login left in the file behind Claude Code's store, and what putting it away would
1335    /// do with it, for somebody to confirm: `None` where nothing is behind that store. What
1336    /// `pitboard stow` and the app's sheet ask first. Takes no lock and writes nothing, and
1337    /// asks Anthropic whose the login is, with its own access token, only where neither its
1338    /// fingerprint nor its expiry already says, and then, for an enrolled account, whose the
1339    /// login stored is, as a read asks it, only where that changed since Anthropic last named
1340    /// it.
1341    pub fn left_login(&self) -> Result<Option<switch::Left>> {
1342        let state = state::load(&self.ctx)?;
1343        switch::left_login(&self.ctx, &state)
1344    }
1345
1346    /// Puts away the login left in the file behind Claude Code's store, while the file still
1347    /// holds what `seen` was taken of, as [`switch::stow`] says. Recorded in the audit log as
1348    /// `stow`, of the account the login was found to be where it is enrolled, with what became
1349    /// of it, or with the error that stopped it. Settled with what a change of Claude Code's
1350    /// login warns of but the file, which is what it puts away.
1351    pub fn stow(&self, seen: &str) -> Changing<switch::Stowed> {
1352        let permit = self.permitted()?;
1353        let tool = ProviderId::Claude;
1354        let (settled, recovered) = self.settled(permit, "stow", "", Some(tool))?;
1355        let warnings = self.overridden(tool).into_iter().chain(recovered).collect();
1356        self.change(permit, "stow", "", (settled, warnings), |settled| {
1357            switch::stow(settled, seen)
1358        })
1359    }
1360
1361    /// What says `tool` signs in with something else than the login Pitboard moves, read from
1362    /// files as well as from this process's environment, so the app, which has no shell
1363    /// environment at all, gets the same answer as the command line.
1364    fn overridden(&self, tool: ProviderId) -> Option<Warning> {
1365        let names = crate::provider::of(tool).overridden_by(&self.ctx);
1366        (!names.is_empty()).then_some(Warning::AuthOverridden { tool, names })
1367    }
1368
1369    /// What a file behind `tool`'s store says, where one is there. It reads that store, so a
1370    /// read that asks nobody does not ask it.
1371    fn left_behind(&self, tool: ProviderId) -> Option<Warning> {
1372        let behind = crate::provider::of(tool).behind(&self.ctx)?;
1373        Some(Warning::FallbackLogin {
1374            tool,
1375            path: behind.path,
1376            held: behind.held,
1377        })
1378    }
1379
1380    /// Settles, runs the change, and records it in the audit log.
1381    ///
1382    /// `tool` is the tool whose login the change is about, where it is about one: its own
1383    /// ways of being signed in by something else are what is worth warning about, and a
1384    /// refusal that is one tool's business does not stop a change to another's.
1385    fn changing<T: Audited>(
1386        &self,
1387        permit: Permit,
1388        verb: &str,
1389        subject: &str,
1390        tool: Option<ProviderId>,
1391        run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
1392    ) -> Changing<T> {
1393        let settled = self.settled_for(permit, verb, subject, tool)?;
1394        self.change(permit, verb, subject, settled, run)
1395    }
1396
1397    /// Runs a change on what is settled for it, beside what settling warned of, and records
1398    /// it in the audit log as `verb`: of the subject the change names where it names one, and
1399    /// otherwise of `subject`.
1400    fn change<T: Audited>(
1401        &self,
1402        permit: Permit,
1403        verb: &str,
1404        subject: &str,
1405        (settled, mut warnings): (Settled, Vec<Warning>),
1406        run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
1407    ) -> Changing<T> {
1408        match run(settled) {
1409            Ok((value, more)) => {
1410                let subject = value.audit_subject().unwrap_or(subject);
1411                audit::record(&self.ctx, permit, verb, subject, value.audit_code());
1412                warnings.extend(more);
1413                Ok(Done { value, warnings })
1414            }
1415            Err(mut error) => {
1416                audit::record(&self.ctx, permit, verb, subject, error.code());
1417                warnings.extend(error.take_warnings());
1418                Err(Failed { error, warnings })
1419            }
1420        }
1421    }
1422
1423    /// Settles for a change, with what to know before it runs: another way `tool` is signed
1424    /// in, a file behind its store, and an interrupted switch settling finished, as
1425    /// [`Pitboard::settled`] records it.
1426    fn settled_for(
1427        &self,
1428        permit: Permit,
1429        verb: &str,
1430        subject: &str,
1431        tool: Option<ProviderId>,
1432    ) -> std::result::Result<(Settled, Vec<Warning>), Failed> {
1433        let (settled, recovered) = self.settled(permit, verb, subject, tool)?;
1434        let mut warnings = Vec::new();
1435        if let Some(tool) = tool {
1436            warnings.extend(self.overridden(tool));
1437            warnings.extend(self.left_behind(tool));
1438        }
1439        warnings.extend(recovered);
1440        Ok((settled, warnings))
1441    }
1442
1443    /// Settles for a change, with an interrupted switch settling finished, which the audit
1444    /// log records as `recover`. A settle that failed is recorded as `verb` of `subject`, with
1445    /// its error.
1446    fn settled(
1447        &self,
1448        permit: Permit,
1449        verb: &str,
1450        subject: &str,
1451        tool: Option<ProviderId>,
1452    ) -> std::result::Result<(Settled, Option<Warning>), Failed> {
1453        let (settled, recovered) = switch::settle(&self.ctx, permit, tool).map_err(|error| {
1454            audit::record(&self.ctx, permit, verb, subject, error.code());
1455            Failed {
1456                error,
1457                warnings: Vec::new(),
1458            }
1459        })?;
1460        let recovered = recovered.map(|r| {
1461            audit::record(&self.ctx, permit, "recover", &r.to, r.code());
1462            Warning::Recovered(r)
1463        });
1464        Ok((settled, recovered))
1465    }
1466}
1467
1468/// How a successful change is written in the audit log.
1469trait Audited {
1470    fn audit_code(&self) -> &'static str {
1471        "ok"
1472    }
1473
1474    /// The account it was of, where only running it could tell.
1475    fn audit_subject(&self) -> Option<&str> {
1476        None
1477    }
1478}
1479
1480impl Audited for Outcome {
1481    fn audit_code(&self) -> &'static str {
1482        match self {
1483            Outcome::Switched { .. } => "ok",
1484            Outcome::AlreadyActive {
1485                config_updated: true,
1486                ..
1487            } => "config_updated",
1488            Outcome::AlreadyActive { .. } => "already_active",
1489        }
1490    }
1491}
1492
1493impl Audited for switch::Reclaimed {}
1494impl Audited for Enrolled {}
1495impl Audited for String {}
1496impl Audited for bool {}
1497impl Audited for crate::warm::Range {}
1498
1499impl Audited for switch::Stowed {
1500    fn audit_code(&self) -> &'static str {
1501        self.kept.code()
1502    }
1503
1504    fn audit_subject(&self) -> Option<&str> {
1505        self.kept.account()
1506    }
1507}
1508
1509impl Audited for switch::Removed {
1510    fn audit_code(&self) -> &'static str {
1511        if self.pending > 0 {
1512            "parks_pending_removal"
1513        } else {
1514            "ok"
1515        }
1516    }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521    use super::*;
1522    use crate::switch::harness::{
1523        Machine, audit_lines, codex_machine, hold, machine, signed_in_outside, state_file,
1524    };
1525    use std::collections::BTreeMap;
1526    use std::sync::Arc;
1527
1528    type Make = fn(&str) -> Machine;
1529
1530    /// A name is refused the same way whichever tool it is for.
1531    const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
1532
1533    type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
1534
1535    /// Every way a change is refused over the name it was given, with the verb the audit
1536    /// log records it under, the code it is refused with and the one the log records: a
1537    /// name nobody enrolled, a name no account could have, whether the account signed in
1538    /// now is being enrolled or one is being signed in to, and a new name that would move
1539    /// an account to another tool.
1540    const REFUSALS: [(&str, &str, &str, &str, Refuse); 4] = [
1541        (
1542            "use",
1543            "use",
1544            "account_unknown",
1545            "account_unknown",
1546            |p, _| p.switch_to("nobody").err(),
1547        ),
1548        ("enroll", "enroll", "usage", "label_unusable", |p, _| {
1549            p.enroll_current("codx/work").err()
1550        }),
1551        ("sign-in", "enroll", "usage", "label_unusable", |p, _| {
1552            p.sign_in("codx/work").err()
1553        }),
1554        ("rename", "rename", "usage", "usage", |p, tool| {
1555            let other = if tool == ProviderId::Claude {
1556                ProviderId::Codex
1557            } else {
1558                ProviderId::Claude
1559            };
1560            p.rename(
1561                &Key::new(tool, "here").qualified(),
1562                &Key::new(other, "moved").qualified(),
1563            )
1564            .err()
1565        }),
1566    ];
1567
1568    /// A switch from `here` to `there` killed after parking `here` and before installing
1569    /// `there`, so its record is all that says it happened.
1570    fn interrupted(make: Make, name: &str) -> Machine {
1571        let m = make(name);
1572        let settled = switch::settle(&m.ctx, Permit::for_a_test(), None)
1573            .expect("nothing to recover yet")
1574            .0;
1575        let died = crate::fault::killing("switch.park_recorded", || {
1576            switch::switch(settled, &m.key("there"))
1577        });
1578        assert_eq!(died.unwrap_err(), "switch.park_recorded");
1579        assert!(switch::interrupted(&m.ctx));
1580        m
1581    }
1582
1583    /// The same switch, which the next change cannot finish: nothing is signed in to the
1584    /// tool any more, so nothing says which side of the switch won. Told apart without
1585    /// asking anyone, for either tool.
1586    fn stuck(make: Make, name: &str) -> Machine {
1587        let m = interrupted(make, name);
1588        m.fault_live(crate::store::memory::Fault::Vanish);
1589        m
1590    }
1591
1592    /// The switch interrupted on a Claude Code machine, which the next change cannot finish
1593    /// either, though only Anthropic could tell: Claude Code has since renewed the login, so
1594    /// it matches neither side the record kept, and its session has expired, so Anthropic
1595    /// will not say whose it is. An access token the scripted service does not know is one
1596    /// it refuses.
1597    fn stuck_unless_asked(name: &str) -> Machine {
1598        let m = interrupted(machine, name);
1599        m.sign_in(&crate::switch::harness::document("renewed-since"));
1600        m
1601    }
1602
1603    /// The warnings a read gives, by code and words.
1604    fn said(read: &Done<status::Report>) -> Vec<(&'static str, String)> {
1605        read.warnings
1606            .iter()
1607            .map(|w| (w.code(), w.to_string()))
1608            .collect()
1609    }
1610
1611    /// Everything a read must leave as it found it: Pitboard's files but the records a
1612    /// live read keeps by design (what it measured and when to ask again), every parked
1613    /// login and the live one.
1614    type Untouched = (
1615        BTreeMap<String, Vec<u8>>,
1616        Vec<(String, Option<String>)>,
1617        Option<serde_json::Value>,
1618    );
1619
1620    fn untouched(m: &Machine) -> Untouched {
1621        let kept_by_a_read = ["usage.json", "usage.lock", "asking.json"];
1622        let mut files = files(m);
1623        files.retain(|path, _| !kept_by_a_read.iter().any(|kept| path.ends_with(kept)));
1624        let vault = m.mem.vault();
1625        let parked = vault
1626            .services()
1627            .into_iter()
1628            .map(|service| {
1629                let held = vault.peek(&service);
1630                (service, held)
1631            })
1632            .collect();
1633        (files, parked, m.live())
1634    }
1635
1636    /// A switch says when Claude Code keeps another login in its fallback file, which a
1637    /// session that cannot read the keychain signs in with and no switch reaches. Codex has
1638    /// no store behind the one in use, so nothing is said of it.
1639    #[test]
1640    #[cfg_attr(
1641        windows,
1642        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1643    )]
1644    fn a_switch_says_a_login_is_left_where_it_does_not_reach() {
1645        let m = machine("switch-fallback-login");
1646        let left = plant_behind(
1647            &m,
1648            &crate::switch::harness::document("left-by-a-sign-in").to_string(),
1649        );
1650        let switched = Pitboard::new(m.ctx.clone())
1651            .switch_to("there")
1652            .expect("a switch");
1653        let said: Vec<String> = switched
1654            .warnings
1655            .iter()
1656            .filter(|w| w.code() == "fallback_login")
1657            .map(ToString::to_string)
1658            .collect();
1659        assert_eq!(said.len(), 1, "{:?}", switched.warnings);
1660        assert!(
1661            said[0].starts_with(&left.display().to_string()),
1662            "{}",
1663            said[0]
1664        );
1665
1666        for (tool, make) in MACHINES {
1667            let m = make(&format!("switch-nothing-left-{tool}"));
1668            let switched = Pitboard::new(m.ctx.clone())
1669                .switch_to(&m.key("there").typed())
1670                .expect("a switch");
1671            assert!(
1672                switched
1673                    .warnings
1674                    .iter()
1675                    .all(|w| w.code() != "fallback_login"),
1676                "{tool}: {:?}",
1677                switched.warnings
1678            );
1679        }
1680    }
1681
1682    /// Plants `contents` in Claude Code's `.credentials.json` behind the keychain, as a
1683    /// sign-in where the keychain could not be read leaves it, and gives back its path.
1684    fn plant_behind(m: &Machine, contents: &str) -> std::path::PathBuf {
1685        let left = crate::provider::claude::live::credential_file(&m.ctx);
1686        m.mem.file_at(left.clone()).plant(
1687            &crate::provider::claude::paths::live_service(&m.ctx),
1688            contents,
1689        );
1690        left
1691    }
1692
1693    /// While a file sits behind the keychain, running sessions keep their account after a
1694    /// switch until their login is next renewed, so a read says it is there, as a change
1695    /// does, and not only somebody about to switch hears of it. A read that asks nobody
1696    /// reads no keychain, and says nothing of what is behind one. Codex has no store behind
1697    /// its own.
1698    #[test]
1699    #[cfg_attr(
1700        windows,
1701        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1702    )]
1703    fn a_read_says_a_file_is_behind_the_keychain() {
1704        let m = machine("read-fallback-login");
1705        let left = plant_behind(
1706            &m,
1707            &crate::switch::harness::document("left-by-a-sign-in").to_string(),
1708        );
1709        let pitboard = Pitboard::new(m.ctx.clone());
1710
1711        let read = pitboard.status(false).expect("a read");
1712        let said: Vec<String> = read
1713            .warnings
1714            .iter()
1715            .filter(|w| w.code() == "fallback_login")
1716            .map(ToString::to_string)
1717            .collect();
1718        assert_eq!(said.len(), 1, "{:?}", read.warnings);
1719        assert!(
1720            said[0].starts_with(&format!(
1721                "{} holds another Claude Code login",
1722                left.display()
1723            )),
1724            "{}",
1725            said[0]
1726        );
1727        assert!(
1728            said[0].contains(
1729                "sessions already running at a switch keep the account they are on until \
1730                 their login is next renewed, or until they are started again"
1731            ),
1732            "{}",
1733            said[0]
1734        );
1735        assert!(
1736            said[0].ends_with(
1737                "`pitboard stow` keeps that login for its account, where Pitboard holds no \
1738                 other it can switch to, then deletes the file."
1739            ),
1740            "{}",
1741            said[0]
1742        );
1743        let offline = pitboard.status_offline().expect("a read of what is known");
1744        assert!(
1745            offline
1746                .warnings
1747                .iter()
1748                .all(|w| w.code() != "fallback_login"),
1749            "{:?}",
1750            offline.warnings
1751        );
1752
1753        for (tool, make) in MACHINES {
1754            let m = make(&format!("read-nothing-left-{tool}"));
1755            let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
1756            assert!(
1757                read.warnings.iter().all(|w| w.code() != "fallback_login"),
1758                "{tool}: {:?}",
1759                read.warnings
1760            );
1761        }
1762    }
1763
1764    /// A running session watches the file by its being there, whatever it holds, so one
1765    /// with no login in it holds sessions to their account all the same, and is said too:
1766    /// in words that say so, and with what lets them follow.
1767    #[test]
1768    #[cfg_attr(
1769        windows,
1770        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1771    )]
1772    fn an_empty_file_behind_the_keychain_is_said_too() {
1773        let m = machine("read-empty-fallback");
1774        let left = plant_behind(&m, "{}");
1775        let pitboard = Pitboard::new(m.ctx.clone());
1776
1777        for warnings in [
1778            pitboard.status(false).expect("a read").warnings,
1779            pitboard.switch_to("there").expect("a switch").warnings,
1780        ] {
1781            let said: Vec<String> = warnings
1782                .iter()
1783                .filter(|w| w.code() == "fallback_login")
1784                .map(ToString::to_string)
1785                .collect();
1786            assert_eq!(
1787                said,
1788                [format!(
1789                    "{path} is there with no Claude Code login in it. While it is, Claude \
1790                     Code sessions already running at a switch keep the account they are on \
1791                     until their login is next renewed, or until they are started again. \
1792                     `pitboard stow` deletes it, which lets them follow a switch.",
1793                    path = left.display()
1794                )],
1795                "{warnings:?}"
1796            );
1797        }
1798    }
1799
1800    /// A running session looks at the file and never reads it to decide, so one Pitboard
1801    /// cannot read, as one that is not text or that this user may not read, holds sessions
1802    /// to their account all the same. It is said, in words that do not guess what it holds.
1803    #[test]
1804    #[cfg_attr(
1805        windows,
1806        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1807    )]
1808    fn a_file_behind_the_keychain_that_cannot_be_read_is_said_too() {
1809        let m = machine("read-unreadable-fallback");
1810        let left = plant_behind(&m, "{}");
1811        m.mem.file_at(left.clone()).fault(
1812            &crate::provider::claude::paths::live_service(&m.ctx),
1813            crate::store::memory::Fault::UnreadableContents(
1814                "stream did not contain valid UTF-8".into(),
1815            ),
1816        );
1817        let pitboard = Pitboard::new(m.ctx.clone());
1818
1819        for warnings in [
1820            pitboard.status(false).expect("a read").warnings,
1821            pitboard.switch_to("there").expect("a switch").warnings,
1822        ] {
1823            let said: Vec<String> = warnings
1824                .iter()
1825                .filter(|w| w.code() == "fallback_login")
1826                .map(ToString::to_string)
1827                .collect();
1828            assert_eq!(
1829                said,
1830                [format!(
1831                    "{} is there, and Pitboard could not read it. While it is, Claude Code \
1832                     sessions already running at a switch keep the account they are on until \
1833                     their login is next renewed, or until they are started again. `pitboard \
1834                     doctor` says why, and what to do.",
1835                    left.display()
1836                )],
1837                "{warnings:?}"
1838            );
1839        }
1840    }
1841
1842    /// A switch nothing can finish was said only by a change refused over it. The app looks
1843    /// for it in what a read says, so it never offered to give up on one, and `pitboard
1844    /// status` never said it at all. A read says it now, in the words of the refusal, and
1845    /// changes nothing: the record is kept for whatever finishes it or gives up on it.
1846    #[test]
1847    #[cfg_attr(
1848        windows,
1849        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1850    )]
1851    fn a_read_says_an_interrupted_switch_it_cannot_finish_is_waiting() {
1852        for (tool, make) in MACHINES {
1853            let m = stuck(make, &format!("read-stuck-{tool}"));
1854            let pitboard = Pitboard::new(m.ctx.clone());
1855            let before = untouched(&m);
1856
1857            let read = pitboard
1858                .status(false)
1859                .expect("a read is not refused over it");
1860
1861            assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1862            assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1863            let refused = pitboard
1864                .switch_to(&m.key("there").typed())
1865                .expect_err("every change stops at it");
1866            assert_eq!(refused.error.code(), "recovery_undetermined", "{tool}");
1867            assert_eq!(
1868                said(&read),
1869                [("recovery_undetermined", refused.error.to_string())],
1870                "{tool}: said in the refusal's own words"
1871            );
1872        }
1873    }
1874
1875    /// Where only the service can say whose the live login is, a read asks it, as the next
1876    /// change would.
1877    #[test]
1878    #[cfg_attr(
1879        windows,
1880        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1881    )]
1882    fn a_read_asks_whose_the_login_is_where_only_the_service_can_say() {
1883        let m = stuck_unless_asked("read-stuck-asked");
1884        let pitboard = Pitboard::new(m.ctx.clone());
1885        let before = untouched(&m);
1886
1887        let read = pitboard.status(false).expect("a read");
1888
1889        assert_eq!(untouched(&m), before, "a read changes nothing");
1890        let refused = pitboard.switch_to("there").expect_err("it stops at it");
1891        assert_eq!(
1892            said(&read),
1893            [("recovery_undetermined", refused.error.to_string())]
1894        );
1895        // Nothing tried to renew the login here, so nothing says it cannot be renewed.
1896        let said = refused.error.to_string();
1897        assert!(said.contains("session has expired"), "{said}");
1898        assert!(!said.contains("sign in"), "{said}");
1899    }
1900
1901    /// A switch the next change finishes by itself is not one anybody has to do anything
1902    /// about. The rows already say who is signed in, from the tool's login and not from
1903    /// Pitboard's record, and the change that finishes it says what it found.
1904    #[test]
1905    #[cfg_attr(
1906        windows,
1907        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1908    )]
1909    fn a_read_says_nothing_of_a_switch_the_next_change_finishes() {
1910        for (tool, make) in MACHINES {
1911            let m = interrupted(make, &format!("read-settles-{tool}"));
1912            let before = untouched(&m);
1913
1914            let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
1915
1916            assert_eq!(said(&read), [], "{tool}");
1917            assert_eq!(untouched(&m), before, "{tool}: a read finishes nothing");
1918            assert!(switch::interrupted(&m.ctx), "{tool}");
1919        }
1920    }
1921
1922    /// The read never says a switch is waiting that the next change would not stop at for
1923    /// that reason, and never offers to give up on one that giving up cannot reach. Under a
1924    /// custom Claude Code endpoint every change is refused over the endpoint before it comes
1925    /// to a Claude Code switch, and giving up on one is refused too. A Codex switch is
1926    /// settled under one as anywhere else, and given up on as anywhere else: giving up
1927    /// refused it, so the read offered a way out that could not be taken.
1928    #[test]
1929    #[cfg_attr(
1930        windows,
1931        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1932    )]
1933    fn a_read_says_a_switch_is_waiting_only_where_a_change_would_stop_at_it() {
1934        for (tool, make) in MACHINES {
1935            let m = stuck(make, &format!("read-stuck-custom-{tool}"));
1936            let mut ctx = m.ctx.clone();
1937            ctx.custom_oauth = true;
1938            let pitboard = Pitboard::new(ctx);
1939
1940            let read = pitboard.status(false).expect("a read");
1941            let known = pitboard.status_offline().expect("a read");
1942            let refused = pitboard
1943                .switch_to(&m.key("there").typed())
1944                .expect_err("refused");
1945
1946            let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
1947            let expected: &[&str] = match m.which {
1948                ProviderId::Claude => &[],
1949                ProviderId::Codex => &["recovery_undetermined"],
1950            };
1951            assert_eq!(codes, expected, "{tool}: {}", refused.error);
1952            let codes: Vec<&str> = known.warnings.iter().map(Warning::code).collect();
1953            assert_eq!(codes, expected, "{tool}: the read that asks nobody too");
1954            assert_eq!(
1955                refused.error.code() == "recovery_undetermined",
1956                !expected.is_empty(),
1957                "{tool}"
1958            );
1959
1960            let gave_up = pitboard.abandon_recovery();
1961            match m.which {
1962                ProviderId::Claude => assert_eq!(
1963                    gave_up.expect_err("refused").code(),
1964                    "custom_oauth_endpoint",
1965                    "{tool}"
1966                ),
1967                ProviderId::Codex => {
1968                    assert!(gave_up.expect("given up").is_some(), "{tool}");
1969                    assert!(!switch::interrupted(&m.ctx), "{tool}: nothing waits");
1970                }
1971            }
1972        }
1973    }
1974
1975    /// Every read says it, the one that asks nobody too: what an app reads each time the
1976    /// account index moves, and what `pitboard status --offline` prints. It sends no request,
1977    /// so it says it wherever the record and the logins tell it without one, in the words of
1978    /// the refusal, and changes nothing. Where only Anthropic could say whose the login is,
1979    /// it does not say what it cannot know.
1980    #[test]
1981    #[cfg_attr(
1982        windows,
1983        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1984    )]
1985    fn a_read_that_asks_nobody_says_a_switch_is_waiting_wherever_it_can_tell() {
1986        for (tool, make) in MACHINES {
1987            let m = stuck(make, &format!("known-stuck-{tool}"));
1988            let pitboard = Pitboard::new(m.ctx.clone());
1989            let (before, asked) = (untouched(&m), m.api.calls());
1990
1991            let read = pitboard.status_offline().expect("a read");
1992
1993            assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1994            assert_eq!(m.api.calls(), asked, "{tool}: and asks nobody");
1995            assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1996            let refused = pitboard
1997                .switch_to(&m.key("there").typed())
1998                .expect_err("every change stops at it");
1999            assert_eq!(
2000                said(&read),
2001                [("recovery_undetermined", refused.error.to_string())],
2002                "{tool}"
2003            );
2004        }
2005
2006        let m = stuck_unless_asked("known-stuck-unasked");
2007        let asked = m.api.calls();
2008        let read = Pitboard::new(m.ctx.clone())
2009            .status_offline()
2010            .expect("a read");
2011        assert_eq!(m.api.calls(), asked, "asks nobody");
2012        assert_eq!(said(&read), [], "only Anthropic could tell");
2013    }
2014
2015    /// Claude Code 2.1.294 stamps its usage cache with the account its config names, and
2016    /// asks with the login its session holds, which can still be the account switched away
2017    /// from. Four of five caches captured on one machine were another login's numbers, so
2018    /// no read takes a reading from it, whether or not Anthropic answered this time.
2019    #[test]
2020    #[cfg_attr(
2021        windows,
2022        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2023    )]
2024    fn what_claude_code_cached_is_never_an_accounts_reading() {
2025        use crate::switch::harness::{NOW, cache_usage, usage_answer};
2026        let m = machine("cached-usage");
2027        readings::answered(
2028            &m.ctx,
2029            Permit::for_a_test(),
2030            &[(
2031                "here".into(),
2032                crate::usage::from_usage_object(&usage_answer(10.0), NOW),
2033            )],
2034        );
2035        cache_usage(&m, usage_answer(100.0));
2036        let pitboard = Pitboard::new(m.ctx.clone());
2037        for (how, read) in [
2038            ("offline", pitboard.status_offline()),
2039            ("online", pitboard.status(false)),
2040        ] {
2041            let read = read.expect("a read");
2042            let here = read
2043                .value
2044                .rows
2045                .iter()
2046                .find(|row| row.label.as_deref() == Some("here"))
2047                .expect("here's row");
2048            let shares: Vec<f64> = here
2049                .usage
2050                .iter()
2051                .flat_map(|usage| &usage.windows)
2052                .map(|window| window.percent)
2053                .collect();
2054            assert_eq!(shares, [10.0], "{how}");
2055        }
2056    }
2057
2058    /// A login Anthropic has named is known by its refresh token's fingerprint from then on,
2059    /// so a read asks whose it is only once the login has changed. It asked on every read,
2060    /// one round trip more each time for an answer it already had.
2061    #[test]
2062    #[cfg_attr(
2063        windows,
2064        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2065    )]
2066    fn a_login_already_identified_is_not_asked_about_again() {
2067        use crate::api::scripted::Asked;
2068        let m = machine("read-known-login");
2069
2070        Pitboard::new(m.ctx.clone()).status(false).expect("a read");
2071
2072        let asked = m.api.asked();
2073        assert!(
2074            !asked.contains(&Asked::Owner("access-here-refresh".into())),
2075            "{asked:?}"
2076        );
2077        assert!(
2078            asked.contains(&Asked::Usage("access-here-refresh".into())),
2079            "what it has left is still asked: {asked:?}"
2080        );
2081    }
2082
2083    /// A sign-in outside Pitboard replaced the only login of the account in use. The first
2084    /// read records it and the activity log says so once; every read after, the one that
2085    /// asks nobody too, says it until that account is signed in again or forgotten, and its
2086    /// row says why it has nothing parked. It read as `nothing parked`, and nothing said what
2087    /// had happened.
2088    #[test]
2089    #[cfg_attr(
2090        windows,
2091        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2092    )]
2093    fn a_sign_in_outside_pitboard_is_said_recorded_and_shown_on_its_row() {
2094        use crate::switch::harness::NOW;
2095        let m = signed_in_outside("read-signed-in-outside");
2096        let pitboard = Pitboard::new(m.ctx.clone());
2097        let replaced = "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code \
2098                        now has `elsewhere`'s login stored, and Pitboard holds no login for \
2099                        `here`. Run `pitboard enroll here --sign-in` to sign in to it again.";
2100        let row = |read: &Done<status::Report>, label: &str| {
2101            read.value
2102                .rows
2103                .iter()
2104                .find(|row| row.label.as_deref() == Some(label))
2105                .map(|row| (row.signed_in, row.stale))
2106        };
2107
2108        let read = pitboard.status(false).expect("a read");
2109
2110        let state = state::load(&m.ctx).expect("state");
2111        assert_eq!(
2112            state
2113                .account_in_use(ProviderId::Claude)
2114                .map(|account| account.label.as_str()),
2115            Some("elsewhere")
2116        );
2117        let account = |label: &str| state.get(&m.key(label)).expect("enrolled");
2118        assert_eq!(account("here").replaced_at, Some(NOW));
2119        assert_eq!(account("elsewhere").last_used_at, Some(NOW));
2120        assert!(account("there").parked.is_some(), "`there` keeps its park");
2121        assert_eq!(
2122            row(&read, "elsewhere").map(|(signed_in, _)| signed_in),
2123            Some(true)
2124        );
2125        assert_eq!(
2126            row(&read, "here"),
2127            Some((false, Some(status::Stale::LoginReplaced)))
2128        );
2129        assert_eq!(said(&read), [("login_replaced", replaced.to_string())]);
2130        let noticed = [
2131            ("elsewhere".to_string(), "signed_in_outside".to_string()),
2132            ("here".to_string(), "login_replaced".to_string()),
2133        ];
2134        assert_eq!(audit_lines(&m, "in-use"), noticed);
2135
2136        let recorded = state_file(&m);
2137        let again = pitboard.status(false).expect("a second read");
2138        assert_eq!(said(&again), [("login_replaced", replaced.to_string())]);
2139        assert_eq!(audit_lines(&m, "in-use"), noticed, "recorded once");
2140        assert_eq!(state_file(&m), recorded, "and written once");
2141
2142        let offline = pitboard.status_offline().expect("a read of what is known");
2143        assert_eq!(said(&offline), [("login_replaced", replaced.to_string())]);
2144    }
2145
2146    /// What a replaced login is said to have been replaced with is what the record holds of
2147    /// the store: an account nobody enrolled, by its email, no login at all, or nothing where
2148    /// nothing is recorded of the store.
2149    #[test]
2150    fn a_replaced_login_says_what_the_store_holds_instead_as_far_as_it_is_known() {
2151        use crate::in_use::InUse;
2152        use crate::switch::harness::{account, owner};
2153        let mut state = state::State::default();
2154        state.accounts.push(account("here", "here", None));
2155        state.accounts[0].replaced_at = Some(1);
2156        let said = |state: &state::State| -> Vec<String> {
2157            replaced(state).iter().map(ToString::to_string).collect()
2158        };
2159        let then = "and Pitboard holds no login for `here`. Run `pitboard enroll here \
2160                    --sign-in` to sign in to it again.";
2161
2162        let somebody = InUse {
2163            owner: Some(owner("new")),
2164            login: "new-login".into(),
2165            known_at: 1,
2166            named: None,
2167        };
2168        state
2169            .in_use
2170            .insert(ProviderId::Claude.code().into(), somebody);
2171        assert_eq!(
2172            said(&state),
2173            [format!(
2174                "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code now has \
2175                 new@example.com's login stored, {then}"
2176            )]
2177        );
2178
2179        state.in_use.insert(
2180            ProviderId::Claude.code().into(),
2181            crate::switch::identify::nobody(None, 1).expect("a store holding no login"),
2182        );
2183        assert_eq!(
2184            said(&state),
2185            [format!(
2186                "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code now has \
2187                 no login stored, {then}"
2188            )]
2189        );
2190
2191        state.in_use.clear();
2192        assert_eq!(
2193            said(&state),
2194            [format!(
2195                "`here`'s login was replaced by a sign-in outside Pitboard, {then}"
2196            )]
2197        );
2198
2199        state.accounts[0].parked = Some(crate::state::Park {
2200            service: "pitboard-park-here-1".into(),
2201            parked_at: 1,
2202            refresh_fingerprint: "f".into(),
2203            access_expires_at: None,
2204            refresh_expires_at: None,
2205        });
2206        assert!(
2207            said(&state).is_empty(),
2208            "a login parked for it is a way back"
2209        );
2210    }
2211
2212    /// A read that finds what the record already says writes nothing, so several front ends
2213    /// reading one machine never take turns rewriting its account index.
2214    #[test]
2215    #[cfg_attr(
2216        windows,
2217        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2218    )]
2219    fn a_read_that_finds_nothing_new_writes_nothing() {
2220        let m = machine("read-nothing-new");
2221        let pitboard = Pitboard::new(m.ctx.clone());
2222        let before = state_file(&m);
2223
2224        pitboard.status(false).expect("a read");
2225        pitboard.status(false).expect("another read");
2226
2227        assert_eq!(state_file(&m), before);
2228        assert!(audit_lines(&m, "in-use").is_empty());
2229    }
2230
2231    /// Each `CLAUDE_CONFIG_DIR` has a login of its own stored, and Pitboard keeps whose for
2232    /// each. A read under another one leaves this one's record, so a sign-in outside Pitboard
2233    /// here is still found by the next read here, and reads taking turns under the two write
2234    /// nothing once each has its record.
2235    #[test]
2236    #[cfg_attr(
2237        windows,
2238        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2239    )]
2240    fn a_read_under_another_claude_config_dir_keeps_this_ones_record() {
2241        use crate::switch::harness::{NOW, account, document, owner};
2242        let m = machine("read-two-slots");
2243        m.api
2244            .owned_by("access-elsewhere-refresh", owner("elsewhere"));
2245        m.api.owned_by("access-there-other", owner("there"));
2246        let mut state = state::load(&m.ctx).expect("state");
2247        state.accounts.push(account("elsewhere", "elsewhere", None));
2248        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2249        let other = m
2250            .ctx
2251            .clone()
2252            .with_claude_config_dir(m.ctx_home().join("other").to_string_lossy().into_owned());
2253        m.mem.live().plant(
2254            &crate::provider::claude::paths::live_service(&other),
2255            &document("there-other").to_string(),
2256        );
2257
2258        Pitboard::new(other.clone())
2259            .status(false)
2260            .expect("a read under the other");
2261        m.sign_in(&document("elsewhere-refresh"));
2262        Pitboard::new(m.ctx.clone())
2263            .status(false)
2264            .expect("a read here");
2265
2266        let state = state::load(&m.ctx).expect("state");
2267        assert_eq!(
2268            state.get(&m.key("here")).expect("enrolled").replaced_at,
2269            Some(NOW)
2270        );
2271        assert_eq!(
2272            audit_lines(&m, "in-use"),
2273            [
2274                ("elsewhere".to_string(), "signed_in_outside".to_string()),
2275                ("here".to_string(), "login_replaced".to_string()),
2276            ]
2277        );
2278
2279        let recorded = state_file(&m);
2280        for ctx in [&other, &m.ctx, &other] {
2281            Pitboard::new(ctx.clone()).status(false).expect("a read");
2282        }
2283        assert_eq!(state_file(&m), recorded, "reads taking turns write nothing");
2284    }
2285
2286    /// `here` is signed in under two `CLAUDE_CONFIG_DIR`s, and a sign-in outside Pitboard
2287    /// replaces its login under one. Its login under the other is still stored, so no read
2288    /// says it was replaced, least of all one under the slot where it is in use. Once the
2289    /// other slot's is replaced too, it is.
2290    #[test]
2291    #[cfg_attr(
2292        windows,
2293        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2294    )]
2295    fn a_login_still_stored_under_another_claude_config_dir_is_not_said_replaced() {
2296        use crate::switch::harness::{NOW, account, document, owner};
2297        let m = machine("read-two-slots-one-account");
2298        m.api.owned_by("access-here-other", owner("here"));
2299        m.api
2300            .owned_by("access-elsewhere-refresh", owner("elsewhere"));
2301        let mut state = state::load(&m.ctx).expect("state");
2302        state.accounts.push(account("elsewhere", "elsewhere", None));
2303        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2304        let other = m
2305            .ctx
2306            .clone()
2307            .with_claude_config_dir(m.ctx_home().join("other").to_string_lossy().into_owned());
2308        let plant_there = |login: &str| {
2309            m.mem.live().plant(
2310                &crate::provider::claude::paths::live_service(&other),
2311                &document(login).to_string(),
2312            );
2313        };
2314        let replaced = |read: &Done<status::Report>| {
2315            said(read)
2316                .into_iter()
2317                .filter(|(code, _)| *code == "login_replaced")
2318                .count()
2319        };
2320
2321        plant_there("here-other");
2322        Pitboard::new(other.clone())
2323            .status(false)
2324            .expect("a read under the other");
2325        plant_there("elsewhere-refresh");
2326        let there = Pitboard::new(other.clone())
2327            .status(false)
2328            .expect("a read under the other after a sign-in there");
2329        let here = Pitboard::new(m.ctx.clone())
2330            .status(false)
2331            .expect("a read here");
2332
2333        assert_eq!((replaced(&there), replaced(&here)), (0, 0));
2334        assert_eq!(
2335            state::load(&m.ctx)
2336                .expect("state")
2337                .get(&m.key("here"))
2338                .expect("enrolled")
2339                .replaced_at,
2340            None
2341        );
2342        assert_eq!(
2343            audit_lines(&m, "in-use"),
2344            [("elsewhere".to_string(), "signed_in_outside".to_string())]
2345        );
2346
2347        m.sign_in(&document("elsewhere-refresh"));
2348        let gone = Pitboard::new(m.ctx.clone())
2349            .status(false)
2350            .expect("a read here after a sign-in here");
2351
2352        assert_eq!(replaced(&gone), 1);
2353        assert_eq!(
2354            state::load(&m.ctx)
2355                .expect("state")
2356                .get(&m.key("here"))
2357                .expect("enrolled")
2358                .replaced_at,
2359            Some(NOW)
2360        );
2361        assert_eq!(
2362            audit_lines(&m, "in-use")[1..],
2363            [
2364                ("elsewhere".to_string(), "signed_in_outside".to_string()),
2365                ("here".to_string(), "login_replaced".to_string()),
2366            ]
2367        );
2368    }
2369
2370    /// Claude Code's config names `here` and Pitboard finds no login where it reads: the
2371    /// login is somewhere Pitboard does not look, which is not a sign-out. A read records
2372    /// nothing, and says of no account that its login was replaced.
2373    #[test]
2374    #[cfg_attr(
2375        windows,
2376        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2377    )]
2378    fn a_read_that_cannot_find_the_login_claude_code_names_records_nothing() {
2379        let m = machine("read-login-elsewhere");
2380        m.mem.live().delete_everything();
2381        let before = state_file(&m);
2382
2383        let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
2384
2385        assert_eq!(state_file(&m), before);
2386        assert!(audit_lines(&m, "in-use").is_empty());
2387        let said = said(&read);
2388        assert!(
2389            !said.iter().any(|(code, _)| *code == "login_replaced"),
2390            "{said:?}"
2391        );
2392    }
2393
2394    /// The labels of the rows a read has in use.
2395    fn in_use(read: &Done<status::Report>) -> Vec<&str> {
2396        read.value
2397            .rows
2398            .iter()
2399            .filter(|row| row.signed_in)
2400            .filter_map(|row| row.label.as_deref())
2401            .collect()
2402    }
2403
2404    /// Another Claude Code process that starts or signs in on another login can write that
2405    /// login's account into Claude Code's config, and leaves the keychain's as it was. A read
2406    /// that asks nobody takes the account in use from what Anthropic last said of the login
2407    /// stored, and says the config has moved since. It took the config's account.
2408    #[test]
2409    #[cfg_attr(
2410        windows,
2411        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2412    )]
2413    fn an_offline_read_names_the_account_anthropic_named_whatever_the_config_says() {
2414        let m = machine("offline-config-moved");
2415        crate::switch::harness::config_names(&m, "there");
2416
2417        let read = Pitboard::new(m.ctx.clone())
2418            .status_offline()
2419            .expect("a read");
2420
2421        assert_eq!(in_use(&read), ["here"]);
2422        assert_eq!(
2423            said(&read),
2424            [(
2425                "in_use_unconfirmed",
2426                "Claude Code's config has named `there` since Pitboard last asked Anthropic \
2427                 whose login Claude Code has stored, so another login may be stored. The one \
2428                 stored then was `here`'s. `pitboard status` asks again."
2429                    .to_string()
2430            )]
2431        );
2432        assert_eq!(m.api.calls(), 0);
2433    }
2434
2435    /// A record brought forward from 0.9.0 names the account it last switched to, which
2436    /// Anthropic never named. Where Claude Code's config names another account, a read that
2437    /// asks nobody says the account in use is not confirmed, as doctor and the status line do.
2438    #[test]
2439    #[cfg_attr(
2440        windows,
2441        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2442    )]
2443    fn an_offline_read_of_a_record_never_asked_about_says_the_config_names_another() {
2444        let m = machine("offline-never-asked");
2445        let mut state = state::load(&m.ctx).expect("state");
2446        let here = state.get(&m.key("here")).expect("here").owner();
2447        state.in_use.insert(
2448            ProviderId::Claude.code().into(),
2449            crate::in_use::InUse {
2450                owner: Some(here),
2451                login: String::new(),
2452                known_at: 0,
2453                named: None,
2454            },
2455        );
2456        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2457        crate::switch::harness::config_names(&m, "there");
2458
2459        let read = Pitboard::new(m.ctx.clone())
2460            .status_offline()
2461            .expect("a read");
2462
2463        assert_eq!(in_use(&read), ["here"]);
2464        assert_eq!(
2465            said(&read),
2466            [(
2467                "in_use_unconfirmed",
2468                "Claude Code's config names `there`, and Pitboard has not asked Anthropic whose \
2469                 login Claude Code has stored since Pitboard was updated, so another login may \
2470                 be stored. The account it last switched to is `here`. `pitboard status` asks."
2471                    .to_string()
2472            )]
2473        );
2474        assert_eq!(m.api.calls(), 0);
2475    }
2476
2477    /// The same record where the config names that account, as on nearly every machine
2478    /// updated from 0.9.0 until its first read that asks: nothing is said, and nobody is asked.
2479    #[test]
2480    #[cfg_attr(
2481        windows,
2482        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2483    )]
2484    fn an_offline_read_of_a_record_never_asked_about_says_nothing_where_the_config_agrees() {
2485        let m = machine("offline-never-asked-agrees");
2486        let mut state = state::load(&m.ctx).expect("state");
2487        let here = state.get(&m.key("here")).expect("here").owner();
2488        state.in_use.insert(
2489            ProviderId::Claude.code().into(),
2490            crate::in_use::InUse {
2491                owner: Some(here),
2492                login: String::new(),
2493                known_at: 0,
2494                named: None,
2495            },
2496        );
2497        state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2498        crate::switch::harness::config_names(&m, "here");
2499
2500        let read = Pitboard::new(m.ctx.clone())
2501            .status_offline()
2502            .expect("a read");
2503
2504        assert_eq!(in_use(&read), ["here"]);
2505        assert_eq!(said(&read), []);
2506        assert_eq!(m.api.calls(), 0);
2507    }
2508
2509    /// A read that asks settles it: the login stored is the one Anthropic named, known by its
2510    /// fingerprint, so nobody is asked whose it is, and what stands is that the config names
2511    /// another account, which `/status` in Claude Code shows. Said by every read after it,
2512    /// the one that asks nobody too, until something writes the config again.
2513    #[test]
2514    #[cfg_attr(
2515        windows,
2516        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2517    )]
2518    fn a_read_settles_it_and_says_the_config_names_another() {
2519        use crate::api::scripted::Asked;
2520        let m = machine("read-config-names-another");
2521        crate::switch::harness::config_names(&m, "there");
2522        let pitboard = Pitboard::new(m.ctx.clone());
2523        let names_another = "Claude Code's config names `there`, and the login Claude Code has \
2524                             stored is `here`'s, as Anthropic said. `/status` in Claude Code \
2525                             shows `there`. `pitboard use here` writes `here` into the config.";
2526
2527        let read = pitboard.status(false).expect("a read");
2528
2529        assert!(
2530            !m.api
2531                .asked()
2532                .contains(&Asked::Owner("access-here-refresh".into())),
2533            "{:?}",
2534            m.api.asked()
2535        );
2536        assert_eq!(in_use(&read), ["here"]);
2537        assert_eq!(
2538            said(&read),
2539            [("config_names_another", names_another.to_string())]
2540        );
2541
2542        let offline = pitboard.status_offline().expect("a read of what is known");
2543        assert_eq!(in_use(&offline), ["here"]);
2544        assert_eq!(
2545            said(&offline),
2546            [("config_names_another", names_another.to_string())]
2547        );
2548    }
2549
2550    /// A tool whose login names its own account is identified from the login, which asks
2551    /// nobody, so a read that sends no request can still tell whether the next change
2552    /// finishes its switch. Here Codex has renewed its login since the switch stopped, so the
2553    /// login matches neither side the record kept, and the copy the switch parked cannot be
2554    /// read. `doctor` and the read that asks nobody said nothing of it, as though only OpenAI
2555    /// could say whose the login is.
2556    #[test]
2557    #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
2558    fn whose_a_codex_login_is_is_told_without_asking_anyone() {
2559        let m = interrupted(codex_machine, "codex-renewed-since");
2560        m.sign_in(&crate::switch::harness::codex_login(
2561            "here",
2562            "renewed-since",
2563        ));
2564        m.mem.vault().fault(
2565            &reserved(&m),
2566            crate::store::memory::Fault::Unreadable("locked".into()),
2567        );
2568        let pitboard = Pitboard::new(m.ctx.clone());
2569        let asked = m.api.calls();
2570
2571        let read = pitboard.status_offline().expect("a read");
2572        let checks = pitboard.doctor().checks;
2573
2574        assert_eq!(m.api.calls(), asked, "asks nobody");
2575        let refused = pitboard
2576            .switch_to("codex/there")
2577            .expect_err("it stops at it");
2578        assert_eq!(refused.error.code(), "recovery_undetermined");
2579        assert_eq!(
2580            said(&read),
2581            [("recovery_undetermined", refused.error.to_string())]
2582        );
2583        let check = checks
2584            .iter()
2585            .find(|c| c.code == "interrupted_switch")
2586            .expect("said");
2587        assert_eq!(check.detail, refused.error.to_string());
2588    }
2589
2590    /// A Codex store that `/etc/codex/requirements.toml` pins is refused before a sign-in
2591    /// starts, naming that file, as it is for a switch: no line of the person's own changes
2592    /// it, and the `-c` Pitboard's sign-ins give is under it, so the login would be kept where
2593    /// Pitboard cannot read it back. Pitboard used to read only `$CODEX_HOME/config.toml`, and
2594    /// started the sign-in.
2595    #[test]
2596    #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
2597    fn a_codex_store_a_requirement_pins_is_refused_before_a_sign_in() {
2598        let m = codex_machine("codex-pinned");
2599        // Named where nothing is, so a sign-in that got past the refusal would find no
2600        // program to run rather than this machine's own `codex`.
2601        let ctx = m.ctx.clone().with_codex_program("/nowhere/codex".into());
2602        m.mem.administers(
2603            "/etc/codex/requirements.toml",
2604            "cli_auth_credentials_store = \"keyring\"\n",
2605        );
2606        let pitboard = Pitboard::new(ctx);
2607        for refused in [
2608            pitboard.sign_in("codex/new").err().expect("refused"),
2609            pitboard
2610                .sign_in_watched("codex/new")
2611                .err()
2612                .expect("refused"),
2613        ] {
2614            assert_eq!(refused.error.code(), "live_store_unsupported");
2615            let said = refused.error.to_string();
2616            assert!(
2617                said.contains("pinned to `keyring` by /etc/codex/requirements.toml"),
2618                "{said}"
2619            );
2620            assert!(
2621                said.contains("No line in your own config.toml can change it"),
2622                "{said}"
2623            );
2624        }
2625        let switched = pitboard.switch_to("codex/there").expect_err("refused too");
2626        assert_eq!(switched.error.code(), "live_store_unsupported");
2627        let file = m
2628            .mem
2629            .file_at(crate::provider::codex::paths::auth_file(&m.ctx));
2630        let kept = crate::store::RawStore::read(&file, "auth.json")
2631            .expect("readable")
2632            .expect("there");
2633        let kept: serde_json::Value = serde_json::from_str(&kept).expect("JSON");
2634        assert_eq!(
2635            kept["tokens"]["refresh_token"], "here-refresh",
2636            "the auth.json Codex does not use is left as it was"
2637        );
2638    }
2639
2640    /// The park the interrupted switch reserved, as its record names it.
2641    fn reserved(m: &Machine) -> String {
2642        let raw = std::fs::read_to_string(crate::home::dir(&m.ctx).join("journal.json"))
2643            .expect("a record");
2644        serde_json::from_str::<serde_json::Value>(&raw).expect("a record is JSON")["park_service"]
2645            .as_str()
2646            .expect("a park")
2647            .to_owned()
2648    }
2649
2650    /// `doctor` sends no request, and says what a read says wherever it can tell without
2651    /// one: the refusal, and the way out. Where only the service could tell, it says what
2652    /// it always said, that a switch did not finish.
2653    #[test]
2654    #[cfg_attr(
2655        windows,
2656        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2657    )]
2658    fn doctor_says_a_switch_is_stuck_where_it_can_tell_without_asking() {
2659        for (tool, make) in MACHINES {
2660            let m = stuck(make, &format!("doctor-stuck-{tool}"));
2661            let pitboard = Pitboard::new(m.ctx.clone());
2662            let before = untouched(&m);
2663
2664            let checks = pitboard.doctor().checks;
2665
2666            assert_eq!(untouched(&m), before, "{tool}: doctor changes nothing");
2667            let refused = pitboard
2668                .switch_to(&m.key("there").typed())
2669                .expect_err("refused");
2670            let check = checks
2671                .iter()
2672                .find(|c| c.code == "interrupted_switch")
2673                .expect("said");
2674            assert_eq!(check.level, doctor::Level::Warn, "{tool}");
2675            assert_eq!(check.detail, refused.error.to_string(), "{tool}");
2676            assert!(check.advice.contains("pitboard abandon"), "{tool}");
2677        }
2678
2679        let m = stuck_unless_asked("doctor-stuck-unasked");
2680        let asked = m.api.calls();
2681        let checks = Pitboard::new(m.ctx.clone()).doctor().checks;
2682        assert_eq!(m.api.calls(), asked, "doctor asks nobody");
2683        let check = checks
2684            .iter()
2685            .find(|c| c.code == "interrupted_switch")
2686            .expect("said");
2687        assert_eq!(check.detail, "a switch did not finish");
2688    }
2689
2690    /// Every file in Pitboard's own directory but the audit log.
2691    fn files(m: &Machine) -> BTreeMap<String, Vec<u8>> {
2692        std::fs::read_dir(crate::home::dir(&m.ctx))
2693            .expect("a Pitboard home")
2694            .map(|entry| entry.expect("an entry").path())
2695            .filter(|path| path.file_name() != Some("audit.log".as_ref()))
2696            .map(|path| {
2697                let body = std::fs::read(&path).unwrap_or_default();
2698                (path.display().to_string(), body)
2699            })
2700            .collect()
2701    }
2702
2703    /// The last two lines of the audit log, as verb and outcome.
2704    fn last_audited(m: &Machine) -> Vec<(String, String)> {
2705        audit::read(&m.ctx, 2)
2706            .into_iter()
2707            .map(|entry| (entry.verb, entry.outcome))
2708            .collect()
2709    }
2710
2711    /// Every other change settles an interrupted switch before anything else, and one
2712    /// refused over its name did not, so the switch stayed unrecovered and the refusal was
2713    /// all anybody was told. It is settled now, recorded the way any recovery is, and
2714    /// reported beside the refusal.
2715    #[test]
2716    #[cfg_attr(
2717        windows,
2718        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2719    )]
2720    fn a_change_refused_over_its_name_still_recovers_an_interrupted_switch() {
2721        for (tool, make) in MACHINES {
2722            for (change, verb, code, audited, refuse) in REFUSALS {
2723                let at = format!("{tool}, {change}");
2724                let m = interrupted(make, &format!("refused-{tool}-{change}"));
2725
2726                let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
2727                    .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
2728
2729                assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
2730                let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
2731                assert_eq!(said, ["interrupted_switch_undone"], "{at}");
2732                assert!(!switch::interrupted(&m.ctx), "{at}: the record is resolved");
2733                hold(&m, &at);
2734                assert_eq!(
2735                    last_audited(&m),
2736                    [
2737                        (
2738                            "recover".to_string(),
2739                            "interrupted_switch_undone".to_string()
2740                        ),
2741                        (verb.to_string(), audited.to_string()),
2742                    ],
2743                    "{at}"
2744                );
2745            }
2746        }
2747    }
2748
2749    /// A mistyped name with nothing to recover takes no lock and writes nothing but its
2750    /// line in the audit log.
2751    #[test]
2752    #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
2753    fn a_change_refused_over_its_name_with_nothing_interrupted_changes_nothing() {
2754        for (tool, make) in MACHINES {
2755            for (change, verb, code, audited, refuse) in REFUSALS {
2756                let at = format!("{tool}, {change}");
2757                let m = make(&format!("refused-quietly-{tool}-{change}"));
2758                let (before, parked, live) = (files(&m), m.mem.vault().services(), m.live());
2759
2760                let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
2761                    .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
2762
2763                assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
2764                assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
2765                assert_eq!(files(&m), before, "{at}: not even the lock file is made");
2766                assert_eq!(m.mem.vault().services(), parked, "{at}");
2767                assert_eq!(m.live(), live, "{at}");
2768                assert_eq!(
2769                    last_audited(&m).last(),
2770                    Some(&(verb.to_string(), audited.to_string())),
2771                    "{at}"
2772                );
2773            }
2774        }
2775    }
2776
2777    /// A new login that did not hold after it was written is parked rather than lost, and
2778    /// parking a login too big for `security`'s standard input puts it on the argument line.
2779    /// The change then fails, and that is still said beside the failure.
2780    #[test]
2781    #[cfg_attr(
2782        windows,
2783        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2784    )]
2785    fn a_new_login_parked_after_it_did_not_hold_says_how_it_was_parked() {
2786        for (tool, make) in MACHINES {
2787            let m = make(&format!("not-installed-said-{tool}"));
2788            m.mem.vault().takes_on_stdin(64);
2789            let login = crate::switch::harness::signed_in(&m, "here", "here-refresh-2");
2790            m.fault_live(crate::store::memory::Fault::DeletedAfterWrite);
2791
2792            let failed = Pitboard::new(m.ctx.clone())
2793                .enroll_signed_in(&m.key("here").typed(), login)
2794                .expect_err("it did not hold");
2795
2796            assert_eq!(failed.error.code(), "sign_in_not_installed", "{tool}");
2797            let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
2798            assert_eq!(said, ["written_on_the_command_line"], "{tool}");
2799        }
2800    }
2801
2802    /// The recovery settles for the tool whose switch was interrupted, not for no tool in
2803    /// particular, so a custom Claude Code endpoint stops exactly what it stops for any
2804    /// change: the recovery of a Claude Code switch, and not of a Codex one. What it stops
2805    /// is left for a later run, and the refusal is reported as it always was.
2806    #[test]
2807    #[cfg_attr(
2808        windows,
2809        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2810    )]
2811    fn a_custom_claude_endpoint_stops_only_the_recovery_of_a_claude_code_switch() {
2812        let codex = interrupted(codex_machine, "refused-custom-codex");
2813        let mut ctx = codex.ctx.clone();
2814        ctx.custom_oauth = true;
2815        let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
2816        assert_eq!(failed.error.code(), "account_unknown");
2817        let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
2818        assert_eq!(said, ["interrupted_switch_undone"]);
2819        assert!(!switch::interrupted(&codex.ctx));
2820
2821        let claude = interrupted(machine, "refused-custom-claude");
2822        let mut ctx = claude.ctx.clone();
2823        ctx.custom_oauth = true;
2824        let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
2825        assert_eq!(
2826            failed.error.code(),
2827            "account_unknown",
2828            "the refusal, not the recovery that could not run"
2829        );
2830        assert!(failed.warnings.is_empty(), "{:?}", failed.warnings);
2831        assert!(
2832            switch::interrupted(&claude.ctx),
2833            "the record is kept for a run that can finish it"
2834        );
2835    }
2836
2837    #[derive(Debug, Clone, Copy)]
2838    enum Refused {
2839        As(Elevation),
2840        On(Floor),
2841    }
2842
2843    const REFUSED: [Refused; 6] = [
2844        Refused::As(Elevation::Elevated { why: "as root" }),
2845        Refused::As(Elevation::Elevated { why: "with sudo" }),
2846        Refused::As(Elevation::Elevated {
2847            why: crate::host::token::AS_A_SERVICE_ACCOUNT,
2848        }),
2849        Refused::As(Elevation::Unknown),
2850        Refused::On(Floor::Below { build: 22631 }),
2851        Refused::On(Floor::Unknown),
2852    ];
2853
2854    impl Refused {
2855        fn said_by(self, m: &Machine) {
2856            match self {
2857                Refused::As(elevation) => m.mem.runs_with(elevation),
2858                Refused::On(floor) => m.mem.runs_on(floor),
2859            }
2860        }
2861
2862        fn lifted(m: &Machine) {
2863            m.mem.runs_with(Elevation::Normal);
2864            m.mem.runs_on(Floor::Met);
2865        }
2866
2867        fn code(self) -> &'static str {
2868            match self {
2869                Refused::As(_) => "elevated",
2870                Refused::On(_) => "system_too_old",
2871            }
2872        }
2873
2874        fn words(self) -> crate::words::ChangesNothing {
2875            match self {
2876                Refused::As(Elevation::Elevated { why }) => {
2877                    crate::words::elevated(crate::host::OS, Some(why))
2878                }
2879                Refused::As(_) => crate::words::elevated(crate::host::OS, None),
2880                Refused::On(Floor::Below { build }) => crate::words::too_old(Some(build)),
2881                Refused::On(_) => crate::words::too_old(None),
2882            }
2883        }
2884    }
2885
2886    /// What one change came to, as a change refused at the gate is reported: its error,
2887    /// and the warnings found on the way, of which there must be none.
2888    fn failed<T>(outcome: Result<T>) -> Option<Failed> {
2889        outcome.err().map(|error| Failed {
2890            error,
2891            warnings: Vec::new(),
2892        })
2893    }
2894
2895    type Change = fn(&Pitboard, &Machine) -> Option<Failed>;
2896
2897    /// Every change a front end can ask for, by its name. `enroll_signed_in` is asked apart,
2898    /// since it takes a sign-in made before.
2899    const CHANGES: [(&str, Change); 16] = [
2900        ("switch_to", |p, m| {
2901            p.switch_to(&m.key("there").typed()).err()
2902        }),
2903        ("enroll_current", |p, m| {
2904            p.enroll_current(&m.key("here").typed()).err()
2905        }),
2906        ("sign_in", |p, m| p.sign_in(&m.key("new").typed()).err()),
2907        ("sign_in_watched", |p, m| {
2908            p.sign_in_watched(&m.key("new").typed()).err()
2909        }),
2910        ("forget", |p, m| p.forget(&m.key("there").typed()).err()),
2911        ("rename", |p, m| {
2912            p.rename(&m.key("there").typed(), "elsewhere").err()
2913        }),
2914        ("abandon_recovery", |p, _| failed(p.abandon_recovery())),
2915        ("renew", |p, _| failed(p.renew())),
2916        ("schedule_install", |p, _| failed(p.schedule_install())),
2917        ("schedule_uninstall", |p, _| failed(p.schedule_uninstall())),
2918        ("schedule_repair", |p, _| failed(p.schedule_repair())),
2919        ("adopt", |p, _| failed(p.adopt())),
2920        ("repair", |p, _| p.repair().err()),
2921        ("keep_app_file", |p, _| {
2922            failed(p.keep_app_file(crate::app::AppFile::Preferences, "{}"))
2923        }),
2924        ("uninstall", |p, _| p.uninstall().err()),
2925        ("auto_switch", |p, _| {
2926            p.auto_switch(crate::autoswitch::Threshold::DEFAULT).err()
2927        }),
2928    ];
2929
2930    /// Everything on a machine a change could leave different: every directory and file
2931    /// under its home, the audit log among them and the scheduler's files with them, every
2932    /// item in its keychain, every login parked and the one in use. The private directory
2933    /// a sign-in made before is not counted: dropping that sign-in takes it away again.
2934    type Everything = (
2935        BTreeMap<String, Option<Vec<u8>>>,
2936        Vec<(String, Option<String>)>,
2937        Vec<(String, Option<String>)>,
2938        Option<serde_json::Value>,
2939    );
2940
2941    fn everything(m: &Machine) -> Everything {
2942        fn walk(dir: &std::path::Path, into: &mut BTreeMap<String, Option<Vec<u8>>>) {
2943            for entry in std::fs::read_dir(dir).expect("a directory to read") {
2944                let path = entry.expect("an entry").path();
2945                if path.ends_with(".pitboard/signin") {
2946                    continue;
2947                }
2948                if path.is_dir() {
2949                    into.insert(path.display().to_string(), None);
2950                    walk(&path, into);
2951                } else {
2952                    let body = std::fs::read(&path).expect("a file to read");
2953                    into.insert(path.display().to_string(), Some(body));
2954                }
2955            }
2956        }
2957        let mut files = BTreeMap::new();
2958        walk(&m.ctx_home(), &mut files);
2959        let items = |store: &crate::store::memory::MemoryStore| {
2960            store
2961                .services()
2962                .into_iter()
2963                .map(|service| {
2964                    let held = store.peek(&service);
2965                    (service, held)
2966                })
2967                .collect()
2968        };
2969        (files, items(m.mem.live()), items(m.mem.vault()), m.live())
2970    }
2971
2972    /// Running as root or under sudo, Pitboard changed things as root: a state file, a
2973    /// schedule or a lock it made was root's, and a park might be, where the person's own
2974    /// runs might never read or replace it again. Every change is refused now, at one gate,
2975    /// before it reads, locks or records anything, and leaves the machine byte for byte as it
2976    /// was, the audit log included. The machine has an interrupted switch waiting, which every
2977    /// change that settles would otherwise finish, and asks nobody.
2978    #[test]
2979    #[cfg_attr(
2980        windows,
2981        ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2982    )]
2983    fn every_change_is_refused_where_pitboard_may_change_nothing_and_changes_nothing() {
2984        for (tool, make) in MACHINES {
2985            for refused in REFUSED {
2986                for (change, run) in CHANGES {
2987                    let at = format!("{tool}, {change}, {refused:?}");
2988                    let m = interrupted(make, &format!("gate-{tool}-{change}"));
2989                    refused.said_by(&m);
2990                    let (before, asked) = (everything(&m), m.api.calls());
2991
2992                    let failed = run(&Pitboard::new(m.ctx.clone()), &m)
2993                        .unwrap_or_else(|| panic!("{at}: refused"));
2994
2995                    assert_eq!(
2996                        failed.error.code(),
2997                        refused.code(),
2998                        "{at}: {}",
2999                        failed.error
3000                    );
3001                    assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
3002                    assert!(everything(&m) == before, "{at}: nothing changes");
3003                    assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
3004                }
3005
3006                let at = format!("{tool}, enroll_signed_in, {refused:?}");
3007                let m = interrupted(make, &format!("gate-{tool}-enroll-signed-in"));
3008                let login = crate::switch::harness::signed_in(&m, "new", "new-refresh");
3009                refused.said_by(&m);
3010                let before = everything(&m);
3011                let failed = Pitboard::new(m.ctx.clone())
3012                    .enroll_signed_in(&m.key("new").typed(), login)
3013                    .expect_err("refused");
3014                assert_eq!(failed.error.code(), refused.code(), "{at}");
3015                assert!(everything(&m) == before, "{at}: nothing changes");
3016            }
3017        }
3018    }
3019
3020    #[test]
3021    #[cfg_attr(
3022        windows,
3023        ignore = "W16: Pitboard writing, replacing and removing files on Windows"
3024    )]
3025    fn the_gate_says_why_it_refuses_and_lets_a_person_through() {
3026        let m = machine("gate-words");
3027        let pitboard = Pitboard::new(m.ctx.clone());
3028        assert!(pitboard.permit().is_ok(), "as the person");
3029        for refused in REFUSED {
3030            refused.said_by(&m);
3031            let refusal = pitboard.permit().expect_err("refused");
3032            assert_eq!(refusal.code(), refused.code(), "{refused:?}");
3033            assert_eq!(refusal.exit_code(), 1, "{refused:?}");
3034            assert_eq!(refusal.to_string(), refused.words().refusal, "{refused:?}");
3035            Refused::lifted(&m);
3036            assert!(pitboard.permit().is_ok(), "{refused:?}: lifted");
3037        }
3038    }
3039
3040    #[test]
3041    #[cfg_attr(
3042        windows,
3043        ignore = "W16: Pitboard writing, replacing and removing files on Windows"
3044    )]
3045    fn the_gate_lets_windows_11_24h2_through_and_says_an_older_one_first() {
3046        let m = machine("gate-floor");
3047        let pitboard = Pitboard::new(m.ctx.clone());
3048        for build in [26100, 26200] {
3049            m.mem
3050                .runs_on(crate::host::windows_floor(Some((10, 0, build))));
3051            assert!(pitboard.permit().is_ok(), "{build}");
3052        }
3053        m.mem
3054            .runs_on(crate::host::windows_floor(Some((10, 0, 22631))));
3055        let refused = pitboard.permit().expect_err("refused");
3056        assert_eq!(refused.code(), "system_too_old");
3057        assert_eq!(
3058            refused.to_string(),
3059            crate::words::too_old(Some(22631)).refusal
3060        );
3061        m.mem.runs_with(Elevation::Elevated { why: "as root" });
3062        let first = pitboard.permit().expect_err("refused");
3063        assert_eq!(
3064            first.code(),
3065            "system_too_old",
3066            "the system before how it runs"
3067        );
3068    }
3069
3070    /// An empty or relative `HOME` made every path under it lead into the folder Pitboard
3071    /// was run from: its own files where `PITBOARD_HOME` was not set, and the scheduler's
3072    /// and each tool's default folder whatever was. The gate refuses it now, and so does
3073    /// every read of the accounts, with `home_not_absolute`, and nothing changes. Asked of
3074    /// the gate and the reads first, which change nothing whatever they answer.
3075    #[test]
3076    #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
3077    fn every_change_and_read_is_refused_where_the_home_is_not_a_full_path() {
3078        for home in ["", "relative"] {
3079            for (tool, make) in MACHINES {
3080                let at = format!("{tool}, HOME={home:?}");
3081                let m = make(&format!("unplaced-{tool}-{}", home.len()));
3082                let mut ctx = m.ctx.clone();
3083                ctx.home = std::path::PathBuf::from(home);
3084                let pitboard = Pitboard::new(ctx);
3085                let refused = |error: Error| {
3086                    assert_eq!(error.code(), "home_not_absolute", "{at}: {error}");
3087                    assert!(error.to_string().starts_with("HOME is "), "{at}: {error}");
3088                };
3089
3090                refused(pitboard.permit().expect_err("the gate refuses"));
3091                refused(pitboard.status_offline().err().expect("refused"));
3092                refused(pitboard.status(false).err().expect("refused"));
3093                let before = (everything(&m), m.api.calls());
3094                for (change, run) in CHANGES {
3095                    let failed = run(&pitboard, &m).unwrap_or_else(|| panic!("{at}: {change}"));
3096                    refused(failed.error);
3097                    assert!(failed.warnings.is_empty(), "{at}, {change}");
3098                }
3099                assert!(
3100                    (everything(&m), m.api.calls()) == before,
3101                    "{at}: nothing changes and nobody is asked"
3102                );
3103            }
3104        }
3105    }
3106
3107    /// What a read says, account by account: enough to tell a live read from one of what
3108    /// was last measured.
3109    type Read = Vec<(
3110        ProviderId,
3111        Option<String>,
3112        bool,
3113        Option<crate::usage::Snapshot>,
3114        Option<status::Stale>,
3115    )>;
3116
3117    fn rows(report: &status::Report) -> Read {
3118        report
3119            .rows
3120            .iter()
3121            .map(|row| {
3122                (
3123                    row.provider,
3124                    row.label.clone(),
3125                    row.signed_in,
3126                    row.usage.clone(),
3127                    row.stale,
3128                )
3129            })
3130            .collect()
3131    }
3132
3133    /// A read renewed every parked login whose access had lapsed, asked each service about
3134    /// every account and wrote what it measured down, as root too. Where this process may
3135    /// change nothing it answers what the read that asks nobody answers, and says why: it
3136    /// renews nothing, asks nobody and writes nothing. A park whose access has lapsed is
3137    /// there to be renewed, so a read that renewed would show it.
3138    #[test]
3139    #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
3140    fn a_read_where_pitboard_may_change_nothing_asks_nobody_and_says_why() {
3141        for (tool, make) in MACHINES {
3142            for refused in REFUSED {
3143                let at = format!("{tool}, {refused:?}");
3144                let m = make(&format!("gate-read-{tool}"));
3145                let later = m
3146                    .ctx
3147                    .clone()
3148                    .with_clock(Arc::new(crate::time::FixedClock::at(
3149                        crate::switch::harness::NOW + 11 * 86_400,
3150                    )));
3151                let pitboard = Pitboard::new(later);
3152                refused.said_by(&m);
3153                let (before, asked) = (everything(&m), m.api.calls());
3154
3155                let read = pitboard.status(false).expect("a read");
3156                let known = pitboard.status_offline().expect("a read");
3157
3158                assert!(everything(&m) == before, "{at}: nothing is written");
3159                assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
3160                assert_eq!(rows(&read.value), rows(&known.value), "{at}");
3161                let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
3162                assert_eq!(codes.last(), Some(&"read_only"), "{at}");
3163                assert_eq!(read.warnings.len(), known.warnings.len() + 1, "{at}");
3164                let said = read.warnings.last().expect("said").to_string();
3165                let words = refused.words();
3166                assert!(
3167                    said.starts_with(&format!("{}, so it changes nothing", words.because)),
3168                    "{at}: {said}"
3169                );
3170                assert!(said.ends_with(words.to_ask_again), "{at}: {said}");
3171
3172                Refused::lifted(&m);
3173                let live = pitboard.status(false).expect("a read");
3174                assert!(m.api.calls() > asked, "{at}: as the person, it asks");
3175                assert!(
3176                    !live.warnings.iter().any(|w| w.code() == "read_only"),
3177                    "{at}"
3178                );
3179            }
3180        }
3181    }
3182
3183    /// The status line kept what each session passed it and the readings it moved, as root
3184    /// too. Where this process may change nothing it draws the same line from the files as
3185    /// they are, and writes neither.
3186    #[test]
3187    #[cfg_attr(
3188        windows,
3189        ignore = "W16: Pitboard writing, replacing and removing files on Windows"
3190    )]
3191    fn the_status_line_where_pitboard_may_change_nothing_draws_the_same_line_and_writes_nothing() {
3192        let session = serde_json::json!({
3193            "session_id": "a-session",
3194            "rate_limits": {
3195                "five_hour": {"used_percentage": 40.0, "resets_at": crate::switch::harness::NOW + 3_600},
3196                "seven_day": {"used_percentage": 20.0, "resets_at": crate::switch::harness::NOW + 86_400},
3197            },
3198        })
3199        .to_string();
3200        for refused in REFUSED {
3201            let m = machine("gate-statusline");
3202            let pitboard = Pitboard::new(m.ctx.clone());
3203            refused.said_by(&m);
3204            let before = everything(&m);
3205
3206            let drawn = pitboard.statusline(&session);
3207
3208            assert!(everything(&m) == before, "{refused:?}: nothing is written");
3209            Refused::lifted(&m);
3210            assert_eq!(pitboard.statusline(&session), drawn, "{refused:?}");
3211            assert!(
3212                crate::home::dir(&m.ctx).join("sessions.json").exists(),
3213                "{refused:?}: as the person, the session is kept"
3214            );
3215        }
3216    }
3217
3218    /// `renew` returned an empty list when it renewed nothing, whatever the reason, so a
3219    /// run that could not renew read as one where nothing was due. Refused, it says so, and
3220    /// asks nobody, with a park that is due to be renewed.
3221    #[test]
3222    #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
3223    fn a_renewal_where_pitboard_may_change_nothing_is_refused_and_asks_nobody() {
3224        for (tool, make) in MACHINES {
3225            for refused in REFUSED {
3226                let at = format!("{tool}, {refused:?}");
3227                let m = make(&format!("gate-renew-{tool}"));
3228                crate::switch::harness::renews(&m, "there-refresh", "there-refresh-2");
3229                let later = m
3230                    .ctx
3231                    .clone()
3232                    .with_clock(Arc::new(crate::time::FixedClock::at(
3233                        crate::switch::harness::NOW + 11 * 86_400,
3234                    )));
3235                let pitboard = Pitboard::new(later);
3236                refused.said_by(&m);
3237                let (before, asked) = (everything(&m), m.api.calls());
3238
3239                let refusal = pitboard.renew().expect_err("refused");
3240
3241                assert_eq!(refusal.code(), refused.code(), "{at}");
3242                assert!(everything(&m) == before, "{at}: nothing changes");
3243                assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
3244
3245                Refused::lifted(&m);
3246                let renewed = pitboard.renew().expect("as the person, it renews");
3247                let codes: Vec<&str> = renewed.iter().map(|(_, r)| r.code()).collect();
3248                assert_eq!(codes, ["renewed"], "{at}");
3249            }
3250        }
3251    }
3252
3253    /// `doctor` says why every change is refused, first, as a check that fails, and says
3254    /// nothing of it where Pitboard runs as the person.
3255    #[test]
3256    #[cfg_attr(
3257        windows,
3258        ignore = "W16: Pitboard writing, replacing and removing files on Windows"
3259    )]
3260    fn doctor_says_why_pitboard_changes_nothing() {
3261        let m = machine("gate-doctor");
3262        let pitboard = Pitboard::new(m.ctx.clone());
3263        let refusing = |checks: &[doctor::Check]| -> Vec<&'static str> {
3264            checks
3265                .iter()
3266                .map(|c| c.code)
3267                .filter(|code| ["elevated", "system_too_old"].contains(code))
3268                .collect()
3269        };
3270        assert!(
3271            refusing(&pitboard.doctor().checks).is_empty(),
3272            "as the person"
3273        );
3274        for refused in REFUSED {
3275            refused.said_by(&m);
3276            let checks = pitboard.doctor().checks;
3277            let check = checks.first().expect("checks");
3278            let words = refused.words();
3279            assert_eq!(check.code, refused.code(), "{refused:?}");
3280            assert_eq!(check.level, doctor::Level::Fail, "{refused:?}");
3281            assert_eq!(check.detail, words.because, "{refused:?}");
3282            assert!(
3283                check.advice.starts_with("Pitboard changes nothing ")
3284                    && check.advice.ends_with(words.way_out),
3285                "{refused:?}: {}",
3286                check.advice
3287            );
3288            Refused::lifted(&m);
3289        }
3290        m.mem.runs_on(Floor::Below { build: 22631 });
3291        m.mem.runs_with(Elevation::Unknown);
3292        assert_eq!(
3293            refusing(&pitboard.doctor().checks),
3294            ["system_too_old", "elevated"]
3295        );
3296    }
3297}