1use 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#[derive(Debug)]
16#[non_exhaustive]
17pub enum Warning {
18 Recovered(Recovered),
20 ConfigNotUpdated(Error),
23 ParksPendingRemoval(usize),
25 ParkedLoginRefused {
27 tool: ProviderId,
28 label: String,
29 },
30 RenewalFailed(Error),
31 LockCompromised {
33 tool: ProviderId,
34 },
35 AuthOverridden {
38 tool: ProviderId,
39 names: Vec<String>,
40 },
41 WrittenOnTheCommandLine {
43 tool: ProviderId,
44 bytes: usize,
45 limit: usize,
46 },
47 SessionsStillRunning {
50 program: &'static str,
51 count: usize,
52 from: String,
53 },
54 SessionsKeepTheOldLogin {
57 program: &'static str,
58 count: usize,
59 label: String,
60 },
61 SignInParkedNotInUse {
64 tool: ProviderId,
65 label: String,
66 why: String,
67 },
68}
69
70impl Warning {
71 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#[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 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 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 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 pub fn statusline(&self, session: &str) -> statusline::StatusLine {
250 statusline::read(&self.ctx, session)
251 }
252
253 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 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 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 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 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 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 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 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 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 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 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 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 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 switch::live_store(&self.ctx, tool)?;
410 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 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 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 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 pub fn schedule(&self) -> schedule::Installed {
472 schedule::status(&self.ctx)
473 }
474
475 pub fn schedule_install(&self) -> Result<std::path::PathBuf> {
477 schedule::install(&self.ctx)
478 }
479
480 pub fn schedule_uninstall(&self) -> Result<bool> {
482 schedule::uninstall(&self.ctx)
483 }
484
485 pub fn schedule_repair(&self) -> Result<bool> {
488 schedule::repair(&self.ctx)
489 }
490
491 pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
497 switch::adopt(&self.ctx)
498 }
499
500 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 pub fn changed_at(&self) -> i64 {
513 state::changed_at(&self.ctx)
514 }
515
516 pub fn readings_changed_at(&self) -> i64 {
519 readings::changed_at(&self.ctx)
520 }
521
522 pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
524 audit::read(&self.ctx, limit)
525 }
526
527 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 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 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 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 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
608trait 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 const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
648
649 type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
650
651 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 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 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 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 #[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 #[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 #[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 #[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}