1use crate::context::Context;
6use crate::doctor::{self, Diagnosis};
7use crate::error::{Error, Result};
8use crate::holder::{self, capitalised};
9use crate::host::Elevation;
10use crate::provider::ProviderId;
11use crate::state::{self, Account, Key};
12use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
13use crate::{audit, readings, schedule, status, statusline};
14use std::fmt;
15
16#[derive(Debug, Clone, Copy)]
26pub struct Permit {
27 _only_the_gate_makes_one: (),
28}
29
30#[cfg(test)]
31impl Permit {
32 pub(crate) fn for_a_test() -> Permit {
35 Permit {
36 _only_the_gate_makes_one: (),
37 }
38 }
39}
40
41pub(crate) fn gate(ctx: &Context) -> Result<Permit> {
54 crate::release::check()?;
55 match ctx.host().elevation(ctx) {
56 Elevation::Normal => {}
57 Elevation::Elevated { why } => return Err(Error::Elevated { why: Some(why) }),
58 Elevation::Unknown => return Err(Error::Elevated { why: None }),
59 }
60 crate::home::check_absolute(ctx)?;
61 Ok(Permit {
62 _only_the_gate_makes_one: (),
63 })
64}
65
66#[derive(Debug)]
68#[non_exhaustive]
69pub enum Warning {
70 Recovered(Recovered),
72 ConfigNotUpdated(Error),
75 ParksPendingRemoval(usize),
77 ParkedLoginRefused {
79 tool: ProviderId,
80 label: String,
81 },
82 RenewalFailed(Error),
83 LockCompromised {
85 tool: ProviderId,
86 },
87 AuthOverridden {
90 tool: ProviderId,
91 names: Vec<String>,
92 },
93 WrittenOnTheCommandLine {
95 tool: ProviderId,
96 bytes: usize,
97 limit: usize,
98 },
99 SessionsStillRunning {
103 from: String,
104 holding: Vec<crate::holder::Holding>,
105 },
106 SessionsKeepTheOldLogin {
109 label: String,
110 holding: Vec<crate::holder::Holding>,
111 },
112 SessionsUnknown {
116 tool: ProviderId,
117 from: String,
118 },
119 SessionsUnknownAfterSignIn {
123 tool: ProviderId,
124 label: String,
125 },
126 SignInParkedNotInUse {
129 tool: ProviderId,
130 label: String,
131 why: String,
132 },
133 SwitchStuck(Error),
137 ReadOnly {
142 why: Option<&'static str>,
143 },
144}
145
146impl Warning {
147 pub fn code(&self) -> &'static str {
149 match self {
150 Warning::Recovered(r) => r.code(),
151 Warning::LockCompromised { .. } => "lock_compromised",
152 Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
153 e.code()
154 }
155 Warning::ParksPendingRemoval(_) => "parks_pending_removal",
156 Warning::ParkedLoginRefused { .. } => "parked_login_refused",
157 Warning::AuthOverridden { .. } => "auth_overridden",
158 Warning::WrittenOnTheCommandLine { .. } => "written_on_the_command_line",
159 Warning::SessionsStillRunning { .. } => "sessions_still_running",
160 Warning::SessionsKeepTheOldLogin { .. } => "sessions_keep_old_login",
161 Warning::SessionsUnknown { .. } | Warning::SessionsUnknownAfterSignIn { .. } => {
162 "sessions_unknown"
163 }
164 Warning::SignInParkedNotInUse { .. } => "sign_in_parked_not_in_use",
165 Warning::ReadOnly { .. } => "read_only",
166 }
167 }
168}
169
170impl fmt::Display for Warning {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 match self {
173 Warning::Recovered(r) => write!(f, "{r}"),
174 Warning::LockCompromised { tool } => write!(
175 f,
176 "{} reclaimed the credential write lock while this change was under way, so \
177 it may have written the login at the same time. Pitboard read the slot back \
178 and the change stood, but check with `pitboard` that the right account is \
179 signed in.",
180 tool.name()
181 ),
182 Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
183 write!(f, "{e}")
184 }
185 Warning::ParksPendingRemoval(count) => write!(
186 f,
187 "{count} parked login(s) no longer in use could not be removed yet; Pitboard \
188 tries again on its next change"
189 ),
190 Warning::ParkedLoginRefused { tool, label } => write!(
191 f,
192 "{} no longer accepts the parked login for `{label}`. Run `pitboard enroll \
193 {label} --sign-in` to sign in to it again.",
194 tool.service()
195 ),
196 Warning::WrittenOnTheCommandLine { tool, bytes, limit } => {
197 write!(
198 f,
199 "this login needs {bytes} bytes and `security` reads {limit} from stdin, \
200 so it was written on the argument line, where a process running as you \
201 could have read it while the call lasted."
202 )?;
203 if *tool == ProviderId::Claude {
204 write!(
205 f,
206 " Claude Code writes this same login the same way whenever it \
207 refreshes the token."
208 )?;
209 }
210 Ok(())
211 }
212 Warning::AuthOverridden { tool, names } => write!(
213 f,
214 "{} is set, so {} signs in with it and not with the login Pitboard moved. \
215 Unset it for the switch to take effect.",
216 names.join(" and "),
217 tool.name()
218 ),
219 Warning::SessionsStillRunning { from, holding } => write!(
220 f,
221 "{} started before this switch {} still running and still using `{from}`. {} \
222 Do not sign out in {}: signing out there revokes `{from}`'s login, which \
223 Pitboard has just parked.",
224 capitalised(&holder::described(holding)),
225 if holder::plural(holding) { "are" } else { "is" },
226 holder::remedies(holding, "to use the new account"),
227 if holder::plural(holding) {
228 "any of them"
229 } else {
230 "it"
231 },
232 ),
233 Warning::SessionsKeepTheOldLogin { label, holding } => write!(
234 f,
235 "{} started before this sign-in {} still running and still using `{label}`'s \
236 old login. {} Otherwise one of them can put the old login back in place of \
237 the new one when it refreshes its token.",
238 capitalised(&holder::described(holding)),
239 if holder::plural(holding) { "are" } else { "is" },
240 holder::remedies(holding, "to use the new one"),
241 ),
242 Warning::SessionsUnknown { tool, from } => write!(
243 f,
244 "Pitboard could not tell whether {} sessions started before this switch are \
245 still running, because it could not read the list of processes. Do not sign \
246 out in one that is: signing out there revokes `{from}`'s login, which \
247 Pitboard has just parked.",
248 tool.name()
249 ),
250 Warning::SessionsUnknownAfterSignIn { tool, label } => write!(
251 f,
252 "Pitboard could not tell whether {} sessions started before this sign-in are \
253 still running, because it could not read the list of processes. Quit any \
254 that are still using `{label}`'s old login and start them again. Otherwise \
255 one of them can put the old login back in place of the new one when it \
256 refreshes its token.",
257 tool.name()
258 ),
259 Warning::SignInParkedNotInUse { tool, label, why } => write!(
260 f,
261 "{} goes on with the login it has: Pitboard could not tell whose it is \
262 ({why}), so it parked the new login for `{label}` rather than write over \
263 that one. If that login no longer works, run `{}` and sign in to `{label}` \
264 there.",
265 tool.name(),
266 tool.login_command()
267 ),
268 Warning::ReadOnly { why } => write!(
269 f,
270 "{}, so it changes nothing: these are the numbers it last measured, and it \
271 renews no parked login and asks nobody. Run it as yourself to ask again.",
272 match why {
273 Some(why) => format!("Pitboard runs {why}"),
274 None => "Pitboard cannot tell whether it runs as root or with sudo".into(),
275 }
276 ),
277 }
278 }
279}
280
281#[derive(Debug)]
282pub struct Done<T> {
283 pub value: T,
284 pub warnings: Vec<Warning>,
285}
286
287#[derive(Debug)]
290pub struct Failed {
291 pub error: Error,
292 pub warnings: Vec<Warning>,
293}
294
295pub type Changing<T> = std::result::Result<Done<T>, Failed>;
296
297pub struct Pitboard {
298 ctx: Context,
299}
300
301impl Pitboard {
302 pub fn new(ctx: Context) -> Pitboard {
303 Pitboard { ctx }
304 }
305
306 pub fn permit(&self) -> Result<Permit> {
314 gate(&self.ctx)
315 }
316
317 pub fn check_homes(&self) -> Result<()> {
323 crate::home::check_absolute(&self.ctx)
324 }
325
326 fn permitted(&self) -> std::result::Result<Permit, Failed> {
328 self.permit().map_err(|error| Failed {
329 error,
330 warnings: Vec::new(),
331 })
332 }
333
334 pub fn status(&self, fresh: bool) -> Result<Done<status::Report>> {
356 let permit = match self.permit() {
357 Ok(permit) => permit,
358 Err(Error::Elevated { why }) => {
359 let mut read = self.status_offline()?;
360 read.warnings.push(Warning::ReadOnly { why });
361 return Ok(read);
362 }
363 Err(other) => return Err(other),
364 };
365 let renewed = switch::renew_parked(&self.ctx, permit);
366 let state = state::load(&self.ctx)?;
369 let (stuck, report) = std::thread::scope(|scope| {
373 let stuck = scope.spawn(|| switch::stuck(&self.ctx, &state, switch::Asking::Service));
374 let report = status::gather(&self.ctx, permit, &state, fresh);
375 (stuck.join().ok().flatten(), report)
376 });
377 let mut warnings: Vec<Warning> = stuck.map(Warning::SwitchStuck).into_iter().collect();
378 for (key, outcome) in renewed {
379 audit::record(&self.ctx, permit, "renew", &key.typed(), outcome.code());
380 match outcome {
381 Renewal::Refused => warnings.push(Warning::ParkedLoginRefused {
382 tool: key.provider,
383 label: state.typed(&key),
384 }),
385 Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
386 Renewal::Renewed | Renewal::Deferred => {}
387 }
388 }
389 Ok(Done {
390 value: report,
391 warnings,
392 })
393 }
394
395 pub fn doctor(&self) -> Diagnosis {
396 doctor::run(&self.ctx)
397 }
398
399 pub fn status_offline(&self) -> Result<Done<status::Report>> {
412 let state = state::load(&self.ctx)?;
413 let warnings = switch::stuck(&self.ctx, &state, switch::Asking::Nobody)
414 .map(Warning::SwitchStuck)
415 .into_iter()
416 .collect();
417 Ok(Done {
418 value: status::gather_offline(&self.ctx, &state),
419 warnings,
420 })
421 }
422
423 pub fn statusline(&self, session: &str) -> statusline::StatusLine {
429 statusline::read(&self.ctx, self.permit().ok(), session)
430 }
431
432 pub fn account(&self, typed: &str) -> Option<Account> {
434 let state = state::load(&self.ctx).ok()?;
435 crate::label::resolve(&state, typed).ok().cloned()
436 }
437
438 pub fn switch_to(&self, typed: &str) -> Changing<Outcome> {
439 let permit = self.permitted()?;
440 let key = self.named(permit, "use", typed)?;
441 self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
442 switch::switch(settled, &key)
443 })
444 }
445
446 pub fn auto_switch(
454 &self,
455 threshold: crate::autoswitch::Threshold,
456 ) -> Changing<crate::autoswitch::Auto> {
457 let permit = self.permitted()?;
458 let state = state::load(&self.ctx).map_err(|error| Failed {
459 error,
460 warnings: Vec::new(),
461 })?;
462 let plan = match crate::autoswitch::look(&self.ctx, &state, threshold) {
463 crate::autoswitch::Next::Say(auto) => {
464 return Ok(Done {
465 value: auto,
466 warnings: Vec::new(),
467 });
468 }
469 crate::autoswitch::Next::Switch(plan) => plan,
470 };
471 let subject = state.typed(&plan.to);
472 self.changing(
473 permit,
474 "auto-switch",
475 &subject,
476 Some(ProviderId::Claude),
477 |settled| switch::automatically(settled, &plan, threshold),
478 )
479 }
480
481 fn named(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
487 let state = state::load(&self.ctx).map_err(|error| Failed {
488 error,
489 warnings: Vec::new(),
490 })?;
491 crate::label::resolve(&state, typed)
492 .map(Account::key)
493 .map_err(|error| self.refused(permit, verb, typed, error.code(), error))
494 }
495
496 pub fn enroll_current(&self, typed: &str) -> Changing<Enrolled> {
498 let permit = self.permitted()?;
499 let key = self.chosen(permit, "enroll", typed)?;
500 self.changing(
501 permit,
502 "enroll",
503 &key.typed(),
504 Some(key.provider),
505 |settled| switch::enroll(settled, &key, None),
506 )
507 }
508
509 fn chosen(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
515 self.enrolling(typed)
516 .map_err(|error| self.refused(permit, verb, typed, "label_unusable", error))
517 }
518
519 fn refused(
529 &self,
530 permit: Permit,
531 verb: &str,
532 subject: &str,
533 code: &str,
534 error: Error,
535 ) -> Failed {
536 let recovered = if switch::interrupted(&self.ctx) {
537 switch::settle(&self.ctx, permit, switch::interrupted_tool(&self.ctx))
538 .ok()
539 .and_then(|(_, recovered)| recovered)
540 } else {
541 None
542 };
543 let mut warnings = Vec::new();
544 if let Some(r) = recovered {
545 audit::record(&self.ctx, permit, "recover", &r.to, r.code());
546 warnings.push(Warning::Recovered(r));
547 }
548 audit::record(&self.ctx, permit, verb, subject, code);
549 Failed { error, warnings }
550 }
551
552 fn enrolling(&self, typed: &str) -> Result<Key> {
559 if typed.contains(crate::label::SEPARATOR)
560 && let Ok(state) = state::load(&self.ctx)
561 && let Some(existing) = state.accounts.iter().find(|a| a.label == typed)
562 {
563 return Ok(existing.key());
564 }
565 crate::label::choose(typed)
566 .map(|chosen| Key::new(chosen.provider, chosen.label))
567 .map_err(Error::Usage)
568 }
569
570 pub fn account_to_enroll(&self, typed: &str) -> Option<Account> {
573 let key = self.enrolling(typed).ok()?;
574 state::load(&self.ctx).ok()?.get(&key).cloned()
575 }
576
577 pub fn name_to_type(&self, typed: &str) -> String {
580 let Ok(key) = self.enrolling(typed) else {
581 return typed.to_string();
582 };
583 state::load(&self.ctx).map_or_else(|_| key.typed(), |state| state.typed(&key))
584 }
585
586 pub fn sign_in(&self, typed: &str) -> std::result::Result<SignIn, Failed> {
590 let permit = self.permitted()?;
591 let key = self.signing_in(permit, typed)?;
592 switch::sign_in(&self.ctx, permit, key.provider)
593 .map_err(|error| self.not_started(permit, typed, error))
594 }
595
596 pub fn sign_in_watched(
599 &self,
600 typed: &str,
601 ) -> std::result::Result<switch::WatchedSignIn, Failed> {
602 let permit = self.permitted()?;
603 let key = self.signing_in(permit, typed)?;
604 switch::sign_in_watched_as(&self.ctx, permit, &key)
605 .map_err(|error| self.not_started(permit, typed, error))
606 }
607
608 fn signing_in(&self, permit: Permit, typed: &str) -> std::result::Result<Key, Failed> {
614 let key = self.chosen(permit, "enroll", typed)?;
615 self.ready_to_sign_in(key.provider)
616 .map_err(|error| self.not_started(permit, typed, error))?;
617 Ok(key)
618 }
619
620 fn not_started(&self, permit: Permit, typed: &str, error: Error) -> Failed {
622 audit::record(&self.ctx, permit, "enroll", typed, error.code());
623 Failed {
624 error,
625 warnings: Vec::new(),
626 }
627 }
628
629 fn ready_to_sign_in(&self, tool: crate::provider::ProviderId) -> Result<()> {
633 if tool == crate::provider::ProviderId::Claude && self.ctx.custom_oauth() {
634 return Err(Error::CustomOauthEndpoint);
635 }
636 state::load(&self.ctx)?;
637 let driver = crate::provider::of(tool);
638 switch::live_store(&self.ctx, tool)?;
641 if let crate::provider::Isolation::NotIsolated { reason } =
646 driver.private_signin_isolation(&self.ctx)
647 {
648 return Err(Error::SignInNotIsolated { reason });
649 }
650 if driver.program(&self.ctx).is_none() {
651 return Err(Error::ProgramMissing {
652 tool,
653 program: self.ctx.program_for(tool).display().to_string(),
654 });
655 }
656 Ok(())
657 }
658
659 pub fn enroll_signed_in(&self, typed: &str, login: SignIn) -> Changing<Enrolled> {
660 let permit = self.permitted()?;
661 let key = self.chosen(permit, "enroll", typed)?;
662 self.changing(
663 permit,
664 "enroll",
665 &key.typed(),
666 Some(key.provider),
667 |settled| switch::enroll(settled, &key, Some(login)),
668 )
669 }
670
671 pub fn forget(&self, typed: &str) -> Changing<String> {
673 let permit = self.permitted()?;
674 let key = self.named(permit, "forget", typed)?;
675 self.changing(
676 permit,
677 "forget",
678 &key.typed(),
679 Some(key.provider),
680 |settled| switch::forget(settled, &key),
681 )
682 }
683
684 pub fn abandon_recovery(&self) -> Result<Option<switch::Abandoned>> {
687 let permit = self.permit()?;
688 let outcome = switch::abandon(&self.ctx, permit);
689 audit::record(
690 &self.ctx,
691 permit,
692 "abandon",
693 "",
694 match &outcome {
695 Ok(_) => "ok",
696 Err(e) => e.code(),
697 },
698 );
699 outcome
700 }
701
702 pub fn renew(&self) -> Result<Vec<(Key, Renewal)>> {
712 let ctx = schedule::for_its_run(&self.ctx);
713 let permit = gate(&ctx)?;
714 let outcomes = switch::renew_due(&ctx, permit, switch::Due::ToStayAlive);
715 for (key, outcome) in &outcomes {
716 audit::record(&ctx, permit, "renew", &key.typed(), outcome.code());
717 }
718 Ok(outcomes)
719 }
720
721 pub fn schedule(&self) -> schedule::Installed {
724 schedule::status(&self.ctx)
725 }
726
727 pub fn schedule_renews_this_home(&self) -> bool {
731 schedule::serves(&self.ctx)
732 }
733
734 pub fn schedule_install(&self) -> Result<std::path::PathBuf> {
738 schedule::install(&self.ctx, self.permit()?)
739 }
740
741 pub fn schedule_uninstall(&self) -> Result<bool> {
743 schedule::uninstall(&self.ctx, self.permit()?)
744 }
745
746 pub fn schedule_repair(&self) -> Result<bool> {
749 schedule::repair(&self.ctx, self.permit()?)
750 }
751
752 pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
758 switch::adopt(&self.ctx, self.permit()?)
759 }
760
761 pub fn repair(&self) -> Changing<switch::Reclaimed> {
766 let permit = self.permitted()?;
767 self.changing(permit, "repair", "", None, |settled| {
768 switch::repair(settled).map(|r| (r, Vec::new()))
769 })
770 }
771
772 pub fn holding(&self, which: ProviderId) -> Vec<holder::Holding> {
777 match switch::still_holding(&self.ctx, which) {
778 switch::StillHolding::These(holding) => holding,
779 switch::StillHolding::Nothing | switch::StillHolding::Unknown => Vec::new(),
780 }
781 }
782
783 pub fn changed_at(&self) -> i64 {
786 state::changed_at(&self.ctx)
787 }
788
789 pub fn readings_changed_at(&self) -> i64 {
792 readings::changed_at(&self.ctx)
793 }
794
795 pub fn app_file(&self, file: crate::app::AppFile) -> std::io::Result<Option<String>> {
798 crate::app::read_app_file(&self.ctx, file)
799 }
800
801 pub fn keep_app_file(&self, file: crate::app::AppFile, body: &str) -> Result<()> {
804 crate::app::write_app_file(&self.ctx, self.permit()?, file, body)
805 }
806
807 pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
809 audit::read(&self.ctx, limit)
810 }
811
812 pub fn uninstall(&self) -> Changing<switch::Removed> {
816 let permit = self.permitted()?;
817 self.changing(permit, "uninstall", "", None, |settled| {
818 switch::uninstall(settled).map(|r| (r, Vec::new()))
819 })
820 }
821
822 pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
826 let permit = self.permitted()?;
827 let from = self.named(permit, "rename", from)?;
828 let chosen = self.chosen(permit, "rename", to)?;
829 if to.contains(crate::label::SEPARATOR) && chosen.provider != from.provider {
832 let error = Error::Usage(format!(
833 "`{from}` is a {} account, and a rename cannot move it to {}. Sign in to that \
834 tool and enrol the account there instead.",
835 from.provider, chosen.provider
836 ));
837 return Err(self.refused(permit, "rename", &from.typed(), error.code(), error));
838 }
839 let to = chosen.label;
840 self.changing(
841 permit,
842 "rename",
843 &format!("{from} -> {to}"),
844 Some(from.provider),
845 |settled| switch::rename(settled, &from, &to).map(|email| (email, Vec::new())),
846 )
847 }
848
849 fn changing<T: Audited>(
855 &self,
856 permit: Permit,
857 verb: &str,
858 subject: &str,
859 tool: Option<ProviderId>,
860 run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
861 ) -> Changing<T> {
862 let (settled, recovered) = switch::settle(&self.ctx, permit, tool).map_err(|error| {
863 audit::record(&self.ctx, permit, verb, subject, error.code());
864 Failed {
865 error,
866 warnings: Vec::new(),
867 }
868 })?;
869 let mut warnings = Vec::new();
870 if let Some(tool) = tool {
873 let names = crate::provider::of(tool).overridden_by(&self.ctx);
874 if !names.is_empty() {
875 warnings.push(Warning::AuthOverridden { tool, names });
876 }
877 }
878 if let Some(r) = recovered {
879 audit::record(&self.ctx, permit, "recover", &r.to, r.code());
880 warnings.push(Warning::Recovered(r));
881 }
882 match run(settled) {
883 Ok((value, more)) => {
884 if value.recorded() {
885 audit::record(&self.ctx, permit, verb, subject, value.audit_code());
886 }
887 warnings.extend(more);
888 Ok(Done { value, warnings })
889 }
890 Err(mut error) => {
891 audit::record(&self.ctx, permit, verb, subject, error.code());
892 warnings.extend(error.take_warnings());
893 Err(Failed { error, warnings })
894 }
895 }
896 }
897}
898
899trait Audited {
901 fn audit_code(&self) -> &'static str {
902 "ok"
903 }
904
905 fn recorded(&self) -> bool {
908 true
909 }
910}
911
912impl Audited for crate::autoswitch::Auto {
913 fn recorded(&self) -> bool {
914 matches!(self, crate::autoswitch::Auto::Switched { .. })
915 }
916}
917
918impl Audited for Outcome {
919 fn audit_code(&self) -> &'static str {
920 match self {
921 Outcome::Switched { .. } => "ok",
922 Outcome::AlreadyActive { .. } => "already_active",
923 }
924 }
925}
926
927impl Audited for switch::Reclaimed {}
928impl Audited for Enrolled {}
929impl Audited for String {}
930
931impl Audited for switch::Removed {
932 fn audit_code(&self) -> &'static str {
933 if self.pending > 0 {
934 "parks_pending_removal"
935 } else {
936 "ok"
937 }
938 }
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944 use crate::switch::harness::{Machine, codex_machine, hold, machine};
945 use std::collections::BTreeMap;
946 use std::sync::Arc;
947
948 type Make = fn(&str) -> Machine;
949
950 const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
952
953 type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
954
955 const REFUSALS: [(&str, &str, &str, &str, Refuse); 4] = [
961 (
962 "use",
963 "use",
964 "account_unknown",
965 "account_unknown",
966 |p, _| p.switch_to("nobody").err(),
967 ),
968 ("enroll", "enroll", "usage", "label_unusable", |p, _| {
969 p.enroll_current("codx/work").err()
970 }),
971 ("sign-in", "enroll", "usage", "label_unusable", |p, _| {
972 p.sign_in("codx/work").err()
973 }),
974 ("rename", "rename", "usage", "usage", |p, tool| {
975 let other = if tool == ProviderId::Claude {
976 ProviderId::Codex
977 } else {
978 ProviderId::Claude
979 };
980 p.rename(
981 &Key::new(tool, "here").qualified(),
982 &Key::new(other, "moved").qualified(),
983 )
984 .err()
985 }),
986 ];
987
988 fn interrupted(make: Make, name: &str) -> Machine {
991 let m = make(name);
992 let settled = switch::settle(&m.ctx, Permit::for_a_test(), None)
993 .expect("nothing to recover yet")
994 .0;
995 let died = crate::fault::killing("switch.park_recorded", || {
996 switch::switch(settled, &m.key("there"))
997 });
998 assert_eq!(died.unwrap_err(), "switch.park_recorded");
999 assert!(switch::interrupted(&m.ctx));
1000 m
1001 }
1002
1003 fn stuck(make: Make, name: &str) -> Machine {
1007 let m = interrupted(make, name);
1008 m.fault_live(crate::store::memory::Fault::Vanish);
1009 m
1010 }
1011
1012 fn stuck_unless_asked(name: &str) -> Machine {
1018 let m = interrupted(machine, name);
1019 m.sign_in(&crate::switch::harness::document("renewed-since"));
1020 m
1021 }
1022
1023 fn said(read: &Done<status::Report>) -> Vec<(&'static str, String)> {
1025 read.warnings
1026 .iter()
1027 .map(|w| (w.code(), w.to_string()))
1028 .collect()
1029 }
1030
1031 type Untouched = (
1035 BTreeMap<String, Vec<u8>>,
1036 Vec<(String, Option<String>)>,
1037 Option<serde_json::Value>,
1038 );
1039
1040 fn untouched(m: &Machine) -> Untouched {
1041 let kept_by_a_read = ["usage.json", "usage.lock", "readings", "asking.json"];
1042 let mut files = files(m);
1043 files.retain(|path, _| !kept_by_a_read.iter().any(|kept| path.ends_with(kept)));
1044 let vault = m.mem.vault();
1045 let parked = vault
1046 .services()
1047 .into_iter()
1048 .map(|service| {
1049 let held = vault.peek(&service);
1050 (service, held)
1051 })
1052 .collect();
1053 (files, parked, m.live())
1054 }
1055
1056 #[test]
1061 fn a_read_says_an_interrupted_switch_it_cannot_finish_is_waiting() {
1062 for (tool, make) in MACHINES {
1063 let m = stuck(make, &format!("read-stuck-{tool}"));
1064 let pitboard = Pitboard::new(m.ctx.clone());
1065 let before = untouched(&m);
1066
1067 let read = pitboard
1068 .status(false)
1069 .expect("a read is not refused over it");
1070
1071 assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1072 assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1073 let refused = pitboard
1074 .switch_to(&m.key("there").typed())
1075 .expect_err("every change stops at it");
1076 assert_eq!(refused.error.code(), "recovery_undetermined", "{tool}");
1077 assert_eq!(
1078 said(&read),
1079 [("recovery_undetermined", refused.error.to_string())],
1080 "{tool}: said in the refusal's own words"
1081 );
1082 }
1083 }
1084
1085 #[test]
1088 fn a_read_asks_whose_the_login_is_where_only_the_service_can_say() {
1089 let m = stuck_unless_asked("read-stuck-asked");
1090 let pitboard = Pitboard::new(m.ctx.clone());
1091 let before = untouched(&m);
1092
1093 let read = pitboard.status(false).expect("a read");
1094
1095 assert_eq!(untouched(&m), before, "a read changes nothing");
1096 let refused = pitboard.switch_to("there").expect_err("it stops at it");
1097 assert_eq!(
1098 said(&read),
1099 [("recovery_undetermined", refused.error.to_string())]
1100 );
1101 assert!(
1102 refused.error.to_string().contains("has expired"),
1103 "{}",
1104 refused.error
1105 );
1106 }
1107
1108 #[test]
1112 fn a_read_says_nothing_of_a_switch_the_next_change_finishes() {
1113 for (tool, make) in MACHINES {
1114 let m = interrupted(make, &format!("read-settles-{tool}"));
1115 let before = untouched(&m);
1116
1117 let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
1118
1119 assert_eq!(said(&read), [], "{tool}");
1120 assert_eq!(untouched(&m), before, "{tool}: a read finishes nothing");
1121 assert!(switch::interrupted(&m.ctx), "{tool}");
1122 }
1123 }
1124
1125 #[test]
1132 fn a_read_says_a_switch_is_waiting_only_where_a_change_would_stop_at_it() {
1133 for (tool, make) in MACHINES {
1134 let m = stuck(make, &format!("read-stuck-custom-{tool}"));
1135 let mut ctx = m.ctx.clone();
1136 ctx.custom_oauth = true;
1137 let pitboard = Pitboard::new(ctx);
1138
1139 let read = pitboard.status(false).expect("a read");
1140 let known = pitboard.status_offline().expect("a read");
1141 let refused = pitboard
1142 .switch_to(&m.key("there").typed())
1143 .expect_err("refused");
1144
1145 let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
1146 let expected: &[&str] = match m.which {
1147 ProviderId::Claude => &[],
1148 ProviderId::Codex => &["recovery_undetermined"],
1149 };
1150 assert_eq!(codes, expected, "{tool}: {}", refused.error);
1151 let codes: Vec<&str> = known.warnings.iter().map(Warning::code).collect();
1152 assert_eq!(codes, expected, "{tool}: the read that asks nobody too");
1153 assert_eq!(
1154 refused.error.code() == "recovery_undetermined",
1155 !expected.is_empty(),
1156 "{tool}"
1157 );
1158
1159 let gave_up = pitboard.abandon_recovery();
1160 match m.which {
1161 ProviderId::Claude => assert_eq!(
1162 gave_up.expect_err("refused").code(),
1163 "custom_oauth_endpoint",
1164 "{tool}"
1165 ),
1166 ProviderId::Codex => {
1167 assert!(gave_up.expect("given up").is_some(), "{tool}");
1168 assert!(!switch::interrupted(&m.ctx), "{tool}: nothing waits");
1169 }
1170 }
1171 }
1172 }
1173
1174 #[test]
1180 fn a_read_that_asks_nobody_says_a_switch_is_waiting_wherever_it_can_tell() {
1181 for (tool, make) in MACHINES {
1182 let m = stuck(make, &format!("known-stuck-{tool}"));
1183 let pitboard = Pitboard::new(m.ctx.clone());
1184 let (before, asked) = (untouched(&m), m.api.calls());
1185
1186 let read = pitboard.status_offline().expect("a read");
1187
1188 assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1189 assert_eq!(m.api.calls(), asked, "{tool}: and asks nobody");
1190 assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1191 let refused = pitboard
1192 .switch_to(&m.key("there").typed())
1193 .expect_err("every change stops at it");
1194 assert_eq!(
1195 said(&read),
1196 [("recovery_undetermined", refused.error.to_string())],
1197 "{tool}"
1198 );
1199 }
1200
1201 let m = stuck_unless_asked("known-stuck-unasked");
1202 let asked = m.api.calls();
1203 let read = Pitboard::new(m.ctx.clone())
1204 .status_offline()
1205 .expect("a read");
1206 assert_eq!(m.api.calls(), asked, "asks nobody");
1207 assert_eq!(said(&read), [], "only Anthropic could tell");
1208 }
1209
1210 #[test]
1217 fn whose_a_codex_login_is_is_told_without_asking_anyone() {
1218 let m = interrupted(codex_machine, "codex-renewed-since");
1219 m.sign_in(&crate::switch::harness::codex_login(
1220 "here",
1221 "renewed-since",
1222 ));
1223 m.mem.vault().fault(
1224 &reserved(&m),
1225 crate::store::memory::Fault::Unreadable("locked".into()),
1226 );
1227 let pitboard = Pitboard::new(m.ctx.clone());
1228 let asked = m.api.calls();
1229
1230 let read = pitboard.status_offline().expect("a read");
1231 let checks = pitboard.doctor().checks;
1232
1233 assert_eq!(m.api.calls(), asked, "asks nobody");
1234 let refused = pitboard
1235 .switch_to("codex/there")
1236 .expect_err("it stops at it");
1237 assert_eq!(refused.error.code(), "recovery_undetermined");
1238 assert_eq!(
1239 said(&read),
1240 [("recovery_undetermined", refused.error.to_string())]
1241 );
1242 let check = checks
1243 .iter()
1244 .find(|c| c.code == "interrupted_switch")
1245 .expect("said");
1246 assert_eq!(check.detail, refused.error.to_string());
1247 }
1248
1249 #[test]
1255 fn a_codex_store_a_requirement_pins_is_refused_before_a_sign_in() {
1256 let m = codex_machine("codex-pinned");
1257 let ctx = m.ctx.clone().with_codex_program("/nowhere/codex".into());
1260 m.mem.administers(
1261 "/etc/codex/requirements.toml",
1262 "cli_auth_credentials_store = \"keyring\"\n",
1263 );
1264 let pitboard = Pitboard::new(ctx);
1265 for refused in [
1266 pitboard.sign_in("codex/new").err().expect("refused"),
1267 pitboard
1268 .sign_in_watched("codex/new")
1269 .err()
1270 .expect("refused"),
1271 ] {
1272 assert_eq!(refused.error.code(), "live_store_unsupported");
1273 let said = refused.error.to_string();
1274 assert!(
1275 said.contains("pinned to `keyring` by /etc/codex/requirements.toml"),
1276 "{said}"
1277 );
1278 assert!(
1279 said.contains("No line in your own config.toml can change it"),
1280 "{said}"
1281 );
1282 }
1283 let switched = pitboard.switch_to("codex/there").expect_err("refused too");
1284 assert_eq!(switched.error.code(), "live_store_unsupported");
1285 let file = m
1286 .mem
1287 .file_at(crate::provider::codex::paths::auth_file(&m.ctx));
1288 let kept = crate::store::RawStore::read(&file, "auth.json")
1289 .expect("readable")
1290 .expect("there");
1291 let kept: serde_json::Value = serde_json::from_str(&kept).expect("JSON");
1292 assert_eq!(
1293 kept["tokens"]["refresh_token"], "here-refresh",
1294 "the auth.json Codex does not use is left as it was"
1295 );
1296 }
1297
1298 fn reserved(m: &Machine) -> String {
1300 let raw = std::fs::read_to_string(crate::home::dir(&m.ctx).join("journal.json"))
1301 .expect("a record");
1302 serde_json::from_str::<serde_json::Value>(&raw).expect("a record is JSON")["park_service"]
1303 .as_str()
1304 .expect("a park")
1305 .to_owned()
1306 }
1307
1308 #[test]
1312 fn doctor_says_a_switch_is_stuck_where_it_can_tell_without_asking() {
1313 for (tool, make) in MACHINES {
1314 let m = stuck(make, &format!("doctor-stuck-{tool}"));
1315 let pitboard = Pitboard::new(m.ctx.clone());
1316 let before = untouched(&m);
1317
1318 let checks = pitboard.doctor().checks;
1319
1320 assert_eq!(untouched(&m), before, "{tool}: doctor changes nothing");
1321 let refused = pitboard
1322 .switch_to(&m.key("there").typed())
1323 .expect_err("refused");
1324 let check = checks
1325 .iter()
1326 .find(|c| c.code == "interrupted_switch")
1327 .expect("said");
1328 assert_eq!(check.level, doctor::Level::Warn, "{tool}");
1329 assert_eq!(check.detail, refused.error.to_string(), "{tool}");
1330 assert!(check.advice.contains("pitboard abandon"), "{tool}");
1331 }
1332
1333 let m = stuck_unless_asked("doctor-stuck-unasked");
1334 let asked = m.api.calls();
1335 let checks = Pitboard::new(m.ctx.clone()).doctor().checks;
1336 assert_eq!(m.api.calls(), asked, "doctor asks nobody");
1337 let check = checks
1338 .iter()
1339 .find(|c| c.code == "interrupted_switch")
1340 .expect("said");
1341 assert_eq!(check.detail, "a switch did not finish");
1342 }
1343
1344 fn files(m: &Machine) -> BTreeMap<String, Vec<u8>> {
1346 std::fs::read_dir(crate::home::dir(&m.ctx))
1347 .expect("a Pitboard home")
1348 .map(|entry| entry.expect("an entry").path())
1349 .filter(|path| path.file_name() != Some("audit.log".as_ref()))
1350 .map(|path| {
1351 let body = std::fs::read(&path).unwrap_or_default();
1352 (path.display().to_string(), body)
1353 })
1354 .collect()
1355 }
1356
1357 fn last_audited(m: &Machine) -> Vec<(String, String)> {
1359 audit::read(&m.ctx, 2)
1360 .into_iter()
1361 .map(|entry| (entry.verb, entry.outcome))
1362 .collect()
1363 }
1364
1365 #[test]
1370 fn a_change_refused_over_its_name_still_recovers_an_interrupted_switch() {
1371 for (tool, make) in MACHINES {
1372 for (change, verb, code, audited, refuse) in REFUSALS {
1373 let at = format!("{tool}, {change}");
1374 let m = interrupted(make, &format!("refused-{tool}-{change}"));
1375
1376 let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
1377 .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
1378
1379 assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
1380 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1381 assert_eq!(said, ["interrupted_switch_undone"], "{at}");
1382 assert!(!switch::interrupted(&m.ctx), "{at}: the record is resolved");
1383 hold(&m, &at);
1384 assert_eq!(
1385 last_audited(&m),
1386 [
1387 (
1388 "recover".to_string(),
1389 "interrupted_switch_undone".to_string()
1390 ),
1391 (verb.to_string(), audited.to_string()),
1392 ],
1393 "{at}"
1394 );
1395 }
1396 }
1397 }
1398
1399 #[test]
1402 fn a_change_refused_over_its_name_with_nothing_interrupted_changes_nothing() {
1403 for (tool, make) in MACHINES {
1404 for (change, verb, code, audited, refuse) in REFUSALS {
1405 let at = format!("{tool}, {change}");
1406 let m = make(&format!("refused-quietly-{tool}-{change}"));
1407 let (before, parked, live) = (files(&m), m.mem.vault().services(), m.live());
1408
1409 let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
1410 .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
1411
1412 assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
1413 assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
1414 assert_eq!(files(&m), before, "{at}: not even the lock file is made");
1415 assert_eq!(m.mem.vault().services(), parked, "{at}");
1416 assert_eq!(m.live(), live, "{at}");
1417 assert_eq!(
1418 last_audited(&m).last(),
1419 Some(&(verb.to_string(), audited.to_string())),
1420 "{at}"
1421 );
1422 }
1423 }
1424 }
1425
1426 #[test]
1430 fn a_new_login_parked_after_it_did_not_hold_says_how_it_was_parked() {
1431 for (tool, make) in MACHINES {
1432 let m = make(&format!("not-installed-said-{tool}"));
1433 m.mem.vault().takes_on_stdin(64);
1434 let login = crate::switch::harness::signed_in(&m, "here", "here-refresh-2");
1435 m.fault_live(crate::store::memory::Fault::DeletedAfterWrite);
1436
1437 let failed = Pitboard::new(m.ctx.clone())
1438 .enroll_signed_in(&m.key("here").typed(), login)
1439 .expect_err("it did not hold");
1440
1441 assert_eq!(failed.error.code(), "sign_in_not_installed", "{tool}");
1442 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1443 assert_eq!(said, ["written_on_the_command_line"], "{tool}");
1444 }
1445 }
1446
1447 #[test]
1452 fn a_custom_claude_endpoint_stops_only_the_recovery_of_a_claude_code_switch() {
1453 let codex = interrupted(codex_machine, "refused-custom-codex");
1454 let mut ctx = codex.ctx.clone();
1455 ctx.custom_oauth = true;
1456 let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
1457 assert_eq!(failed.error.code(), "account_unknown");
1458 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
1459 assert_eq!(said, ["interrupted_switch_undone"]);
1460 assert!(!switch::interrupted(&codex.ctx));
1461
1462 let claude = interrupted(machine, "refused-custom-claude");
1463 let mut ctx = claude.ctx.clone();
1464 ctx.custom_oauth = true;
1465 let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
1466 assert_eq!(
1467 failed.error.code(),
1468 "account_unknown",
1469 "the refusal, not the recovery that could not run"
1470 );
1471 assert!(failed.warnings.is_empty(), "{:?}", failed.warnings);
1472 assert!(
1473 switch::interrupted(&claude.ctx),
1474 "the record is kept for a run that can finish it"
1475 );
1476 }
1477
1478 const REFUSED_AS: [Elevation; 3] = [
1481 Elevation::Elevated { why: "as root" },
1482 Elevation::Elevated { why: "with sudo" },
1483 Elevation::Unknown,
1484 ];
1485
1486 fn failed<T>(outcome: Result<T>) -> Option<Failed> {
1489 outcome.err().map(|error| Failed {
1490 error,
1491 warnings: Vec::new(),
1492 })
1493 }
1494
1495 type Change = fn(&Pitboard, &Machine) -> Option<Failed>;
1496
1497 const CHANGES: [(&str, Change); 15] = [
1500 ("switch_to", |p, m| {
1501 p.switch_to(&m.key("there").typed()).err()
1502 }),
1503 ("enroll_current", |p, m| {
1504 p.enroll_current(&m.key("here").typed()).err()
1505 }),
1506 ("sign_in", |p, m| p.sign_in(&m.key("new").typed()).err()),
1507 ("sign_in_watched", |p, m| {
1508 p.sign_in_watched(&m.key("new").typed()).err()
1509 }),
1510 ("forget", |p, m| p.forget(&m.key("there").typed()).err()),
1511 ("rename", |p, m| {
1512 p.rename(&m.key("there").typed(), "elsewhere").err()
1513 }),
1514 ("abandon_recovery", |p, _| failed(p.abandon_recovery())),
1515 ("renew", |p, _| failed(p.renew())),
1516 ("schedule_install", |p, _| failed(p.schedule_install())),
1517 ("schedule_uninstall", |p, _| failed(p.schedule_uninstall())),
1518 ("schedule_repair", |p, _| failed(p.schedule_repair())),
1519 ("adopt", |p, _| failed(p.adopt())),
1520 ("repair", |p, _| p.repair().err()),
1521 ("keep_app_file", |p, _| {
1522 failed(p.keep_app_file(crate::app::AppFile::Preferences, "{}"))
1523 }),
1524 ("uninstall", |p, _| p.uninstall().err()),
1525 ];
1526
1527 type Everything = (
1532 BTreeMap<String, Option<Vec<u8>>>,
1533 Vec<(String, Option<String>)>,
1534 Vec<(String, Option<String>)>,
1535 Option<serde_json::Value>,
1536 );
1537
1538 fn everything(m: &Machine) -> Everything {
1539 fn walk(dir: &std::path::Path, into: &mut BTreeMap<String, Option<Vec<u8>>>) {
1540 for entry in std::fs::read_dir(dir).expect("a directory to read") {
1541 let path = entry.expect("an entry").path();
1542 if path.ends_with(".pitboard/signin") {
1543 continue;
1544 }
1545 if path.is_dir() {
1546 into.insert(path.display().to_string(), None);
1547 walk(&path, into);
1548 } else {
1549 let body = std::fs::read(&path).expect("a file to read");
1550 into.insert(path.display().to_string(), Some(body));
1551 }
1552 }
1553 }
1554 let mut files = BTreeMap::new();
1555 walk(&m.ctx_home(), &mut files);
1556 let items = |store: &crate::store::memory::MemoryStore| {
1557 store
1558 .services()
1559 .into_iter()
1560 .map(|service| {
1561 let held = store.peek(&service);
1562 (service, held)
1563 })
1564 .collect()
1565 };
1566 (files, items(m.mem.live()), items(m.mem.vault()), m.live())
1567 }
1568
1569 #[test]
1576 fn every_change_is_refused_where_pitboard_runs_as_root_or_with_sudo_and_changes_nothing() {
1577 for (tool, make) in MACHINES {
1578 for elevation in REFUSED_AS {
1579 for (change, run) in CHANGES {
1580 let at = format!("{tool}, {change}, {elevation:?}");
1581 let m = interrupted(make, &format!("gate-{tool}-{change}"));
1582 m.mem.runs_with(elevation);
1583 let (before, asked) = (everything(&m), m.api.calls());
1584
1585 let failed = run(&Pitboard::new(m.ctx.clone()), &m)
1586 .unwrap_or_else(|| panic!("{at}: refused"));
1587
1588 assert_eq!(failed.error.code(), "elevated", "{at}: {}", failed.error);
1589 assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
1590 assert!(everything(&m) == before, "{at}: nothing changes");
1591 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1592 }
1593
1594 let at = format!("{tool}, enroll_signed_in, {elevation:?}");
1595 let m = interrupted(make, &format!("gate-{tool}-enroll-signed-in"));
1596 let login = crate::switch::harness::signed_in(&m, "new", "new-refresh");
1597 m.mem.runs_with(elevation);
1598 let before = everything(&m);
1599 let failed = Pitboard::new(m.ctx.clone())
1600 .enroll_signed_in(&m.key("new").typed(), login)
1601 .expect_err("refused");
1602 assert_eq!(failed.error.code(), "elevated", "{at}");
1603 assert!(everything(&m) == before, "{at}: nothing changes");
1604 }
1605 }
1606 }
1607
1608 #[test]
1611 fn the_gate_says_why_it_refuses_and_lets_a_person_through() {
1612 let m = machine("gate-words");
1613 let pitboard = Pitboard::new(m.ctx.clone());
1614 assert!(pitboard.permit().is_ok(), "as the person");
1615 for elevation in REFUSED_AS {
1616 m.mem.runs_with(elevation);
1617 let refused = pitboard.permit().expect_err("refused");
1618 assert_eq!(refused.code(), "elevated");
1619 assert_eq!(refused.exit_code(), 1);
1620 assert_eq!(
1621 refused.to_string(),
1622 match elevation {
1623 Elevation::Unknown => {
1624 "Pitboard changes nothing when it cannot tell whether it runs as root \
1625 or with sudo. Run it as yourself."
1626 }
1627 _ => {
1628 "Pitboard changes nothing when it runs as root or with sudo. Run it \
1629 as yourself."
1630 }
1631 },
1632 );
1633 }
1634 }
1635
1636 #[test]
1642 fn every_change_and_read_is_refused_where_the_home_is_not_a_full_path() {
1643 for home in ["", "relative"] {
1644 for (tool, make) in MACHINES {
1645 let at = format!("{tool}, HOME={home:?}");
1646 let m = make(&format!("unplaced-{tool}-{}", home.len()));
1647 let mut ctx = m.ctx.clone();
1648 ctx.home = std::path::PathBuf::from(home);
1649 let pitboard = Pitboard::new(ctx);
1650 let refused = |error: Error| {
1651 assert_eq!(error.code(), "home_not_absolute", "{at}: {error}");
1652 assert!(error.to_string().starts_with("HOME is "), "{at}: {error}");
1653 };
1654
1655 refused(pitboard.permit().expect_err("the gate refuses"));
1656 refused(pitboard.status_offline().err().expect("refused"));
1657 refused(pitboard.status(false).err().expect("refused"));
1658 let before = (everything(&m), m.api.calls());
1659 for (change, run) in CHANGES {
1660 let failed = run(&pitboard, &m).unwrap_or_else(|| panic!("{at}: {change}"));
1661 refused(failed.error);
1662 assert!(failed.warnings.is_empty(), "{at}, {change}");
1663 }
1664 assert!(
1665 (everything(&m), m.api.calls()) == before,
1666 "{at}: nothing changes and nobody is asked"
1667 );
1668 }
1669 }
1670 }
1671
1672 type Read = Vec<(
1675 ProviderId,
1676 Option<String>,
1677 bool,
1678 Option<crate::usage::Snapshot>,
1679 Option<status::Stale>,
1680 )>;
1681
1682 fn rows(report: &status::Report) -> Read {
1683 report
1684 .rows
1685 .iter()
1686 .map(|row| {
1687 (
1688 row.provider,
1689 row.label.clone(),
1690 row.signed_in,
1691 row.usage.clone(),
1692 row.stale,
1693 )
1694 })
1695 .collect()
1696 }
1697
1698 #[test]
1704 fn a_read_where_pitboard_may_change_nothing_asks_nobody_and_says_why() {
1705 for (tool, make) in MACHINES {
1706 for elevation in REFUSED_AS {
1707 let at = format!("{tool}, {elevation:?}");
1708 let m = make(&format!("gate-read-{tool}"));
1709 let later = m
1710 .ctx
1711 .clone()
1712 .with_clock(Arc::new(crate::time::FixedClock::at(
1713 crate::switch::harness::NOW + 11 * 86_400,
1714 )));
1715 let pitboard = Pitboard::new(later);
1716 m.mem.runs_with(elevation);
1717 let (before, asked) = (everything(&m), m.api.calls());
1718
1719 let read = pitboard.status(false).expect("a read");
1720 let known = pitboard.status_offline().expect("a read");
1721
1722 assert!(everything(&m) == before, "{at}: nothing is written");
1723 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1724 assert_eq!(rows(&read.value), rows(&known.value), "{at}");
1725 let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
1726 assert_eq!(codes.last(), Some(&"read_only"), "{at}");
1727 assert_eq!(read.warnings.len(), known.warnings.len() + 1, "{at}");
1728 let said = read.warnings.last().expect("said").to_string();
1729 match elevation {
1730 Elevation::Elevated { why } => assert!(
1731 said.starts_with(&format!("Pitboard runs {why}, so it changes nothing")),
1732 "{at}: {said}"
1733 ),
1734 _ => assert!(said.starts_with("Pitboard cannot tell"), "{at}: {said}"),
1735 }
1736
1737 m.mem.runs_with(Elevation::Normal);
1738 let live = pitboard.status(false).expect("a read");
1739 assert!(m.api.calls() > asked, "{at}: as the person, it asks");
1740 assert!(
1741 !live.warnings.iter().any(|w| w.code() == "read_only"),
1742 "{at}"
1743 );
1744 }
1745 }
1746 }
1747
1748 #[test]
1752 fn the_status_line_where_pitboard_may_change_nothing_draws_the_same_line_and_writes_nothing() {
1753 let session = serde_json::json!({
1754 "session_id": "a-session",
1755 "rate_limits": {
1756 "five_hour": {"used_percentage": 40.0, "resets_at": crate::switch::harness::NOW + 3_600},
1757 "seven_day": {"used_percentage": 20.0, "resets_at": crate::switch::harness::NOW + 86_400},
1758 },
1759 })
1760 .to_string();
1761 for elevation in REFUSED_AS {
1762 let m = machine("gate-statusline");
1763 let pitboard = Pitboard::new(m.ctx.clone());
1764 m.mem.runs_with(elevation);
1765 let before = everything(&m);
1766
1767 let refused = pitboard.statusline(&session);
1768
1769 assert!(
1770 everything(&m) == before,
1771 "{elevation:?}: nothing is written"
1772 );
1773 m.mem.runs_with(Elevation::Normal);
1774 assert_eq!(pitboard.statusline(&session), refused, "{elevation:?}");
1775 assert!(
1776 crate::home::dir(&m.ctx).join("sessions.json").exists(),
1777 "{elevation:?}: as the person, the session is kept"
1778 );
1779 }
1780 }
1781
1782 #[test]
1786 fn a_renewal_where_pitboard_may_change_nothing_is_refused_and_asks_nobody() {
1787 for (tool, make) in MACHINES {
1788 for elevation in REFUSED_AS {
1789 let at = format!("{tool}, {elevation:?}");
1790 let m = make(&format!("gate-renew-{tool}"));
1791 crate::switch::harness::renews(&m, "there-refresh", "there-refresh-2");
1792 let later = m
1793 .ctx
1794 .clone()
1795 .with_clock(Arc::new(crate::time::FixedClock::at(
1796 crate::switch::harness::NOW + 11 * 86_400,
1797 )));
1798 let pitboard = Pitboard::new(later);
1799 m.mem.runs_with(elevation);
1800 let (before, asked) = (everything(&m), m.api.calls());
1801
1802 let refused = pitboard.renew().expect_err("refused");
1803
1804 assert_eq!(refused.code(), "elevated", "{at}");
1805 assert!(everything(&m) == before, "{at}: nothing changes");
1806 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
1807
1808 m.mem.runs_with(Elevation::Normal);
1809 let renewed = pitboard.renew().expect("as the person, it renews");
1810 let codes: Vec<&str> = renewed.iter().map(|(_, r)| r.code()).collect();
1811 assert_eq!(codes, ["renewed"], "{at}");
1812 }
1813 }
1814 }
1815
1816 #[test]
1819 fn doctor_says_why_pitboard_changes_nothing() {
1820 let m = machine("gate-doctor");
1821 let pitboard = Pitboard::new(m.ctx.clone());
1822 assert!(
1823 !pitboard
1824 .doctor()
1825 .checks
1826 .iter()
1827 .any(|c| c.code == "elevated"),
1828 "as the person"
1829 );
1830 for elevation in REFUSED_AS {
1831 m.mem.runs_with(elevation);
1832 let checks = pitboard.doctor().checks;
1833 let check = checks.first().expect("checks");
1834 assert_eq!(check.code, "elevated", "{elevation:?}");
1835 assert_eq!(check.level, doctor::Level::Fail, "{elevation:?}");
1836 assert_eq!(
1837 check.detail,
1838 match elevation {
1839 Elevation::Elevated { why } => format!("Pitboard runs {why}"),
1840 _ => "Pitboard cannot tell whether it runs as root or with sudo".into(),
1841 }
1842 );
1843 assert!(
1844 check.advice.ends_with("Run it as yourself."),
1845 "{}",
1846 check.advice
1847 );
1848 }
1849 }
1850}