Skip to main content

pitboard_core/
service.rs

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