Skip to main content

pitboard_core/
service.rs

1//! pitboard's operations, each run the way every front end must run it: a change settles any
2//! interrupted switch first and is recorded in the audit log, and what went wrong on the way
3//! is reported alongside the result, whether or not the change then succeeds.
4
5use crate::context::Context;
6use crate::doctor::{self, Diagnosis};
7use crate::error::{Error, Result};
8use crate::provider::ProviderId;
9use crate::state::{self, Account, Key};
10use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
11use crate::{audit, readings, schedule, status, statusline};
12use std::fmt;
13
14/// Something to know about that did not stop the operation.
15#[derive(Debug)]
16#[non_exhaustive]
17pub enum Warning {
18    /// An earlier switch had been interrupted; this run found what it did and recorded it.
19    Recovered(Recovered),
20    /// The login moved, but what the tool caches about who is signed in still names the
21    /// previous account.
22    ConfigNotUpdated(Error),
23    /// Parked logins no longer in use that could not be deleted yet.
24    ParksPendingRemoval(usize),
25    /// The service refuses a parked login for good, so it was dropped.
26    ParkedLoginRefused {
27        tool: ProviderId,
28        label: String,
29    },
30    RenewalFailed(Error),
31    /// The tool's write lock stopped being pitboard's while a change was under way.
32    LockCompromised {
33        tool: ProviderId,
34    },
35    /// The environment authenticates the tool some other way, so the login pitboard moved
36    /// is not the one a session will use.
37    AuthOverridden {
38        tool: ProviderId,
39        names: Vec<String>,
40    },
41    /// The login was too large for `security`'s stdin, so it went on the argument line.
42    WrittenOnTheCommandLine {
43        tool: ProviderId,
44        bytes: usize,
45        limit: usize,
46    },
47    /// Sessions of a tool that never follows a switch on its own were running when it
48    /// happened, and go on using the account they started with until they are restarted.
49    SessionsStillRunning {
50        program: &'static str,
51        count: usize,
52        from: String,
53    },
54    /// A sign-in put a new login in use in place of the old one of the same account, and
55    /// sessions of a tool that never reads its login again were running with the old one.
56    SessionsKeepTheOldLogin {
57        program: &'static str,
58        count: usize,
59        label: String,
60    },
61    /// A sign-in to the account pitboard last recorded in use was parked rather than put in
62    /// use, because nobody could say whose login the tool has in use, for `why`.
63    SignInParkedNotInUse {
64        tool: ProviderId,
65        label: String,
66        why: String,
67    },
68}
69
70impl Warning {
71    /// Stable, for a program to branch on.
72    pub fn code(&self) -> &'static str {
73        match self {
74            Warning::Recovered(r) => r.code(),
75            Warning::LockCompromised { .. } => "lock_compromised",
76            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) => e.code(),
77            Warning::ParksPendingRemoval(_) => "parks_pending_removal",
78            Warning::ParkedLoginRefused { .. } => "parked_login_refused",
79            Warning::AuthOverridden { .. } => "auth_overridden",
80            Warning::WrittenOnTheCommandLine { .. } => "written_on_the_command_line",
81            Warning::SessionsStillRunning { .. } => "sessions_still_running",
82            Warning::SessionsKeepTheOldLogin { .. } => "sessions_keep_old_login",
83            Warning::SignInParkedNotInUse { .. } => "sign_in_parked_not_in_use",
84        }
85    }
86}
87
88impl fmt::Display for Warning {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Warning::Recovered(r) => write!(f, "{r}"),
92            Warning::LockCompromised { tool } => write!(
93                f,
94                "{} reclaimed the credential write lock while this change was under way, so \
95                 it may have written the login at the same time. pitboard read the slot back \
96                 and the change stood, but check with `pitboard` that the right account is \
97                 signed in.",
98                tool.name()
99            ),
100            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) => write!(f, "{e}"),
101            Warning::ParksPendingRemoval(count) => write!(
102                f,
103                "{count} parked login(s) no longer in use could not be removed yet; pitboard \
104                 tries again on its next change"
105            ),
106            Warning::ParkedLoginRefused { tool, label } => write!(
107                f,
108                "{} no longer accepts the parked login for `{label}`. Run `pitboard enroll \
109                 {label} --sign-in` to sign in to it again.",
110                tool.service()
111            ),
112            Warning::WrittenOnTheCommandLine { tool, bytes, limit } => {
113                write!(
114                    f,
115                    "this login needs {bytes} bytes and `security` reads {limit} from stdin, \
116                     so it was written on the argument line, where a process running as you \
117                     could have read it while the call lasted."
118                )?;
119                if *tool == ProviderId::Claude {
120                    write!(
121                        f,
122                        " Claude Code writes this same login the same way whenever it \
123                         refreshes the token."
124                    )?;
125                }
126                Ok(())
127            }
128            Warning::AuthOverridden { tool, names } => write!(
129                f,
130                "{} is set, so {} signs in with it and not with the login pitboard moved. \
131                 Unset it for the switch to take effect.",
132                names.join(" and "),
133                tool.name()
134            ),
135            Warning::SessionsStillRunning {
136                program,
137                count,
138                from,
139            } => write!(
140                f,
141                "{count} `{program}` session{} started before this switch {} still running and \
142                 still using `{from}`. Quit {} and start again to use the new account. Quit \
143                 rather than signing out inside one: signing out there revokes `{from}`'s \
144                 login, which pitboard has just parked.",
145                if *count == 1 { "" } else { "s" },
146                if *count == 1 { "is" } else { "are" },
147                if *count == 1 { "it" } else { "them" },
148            ),
149            Warning::SessionsKeepTheOldLogin {
150                program,
151                count,
152                label,
153            } => write!(
154                f,
155                "{count} `{program}` session{} started before this sign-in {} still running and \
156                 still using `{label}`'s old login. Quit {} and start again to use the new one. \
157                 Otherwise one of them can put the old login back in place of the new one when \
158                 it refreshes its token.",
159                if *count == 1 { "" } else { "s" },
160                if *count == 1 { "is" } else { "are" },
161                if *count == 1 { "it" } else { "them" },
162            ),
163            Warning::SignInParkedNotInUse { tool, label, why } => write!(
164                f,
165                "{} goes on with the login it has: pitboard could not tell whose it is \
166                 ({why}), so it parked the new login for `{label}` rather than write over \
167                 that one. If that login no longer works, run `{}` and sign in to `{label}` \
168                 there.",
169                tool.name(),
170                tool.login_command()
171            ),
172        }
173    }
174}
175
176#[derive(Debug)]
177pub struct Done<T> {
178    pub value: T,
179    pub warnings: Vec<Warning>,
180}
181
182/// A change that failed, with what was found on the way: recovering an interrupted switch
183/// is reported even when the change that followed it fails.
184#[derive(Debug)]
185pub struct Failed {
186    pub error: Error,
187    pub warnings: Vec<Warning>,
188}
189
190pub type Changing<T> = std::result::Result<Done<T>, Failed>;
191
192pub struct Pitboard {
193    ctx: Context,
194}
195
196impl Pitboard {
197    pub fn new(ctx: Context) -> Pitboard {
198        Pitboard { ctx }
199    }
200
201    /// Who is signed in and what every account has left. Parked logins whose access has
202    /// lapsed are renewed first, so every account is asked live.
203    ///
204    /// `fresh` asks Anthropic about every account whatever was asked recently. Ordinarily
205    /// false: a number is only asked for again once the tightest limit it describes could
206    /// have moved by a percentage point, which collapses several front ends on one machine
207    /// to one request per account per few minutes.
208    pub fn status(&self, fresh: bool) -> Result<Done<status::Report>> {
209        let mut warnings = Vec::new();
210        let renewed = switch::renew_parked(&self.ctx);
211        // Unreadable is not the same as empty: reporting it as empty would say the enrolled
212        // logins are gone.
213        let state = state::load(&self.ctx)?;
214        for (key, outcome) in renewed {
215            audit::record(&self.ctx, "renew", &key.typed(), outcome.code());
216            match outcome {
217                Renewal::Refused => warnings.push(Warning::ParkedLoginRefused {
218                    tool: key.provider,
219                    label: state.typed(&key),
220                }),
221                Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
222                Renewal::Renewed | Renewal::Deferred => {}
223            }
224        }
225        Ok(Done {
226            value: status::gather(&self.ctx, &state, fresh),
227            warnings,
228        })
229    }
230
231    pub fn doctor(&self) -> Diagnosis {
232        doctor::run(&self.ctx)
233    }
234
235    /// The same report without asking anyone: the last numbers pitboard measured, and who
236    /// each tool's own files say is signed in. Nothing is renewed and nothing is asked, so
237    /// it answers at once wherever there is no network.
238    pub fn status_offline(&self) -> Result<Done<status::Report>> {
239        let state = state::load(&self.ctx)?;
240        Ok(Done {
241            value: status::gather_offline(&self.ctx, &state),
242            warnings: Vec::new(),
243        })
244    }
245
246    /// The status line for Claude Code's session JSON. Reads only files, and writes only
247    /// pitboard's own: what the session passed, for its next run to compare with, and the
248    /// usage readings, which keep what moved since its last run where it is newer.
249    pub fn statusline(&self, session: &str) -> statusline::StatusLine {
250        statusline::read(&self.ctx, session)
251    }
252
253    /// The enrolled account under `typed`, if any, read without taking the lock.
254    pub fn account(&self, typed: &str) -> Option<Account> {
255        let state = state::load(&self.ctx).ok()?;
256        crate::label::resolve(&state, typed).ok().cloned()
257    }
258
259    pub fn switch_to(&self, typed: &str) -> Changing<Outcome> {
260        let key = self.named("use", typed)?;
261        self.changing("use", &key.typed(), Some(key.provider), |settled| {
262            switch::switch(settled, &key)
263        })
264    }
265
266    /// Which account somebody meant, as the key the engine looks accounts up by.
267    ///
268    /// Resolving here rather than deeper down means every command takes `codex/work` and
269    /// a bare `work` on the same terms, and the one place that decides what an ambiguous
270    /// bare label does is the one place that knows every provider's accounts.
271    fn named(&self, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
272        let state = state::load(&self.ctx).map_err(|error| Failed {
273            error,
274            warnings: Vec::new(),
275        })?;
276        crate::label::resolve(&state, typed)
277            .map(Account::key)
278            .map_err(|error| self.refused(verb, typed, error.code(), error))
279    }
280
281    /// `typed` may name a tool, as in `claude/work`. A bare name means the default tool.
282    pub fn enroll_current(&self, typed: &str) -> Changing<Enrolled> {
283        let key = self.chosen("enroll", typed)?;
284        self.changing("enroll", &key.typed(), Some(key.provider), |settled| {
285            switch::enroll(settled, &key, None)
286        })
287    }
288
289    /// Which tool a new account is for, and what it is called there.
290    ///
291    /// Split here rather than deeper down so nothing below ever sees a name with a tool
292    /// still stuck to the front of it, which would enrol an account literally called
293    /// `claude/work`.
294    fn chosen(&self, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
295        self.enrolling(typed)
296            .map_err(|error| self.refused(verb, typed, "label_unusable", error))
297    }
298
299    /// A change refused over the name it was given, which happens before it settles.
300    ///
301    /// A mistyped name takes no lock and writes nothing but its line in the audit log. But
302    /// every change settles an interrupted switch first, and one refused here would leave
303    /// that switch for whatever runs next and say nothing about it. So where a switch was
304    /// interrupted, this settles for the tool that switch was of, which a custom OAuth
305    /// endpoint allows or refuses exactly as it would a change to that tool, and reports what
306    /// it found beside the refusal. Only as far as it can: a recovery that cannot finish is
307    /// the next change's to report, and what this one reports is why it was refused.
308    fn refused(&self, verb: &str, subject: &str, code: &str, error: Error) -> Failed {
309        let recovered = if switch::interrupted(&self.ctx) {
310            switch::settle(&self.ctx, switch::interrupted_tool(&self.ctx))
311                .ok()
312                .and_then(|(_, recovered)| recovered)
313        } else {
314            None
315        };
316        let mut warnings = Vec::new();
317        if let Some(r) = recovered {
318            audit::record(&self.ctx, "recover", &r.to, r.code());
319            warnings.push(Warning::Recovered(r));
320        }
321        audit::record(&self.ctx, verb, subject, code);
322        Failed { error, warnings }
323    }
324
325    /// The account `pitboard enroll <typed>` is about: the one already enrolled under that
326    /// exact name, or a new one of the tool the name says.
327    ///
328    /// A label written by 0.1.x could contain a slash, which a new name cannot, and signing
329    /// in to such an account again is exactly what every message about a lapsed park tells
330    /// somebody to do. So an existing account is found by its whole name first.
331    fn enrolling(&self, typed: &str) -> Result<Key> {
332        if typed.contains(crate::label::SEPARATOR)
333            && let Ok(state) = state::load(&self.ctx)
334            && let Some(existing) = state.accounts.iter().find(|a| a.label == typed)
335        {
336            return Ok(existing.key());
337        }
338        crate::label::choose(typed)
339            .map(|chosen| Key::new(chosen.provider, chosen.label))
340            .map_err(Error::Usage)
341    }
342
343    /// The enrolled account `pitboard enroll <typed>` would sign in to again, if it names
344    /// one, for saying whose login to sign in with before a browser opens.
345    pub fn account_to_enroll(&self, typed: &str) -> Option<Account> {
346        let key = self.enrolling(typed).ok()?;
347        state::load(&self.ctx).ok()?.get(&key).cloned()
348    }
349
350    /// What to type to name the account `pitboard enroll <typed>` is about, on this
351    /// machine: bare where that names it alone, qualified where another tool shares it.
352    pub fn name_to_type(&self, typed: &str) -> String {
353        let Ok(key) = self.enrolling(typed) else {
354            return typed.to_string();
355        };
356        state::load(&self.ctx).map_or_else(|_| key.typed(), |state| state.typed(&key))
357    }
358
359    /// The tool's own sign-in in a private directory, for the tool `typed` names. It takes
360    /// no lock but its own, so a person taking their time in a browser never holds up a
361    /// switch.
362    pub fn sign_in(&self, typed: &str) -> std::result::Result<SignIn, Failed> {
363        let tool = self.signing_in(typed)?;
364        switch::sign_in(&self.ctx, tool).map_err(|error| self.not_started(typed, error))
365    }
366
367    /// The same sign-in with its output piped, for a front end that has no terminal to
368    /// hand over. The caller shows what the tool says and can type a code back.
369    pub fn sign_in_watched(
370        &self,
371        typed: &str,
372    ) -> std::result::Result<switch::WatchedSignIn, Failed> {
373        let tool = self.signing_in(typed)?;
374        switch::sign_in_watched(&self.ctx, tool).map_err(|error| self.not_started(typed, error))
375    }
376
377    /// Which tool a sign-in is for, once everything that could refuse it has been asked.
378    ///
379    /// A name no account could have is refused the way every change refuses one, settling
380    /// an interrupted switch on the way; anything else refused here is recorded and nothing
381    /// more, since nothing was about to change.
382    fn signing_in(&self, typed: &str) -> std::result::Result<crate::provider::ProviderId, Failed> {
383        let tool = self.chosen("enroll", typed)?.provider;
384        self.ready_to_sign_in(tool)
385            .map_err(|error| self.not_started(typed, error))?;
386        Ok(tool)
387    }
388
389    /// A sign-in that did not start, or did not finish, for a reason other than its name.
390    fn not_started(&self, typed: &str, error: Error) -> Failed {
391        audit::record(&self.ctx, "enroll", typed, error.code());
392        Failed {
393            error,
394            warnings: Vec::new(),
395        }
396    }
397
398    /// Checked before a sign-in starts, so a person does not sign in through a browser
399    /// only to be told the state file belongs to another machine, that the tool is not
400    /// installed, or that the account could never be switched to afterwards.
401    fn ready_to_sign_in(&self, tool: crate::provider::ProviderId) -> Result<()> {
402        if tool == crate::provider::ProviderId::Claude && self.ctx.custom_oauth() {
403            return Err(Error::CustomOauthEndpoint);
404        }
405        state::load(&self.ctx)?;
406        let driver = crate::provider::of(tool);
407        // An account signed in here is one to switch to later, which needs a live store
408        // pitboard can write. Asked now rather than after a browser round trip.
409        switch::live_store(&self.ctx, tool)?;
410        // A private sign-in works by pointing the tool's own login at a scratch directory
411        // through its home variable. Where that does not really isolate it, running one
412        // would write over the login somebody is using, and there is no override: forcing
413        // past "this could touch your live login" is what the rule exists to prevent.
414        if let crate::provider::Isolation::NotIsolated { reason } =
415            driver.private_signin_isolation(&self.ctx)
416        {
417            return Err(Error::SignInNotIsolated { reason });
418        }
419        if driver.program(&self.ctx).is_none() {
420            return Err(Error::ProgramMissing {
421                tool,
422                program: self.ctx.program_for(tool).display().to_string(),
423            });
424        }
425        Ok(())
426    }
427
428    pub fn enroll_signed_in(&self, typed: &str, login: SignIn) -> Changing<Enrolled> {
429        let key = self.chosen("enroll", typed)?;
430        self.changing("enroll", &key.typed(), Some(key.provider), |settled| {
431            switch::enroll(settled, &key, Some(login))
432        })
433    }
434
435    /// Returns the account's email.
436    pub fn forget(&self, typed: &str) -> Changing<String> {
437        let key = self.named("forget", typed)?;
438        self.changing("forget", &key.typed(), Some(key.provider), |settled| {
439            switch::forget(settled, &key)
440        })
441    }
442
443    /// Throws away a record of an interrupted switch that cannot be finished, keeping
444    /// every login it names. The way out when recovery cannot reach Anthropic.
445    pub fn abandon_recovery(&self) -> Result<Option<switch::Abandoned>> {
446        let outcome = switch::abandon(&self.ctx);
447        audit::record(
448            &self.ctx,
449            "abandon",
450            "",
451            match &outcome {
452                Ok(_) => "ok",
453                Err(e) => e.code(),
454            },
455        );
456        outcome
457    }
458
459    /// Renew every parked login that is due, and nothing else. No switch, no usage, and
460    /// no request but the token exchange. This is what the schedule runs.
461    pub fn renew(&self) -> Vec<(Key, Renewal)> {
462        let outcomes = switch::renew_due(&self.ctx, switch::Due::ToStayAlive);
463        for (key, outcome) in &outcomes {
464            audit::record(&self.ctx, "renew", &key.typed(), outcome.code());
465        }
466        outcomes
467    }
468
469    /// Whether anything is keeping parked logins alive on this machine without somebody
470    /// running a command.
471    pub fn schedule(&self) -> schedule::Installed {
472        schedule::status(&self.ctx)
473    }
474
475    /// Ask the platform's own scheduler to run `renew` daily. Opt-in, and stays opt-in.
476    pub fn schedule_install(&self) -> Result<std::path::PathBuf> {
477        schedule::install(&self.ctx)
478    }
479
480    /// Take it away. `false` when there was nothing installed.
481    pub fn schedule_uninstall(&self) -> Result<bool> {
482        schedule::uninstall(&self.ctx)
483    }
484
485    /// Point a schedule an app up to 0.3.0 wrote, which runs the app itself, at the command
486    /// line this context names. `true` when it did; nothing changes otherwise.
487    pub fn schedule_repair(&self) -> Result<bool> {
488        schedule::repair(&self.ctx)
489    }
490
491    /// Take over a pitboard directory another machine wrote: keep the accounts, drop the
492    /// logins that came with them. `None` when the directory was already this machine's.
493    ///
494    /// The one change that does not settle first, because a stamp from elsewhere is what
495    /// stops settling. Everything after it settles normally.
496    pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
497        switch::adopt(&self.ctx)
498    }
499
500    /// Ask the credential store what parked logins are on this machine, and give back or
501    /// delete every one pitboard's own records do not name. Ordinarily there is nothing to
502    /// do: every change resolves the names it wrote down. This is for a machine whose state
503    /// file was lost or restored from a backup, where the store is the only record left.
504    pub fn repair(&self) -> Changing<switch::Reclaimed> {
505        self.changing("repair", "", None, |settled| {
506            switch::repair(settled).map(|r| (r, Vec::new()))
507        })
508    }
509
510    /// When pitboard's account index last changed, for a front end that wants to know
511    /// whether another one has done something without asking Anthropic about it.
512    pub fn changed_at(&self) -> i64 {
513        state::changed_at(&self.ctx)
514    }
515
516    /// When pitboard's usage readings last changed, in epoch milliseconds, for a front end
517    /// that shows them to follow what the others record without asking anyone.
518    pub fn readings_changed_at(&self) -> i64 {
519        readings::changed_at(&self.ctx)
520    }
521
522    /// The changes pitboard has made, newest last.
523    pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
524        audit::read(&self.ctx, limit)
525    }
526
527    /// Takes away the daily renewal schedule, deletes every parked login this pitboard
528    /// wrote, and removes pitboard's own directory. Each tool's login is left alone: whoever
529    /// is signed in stays signed in.
530    pub fn uninstall(&self) -> Changing<switch::Removed> {
531        self.changing("uninstall", "", None, |settled| {
532            switch::uninstall(settled).map(|r| (r, Vec::new()))
533        })
534    }
535
536    /// Returns the account's email.
537    /// `from` may be qualified; `to` is a plain name, and stays inside whichever provider
538    /// the account already belongs to. Renaming cannot move an account between tools.
539    pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
540        let from = self.named("rename", from)?;
541        let chosen = self.chosen("rename", to)?;
542        // Only a prefix somebody actually typed can disagree: a bare new name stays inside
543        // the account's own tool whatever tool a bare name would mean for a new account.
544        if to.contains(crate::label::SEPARATOR) && chosen.provider != from.provider {
545            let error = Error::Usage(format!(
546                "`{from}` is a {} account, and a rename cannot move it to {}. Sign in to that \
547                 tool and enrol the account there instead.",
548                from.provider, chosen.provider
549            ));
550            return Err(self.refused("rename", &from.typed(), error.code(), error));
551        }
552        let to = chosen.label;
553        self.changing(
554            "rename",
555            &format!("{from} -> {to}"),
556            Some(from.provider),
557            |settled| switch::rename(settled, &from, &to).map(|email| (email, Vec::new())),
558        )
559    }
560
561    /// Settles, runs the change, and records it in the audit log.
562    ///
563    /// `tool` is the tool whose login the change is about, where it is about one: its own
564    /// ways of being signed in by something else are what is worth warning about, and a
565    /// refusal that is one tool's business does not stop a change to another's.
566    fn changing<T: Audited>(
567        &self,
568        verb: &str,
569        subject: &str,
570        tool: Option<ProviderId>,
571        run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
572    ) -> Changing<T> {
573        let (settled, recovered) = switch::settle(&self.ctx, tool).map_err(|error| {
574            audit::record(&self.ctx, verb, subject, error.code());
575            Failed {
576                error,
577                warnings: Vec::new(),
578            }
579        })?;
580        let mut warnings = Vec::new();
581        // Read from files as well as from this process's environment, so the app, which
582        // has no shell environment at all, gets the same answer as the command line.
583        if let Some(tool) = tool {
584            let names = crate::provider::of(tool).overridden_by(&self.ctx);
585            if !names.is_empty() {
586                warnings.push(Warning::AuthOverridden { tool, names });
587            }
588        }
589        if let Some(r) = recovered {
590            audit::record(&self.ctx, "recover", &r.to, r.code());
591            warnings.push(Warning::Recovered(r));
592        }
593        match run(settled) {
594            Ok((value, more)) => {
595                audit::record(&self.ctx, verb, subject, value.audit_code());
596                warnings.extend(more);
597                Ok(Done { value, warnings })
598            }
599            Err(mut error) => {
600                audit::record(&self.ctx, verb, subject, error.code());
601                warnings.extend(error.take_warnings());
602                Err(Failed { error, warnings })
603            }
604        }
605    }
606}
607
608/// How a successful change is written in the audit log.
609trait Audited {
610    fn audit_code(&self) -> &'static str {
611        "ok"
612    }
613}
614
615impl Audited for Outcome {
616    fn audit_code(&self) -> &'static str {
617        match self {
618            Outcome::Switched { .. } => "ok",
619            Outcome::AlreadyActive { .. } => "already_active",
620        }
621    }
622}
623
624impl Audited for switch::Reclaimed {}
625impl Audited for Enrolled {}
626impl Audited for String {}
627
628impl Audited for switch::Removed {
629    fn audit_code(&self) -> &'static str {
630        if self.pending > 0 {
631            "parks_pending_removal"
632        } else {
633            "ok"
634        }
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use crate::switch::harness::{Machine, codex_machine, hold, machine};
642    use std::collections::BTreeMap;
643
644    type Make = fn(&str) -> Machine;
645
646    /// A name is refused the same way whichever tool it is for.
647    const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
648
649    type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
650
651    /// Every way a change is refused over the name it was given, with the verb the audit
652    /// log records it under, the code it is refused with and the one the log records: a
653    /// name nobody enrolled, a name no account could have, whether the account signed in
654    /// now is being enrolled or one is being signed in to, and a new name that would move
655    /// an account to another tool.
656    const REFUSALS: [(&str, &str, &str, &str, Refuse); 4] = [
657        (
658            "use",
659            "use",
660            "account_unknown",
661            "account_unknown",
662            |p, _| p.switch_to("nobody").err(),
663        ),
664        ("enroll", "enroll", "usage", "label_unusable", |p, _| {
665            p.enroll_current("codx/work").err()
666        }),
667        ("sign-in", "enroll", "usage", "label_unusable", |p, _| {
668            p.sign_in("codx/work").err()
669        }),
670        ("rename", "rename", "usage", "usage", |p, tool| {
671            let other = if tool == ProviderId::Claude {
672                ProviderId::Codex
673            } else {
674                ProviderId::Claude
675            };
676            p.rename(
677                &Key::new(tool, "here").qualified(),
678                &Key::new(other, "moved").qualified(),
679            )
680            .err()
681        }),
682    ];
683
684    /// A switch from `here` to `there` killed after parking `here` and before installing
685    /// `there`, so its record is all that says it happened.
686    fn interrupted(make: Make, name: &str) -> Machine {
687        let m = make(name);
688        let settled = switch::settle(&m.ctx, None)
689            .expect("nothing to recover yet")
690            .0;
691        let died = crate::fault::killing("switch.park_recorded", || {
692            switch::switch(settled, &m.key("there"))
693        });
694        assert_eq!(died.unwrap_err(), "switch.park_recorded");
695        assert!(switch::interrupted(&m.ctx));
696        m
697    }
698
699    /// Every file in pitboard's own directory but the audit log.
700    fn files(m: &Machine) -> BTreeMap<String, Vec<u8>> {
701        std::fs::read_dir(crate::home::dir(&m.ctx))
702            .expect("a pitboard home")
703            .map(|entry| entry.expect("an entry").path())
704            .filter(|path| path.file_name() != Some("audit.log".as_ref()))
705            .map(|path| {
706                let body = std::fs::read(&path).unwrap_or_default();
707                (path.display().to_string(), body)
708            })
709            .collect()
710    }
711
712    /// The last two lines of the audit log, as verb and outcome.
713    fn last_audited(m: &Machine) -> Vec<(String, String)> {
714        audit::read(&m.ctx, 2)
715            .into_iter()
716            .map(|entry| (entry.verb, entry.outcome))
717            .collect()
718    }
719
720    /// Every other change settles an interrupted switch before anything else, and one
721    /// refused over its name did not, so the switch stayed unrecovered and the refusal was
722    /// all anybody was told. It is settled now, recorded the way any recovery is, and
723    /// reported beside the refusal.
724    #[test]
725    fn a_change_refused_over_its_name_still_recovers_an_interrupted_switch() {
726        for (tool, make) in MACHINES {
727            for (change, verb, code, audited, refuse) in REFUSALS {
728                let at = format!("{tool}, {change}");
729                let m = interrupted(make, &format!("refused-{tool}-{change}"));
730
731                let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
732                    .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
733
734                assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
735                let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
736                assert_eq!(said, ["interrupted_switch_undone"], "{at}");
737                assert!(!switch::interrupted(&m.ctx), "{at}: the record is resolved");
738                hold(&m, &at);
739                assert_eq!(
740                    last_audited(&m),
741                    [
742                        (
743                            "recover".to_string(),
744                            "interrupted_switch_undone".to_string()
745                        ),
746                        (verb.to_string(), audited.to_string()),
747                    ],
748                    "{at}"
749                );
750            }
751        }
752    }
753
754    /// A mistyped name with nothing to recover takes no lock and writes nothing but its
755    /// line in the audit log.
756    #[test]
757    fn a_change_refused_over_its_name_with_nothing_interrupted_changes_nothing() {
758        for (tool, make) in MACHINES {
759            for (change, verb, code, audited, refuse) in REFUSALS {
760                let at = format!("{tool}, {change}");
761                let m = make(&format!("refused-quietly-{tool}-{change}"));
762                let (before, parked, live) = (files(&m), m.mem.vault().services(), m.live());
763
764                let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
765                    .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
766
767                assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
768                assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
769                assert_eq!(files(&m), before, "{at}: not even the lock file is made");
770                assert_eq!(m.mem.vault().services(), parked, "{at}");
771                assert_eq!(m.live(), live, "{at}");
772                assert_eq!(
773                    last_audited(&m).last(),
774                    Some(&(verb.to_string(), audited.to_string())),
775                    "{at}"
776                );
777            }
778        }
779    }
780
781    /// A new login that did not hold after it was written is parked rather than lost, and
782    /// parking a login too big for `security`'s standard input puts it on the argument line.
783    /// The change then fails, and that is still said beside the failure.
784    #[test]
785    fn a_new_login_parked_after_it_did_not_hold_says_how_it_was_parked() {
786        for (tool, make) in MACHINES {
787            let m = make(&format!("not-installed-said-{tool}"));
788            m.mem.vault().takes_on_stdin(64);
789            let login = crate::switch::harness::signed_in(&m, "here", "here-refresh-2");
790            m.fault_live(crate::store::memory::Fault::DeletedAfterWrite);
791
792            let failed = Pitboard::new(m.ctx.clone())
793                .enroll_signed_in(&m.key("here").typed(), login)
794                .expect_err("it did not hold");
795
796            assert_eq!(failed.error.code(), "sign_in_not_installed", "{tool}");
797            let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
798            assert_eq!(said, ["written_on_the_command_line"], "{tool}");
799        }
800    }
801
802    /// The recovery settles for the tool whose switch was interrupted, not for no tool in
803    /// particular, so a custom Claude Code endpoint stops exactly what it stops for any
804    /// change: the recovery of a Claude Code switch, and not of a Codex one. What it stops
805    /// is left for a later run, and the refusal is reported as it always was.
806    #[test]
807    fn a_custom_claude_endpoint_stops_only_the_recovery_of_a_claude_code_switch() {
808        let codex = interrupted(codex_machine, "refused-custom-codex");
809        let mut ctx = codex.ctx.clone();
810        ctx.custom_oauth = true;
811        let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
812        assert_eq!(failed.error.code(), "account_unknown");
813        let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
814        assert_eq!(said, ["interrupted_switch_undone"]);
815        assert!(!switch::interrupted(&codex.ctx));
816
817        let claude = interrupted(machine, "refused-custom-claude");
818        let mut ctx = claude.ctx.clone();
819        ctx.custom_oauth = true;
820        let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
821        assert_eq!(
822            failed.error.code(),
823            "account_unknown",
824            "the refusal, not the recovery that could not run"
825        );
826        assert!(failed.warnings.is_empty(), "{:?}", failed.warnings);
827        assert!(
828            switch::interrupted(&claude.ctx),
829            "the record is kept for a run that can finish it"
830        );
831    }
832}