1use 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#[derive(Debug)]
17#[non_exhaustive]
18pub enum Warning {
19 Recovered(Recovered),
21 ConfigNotUpdated(Error),
24 ParksPendingRemoval(usize),
26 ParkedLoginRefused {
28 tool: ProviderId,
29 label: String,
30 },
31 RenewalFailed(Error),
32 LockCompromised {
34 tool: ProviderId,
35 },
36 AuthOverridden {
39 tool: ProviderId,
40 names: Vec<String>,
41 },
42 WrittenOnTheCommandLine {
44 tool: ProviderId,
45 bytes: usize,
46 limit: usize,
47 },
48 SessionsStillRunning {
52 from: String,
53 holding: Vec<crate::holder::Holding>,
54 },
55 SessionsKeepTheOldLogin {
58 label: String,
59 holding: Vec<crate::holder::Holding>,
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 { 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#[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 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 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 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 pub fn statusline(&self, session: &str) -> statusline::StatusLine {
245 statusline::read(&self.ctx, session)
246 }
247
248 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 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 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 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 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 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 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 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 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 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 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 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 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 switch::live_store(&self.ctx, tool)?;
405 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 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 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 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 pub fn schedule(&self) -> schedule::Installed {
467 schedule::status(&self.ctx)
468 }
469
470 pub fn schedule_install(&self) -> Result<std::path::PathBuf> {
472 schedule::install(&self.ctx)
473 }
474
475 pub fn schedule_uninstall(&self) -> Result<bool> {
477 schedule::uninstall(&self.ctx)
478 }
479
480 pub fn schedule_repair(&self) -> Result<bool> {
483 schedule::repair(&self.ctx)
484 }
485
486 pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
492 switch::adopt(&self.ctx)
493 }
494
495 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 pub fn holding(&self, which: ProviderId) -> Vec<holder::Holding> {
510 switch::still_holding(&self.ctx, which).unwrap_or_default()
511 }
512
513 pub fn changed_at(&self) -> i64 {
516 state::changed_at(&self.ctx)
517 }
518
519 pub fn readings_changed_at(&self) -> i64 {
522 readings::changed_at(&self.ctx)
523 }
524
525 pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
527 audit::read(&self.ctx, limit)
528 }
529
530 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 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 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 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 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
611trait 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 const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
651
652 type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
653
654 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 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 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 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 #[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 #[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 #[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 #[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}