1use crate::autoswitch::{Auto, Look, Threshold};
6use crate::context::Context;
7use crate::doctor::{self, Diagnosis};
8use crate::error::{Error, Result};
9use crate::holder::{self, capitalised};
10use crate::host::{Elevation, Floor};
11use crate::in_use::{self, Doubt, InUse, Known};
12use crate::provider::{Held, ProviderId};
13use crate::state::{self, Account, Key, new_id};
14use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
15use crate::{audit, readings, schedule, status, statusline};
16use std::fmt;
17
18#[derive(Debug, Clone, Copy)]
28pub struct Permit {
29 _only_the_gate_makes_one: (),
30}
31
32#[cfg(test)]
33impl Permit {
34 pub(crate) fn for_a_test() -> Permit {
37 Permit {
38 _only_the_gate_makes_one: (),
39 }
40 }
41}
42
43pub(crate) fn gate(ctx: &Context) -> Result<Permit> {
58 crate::release::check()?;
59 match ctx.host().floor() {
60 Floor::Met => {}
61 Floor::Below { build } => return Err(Error::SystemTooOld { build: Some(build) }),
62 Floor::Unknown => return Err(Error::SystemTooOld { build: None }),
63 }
64 match ctx.host().elevation(ctx) {
65 Elevation::Normal => {}
66 Elevation::Elevated { why } => return Err(Error::Elevated { why: Some(why) }),
67 Elevation::Unknown => return Err(Error::Elevated { why: None }),
68 }
69 crate::home::check_absolute(ctx)?;
70 Ok(Permit {
71 _only_the_gate_makes_one: (),
72 })
73}
74
75#[derive(Debug)]
77#[non_exhaustive]
78pub enum Warning {
79 Recovered(Recovered),
81 ConfigNotUpdated(Error),
84 ParksPendingRemoval(usize),
86 ParkedLoginRefused {
88 tool: ProviderId,
89 label: String,
90 },
91 RenewalFailed(Error),
92 LockCompromised {
95 tool: ProviderId,
96 },
97 AuthOverridden {
100 tool: ProviderId,
101 names: Vec<String>,
102 },
103 FallbackLogin {
108 tool: ProviderId,
109 path: std::path::PathBuf,
110 held: Held,
111 },
112 WrittenOnTheCommandLine {
114 tool: ProviderId,
115 bytes: usize,
116 limit: usize,
117 },
118 SessionsStillRunning {
122 from: String,
123 holding: Vec<crate::holder::Holding>,
124 },
125 SessionsKeepTheOldLogin {
128 label: String,
129 holding: Vec<crate::holder::Holding>,
130 },
131 SessionsUnknown {
135 tool: ProviderId,
136 from: String,
137 },
138 SessionsUnknownAfterSignIn {
142 tool: ProviderId,
143 label: String,
144 },
145 SignInParkedNotInUse {
148 tool: ProviderId,
149 label: String,
150 why: String,
151 },
152 SwitchStuck(Error),
156 LoginReplaced {
161 tool: ProviderId,
162 id: String,
163 label: String,
164 now: Stored,
165 },
166 InUseUnconfirmed {
173 tool: ProviderId,
174 last: Stored,
175 named: Stored,
176 asked: bool,
177 },
178 ConfigNamesAnother {
182 tool: ProviderId,
183 config: Stored,
184 in_use: Stored,
185 },
186 ReadOnly {
191 why: Option<&'static str>,
192 },
193 ReadOnlyBelowTheFloor {
194 build: Option<u32>,
195 },
196}
197
198impl Warning {
199 fn read_only(refused: &Error) -> Option<Warning> {
200 match *refused {
201 Error::Elevated { why } => Some(Warning::ReadOnly { why }),
202 Error::SystemTooOld { build } => Some(Warning::ReadOnlyBelowTheFloor { build }),
203 _ => None,
204 }
205 }
206
207 pub fn code(&self) -> &'static str {
209 match self {
210 Warning::Recovered(r) => r.code(),
211 Warning::LockCompromised { .. } => "lock_compromised",
212 Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
213 e.code()
214 }
215 Warning::ParksPendingRemoval(_) => "parks_pending_removal",
216 Warning::ParkedLoginRefused { .. } => "parked_login_refused",
217 Warning::AuthOverridden { .. } => "auth_overridden",
218 Warning::FallbackLogin { .. } => "fallback_login",
219 Warning::WrittenOnTheCommandLine { .. } => "written_on_the_command_line",
220 Warning::SessionsStillRunning { .. } => "sessions_still_running",
221 Warning::SessionsKeepTheOldLogin { .. } => "sessions_keep_old_login",
222 Warning::SessionsUnknown { .. } | Warning::SessionsUnknownAfterSignIn { .. } => {
223 "sessions_unknown"
224 }
225 Warning::SignInParkedNotInUse { .. } => "sign_in_parked_not_in_use",
226 Warning::LoginReplaced { .. } => "login_replaced",
227 Warning::InUseUnconfirmed { .. } => "in_use_unconfirmed",
228 Warning::ConfigNamesAnother { .. } => "config_names_another",
229 Warning::ReadOnly { .. } | Warning::ReadOnlyBelowTheFloor { .. } => "read_only",
230 }
231 }
232}
233
234impl fmt::Display for Warning {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 match self {
237 Warning::Recovered(r) => write!(f, "{r}"),
238 Warning::LockCompromised { tool } => write!(
239 f,
240 "{} reclaimed a credential lock while this change was under way, so it may \
241 have written or renewed a login at the same time. Pitboard read the slot back \
242 and the change stood, but check with `pitboard` that the right account is \
243 signed in. A login Pitboard parked meanwhile may need signing in to again \
244 when it is next used.",
245 tool.name()
246 ),
247 Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
248 write!(f, "{e}")
249 }
250 Warning::ParksPendingRemoval(count) => write!(
251 f,
252 "{count} parked login(s) no longer in use could not be removed yet; Pitboard \
253 tries again on its next change"
254 ),
255 Warning::ParkedLoginRefused { tool, label } => write!(
256 f,
257 "{} no longer accepts the parked login for `{label}`. Run `pitboard enroll \
258 {label} --sign-in` to sign in to it again.",
259 tool.service()
260 ),
261 Warning::WrittenOnTheCommandLine { tool, bytes, limit } => {
262 write!(
263 f,
264 "this login needs {bytes} bytes and `security` reads {limit} from stdin, \
265 so it was written on the argument line, where a process running as you \
266 could have read it while the call lasted."
267 )?;
268 if *tool == ProviderId::Claude {
269 write!(
270 f,
271 " Claude Code writes this same login the same way whenever it \
272 refreshes the token."
273 )?;
274 }
275 Ok(())
276 }
277 Warning::AuthOverridden { tool, names } => write!(
278 f,
279 "{} is set, so {} signs in with it and not with the login Pitboard moved. \
280 Unset it for the switch to take effect.",
281 names.join(" and "),
282 tool.name()
283 ),
284 Warning::FallbackLogin {
285 tool,
286 path,
287 held: Held::Login,
288 } => write!(
289 f,
290 "{path} holds another {tool} login, which a session that cannot read the \
291 keychain, such as one started over SSH, signs in with. No switch reaches it. \
292 While it is there, {tool} sessions already running at a switch {running}. \
293 `pitboard stow` keeps that login for its account, where Pitboard holds no \
294 other it can switch to, then deletes the file.",
295 path = path.display(),
296 tool = tool.name(),
297 running = crate::words::kept_until_renewed(),
298 ),
299 Warning::FallbackLogin {
300 tool,
301 path,
302 held: Held::NoLogin,
303 } => write!(
304 f,
305 "{path} is there with no {tool} login in it. While it is, {tool} sessions \
306 already running at a switch {running}. `pitboard stow` deletes it, which lets \
307 them follow a switch.",
308 path = path.display(),
309 tool = tool.name(),
310 running = crate::words::kept_until_renewed(),
311 ),
312 Warning::FallbackLogin {
313 tool,
314 path,
315 held: Held::Unreadable,
316 } => write!(
317 f,
318 "{path} is there, and Pitboard could not read it. While it is, {tool} sessions \
319 already running at a switch {running}. `pitboard doctor` says why, and what to \
320 do.",
321 path = path.display(),
322 tool = tool.name(),
323 running = crate::words::kept_until_renewed(),
324 ),
325 Warning::SessionsStillRunning { from, holding } => write!(
326 f,
327 "{} started before this switch {} still running and still using `{from}`. {} \
328 Do not sign out in {}: signing out there revokes `{from}`'s login, which \
329 Pitboard has just parked.",
330 capitalised(&holder::described(holding)),
331 if holder::plural(holding) { "are" } else { "is" },
332 holder::remedies(holding, "to use the new account"),
333 if holder::plural(holding) {
334 "any of them"
335 } else {
336 "it"
337 },
338 ),
339 Warning::SessionsKeepTheOldLogin { label, holding } => write!(
340 f,
341 "{} started before this sign-in {} still running and still using `{label}`'s \
342 old login. {} Otherwise one of them can put the old login back in place of \
343 the new one when it refreshes its token.",
344 capitalised(&holder::described(holding)),
345 if holder::plural(holding) { "are" } else { "is" },
346 holder::remedies(holding, "to use the new one"),
347 ),
348 Warning::SessionsUnknown { tool, from } => write!(
349 f,
350 "Pitboard could not tell whether {} sessions started before this switch are \
351 still running, because it could not read the list of processes. Do not sign \
352 out in one that is: signing out there revokes `{from}`'s login, which \
353 Pitboard has just parked.",
354 tool.name()
355 ),
356 Warning::SessionsUnknownAfterSignIn { tool, label } => write!(
357 f,
358 "Pitboard could not tell whether {} sessions started before this sign-in are \
359 still running, because it could not read the list of processes. Quit any \
360 that are still using `{label}`'s old login and start them again. Otherwise \
361 one of them can put the old login back in place of the new one when it \
362 refreshes its token.",
363 tool.name()
364 ),
365 Warning::SignInParkedNotInUse { tool, label, why } => write!(
366 f,
367 "{} goes on with the login it has: Pitboard could not tell whose it is \
368 ({why}), so it parked the new login for `{label}` rather than write over \
369 that one. If that login no longer works, run `{}` and sign in to `{label}` \
370 there.",
371 tool.name(),
372 tool.login_command()
373 ),
374 Warning::LoginReplaced {
375 tool, label, now, ..
376 } => {
377 write!(
378 f,
379 "`{label}`'s login was replaced by a sign-in outside Pitboard"
380 )?;
381 let tool = tool.name();
382 match now {
383 Stored::Account(now) => write!(f, ": {tool} now has `{now}`'s login stored,")?,
384 Stored::Unenrolled(email) => {
385 write!(f, ": {tool} now has {email}'s login stored,")?;
386 }
387 Stored::Nothing => write!(f, ": {tool} now has no login stored,")?,
388 Stored::Unknown => write!(f, ",")?,
389 }
390 write!(
391 f,
392 " and Pitboard holds no login for `{label}`. Run `pitboard enroll {label} \
393 --sign-in` to sign in to it again."
394 )
395 }
396 Warning::InUseUnconfirmed {
397 tool,
398 last,
399 named,
400 asked: false,
401 } => write!(
402 f,
403 "{name}'s config names {named}, and Pitboard has not asked {service} whose login \
404 {name} has stored since Pitboard was updated, so another login may be stored. \
405 The account it last switched to is {last}. `pitboard status` asks.",
406 name = tool.name(),
407 named = named.said("no account"),
408 service = tool.service(),
409 last = last.said("none"),
410 ),
411 Warning::InUseUnconfirmed {
412 tool, last, named, ..
413 } => {
414 write!(
415 f,
416 "{name}'s config has named {named} since Pitboard last asked {service} whose \
417 login {name} has stored, so another login may be stored.",
418 name = tool.name(),
419 named = named.said("no account"),
420 service = tool.service(),
421 )?;
422 match last {
423 Stored::Account(_) | Stored::Unenrolled(_) => {
424 write!(f, " The one stored then was {}'s.", last.said(""))?;
425 }
426 Stored::Nothing => write!(f, " None was stored then.")?,
427 Stored::Unknown => {}
428 }
429 write!(f, " `pitboard status` asks again.")
430 }
431 Warning::ConfigNamesAnother {
432 tool,
433 config,
434 in_use,
435 } => {
436 let config = config.said("no account");
437 write!(
438 f,
439 "{name}'s config names {config}, and the login {name} has stored is {}'s, as \
440 {} said. `/status` in {name} shows {config}.",
441 in_use.said("nobody"),
442 tool.service(),
443 name = tool.name(),
444 )?;
445 write!(f, " {}", in_use.how_to_name())
446 }
447 Warning::ReadOnly { why } => {
448 read_only(f, &crate::words::elevated(crate::host::OS, *why))
449 }
450 Warning::ReadOnlyBelowTheFloor { build } => {
451 read_only(f, &crate::words::too_old(*build))
452 }
453 }
454 }
455}
456
457#[derive(Debug, Clone, PartialEq, Eq)]
460pub enum Stored {
461 Account(String),
463 Unenrolled(String),
465 Nothing,
467 Unknown,
469}
470
471impl Stored {
472 pub(crate) fn of(
474 state: &state::State,
475 tool: ProviderId,
476 owner: Option<&crate::api::Owner>,
477 ) -> Stored {
478 match owner {
479 None => Stored::Nothing,
480 Some(owner) => state.account_of(tool, owner).map_or_else(
481 || Stored::Unenrolled(owner.email.clone()),
482 |account| Stored::Account(state.typed(&account.key())),
483 ),
484 }
485 }
486
487 pub(crate) fn said(&self, nobody: &str) -> String {
489 match self {
490 Stored::Account(name) => format!("`{name}`"),
491 Stored::Unenrolled(email) => email.clone(),
492 Stored::Nothing | Stored::Unknown => nobody.to_string(),
493 }
494 }
495
496 pub(crate) fn how_to_name(&self) -> String {
500 match self {
501 Stored::Account(name) => {
502 format!("`pitboard use {name}` writes `{name}` into the config.")
503 }
504 _ => "A switch writes the account it puts in use into the config.".into(),
505 }
506 }
507}
508
509fn replaced(state: &state::State) -> Vec<Warning> {
512 state
513 .accounts
514 .iter()
515 .filter(|account| account.replaced_at.is_some() && account.parked.is_none())
516 .map(|account| {
517 let tool = account.provider();
518 let now = state.in_use(tool).map_or(Stored::Unknown, |record| {
519 Stored::of(state, tool, record.owner.as_ref())
520 });
521 Warning::LoginReplaced {
522 tool,
523 id: account.id.clone(),
524 label: state.typed(&account.key()),
525 now,
526 }
527 })
528 .collect()
529}
530
531fn in_doubt(state: &state::State, tool: ProviderId, known: &Known) -> Option<Warning> {
535 let named = Stored::of(state, tool, known.own_record.as_ref());
536 let unconfirmed = |asked| {
537 let last = known.last.as_ref().map_or(Stored::Unknown, |record| {
538 Stored::of(state, tool, record.owner.as_ref())
539 });
540 Some(Warning::InUseUnconfirmed {
541 tool,
542 last,
543 named: named.clone(),
544 asked,
545 })
546 };
547 match known.doubt {
548 Some(Doubt::NamedMoved) => return unconfirmed(true),
549 Some(Doubt::NeverEstablished) => {
550 let owner = known.owner()?;
551 let config = known.own_record.as_ref()?;
552 return if new_id(tool, config) == new_id(tool, owner) {
553 None
554 } else {
555 unconfirmed(false)
556 };
557 }
558 Some(Doubt::LoginUnreadable) | None => {}
559 }
560 known
561 .named_another(tool)
562 .map(|_| Warning::ConfigNamesAnother {
563 tool,
564 config: named,
565 in_use: Stored::of(state, tool, known.owner()),
566 })
567}
568
569fn read_only(f: &mut fmt::Formatter<'_>, said: &crate::words::ChangesNothing) -> fmt::Result {
570 write!(
571 f,
572 "{}, so it changes nothing: these are the numbers it last measured, and it renews no \
573 parked login and asks nobody. {}",
574 said.because, said.to_ask_again
575 )
576}
577
578#[derive(Debug)]
579pub struct Done<T> {
580 pub value: T,
581 pub warnings: Vec<Warning>,
582}
583
584#[derive(Debug)]
587pub struct Failed {
588 pub error: Error,
589 pub warnings: Vec<Warning>,
590}
591
592pub type Changing<T> = std::result::Result<Done<T>, Failed>;
593
594pub struct Pitboard {
595 ctx: Context,
596}
597
598impl Pitboard {
599 pub fn new(ctx: Context) -> Pitboard {
600 Pitboard { ctx }
601 }
602
603 pub fn permit(&self) -> Result<Permit> {
611 gate(&self.ctx)
612 }
613
614 pub fn check_homes(&self) -> Result<()> {
620 crate::home::check_absolute(&self.ctx)
621 }
622
623 fn permitted(&self) -> std::result::Result<Permit, Failed> {
625 self.permit().map_err(|error| Failed {
626 error,
627 warnings: Vec::new(),
628 })
629 }
630
631 pub fn status(&self, fresh: bool) -> Result<Done<status::Report>> {
665 let permit = match self.permit() {
666 Ok(permit) => permit,
667 Err(refused) => {
668 let Some(warning) = Warning::read_only(&refused) else {
669 return Err(refused);
670 };
671 let mut read = self.status_offline()?;
672 read.warnings.push(warning);
673 return Ok(read);
674 }
675 };
676 let renewed = switch::renew_parked(&self.ctx, permit);
677 let state = state::load(&self.ctx)?;
680 let (stuck, answers) = std::thread::scope(|scope| {
684 let stuck = scope.spawn(|| switch::stuck(&self.ctx, &state, switch::Asking::Service));
685 let answers = status::ask(&self.ctx, permit, &state, fresh);
686 (stuck.join().ok().flatten(), answers)
687 });
688 switch::identify::record_read(&self.ctx, permit, &answers.found, &answers.plans);
689 let found = answers.found.clone();
690 let state = state::load(&self.ctx)?;
693 let report = status::report(&self.ctx, permit, &state, answers);
694 let mut warnings: Vec<Warning> = stuck.map(Warning::SwitchStuck).into_iter().collect();
695 for (key, outcome) in renewed {
696 audit::record(&self.ctx, permit, "renew", &key.typed(), outcome.code());
697 match outcome {
698 Renewal::Refused => warnings.push(Warning::ParkedLoginRefused {
699 tool: key.provider,
700 label: state.typed(&key),
701 }),
702 Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
703 Renewal::Renewed | Renewal::Deferred => {}
704 }
705 }
706 warnings.extend(
707 ProviderId::ALL
708 .iter()
709 .filter_map(|&tool| self.left_behind(tool)),
710 );
711 warnings.extend(self.standing(&state, &found));
712 Ok(Done {
713 value: report,
714 warnings,
715 })
716 }
717
718 fn standing(&self, state: &state::State, found: &[(ProviderId, InUse)]) -> Vec<Warning> {
727 let doubts = ProviderId::ALL
728 .iter()
729 .filter(|&&tool| !crate::provider::of(tool).identifies_by_itself())
730 .filter_map(|&tool| {
731 let known = match found.iter().find(|(which, _)| *which == tool) {
732 Some((_, found)) => in_use::judged(&self.ctx, tool, Some(found.clone())),
733 None => in_use::known(&self.ctx, state, tool),
734 };
735 in_doubt(state, tool, &known)
736 });
737 replaced(state).into_iter().chain(doubts).collect()
738 }
739
740 pub fn doctor(&self) -> Diagnosis {
741 doctor::run(&self.ctx)
742 }
743
744 pub fn status_offline(&self) -> Result<Done<status::Report>> {
762 let state = state::load(&self.ctx)?;
763 let warnings = switch::stuck(&self.ctx, &state, switch::Asking::Nobody)
764 .map(Warning::SwitchStuck)
765 .into_iter()
766 .chain(self.standing(&state, &[]))
767 .collect();
768 Ok(Done {
769 value: status::gather_offline(&self.ctx, &state),
770 warnings,
771 })
772 }
773
774 pub fn statusline(&self, session: &str) -> statusline::StatusLine {
780 statusline::read(&self.ctx, self.permit().ok(), session)
781 }
782
783 pub fn account(&self, typed: &str) -> Option<Account> {
785 let state = state::load(&self.ctx).ok()?;
786 crate::label::resolve(&state, typed).ok().cloned()
787 }
788
789 pub fn switch_to(&self, typed: &str) -> Changing<Outcome> {
790 let permit = self.permitted()?;
791 let key = self.named(permit, "use", typed)?;
792 self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
793 switch::switch(settled, &key)
794 })
795 }
796
797 pub fn update_config(&self, typed: &str) -> Changing<Outcome> {
803 let permit = self.permitted()?;
804 let key = self.named(permit, "use", typed)?;
805 self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
806 switch::update_config(settled, &key)
807 })
808 }
809
810 pub fn auto_switch(&self, threshold: Threshold) -> Changing<Auto> {
822 let permit = self.permitted()?;
823 let looked = self.auto_look(threshold).map_err(|error| Failed {
824 error,
825 warnings: Vec::new(),
826 })?;
827 if let Look::Stands(value) = looked {
828 return Ok(Done {
829 value: *value,
830 warnings: Vec::new(),
831 });
832 }
833 let tool = ProviderId::Claude;
834 let (settled, mut warnings) = self.settled_for(permit, "auto-switch", "", Some(tool))?;
835 match switch::automatically(settled, threshold) {
836 Ok((value, more)) => {
837 warnings.extend(more);
838 Ok(Done { value, warnings })
839 }
840 Err(mut error) => {
841 warnings.extend(error.take_warnings());
842 Err(Failed { error, warnings })
843 }
844 }
845 }
846
847 pub fn auto_look(&self, threshold: Threshold) -> Result<Look> {
855 let state = state::load(&self.ctx)?;
856 Ok(crate::autoswitch::look(&self.ctx, &state, threshold))
857 }
858
859 fn named(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
865 let state = state::load(&self.ctx).map_err(|error| Failed {
866 error,
867 warnings: Vec::new(),
868 })?;
869 crate::label::resolve(&state, typed)
870 .map(Account::key)
871 .map_err(|error| self.refused(permit, verb, typed, error.code(), error))
872 }
873
874 pub fn enroll_current(&self, typed: &str) -> Changing<Enrolled> {
876 let permit = self.permitted()?;
877 let key = self.chosen(permit, "enroll", typed)?;
878 self.changing(
879 permit,
880 "enroll",
881 &key.typed(),
882 Some(key.provider),
883 |settled| switch::enroll(settled, &key, None),
884 )
885 }
886
887 fn chosen(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
893 self.enrolling(typed)
894 .map_err(|error| self.refused(permit, verb, typed, "label_unusable", error))
895 }
896
897 fn refused(
907 &self,
908 permit: Permit,
909 verb: &str,
910 subject: &str,
911 code: &str,
912 error: Error,
913 ) -> Failed {
914 let recovered = if switch::interrupted(&self.ctx) {
915 switch::settle(&self.ctx, permit, switch::interrupted_tool(&self.ctx))
916 .ok()
917 .and_then(|(_, recovered)| recovered)
918 } else {
919 None
920 };
921 let mut warnings = Vec::new();
922 if let Some(r) = recovered {
923 audit::record(&self.ctx, permit, "recover", &r.to, r.code());
924 warnings.push(Warning::Recovered(r));
925 }
926 audit::record(&self.ctx, permit, verb, subject, code);
927 Failed { error, warnings }
928 }
929
930 fn enrolling(&self, typed: &str) -> Result<Key> {
937 if typed.contains(crate::label::SEPARATOR)
938 && let Ok(state) = state::load(&self.ctx)
939 && let Some(existing) = state.accounts.iter().find(|a| a.label == typed)
940 {
941 return Ok(existing.key());
942 }
943 crate::label::choose(typed)
944 .map(|chosen| Key::new(chosen.provider, chosen.label))
945 .map_err(Error::Usage)
946 }
947
948 pub fn account_to_enroll(&self, typed: &str) -> Option<Account> {
951 let key = self.enrolling(typed).ok()?;
952 state::load(&self.ctx).ok()?.get(&key).cloned()
953 }
954
955 pub fn name_to_type(&self, typed: &str) -> String {
958 let Ok(key) = self.enrolling(typed) else {
959 return typed.to_string();
960 };
961 state::load(&self.ctx).map_or_else(|_| key.typed(), |state| state.typed(&key))
962 }
963
964 pub fn sign_in(&self, typed: &str) -> std::result::Result<SignIn, Failed> {
968 let permit = self.permitted()?;
969 let key = self.signing_in(permit, typed)?;
970 switch::sign_in(&self.ctx, permit, key.provider)
971 .map_err(|error| self.not_started(permit, typed, error))
972 }
973
974 pub fn sign_in_watched(
977 &self,
978 typed: &str,
979 ) -> std::result::Result<switch::WatchedSignIn, Failed> {
980 let permit = self.permitted()?;
981 let key = self.signing_in(permit, typed)?;
982 switch::sign_in_watched_as(&self.ctx, permit, &key)
983 .map_err(|error| self.not_started(permit, typed, error))
984 }
985
986 fn signing_in(&self, permit: Permit, typed: &str) -> std::result::Result<Key, Failed> {
992 let key = self.chosen(permit, "enroll", typed)?;
993 self.ready_to_sign_in(key.provider)
994 .map_err(|error| self.not_started(permit, typed, error))?;
995 Ok(key)
996 }
997
998 fn not_started(&self, permit: Permit, typed: &str, error: Error) -> Failed {
1000 audit::record(&self.ctx, permit, "enroll", typed, error.code());
1001 Failed {
1002 error,
1003 warnings: Vec::new(),
1004 }
1005 }
1006
1007 fn ready_to_sign_in(&self, tool: crate::provider::ProviderId) -> Result<()> {
1011 if tool == crate::provider::ProviderId::Claude && self.ctx.custom_oauth() {
1012 return Err(Error::CustomOauthEndpoint);
1013 }
1014 state::load(&self.ctx)?;
1015 let driver = crate::provider::of(tool);
1016 switch::live_store(&self.ctx, tool)?;
1019 if let crate::provider::Isolation::NotIsolated { reason } =
1024 driver.private_signin_isolation(&self.ctx)
1025 {
1026 return Err(Error::SignInNotIsolated { reason });
1027 }
1028 if driver.program(&self.ctx).is_none() {
1029 return Err(Error::ProgramMissing {
1030 tool,
1031 program: self.ctx.program_for(tool).display().to_string(),
1032 });
1033 }
1034 Ok(())
1035 }
1036
1037 pub fn enroll_signed_in(&self, typed: &str, login: SignIn) -> Changing<Enrolled> {
1038 let permit = self.permitted()?;
1039 let key = self.chosen(permit, "enroll", typed)?;
1040 self.changing(
1041 permit,
1042 "enroll",
1043 &key.typed(),
1044 Some(key.provider),
1045 |settled| switch::enroll(settled, &key, Some(login)),
1046 )
1047 }
1048
1049 pub fn forget(&self, typed: &str) -> Changing<String> {
1051 let permit = self.permitted()?;
1052 let key = self.named(permit, "forget", typed)?;
1053 self.changing(
1054 permit,
1055 "forget",
1056 &key.typed(),
1057 Some(key.provider),
1058 |settled| switch::forget(settled, &key),
1059 )
1060 }
1061
1062 pub fn abandon_recovery(&self) -> Result<Option<switch::Abandoned>> {
1065 let permit = self.permit()?;
1066 let outcome = switch::abandon(&self.ctx, permit);
1067 audit::record(
1068 &self.ctx,
1069 permit,
1070 "abandon",
1071 "",
1072 match &outcome {
1073 Ok(_) => "ok",
1074 Err(e) => e.code(),
1075 },
1076 );
1077 outcome
1078 }
1079
1080 pub fn renew(&self) -> Result<Vec<(Key, Renewal)>> {
1090 let ctx = schedule::for_its_run(&self.ctx);
1091 let permit = gate(&ctx)?;
1092 let outcomes = switch::renew_due(&ctx, permit, switch::Due::ToStayAlive);
1093 for (key, outcome) in &outcomes {
1094 audit::record(&ctx, permit, "renew", &key.typed(), outcome.code());
1095 }
1096 Ok(outcomes)
1097 }
1098
1099 pub fn schedule(&self) -> schedule::Installed {
1102 schedule::status(&self.ctx)
1103 }
1104
1105 pub fn schedule_renews_this_home(&self) -> bool {
1109 schedule::serves(&self.ctx)
1110 }
1111
1112 pub fn schedule_install(&self) -> Result<std::path::PathBuf> {
1116 schedule::install(&self.ctx, self.permit()?)
1117 }
1118
1119 pub fn schedule_uninstall(&self) -> Result<bool> {
1121 schedule::uninstall(&self.ctx, self.permit()?)
1122 }
1123
1124 pub fn schedule_repair(&self) -> Result<bool> {
1127 schedule::repair(&self.ctx, self.permit()?)
1128 }
1129
1130 pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
1136 switch::adopt(&self.ctx, self.permit()?)
1137 }
1138
1139 pub fn repair(&self) -> Changing<switch::Reclaimed> {
1144 let permit = self.permitted()?;
1145 self.changing(permit, "repair", "", None, |settled| {
1146 switch::repair(settled).map(|r| (r, Vec::new()))
1147 })
1148 }
1149
1150 pub fn holding(&self, which: ProviderId) -> Vec<holder::Holding> {
1155 match switch::still_holding(&self.ctx, which) {
1156 switch::StillHolding::These(holding) => holding,
1157 switch::StillHolding::Nothing | switch::StillHolding::Unknown => Vec::new(),
1158 }
1159 }
1160
1161 pub fn changed_at(&self) -> i64 {
1165 state::changed_at(&self.ctx)
1166 }
1167
1168 pub fn readings_changed_at(&self) -> i64 {
1171 readings::changed_at(&self.ctx)
1172 }
1173
1174 pub fn app_file(&self, file: crate::app::AppFile) -> std::io::Result<Option<String>> {
1177 crate::app::read_app_file(&self.ctx, file)
1178 }
1179
1180 pub fn keep_app_file(&self, file: crate::app::AppFile, body: &str) -> Result<()> {
1183 crate::app::write_app_file(&self.ctx, self.permit()?, file, body)
1184 }
1185
1186 pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
1188 audit::read(&self.ctx, limit)
1189 }
1190
1191 pub fn uninstall(&self) -> Changing<switch::Removed> {
1195 let permit = self.permitted()?;
1196 self.changing(permit, "uninstall", "", None, |settled| {
1197 switch::uninstall(settled).map(|r| (r, Vec::new()))
1198 })
1199 }
1200
1201 pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
1205 let permit = self.permitted()?;
1206 let from = self.named(permit, "rename", from)?;
1207 let chosen = self.chosen(permit, "rename", to)?;
1208 if to.contains(crate::label::SEPARATOR) && chosen.provider != from.provider {
1211 let error = Error::Usage(format!(
1212 "`{from}` is a {} account, and a rename cannot move it to {}. Sign in to that \
1213 tool and enrol the account there instead.",
1214 from.provider, chosen.provider
1215 ));
1216 return Err(self.refused(permit, "rename", &from.typed(), error.code(), error));
1217 }
1218 let to = chosen.label;
1219 self.changing(
1220 permit,
1221 "rename",
1222 &format!("{from} -> {to}"),
1223 Some(from.provider),
1224 |settled| switch::rename(settled, &from, &to).map(|email| (email, Vec::new())),
1225 )
1226 }
1227
1228 pub fn warm_overview(&self) -> Result<crate::warm::Overview> {
1231 let state = state::load(&self.ctx)?;
1232 let setting = crate::warm::load(&self.ctx)?;
1233 Ok(crate::warm::overview(&self.ctx, &state, &setting))
1234 }
1235
1236 pub fn warm_now(&self, typed: &str) -> Changing<switch::warm::WarmUp> {
1241 let permit = self.permitted()?;
1242 let key = self.named(permit, "warm-up", typed)?;
1243 let settled = self.settled_to_warm(permit, "warm-up", &key.typed())?;
1244 self.warming(settled, switch::warm::Target::Account(key))
1245 }
1246
1247 pub fn warm_up(&self) -> Changing<switch::warm::WarmUp> {
1251 let permit = self.permitted()?;
1252 let looked = self.warm_look().map_err(|error| Failed {
1253 error,
1254 warnings: Vec::new(),
1255 })?;
1256 if let crate::duty::Look::Stands(overview) = looked {
1257 return Ok(Done {
1258 value: switch::warm::WarmUp::Stands(overview),
1259 warnings: Vec::new(),
1260 });
1261 }
1262 let settled = self.settled_to_warm(permit, "warm-up", "")?;
1263 self.warming(settled, switch::warm::Target::Due)
1264 }
1265
1266 pub fn warm_look(&self) -> Result<crate::duty::Look<crate::warm::Overview>> {
1270 let state = state::load(&self.ctx)?;
1271 crate::warm::look(&self.ctx, &state)
1272 }
1273
1274 fn settled_to_warm(
1279 &self,
1280 permit: Permit,
1281 verb: &str,
1282 subject: &str,
1283 ) -> std::result::Result<(Settled, Vec<Warning>), Failed> {
1284 let (settled, recovered) = self.settled(permit, verb, subject, Some(ProviderId::Claude))?;
1285 Ok((settled, recovered.into_iter().collect()))
1286 }
1287
1288 fn warming(
1289 &self,
1290 (settled, mut warnings): (Settled, Vec<Warning>),
1291 target: switch::warm::Target,
1292 ) -> Changing<switch::warm::WarmUp> {
1293 match switch::warm::warm_up(settled, target) {
1294 Ok((value, more)) => {
1295 warnings.extend(more);
1296 Ok(Done { value, warnings })
1297 }
1298 Err(mut error) => {
1299 warnings.extend(error.take_warnings());
1300 Err(Failed { error, warnings })
1301 }
1302 }
1303 }
1304
1305 pub fn warm_turn(&self, typed: &str, on: bool) -> Changing<bool> {
1309 let verb = if on { "warm-on" } else { "warm-off" };
1310 let permit = self.permitted()?;
1311 let key = self.named(permit, verb, typed)?;
1312 let subject = key.typed();
1313 let settled = self.settled_to_warm(permit, verb, &subject)?;
1314 self.change(permit, verb, &subject, settled, |settled| {
1315 switch::warm::turn(settled, &key, on).map(|changed| (changed, Vec::new()))
1316 })
1317 }
1318
1319 pub fn warm_range(&self, range: crate::warm::Range) -> Changing<crate::warm::Range> {
1322 let permit = self.permitted()?;
1323 let subject = format!(
1324 "{}-{}",
1325 crate::warm::clock(range.from()),
1326 crate::warm::clock(range.to())
1327 );
1328 let settled = self.settled_to_warm(permit, "warm-range", &subject)?;
1329 self.change(permit, "warm-range", &subject, settled, |settled| {
1330 switch::warm::range(settled, range).map(|range| (range, Vec::new()))
1331 })
1332 }
1333
1334 pub fn left_login(&self) -> Result<Option<switch::Left>> {
1342 let state = state::load(&self.ctx)?;
1343 switch::left_login(&self.ctx, &state)
1344 }
1345
1346 pub fn stow(&self, seen: &str) -> Changing<switch::Stowed> {
1352 let permit = self.permitted()?;
1353 let tool = ProviderId::Claude;
1354 let (settled, recovered) = self.settled(permit, "stow", "", Some(tool))?;
1355 let warnings = self.overridden(tool).into_iter().chain(recovered).collect();
1356 self.change(permit, "stow", "", (settled, warnings), |settled| {
1357 switch::stow(settled, seen)
1358 })
1359 }
1360
1361 fn overridden(&self, tool: ProviderId) -> Option<Warning> {
1365 let names = crate::provider::of(tool).overridden_by(&self.ctx);
1366 (!names.is_empty()).then_some(Warning::AuthOverridden { tool, names })
1367 }
1368
1369 fn left_behind(&self, tool: ProviderId) -> Option<Warning> {
1372 let behind = crate::provider::of(tool).behind(&self.ctx)?;
1373 Some(Warning::FallbackLogin {
1374 tool,
1375 path: behind.path,
1376 held: behind.held,
1377 })
1378 }
1379
1380 fn changing<T: Audited>(
1386 &self,
1387 permit: Permit,
1388 verb: &str,
1389 subject: &str,
1390 tool: Option<ProviderId>,
1391 run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
1392 ) -> Changing<T> {
1393 let settled = self.settled_for(permit, verb, subject, tool)?;
1394 self.change(permit, verb, subject, settled, run)
1395 }
1396
1397 fn change<T: Audited>(
1401 &self,
1402 permit: Permit,
1403 verb: &str,
1404 subject: &str,
1405 (settled, mut warnings): (Settled, Vec<Warning>),
1406 run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
1407 ) -> Changing<T> {
1408 match run(settled) {
1409 Ok((value, more)) => {
1410 let subject = value.audit_subject().unwrap_or(subject);
1411 audit::record(&self.ctx, permit, verb, subject, value.audit_code());
1412 warnings.extend(more);
1413 Ok(Done { value, warnings })
1414 }
1415 Err(mut error) => {
1416 audit::record(&self.ctx, permit, verb, subject, error.code());
1417 warnings.extend(error.take_warnings());
1418 Err(Failed { error, warnings })
1419 }
1420 }
1421 }
1422
1423 fn settled_for(
1427 &self,
1428 permit: Permit,
1429 verb: &str,
1430 subject: &str,
1431 tool: Option<ProviderId>,
1432 ) -> std::result::Result<(Settled, Vec<Warning>), Failed> {
1433 let (settled, recovered) = self.settled(permit, verb, subject, tool)?;
1434 let mut warnings = Vec::new();
1435 if let Some(tool) = tool {
1436 warnings.extend(self.overridden(tool));
1437 warnings.extend(self.left_behind(tool));
1438 }
1439 warnings.extend(recovered);
1440 Ok((settled, warnings))
1441 }
1442
1443 fn settled(
1447 &self,
1448 permit: Permit,
1449 verb: &str,
1450 subject: &str,
1451 tool: Option<ProviderId>,
1452 ) -> std::result::Result<(Settled, Option<Warning>), Failed> {
1453 let (settled, recovered) = switch::settle(&self.ctx, permit, tool).map_err(|error| {
1454 audit::record(&self.ctx, permit, verb, subject, error.code());
1455 Failed {
1456 error,
1457 warnings: Vec::new(),
1458 }
1459 })?;
1460 let recovered = recovered.map(|r| {
1461 audit::record(&self.ctx, permit, "recover", &r.to, r.code());
1462 Warning::Recovered(r)
1463 });
1464 Ok((settled, recovered))
1465 }
1466}
1467
1468trait Audited {
1470 fn audit_code(&self) -> &'static str {
1471 "ok"
1472 }
1473
1474 fn audit_subject(&self) -> Option<&str> {
1476 None
1477 }
1478}
1479
1480impl Audited for Outcome {
1481 fn audit_code(&self) -> &'static str {
1482 match self {
1483 Outcome::Switched { .. } => "ok",
1484 Outcome::AlreadyActive {
1485 config_updated: true,
1486 ..
1487 } => "config_updated",
1488 Outcome::AlreadyActive { .. } => "already_active",
1489 }
1490 }
1491}
1492
1493impl Audited for switch::Reclaimed {}
1494impl Audited for Enrolled {}
1495impl Audited for String {}
1496impl Audited for bool {}
1497impl Audited for crate::warm::Range {}
1498
1499impl Audited for switch::Stowed {
1500 fn audit_code(&self) -> &'static str {
1501 self.kept.code()
1502 }
1503
1504 fn audit_subject(&self) -> Option<&str> {
1505 self.kept.account()
1506 }
1507}
1508
1509impl Audited for switch::Removed {
1510 fn audit_code(&self) -> &'static str {
1511 if self.pending > 0 {
1512 "parks_pending_removal"
1513 } else {
1514 "ok"
1515 }
1516 }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521 use super::*;
1522 use crate::switch::harness::{
1523 Machine, audit_lines, codex_machine, hold, machine, signed_in_outside, state_file,
1524 };
1525 use std::collections::BTreeMap;
1526 use std::sync::Arc;
1527
1528 type Make = fn(&str) -> Machine;
1529
1530 const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
1532
1533 type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
1534
1535 const REFUSALS: [(&str, &str, &str, &str, Refuse); 4] = [
1541 (
1542 "use",
1543 "use",
1544 "account_unknown",
1545 "account_unknown",
1546 |p, _| p.switch_to("nobody").err(),
1547 ),
1548 ("enroll", "enroll", "usage", "label_unusable", |p, _| {
1549 p.enroll_current("codx/work").err()
1550 }),
1551 ("sign-in", "enroll", "usage", "label_unusable", |p, _| {
1552 p.sign_in("codx/work").err()
1553 }),
1554 ("rename", "rename", "usage", "usage", |p, tool| {
1555 let other = if tool == ProviderId::Claude {
1556 ProviderId::Codex
1557 } else {
1558 ProviderId::Claude
1559 };
1560 p.rename(
1561 &Key::new(tool, "here").qualified(),
1562 &Key::new(other, "moved").qualified(),
1563 )
1564 .err()
1565 }),
1566 ];
1567
1568 fn interrupted(make: Make, name: &str) -> Machine {
1571 let m = make(name);
1572 let settled = switch::settle(&m.ctx, Permit::for_a_test(), None)
1573 .expect("nothing to recover yet")
1574 .0;
1575 let died = crate::fault::killing("switch.park_recorded", || {
1576 switch::switch(settled, &m.key("there"))
1577 });
1578 assert_eq!(died.unwrap_err(), "switch.park_recorded");
1579 assert!(switch::interrupted(&m.ctx));
1580 m
1581 }
1582
1583 fn stuck(make: Make, name: &str) -> Machine {
1587 let m = interrupted(make, name);
1588 m.fault_live(crate::store::memory::Fault::Vanish);
1589 m
1590 }
1591
1592 fn stuck_unless_asked(name: &str) -> Machine {
1598 let m = interrupted(machine, name);
1599 m.sign_in(&crate::switch::harness::document("renewed-since"));
1600 m
1601 }
1602
1603 fn said(read: &Done<status::Report>) -> Vec<(&'static str, String)> {
1605 read.warnings
1606 .iter()
1607 .map(|w| (w.code(), w.to_string()))
1608 .collect()
1609 }
1610
1611 type Untouched = (
1615 BTreeMap<String, Vec<u8>>,
1616 Vec<(String, Option<String>)>,
1617 Option<serde_json::Value>,
1618 );
1619
1620 fn untouched(m: &Machine) -> Untouched {
1621 let kept_by_a_read = ["usage.json", "usage.lock", "asking.json"];
1622 let mut files = files(m);
1623 files.retain(|path, _| !kept_by_a_read.iter().any(|kept| path.ends_with(kept)));
1624 let vault = m.mem.vault();
1625 let parked = vault
1626 .services()
1627 .into_iter()
1628 .map(|service| {
1629 let held = vault.peek(&service);
1630 (service, held)
1631 })
1632 .collect();
1633 (files, parked, m.live())
1634 }
1635
1636 #[test]
1640 #[cfg_attr(
1641 windows,
1642 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1643 )]
1644 fn a_switch_says_a_login_is_left_where_it_does_not_reach() {
1645 let m = machine("switch-fallback-login");
1646 let left = plant_behind(
1647 &m,
1648 &crate::switch::harness::document("left-by-a-sign-in").to_string(),
1649 );
1650 let switched = Pitboard::new(m.ctx.clone())
1651 .switch_to("there")
1652 .expect("a switch");
1653 let said: Vec<String> = switched
1654 .warnings
1655 .iter()
1656 .filter(|w| w.code() == "fallback_login")
1657 .map(ToString::to_string)
1658 .collect();
1659 assert_eq!(said.len(), 1, "{:?}", switched.warnings);
1660 assert!(
1661 said[0].starts_with(&left.display().to_string()),
1662 "{}",
1663 said[0]
1664 );
1665
1666 for (tool, make) in MACHINES {
1667 let m = make(&format!("switch-nothing-left-{tool}"));
1668 let switched = Pitboard::new(m.ctx.clone())
1669 .switch_to(&m.key("there").typed())
1670 .expect("a switch");
1671 assert!(
1672 switched
1673 .warnings
1674 .iter()
1675 .all(|w| w.code() != "fallback_login"),
1676 "{tool}: {:?}",
1677 switched.warnings
1678 );
1679 }
1680 }
1681
1682 fn plant_behind(m: &Machine, contents: &str) -> std::path::PathBuf {
1685 let left = crate::provider::claude::live::credential_file(&m.ctx);
1686 m.mem.file_at(left.clone()).plant(
1687 &crate::provider::claude::paths::live_service(&m.ctx),
1688 contents,
1689 );
1690 left
1691 }
1692
1693 #[test]
1699 #[cfg_attr(
1700 windows,
1701 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1702 )]
1703 fn a_read_says_a_file_is_behind_the_keychain() {
1704 let m = machine("read-fallback-login");
1705 let left = plant_behind(
1706 &m,
1707 &crate::switch::harness::document("left-by-a-sign-in").to_string(),
1708 );
1709 let pitboard = Pitboard::new(m.ctx.clone());
1710
1711 let read = pitboard.status(false).expect("a read");
1712 let said: Vec<String> = read
1713 .warnings
1714 .iter()
1715 .filter(|w| w.code() == "fallback_login")
1716 .map(ToString::to_string)
1717 .collect();
1718 assert_eq!(said.len(), 1, "{:?}", read.warnings);
1719 assert!(
1720 said[0].starts_with(&format!(
1721 "{} holds another Claude Code login",
1722 left.display()
1723 )),
1724 "{}",
1725 said[0]
1726 );
1727 assert!(
1728 said[0].contains(
1729 "sessions already running at a switch keep the account they are on until \
1730 their login is next renewed, or until they are started again"
1731 ),
1732 "{}",
1733 said[0]
1734 );
1735 assert!(
1736 said[0].ends_with(
1737 "`pitboard stow` keeps that login for its account, where Pitboard holds no \
1738 other it can switch to, then deletes the file."
1739 ),
1740 "{}",
1741 said[0]
1742 );
1743 let offline = pitboard.status_offline().expect("a read of what is known");
1744 assert!(
1745 offline
1746 .warnings
1747 .iter()
1748 .all(|w| w.code() != "fallback_login"),
1749 "{:?}",
1750 offline.warnings
1751 );
1752
1753 for (tool, make) in MACHINES {
1754 let m = make(&format!("read-nothing-left-{tool}"));
1755 let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
1756 assert!(
1757 read.warnings.iter().all(|w| w.code() != "fallback_login"),
1758 "{tool}: {:?}",
1759 read.warnings
1760 );
1761 }
1762 }
1763
1764 #[test]
1768 #[cfg_attr(
1769 windows,
1770 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1771 )]
1772 fn an_empty_file_behind_the_keychain_is_said_too() {
1773 let m = machine("read-empty-fallback");
1774 let left = plant_behind(&m, "{}");
1775 let pitboard = Pitboard::new(m.ctx.clone());
1776
1777 for warnings in [
1778 pitboard.status(false).expect("a read").warnings,
1779 pitboard.switch_to("there").expect("a switch").warnings,
1780 ] {
1781 let said: Vec<String> = warnings
1782 .iter()
1783 .filter(|w| w.code() == "fallback_login")
1784 .map(ToString::to_string)
1785 .collect();
1786 assert_eq!(
1787 said,
1788 [format!(
1789 "{path} is there with no Claude Code login in it. While it is, Claude \
1790 Code sessions already running at a switch keep the account they are on \
1791 until their login is next renewed, or until they are started again. \
1792 `pitboard stow` deletes it, which lets them follow a switch.",
1793 path = left.display()
1794 )],
1795 "{warnings:?}"
1796 );
1797 }
1798 }
1799
1800 #[test]
1804 #[cfg_attr(
1805 windows,
1806 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1807 )]
1808 fn a_file_behind_the_keychain_that_cannot_be_read_is_said_too() {
1809 let m = machine("read-unreadable-fallback");
1810 let left = plant_behind(&m, "{}");
1811 m.mem.file_at(left.clone()).fault(
1812 &crate::provider::claude::paths::live_service(&m.ctx),
1813 crate::store::memory::Fault::UnreadableContents(
1814 "stream did not contain valid UTF-8".into(),
1815 ),
1816 );
1817 let pitboard = Pitboard::new(m.ctx.clone());
1818
1819 for warnings in [
1820 pitboard.status(false).expect("a read").warnings,
1821 pitboard.switch_to("there").expect("a switch").warnings,
1822 ] {
1823 let said: Vec<String> = warnings
1824 .iter()
1825 .filter(|w| w.code() == "fallback_login")
1826 .map(ToString::to_string)
1827 .collect();
1828 assert_eq!(
1829 said,
1830 [format!(
1831 "{} is there, and Pitboard could not read it. While it is, Claude Code \
1832 sessions already running at a switch keep the account they are on until \
1833 their login is next renewed, or until they are started again. `pitboard \
1834 doctor` says why, and what to do.",
1835 left.display()
1836 )],
1837 "{warnings:?}"
1838 );
1839 }
1840 }
1841
1842 #[test]
1847 #[cfg_attr(
1848 windows,
1849 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1850 )]
1851 fn a_read_says_an_interrupted_switch_it_cannot_finish_is_waiting() {
1852 for (tool, make) in MACHINES {
1853 let m = stuck(make, &format!("read-stuck-{tool}"));
1854 let pitboard = Pitboard::new(m.ctx.clone());
1855 let before = untouched(&m);
1856
1857 let read = pitboard
1858 .status(false)
1859 .expect("a read is not refused over it");
1860
1861 assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1862 assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1863 let refused = pitboard
1864 .switch_to(&m.key("there").typed())
1865 .expect_err("every change stops at it");
1866 assert_eq!(refused.error.code(), "recovery_undetermined", "{tool}");
1867 assert_eq!(
1868 said(&read),
1869 [("recovery_undetermined", refused.error.to_string())],
1870 "{tool}: said in the refusal's own words"
1871 );
1872 }
1873 }
1874
1875 #[test]
1878 #[cfg_attr(
1879 windows,
1880 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1881 )]
1882 fn a_read_asks_whose_the_login_is_where_only_the_service_can_say() {
1883 let m = stuck_unless_asked("read-stuck-asked");
1884 let pitboard = Pitboard::new(m.ctx.clone());
1885 let before = untouched(&m);
1886
1887 let read = pitboard.status(false).expect("a read");
1888
1889 assert_eq!(untouched(&m), before, "a read changes nothing");
1890 let refused = pitboard.switch_to("there").expect_err("it stops at it");
1891 assert_eq!(
1892 said(&read),
1893 [("recovery_undetermined", refused.error.to_string())]
1894 );
1895 let said = refused.error.to_string();
1897 assert!(said.contains("session has expired"), "{said}");
1898 assert!(!said.contains("sign in"), "{said}");
1899 }
1900
1901 #[test]
1905 #[cfg_attr(
1906 windows,
1907 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1908 )]
1909 fn a_read_says_nothing_of_a_switch_the_next_change_finishes() {
1910 for (tool, make) in MACHINES {
1911 let m = interrupted(make, &format!("read-settles-{tool}"));
1912 let before = untouched(&m);
1913
1914 let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
1915
1916 assert_eq!(said(&read), [], "{tool}");
1917 assert_eq!(untouched(&m), before, "{tool}: a read finishes nothing");
1918 assert!(switch::interrupted(&m.ctx), "{tool}");
1919 }
1920 }
1921
1922 #[test]
1929 #[cfg_attr(
1930 windows,
1931 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1932 )]
1933 fn a_read_says_a_switch_is_waiting_only_where_a_change_would_stop_at_it() {
1934 for (tool, make) in MACHINES {
1935 let m = stuck(make, &format!("read-stuck-custom-{tool}"));
1936 let mut ctx = m.ctx.clone();
1937 ctx.custom_oauth = true;
1938 let pitboard = Pitboard::new(ctx);
1939
1940 let read = pitboard.status(false).expect("a read");
1941 let known = pitboard.status_offline().expect("a read");
1942 let refused = pitboard
1943 .switch_to(&m.key("there").typed())
1944 .expect_err("refused");
1945
1946 let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
1947 let expected: &[&str] = match m.which {
1948 ProviderId::Claude => &[],
1949 ProviderId::Codex => &["recovery_undetermined"],
1950 };
1951 assert_eq!(codes, expected, "{tool}: {}", refused.error);
1952 let codes: Vec<&str> = known.warnings.iter().map(Warning::code).collect();
1953 assert_eq!(codes, expected, "{tool}: the read that asks nobody too");
1954 assert_eq!(
1955 refused.error.code() == "recovery_undetermined",
1956 !expected.is_empty(),
1957 "{tool}"
1958 );
1959
1960 let gave_up = pitboard.abandon_recovery();
1961 match m.which {
1962 ProviderId::Claude => assert_eq!(
1963 gave_up.expect_err("refused").code(),
1964 "custom_oauth_endpoint",
1965 "{tool}"
1966 ),
1967 ProviderId::Codex => {
1968 assert!(gave_up.expect("given up").is_some(), "{tool}");
1969 assert!(!switch::interrupted(&m.ctx), "{tool}: nothing waits");
1970 }
1971 }
1972 }
1973 }
1974
1975 #[test]
1981 #[cfg_attr(
1982 windows,
1983 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
1984 )]
1985 fn a_read_that_asks_nobody_says_a_switch_is_waiting_wherever_it_can_tell() {
1986 for (tool, make) in MACHINES {
1987 let m = stuck(make, &format!("known-stuck-{tool}"));
1988 let pitboard = Pitboard::new(m.ctx.clone());
1989 let (before, asked) = (untouched(&m), m.api.calls());
1990
1991 let read = pitboard.status_offline().expect("a read");
1992
1993 assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
1994 assert_eq!(m.api.calls(), asked, "{tool}: and asks nobody");
1995 assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
1996 let refused = pitboard
1997 .switch_to(&m.key("there").typed())
1998 .expect_err("every change stops at it");
1999 assert_eq!(
2000 said(&read),
2001 [("recovery_undetermined", refused.error.to_string())],
2002 "{tool}"
2003 );
2004 }
2005
2006 let m = stuck_unless_asked("known-stuck-unasked");
2007 let asked = m.api.calls();
2008 let read = Pitboard::new(m.ctx.clone())
2009 .status_offline()
2010 .expect("a read");
2011 assert_eq!(m.api.calls(), asked, "asks nobody");
2012 assert_eq!(said(&read), [], "only Anthropic could tell");
2013 }
2014
2015 #[test]
2020 #[cfg_attr(
2021 windows,
2022 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2023 )]
2024 fn what_claude_code_cached_is_never_an_accounts_reading() {
2025 use crate::switch::harness::{NOW, cache_usage, usage_answer};
2026 let m = machine("cached-usage");
2027 readings::answered(
2028 &m.ctx,
2029 Permit::for_a_test(),
2030 &[(
2031 "here".into(),
2032 crate::usage::from_usage_object(&usage_answer(10.0), NOW),
2033 )],
2034 );
2035 cache_usage(&m, usage_answer(100.0));
2036 let pitboard = Pitboard::new(m.ctx.clone());
2037 for (how, read) in [
2038 ("offline", pitboard.status_offline()),
2039 ("online", pitboard.status(false)),
2040 ] {
2041 let read = read.expect("a read");
2042 let here = read
2043 .value
2044 .rows
2045 .iter()
2046 .find(|row| row.label.as_deref() == Some("here"))
2047 .expect("here's row");
2048 let shares: Vec<f64> = here
2049 .usage
2050 .iter()
2051 .flat_map(|usage| &usage.windows)
2052 .map(|window| window.percent)
2053 .collect();
2054 assert_eq!(shares, [10.0], "{how}");
2055 }
2056 }
2057
2058 #[test]
2062 #[cfg_attr(
2063 windows,
2064 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2065 )]
2066 fn a_login_already_identified_is_not_asked_about_again() {
2067 use crate::api::scripted::Asked;
2068 let m = machine("read-known-login");
2069
2070 Pitboard::new(m.ctx.clone()).status(false).expect("a read");
2071
2072 let asked = m.api.asked();
2073 assert!(
2074 !asked.contains(&Asked::Owner("access-here-refresh".into())),
2075 "{asked:?}"
2076 );
2077 assert!(
2078 asked.contains(&Asked::Usage("access-here-refresh".into())),
2079 "what it has left is still asked: {asked:?}"
2080 );
2081 }
2082
2083 #[test]
2089 #[cfg_attr(
2090 windows,
2091 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2092 )]
2093 fn a_sign_in_outside_pitboard_is_said_recorded_and_shown_on_its_row() {
2094 use crate::switch::harness::NOW;
2095 let m = signed_in_outside("read-signed-in-outside");
2096 let pitboard = Pitboard::new(m.ctx.clone());
2097 let replaced = "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code \
2098 now has `elsewhere`'s login stored, and Pitboard holds no login for \
2099 `here`. Run `pitboard enroll here --sign-in` to sign in to it again.";
2100 let row = |read: &Done<status::Report>, label: &str| {
2101 read.value
2102 .rows
2103 .iter()
2104 .find(|row| row.label.as_deref() == Some(label))
2105 .map(|row| (row.signed_in, row.stale))
2106 };
2107
2108 let read = pitboard.status(false).expect("a read");
2109
2110 let state = state::load(&m.ctx).expect("state");
2111 assert_eq!(
2112 state
2113 .account_in_use(ProviderId::Claude)
2114 .map(|account| account.label.as_str()),
2115 Some("elsewhere")
2116 );
2117 let account = |label: &str| state.get(&m.key(label)).expect("enrolled");
2118 assert_eq!(account("here").replaced_at, Some(NOW));
2119 assert_eq!(account("elsewhere").last_used_at, Some(NOW));
2120 assert!(account("there").parked.is_some(), "`there` keeps its park");
2121 assert_eq!(
2122 row(&read, "elsewhere").map(|(signed_in, _)| signed_in),
2123 Some(true)
2124 );
2125 assert_eq!(
2126 row(&read, "here"),
2127 Some((false, Some(status::Stale::LoginReplaced)))
2128 );
2129 assert_eq!(said(&read), [("login_replaced", replaced.to_string())]);
2130 let noticed = [
2131 ("elsewhere".to_string(), "signed_in_outside".to_string()),
2132 ("here".to_string(), "login_replaced".to_string()),
2133 ];
2134 assert_eq!(audit_lines(&m, "in-use"), noticed);
2135
2136 let recorded = state_file(&m);
2137 let again = pitboard.status(false).expect("a second read");
2138 assert_eq!(said(&again), [("login_replaced", replaced.to_string())]);
2139 assert_eq!(audit_lines(&m, "in-use"), noticed, "recorded once");
2140 assert_eq!(state_file(&m), recorded, "and written once");
2141
2142 let offline = pitboard.status_offline().expect("a read of what is known");
2143 assert_eq!(said(&offline), [("login_replaced", replaced.to_string())]);
2144 }
2145
2146 #[test]
2150 fn a_replaced_login_says_what_the_store_holds_instead_as_far_as_it_is_known() {
2151 use crate::in_use::InUse;
2152 use crate::switch::harness::{account, owner};
2153 let mut state = state::State::default();
2154 state.accounts.push(account("here", "here", None));
2155 state.accounts[0].replaced_at = Some(1);
2156 let said = |state: &state::State| -> Vec<String> {
2157 replaced(state).iter().map(ToString::to_string).collect()
2158 };
2159 let then = "and Pitboard holds no login for `here`. Run `pitboard enroll here \
2160 --sign-in` to sign in to it again.";
2161
2162 let somebody = InUse {
2163 owner: Some(owner("new")),
2164 login: "new-login".into(),
2165 known_at: 1,
2166 named: None,
2167 };
2168 state
2169 .in_use
2170 .insert(ProviderId::Claude.code().into(), somebody);
2171 assert_eq!(
2172 said(&state),
2173 [format!(
2174 "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code now has \
2175 new@example.com's login stored, {then}"
2176 )]
2177 );
2178
2179 state.in_use.insert(
2180 ProviderId::Claude.code().into(),
2181 crate::switch::identify::nobody(None, 1).expect("a store holding no login"),
2182 );
2183 assert_eq!(
2184 said(&state),
2185 [format!(
2186 "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code now has \
2187 no login stored, {then}"
2188 )]
2189 );
2190
2191 state.in_use.clear();
2192 assert_eq!(
2193 said(&state),
2194 [format!(
2195 "`here`'s login was replaced by a sign-in outside Pitboard, {then}"
2196 )]
2197 );
2198
2199 state.accounts[0].parked = Some(crate::state::Park {
2200 service: "pitboard-park-here-1".into(),
2201 parked_at: 1,
2202 refresh_fingerprint: "f".into(),
2203 access_expires_at: None,
2204 refresh_expires_at: None,
2205 });
2206 assert!(
2207 said(&state).is_empty(),
2208 "a login parked for it is a way back"
2209 );
2210 }
2211
2212 #[test]
2215 #[cfg_attr(
2216 windows,
2217 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2218 )]
2219 fn a_read_that_finds_nothing_new_writes_nothing() {
2220 let m = machine("read-nothing-new");
2221 let pitboard = Pitboard::new(m.ctx.clone());
2222 let before = state_file(&m);
2223
2224 pitboard.status(false).expect("a read");
2225 pitboard.status(false).expect("another read");
2226
2227 assert_eq!(state_file(&m), before);
2228 assert!(audit_lines(&m, "in-use").is_empty());
2229 }
2230
2231 #[test]
2236 #[cfg_attr(
2237 windows,
2238 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2239 )]
2240 fn a_read_under_another_claude_config_dir_keeps_this_ones_record() {
2241 use crate::switch::harness::{NOW, account, document, owner};
2242 let m = machine("read-two-slots");
2243 m.api
2244 .owned_by("access-elsewhere-refresh", owner("elsewhere"));
2245 m.api.owned_by("access-there-other", owner("there"));
2246 let mut state = state::load(&m.ctx).expect("state");
2247 state.accounts.push(account("elsewhere", "elsewhere", None));
2248 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2249 let other = m
2250 .ctx
2251 .clone()
2252 .with_claude_config_dir(m.ctx_home().join("other").to_string_lossy().into_owned());
2253 m.mem.live().plant(
2254 &crate::provider::claude::paths::live_service(&other),
2255 &document("there-other").to_string(),
2256 );
2257
2258 Pitboard::new(other.clone())
2259 .status(false)
2260 .expect("a read under the other");
2261 m.sign_in(&document("elsewhere-refresh"));
2262 Pitboard::new(m.ctx.clone())
2263 .status(false)
2264 .expect("a read here");
2265
2266 let state = state::load(&m.ctx).expect("state");
2267 assert_eq!(
2268 state.get(&m.key("here")).expect("enrolled").replaced_at,
2269 Some(NOW)
2270 );
2271 assert_eq!(
2272 audit_lines(&m, "in-use"),
2273 [
2274 ("elsewhere".to_string(), "signed_in_outside".to_string()),
2275 ("here".to_string(), "login_replaced".to_string()),
2276 ]
2277 );
2278
2279 let recorded = state_file(&m);
2280 for ctx in [&other, &m.ctx, &other] {
2281 Pitboard::new(ctx.clone()).status(false).expect("a read");
2282 }
2283 assert_eq!(state_file(&m), recorded, "reads taking turns write nothing");
2284 }
2285
2286 #[test]
2291 #[cfg_attr(
2292 windows,
2293 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2294 )]
2295 fn a_login_still_stored_under_another_claude_config_dir_is_not_said_replaced() {
2296 use crate::switch::harness::{NOW, account, document, owner};
2297 let m = machine("read-two-slots-one-account");
2298 m.api.owned_by("access-here-other", owner("here"));
2299 m.api
2300 .owned_by("access-elsewhere-refresh", owner("elsewhere"));
2301 let mut state = state::load(&m.ctx).expect("state");
2302 state.accounts.push(account("elsewhere", "elsewhere", None));
2303 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2304 let other = m
2305 .ctx
2306 .clone()
2307 .with_claude_config_dir(m.ctx_home().join("other").to_string_lossy().into_owned());
2308 let plant_there = |login: &str| {
2309 m.mem.live().plant(
2310 &crate::provider::claude::paths::live_service(&other),
2311 &document(login).to_string(),
2312 );
2313 };
2314 let replaced = |read: &Done<status::Report>| {
2315 said(read)
2316 .into_iter()
2317 .filter(|(code, _)| *code == "login_replaced")
2318 .count()
2319 };
2320
2321 plant_there("here-other");
2322 Pitboard::new(other.clone())
2323 .status(false)
2324 .expect("a read under the other");
2325 plant_there("elsewhere-refresh");
2326 let there = Pitboard::new(other.clone())
2327 .status(false)
2328 .expect("a read under the other after a sign-in there");
2329 let here = Pitboard::new(m.ctx.clone())
2330 .status(false)
2331 .expect("a read here");
2332
2333 assert_eq!((replaced(&there), replaced(&here)), (0, 0));
2334 assert_eq!(
2335 state::load(&m.ctx)
2336 .expect("state")
2337 .get(&m.key("here"))
2338 .expect("enrolled")
2339 .replaced_at,
2340 None
2341 );
2342 assert_eq!(
2343 audit_lines(&m, "in-use"),
2344 [("elsewhere".to_string(), "signed_in_outside".to_string())]
2345 );
2346
2347 m.sign_in(&document("elsewhere-refresh"));
2348 let gone = Pitboard::new(m.ctx.clone())
2349 .status(false)
2350 .expect("a read here after a sign-in here");
2351
2352 assert_eq!(replaced(&gone), 1);
2353 assert_eq!(
2354 state::load(&m.ctx)
2355 .expect("state")
2356 .get(&m.key("here"))
2357 .expect("enrolled")
2358 .replaced_at,
2359 Some(NOW)
2360 );
2361 assert_eq!(
2362 audit_lines(&m, "in-use")[1..],
2363 [
2364 ("elsewhere".to_string(), "signed_in_outside".to_string()),
2365 ("here".to_string(), "login_replaced".to_string()),
2366 ]
2367 );
2368 }
2369
2370 #[test]
2374 #[cfg_attr(
2375 windows,
2376 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2377 )]
2378 fn a_read_that_cannot_find_the_login_claude_code_names_records_nothing() {
2379 let m = machine("read-login-elsewhere");
2380 m.mem.live().delete_everything();
2381 let before = state_file(&m);
2382
2383 let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
2384
2385 assert_eq!(state_file(&m), before);
2386 assert!(audit_lines(&m, "in-use").is_empty());
2387 let said = said(&read);
2388 assert!(
2389 !said.iter().any(|(code, _)| *code == "login_replaced"),
2390 "{said:?}"
2391 );
2392 }
2393
2394 fn in_use(read: &Done<status::Report>) -> Vec<&str> {
2396 read.value
2397 .rows
2398 .iter()
2399 .filter(|row| row.signed_in)
2400 .filter_map(|row| row.label.as_deref())
2401 .collect()
2402 }
2403
2404 #[test]
2409 #[cfg_attr(
2410 windows,
2411 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2412 )]
2413 fn an_offline_read_names_the_account_anthropic_named_whatever_the_config_says() {
2414 let m = machine("offline-config-moved");
2415 crate::switch::harness::config_names(&m, "there");
2416
2417 let read = Pitboard::new(m.ctx.clone())
2418 .status_offline()
2419 .expect("a read");
2420
2421 assert_eq!(in_use(&read), ["here"]);
2422 assert_eq!(
2423 said(&read),
2424 [(
2425 "in_use_unconfirmed",
2426 "Claude Code's config has named `there` since Pitboard last asked Anthropic \
2427 whose login Claude Code has stored, so another login may be stored. The one \
2428 stored then was `here`'s. `pitboard status` asks again."
2429 .to_string()
2430 )]
2431 );
2432 assert_eq!(m.api.calls(), 0);
2433 }
2434
2435 #[test]
2439 #[cfg_attr(
2440 windows,
2441 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2442 )]
2443 fn an_offline_read_of_a_record_never_asked_about_says_the_config_names_another() {
2444 let m = machine("offline-never-asked");
2445 let mut state = state::load(&m.ctx).expect("state");
2446 let here = state.get(&m.key("here")).expect("here").owner();
2447 state.in_use.insert(
2448 ProviderId::Claude.code().into(),
2449 crate::in_use::InUse {
2450 owner: Some(here),
2451 login: String::new(),
2452 known_at: 0,
2453 named: None,
2454 },
2455 );
2456 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2457 crate::switch::harness::config_names(&m, "there");
2458
2459 let read = Pitboard::new(m.ctx.clone())
2460 .status_offline()
2461 .expect("a read");
2462
2463 assert_eq!(in_use(&read), ["here"]);
2464 assert_eq!(
2465 said(&read),
2466 [(
2467 "in_use_unconfirmed",
2468 "Claude Code's config names `there`, and Pitboard has not asked Anthropic whose \
2469 login Claude Code has stored since Pitboard was updated, so another login may \
2470 be stored. The account it last switched to is `here`. `pitboard status` asks."
2471 .to_string()
2472 )]
2473 );
2474 assert_eq!(m.api.calls(), 0);
2475 }
2476
2477 #[test]
2480 #[cfg_attr(
2481 windows,
2482 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2483 )]
2484 fn an_offline_read_of_a_record_never_asked_about_says_nothing_where_the_config_agrees() {
2485 let m = machine("offline-never-asked-agrees");
2486 let mut state = state::load(&m.ctx).expect("state");
2487 let here = state.get(&m.key("here")).expect("here").owner();
2488 state.in_use.insert(
2489 ProviderId::Claude.code().into(),
2490 crate::in_use::InUse {
2491 owner: Some(here),
2492 login: String::new(),
2493 known_at: 0,
2494 named: None,
2495 },
2496 );
2497 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2498 crate::switch::harness::config_names(&m, "here");
2499
2500 let read = Pitboard::new(m.ctx.clone())
2501 .status_offline()
2502 .expect("a read");
2503
2504 assert_eq!(in_use(&read), ["here"]);
2505 assert_eq!(said(&read), []);
2506 assert_eq!(m.api.calls(), 0);
2507 }
2508
2509 #[test]
2514 #[cfg_attr(
2515 windows,
2516 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2517 )]
2518 fn a_read_settles_it_and_says_the_config_names_another() {
2519 use crate::api::scripted::Asked;
2520 let m = machine("read-config-names-another");
2521 crate::switch::harness::config_names(&m, "there");
2522 let pitboard = Pitboard::new(m.ctx.clone());
2523 let names_another = "Claude Code's config names `there`, and the login Claude Code has \
2524 stored is `here`'s, as Anthropic said. `/status` in Claude Code \
2525 shows `there`. `pitboard use here` writes `here` into the config.";
2526
2527 let read = pitboard.status(false).expect("a read");
2528
2529 assert!(
2530 !m.api
2531 .asked()
2532 .contains(&Asked::Owner("access-here-refresh".into())),
2533 "{:?}",
2534 m.api.asked()
2535 );
2536 assert_eq!(in_use(&read), ["here"]);
2537 assert_eq!(
2538 said(&read),
2539 [("config_names_another", names_another.to_string())]
2540 );
2541
2542 let offline = pitboard.status_offline().expect("a read of what is known");
2543 assert_eq!(in_use(&offline), ["here"]);
2544 assert_eq!(
2545 said(&offline),
2546 [("config_names_another", names_another.to_string())]
2547 );
2548 }
2549
2550 #[test]
2557 #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
2558 fn whose_a_codex_login_is_is_told_without_asking_anyone() {
2559 let m = interrupted(codex_machine, "codex-renewed-since");
2560 m.sign_in(&crate::switch::harness::codex_login(
2561 "here",
2562 "renewed-since",
2563 ));
2564 m.mem.vault().fault(
2565 &reserved(&m),
2566 crate::store::memory::Fault::Unreadable("locked".into()),
2567 );
2568 let pitboard = Pitboard::new(m.ctx.clone());
2569 let asked = m.api.calls();
2570
2571 let read = pitboard.status_offline().expect("a read");
2572 let checks = pitboard.doctor().checks;
2573
2574 assert_eq!(m.api.calls(), asked, "asks nobody");
2575 let refused = pitboard
2576 .switch_to("codex/there")
2577 .expect_err("it stops at it");
2578 assert_eq!(refused.error.code(), "recovery_undetermined");
2579 assert_eq!(
2580 said(&read),
2581 [("recovery_undetermined", refused.error.to_string())]
2582 );
2583 let check = checks
2584 .iter()
2585 .find(|c| c.code == "interrupted_switch")
2586 .expect("said");
2587 assert_eq!(check.detail, refused.error.to_string());
2588 }
2589
2590 #[test]
2596 #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
2597 fn a_codex_store_a_requirement_pins_is_refused_before_a_sign_in() {
2598 let m = codex_machine("codex-pinned");
2599 let ctx = m.ctx.clone().with_codex_program("/nowhere/codex".into());
2602 m.mem.administers(
2603 "/etc/codex/requirements.toml",
2604 "cli_auth_credentials_store = \"keyring\"\n",
2605 );
2606 let pitboard = Pitboard::new(ctx);
2607 for refused in [
2608 pitboard.sign_in("codex/new").err().expect("refused"),
2609 pitboard
2610 .sign_in_watched("codex/new")
2611 .err()
2612 .expect("refused"),
2613 ] {
2614 assert_eq!(refused.error.code(), "live_store_unsupported");
2615 let said = refused.error.to_string();
2616 assert!(
2617 said.contains("pinned to `keyring` by /etc/codex/requirements.toml"),
2618 "{said}"
2619 );
2620 assert!(
2621 said.contains("No line in your own config.toml can change it"),
2622 "{said}"
2623 );
2624 }
2625 let switched = pitboard.switch_to("codex/there").expect_err("refused too");
2626 assert_eq!(switched.error.code(), "live_store_unsupported");
2627 let file = m
2628 .mem
2629 .file_at(crate::provider::codex::paths::auth_file(&m.ctx));
2630 let kept = crate::store::RawStore::read(&file, "auth.json")
2631 .expect("readable")
2632 .expect("there");
2633 let kept: serde_json::Value = serde_json::from_str(&kept).expect("JSON");
2634 assert_eq!(
2635 kept["tokens"]["refresh_token"], "here-refresh",
2636 "the auth.json Codex does not use is left as it was"
2637 );
2638 }
2639
2640 fn reserved(m: &Machine) -> String {
2642 let raw = std::fs::read_to_string(crate::home::dir(&m.ctx).join("journal.json"))
2643 .expect("a record");
2644 serde_json::from_str::<serde_json::Value>(&raw).expect("a record is JSON")["park_service"]
2645 .as_str()
2646 .expect("a park")
2647 .to_owned()
2648 }
2649
2650 #[test]
2654 #[cfg_attr(
2655 windows,
2656 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2657 )]
2658 fn doctor_says_a_switch_is_stuck_where_it_can_tell_without_asking() {
2659 for (tool, make) in MACHINES {
2660 let m = stuck(make, &format!("doctor-stuck-{tool}"));
2661 let pitboard = Pitboard::new(m.ctx.clone());
2662 let before = untouched(&m);
2663
2664 let checks = pitboard.doctor().checks;
2665
2666 assert_eq!(untouched(&m), before, "{tool}: doctor changes nothing");
2667 let refused = pitboard
2668 .switch_to(&m.key("there").typed())
2669 .expect_err("refused");
2670 let check = checks
2671 .iter()
2672 .find(|c| c.code == "interrupted_switch")
2673 .expect("said");
2674 assert_eq!(check.level, doctor::Level::Warn, "{tool}");
2675 assert_eq!(check.detail, refused.error.to_string(), "{tool}");
2676 assert!(check.advice.contains("pitboard abandon"), "{tool}");
2677 }
2678
2679 let m = stuck_unless_asked("doctor-stuck-unasked");
2680 let asked = m.api.calls();
2681 let checks = Pitboard::new(m.ctx.clone()).doctor().checks;
2682 assert_eq!(m.api.calls(), asked, "doctor asks nobody");
2683 let check = checks
2684 .iter()
2685 .find(|c| c.code == "interrupted_switch")
2686 .expect("said");
2687 assert_eq!(check.detail, "a switch did not finish");
2688 }
2689
2690 fn files(m: &Machine) -> BTreeMap<String, Vec<u8>> {
2692 std::fs::read_dir(crate::home::dir(&m.ctx))
2693 .expect("a Pitboard home")
2694 .map(|entry| entry.expect("an entry").path())
2695 .filter(|path| path.file_name() != Some("audit.log".as_ref()))
2696 .map(|path| {
2697 let body = std::fs::read(&path).unwrap_or_default();
2698 (path.display().to_string(), body)
2699 })
2700 .collect()
2701 }
2702
2703 fn last_audited(m: &Machine) -> Vec<(String, String)> {
2705 audit::read(&m.ctx, 2)
2706 .into_iter()
2707 .map(|entry| (entry.verb, entry.outcome))
2708 .collect()
2709 }
2710
2711 #[test]
2716 #[cfg_attr(
2717 windows,
2718 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2719 )]
2720 fn a_change_refused_over_its_name_still_recovers_an_interrupted_switch() {
2721 for (tool, make) in MACHINES {
2722 for (change, verb, code, audited, refuse) in REFUSALS {
2723 let at = format!("{tool}, {change}");
2724 let m = interrupted(make, &format!("refused-{tool}-{change}"));
2725
2726 let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
2727 .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
2728
2729 assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
2730 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
2731 assert_eq!(said, ["interrupted_switch_undone"], "{at}");
2732 assert!(!switch::interrupted(&m.ctx), "{at}: the record is resolved");
2733 hold(&m, &at);
2734 assert_eq!(
2735 last_audited(&m),
2736 [
2737 (
2738 "recover".to_string(),
2739 "interrupted_switch_undone".to_string()
2740 ),
2741 (verb.to_string(), audited.to_string()),
2742 ],
2743 "{at}"
2744 );
2745 }
2746 }
2747 }
2748
2749 #[test]
2752 #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
2753 fn a_change_refused_over_its_name_with_nothing_interrupted_changes_nothing() {
2754 for (tool, make) in MACHINES {
2755 for (change, verb, code, audited, refuse) in REFUSALS {
2756 let at = format!("{tool}, {change}");
2757 let m = make(&format!("refused-quietly-{tool}-{change}"));
2758 let (before, parked, live) = (files(&m), m.mem.vault().services(), m.live());
2759
2760 let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
2761 .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
2762
2763 assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
2764 assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
2765 assert_eq!(files(&m), before, "{at}: not even the lock file is made");
2766 assert_eq!(m.mem.vault().services(), parked, "{at}");
2767 assert_eq!(m.live(), live, "{at}");
2768 assert_eq!(
2769 last_audited(&m).last(),
2770 Some(&(verb.to_string(), audited.to_string())),
2771 "{at}"
2772 );
2773 }
2774 }
2775 }
2776
2777 #[test]
2781 #[cfg_attr(
2782 windows,
2783 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2784 )]
2785 fn a_new_login_parked_after_it_did_not_hold_says_how_it_was_parked() {
2786 for (tool, make) in MACHINES {
2787 let m = make(&format!("not-installed-said-{tool}"));
2788 m.mem.vault().takes_on_stdin(64);
2789 let login = crate::switch::harness::signed_in(&m, "here", "here-refresh-2");
2790 m.fault_live(crate::store::memory::Fault::DeletedAfterWrite);
2791
2792 let failed = Pitboard::new(m.ctx.clone())
2793 .enroll_signed_in(&m.key("here").typed(), login)
2794 .expect_err("it did not hold");
2795
2796 assert_eq!(failed.error.code(), "sign_in_not_installed", "{tool}");
2797 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
2798 assert_eq!(said, ["written_on_the_command_line"], "{tool}");
2799 }
2800 }
2801
2802 #[test]
2807 #[cfg_attr(
2808 windows,
2809 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2810 )]
2811 fn a_custom_claude_endpoint_stops_only_the_recovery_of_a_claude_code_switch() {
2812 let codex = interrupted(codex_machine, "refused-custom-codex");
2813 let mut ctx = codex.ctx.clone();
2814 ctx.custom_oauth = true;
2815 let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
2816 assert_eq!(failed.error.code(), "account_unknown");
2817 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
2818 assert_eq!(said, ["interrupted_switch_undone"]);
2819 assert!(!switch::interrupted(&codex.ctx));
2820
2821 let claude = interrupted(machine, "refused-custom-claude");
2822 let mut ctx = claude.ctx.clone();
2823 ctx.custom_oauth = true;
2824 let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
2825 assert_eq!(
2826 failed.error.code(),
2827 "account_unknown",
2828 "the refusal, not the recovery that could not run"
2829 );
2830 assert!(failed.warnings.is_empty(), "{:?}", failed.warnings);
2831 assert!(
2832 switch::interrupted(&claude.ctx),
2833 "the record is kept for a run that can finish it"
2834 );
2835 }
2836
2837 #[derive(Debug, Clone, Copy)]
2838 enum Refused {
2839 As(Elevation),
2840 On(Floor),
2841 }
2842
2843 const REFUSED: [Refused; 6] = [
2844 Refused::As(Elevation::Elevated { why: "as root" }),
2845 Refused::As(Elevation::Elevated { why: "with sudo" }),
2846 Refused::As(Elevation::Elevated {
2847 why: crate::host::token::AS_A_SERVICE_ACCOUNT,
2848 }),
2849 Refused::As(Elevation::Unknown),
2850 Refused::On(Floor::Below { build: 22631 }),
2851 Refused::On(Floor::Unknown),
2852 ];
2853
2854 impl Refused {
2855 fn said_by(self, m: &Machine) {
2856 match self {
2857 Refused::As(elevation) => m.mem.runs_with(elevation),
2858 Refused::On(floor) => m.mem.runs_on(floor),
2859 }
2860 }
2861
2862 fn lifted(m: &Machine) {
2863 m.mem.runs_with(Elevation::Normal);
2864 m.mem.runs_on(Floor::Met);
2865 }
2866
2867 fn code(self) -> &'static str {
2868 match self {
2869 Refused::As(_) => "elevated",
2870 Refused::On(_) => "system_too_old",
2871 }
2872 }
2873
2874 fn words(self) -> crate::words::ChangesNothing {
2875 match self {
2876 Refused::As(Elevation::Elevated { why }) => {
2877 crate::words::elevated(crate::host::OS, Some(why))
2878 }
2879 Refused::As(_) => crate::words::elevated(crate::host::OS, None),
2880 Refused::On(Floor::Below { build }) => crate::words::too_old(Some(build)),
2881 Refused::On(_) => crate::words::too_old(None),
2882 }
2883 }
2884 }
2885
2886 fn failed<T>(outcome: Result<T>) -> Option<Failed> {
2889 outcome.err().map(|error| Failed {
2890 error,
2891 warnings: Vec::new(),
2892 })
2893 }
2894
2895 type Change = fn(&Pitboard, &Machine) -> Option<Failed>;
2896
2897 const CHANGES: [(&str, Change); 16] = [
2900 ("switch_to", |p, m| {
2901 p.switch_to(&m.key("there").typed()).err()
2902 }),
2903 ("enroll_current", |p, m| {
2904 p.enroll_current(&m.key("here").typed()).err()
2905 }),
2906 ("sign_in", |p, m| p.sign_in(&m.key("new").typed()).err()),
2907 ("sign_in_watched", |p, m| {
2908 p.sign_in_watched(&m.key("new").typed()).err()
2909 }),
2910 ("forget", |p, m| p.forget(&m.key("there").typed()).err()),
2911 ("rename", |p, m| {
2912 p.rename(&m.key("there").typed(), "elsewhere").err()
2913 }),
2914 ("abandon_recovery", |p, _| failed(p.abandon_recovery())),
2915 ("renew", |p, _| failed(p.renew())),
2916 ("schedule_install", |p, _| failed(p.schedule_install())),
2917 ("schedule_uninstall", |p, _| failed(p.schedule_uninstall())),
2918 ("schedule_repair", |p, _| failed(p.schedule_repair())),
2919 ("adopt", |p, _| failed(p.adopt())),
2920 ("repair", |p, _| p.repair().err()),
2921 ("keep_app_file", |p, _| {
2922 failed(p.keep_app_file(crate::app::AppFile::Preferences, "{}"))
2923 }),
2924 ("uninstall", |p, _| p.uninstall().err()),
2925 ("auto_switch", |p, _| {
2926 p.auto_switch(crate::autoswitch::Threshold::DEFAULT).err()
2927 }),
2928 ];
2929
2930 type Everything = (
2935 BTreeMap<String, Option<Vec<u8>>>,
2936 Vec<(String, Option<String>)>,
2937 Vec<(String, Option<String>)>,
2938 Option<serde_json::Value>,
2939 );
2940
2941 fn everything(m: &Machine) -> Everything {
2942 fn walk(dir: &std::path::Path, into: &mut BTreeMap<String, Option<Vec<u8>>>) {
2943 for entry in std::fs::read_dir(dir).expect("a directory to read") {
2944 let path = entry.expect("an entry").path();
2945 if path.ends_with(".pitboard/signin") {
2946 continue;
2947 }
2948 if path.is_dir() {
2949 into.insert(path.display().to_string(), None);
2950 walk(&path, into);
2951 } else {
2952 let body = std::fs::read(&path).expect("a file to read");
2953 into.insert(path.display().to_string(), Some(body));
2954 }
2955 }
2956 }
2957 let mut files = BTreeMap::new();
2958 walk(&m.ctx_home(), &mut files);
2959 let items = |store: &crate::store::memory::MemoryStore| {
2960 store
2961 .services()
2962 .into_iter()
2963 .map(|service| {
2964 let held = store.peek(&service);
2965 (service, held)
2966 })
2967 .collect()
2968 };
2969 (files, items(m.mem.live()), items(m.mem.vault()), m.live())
2970 }
2971
2972 #[test]
2979 #[cfg_attr(
2980 windows,
2981 ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
2982 )]
2983 fn every_change_is_refused_where_pitboard_may_change_nothing_and_changes_nothing() {
2984 for (tool, make) in MACHINES {
2985 for refused in REFUSED {
2986 for (change, run) in CHANGES {
2987 let at = format!("{tool}, {change}, {refused:?}");
2988 let m = interrupted(make, &format!("gate-{tool}-{change}"));
2989 refused.said_by(&m);
2990 let (before, asked) = (everything(&m), m.api.calls());
2991
2992 let failed = run(&Pitboard::new(m.ctx.clone()), &m)
2993 .unwrap_or_else(|| panic!("{at}: refused"));
2994
2995 assert_eq!(
2996 failed.error.code(),
2997 refused.code(),
2998 "{at}: {}",
2999 failed.error
3000 );
3001 assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
3002 assert!(everything(&m) == before, "{at}: nothing changes");
3003 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
3004 }
3005
3006 let at = format!("{tool}, enroll_signed_in, {refused:?}");
3007 let m = interrupted(make, &format!("gate-{tool}-enroll-signed-in"));
3008 let login = crate::switch::harness::signed_in(&m, "new", "new-refresh");
3009 refused.said_by(&m);
3010 let before = everything(&m);
3011 let failed = Pitboard::new(m.ctx.clone())
3012 .enroll_signed_in(&m.key("new").typed(), login)
3013 .expect_err("refused");
3014 assert_eq!(failed.error.code(), refused.code(), "{at}");
3015 assert!(everything(&m) == before, "{at}: nothing changes");
3016 }
3017 }
3018 }
3019
3020 #[test]
3021 #[cfg_attr(
3022 windows,
3023 ignore = "W16: Pitboard writing, replacing and removing files on Windows"
3024 )]
3025 fn the_gate_says_why_it_refuses_and_lets_a_person_through() {
3026 let m = machine("gate-words");
3027 let pitboard = Pitboard::new(m.ctx.clone());
3028 assert!(pitboard.permit().is_ok(), "as the person");
3029 for refused in REFUSED {
3030 refused.said_by(&m);
3031 let refusal = pitboard.permit().expect_err("refused");
3032 assert_eq!(refusal.code(), refused.code(), "{refused:?}");
3033 assert_eq!(refusal.exit_code(), 1, "{refused:?}");
3034 assert_eq!(refusal.to_string(), refused.words().refusal, "{refused:?}");
3035 Refused::lifted(&m);
3036 assert!(pitboard.permit().is_ok(), "{refused:?}: lifted");
3037 }
3038 }
3039
3040 #[test]
3041 #[cfg_attr(
3042 windows,
3043 ignore = "W16: Pitboard writing, replacing and removing files on Windows"
3044 )]
3045 fn the_gate_lets_windows_11_24h2_through_and_says_an_older_one_first() {
3046 let m = machine("gate-floor");
3047 let pitboard = Pitboard::new(m.ctx.clone());
3048 for build in [26100, 26200] {
3049 m.mem
3050 .runs_on(crate::host::windows_floor(Some((10, 0, build))));
3051 assert!(pitboard.permit().is_ok(), "{build}");
3052 }
3053 m.mem
3054 .runs_on(crate::host::windows_floor(Some((10, 0, 22631))));
3055 let refused = pitboard.permit().expect_err("refused");
3056 assert_eq!(refused.code(), "system_too_old");
3057 assert_eq!(
3058 refused.to_string(),
3059 crate::words::too_old(Some(22631)).refusal
3060 );
3061 m.mem.runs_with(Elevation::Elevated { why: "as root" });
3062 let first = pitboard.permit().expect_err("refused");
3063 assert_eq!(
3064 first.code(),
3065 "system_too_old",
3066 "the system before how it runs"
3067 );
3068 }
3069
3070 #[test]
3076 #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
3077 fn every_change_and_read_is_refused_where_the_home_is_not_a_full_path() {
3078 for home in ["", "relative"] {
3079 for (tool, make) in MACHINES {
3080 let at = format!("{tool}, HOME={home:?}");
3081 let m = make(&format!("unplaced-{tool}-{}", home.len()));
3082 let mut ctx = m.ctx.clone();
3083 ctx.home = std::path::PathBuf::from(home);
3084 let pitboard = Pitboard::new(ctx);
3085 let refused = |error: Error| {
3086 assert_eq!(error.code(), "home_not_absolute", "{at}: {error}");
3087 assert!(error.to_string().starts_with("HOME is "), "{at}: {error}");
3088 };
3089
3090 refused(pitboard.permit().expect_err("the gate refuses"));
3091 refused(pitboard.status_offline().err().expect("refused"));
3092 refused(pitboard.status(false).err().expect("refused"));
3093 let before = (everything(&m), m.api.calls());
3094 for (change, run) in CHANGES {
3095 let failed = run(&pitboard, &m).unwrap_or_else(|| panic!("{at}: {change}"));
3096 refused(failed.error);
3097 assert!(failed.warnings.is_empty(), "{at}, {change}");
3098 }
3099 assert!(
3100 (everything(&m), m.api.calls()) == before,
3101 "{at}: nothing changes and nobody is asked"
3102 );
3103 }
3104 }
3105 }
3106
3107 type Read = Vec<(
3110 ProviderId,
3111 Option<String>,
3112 bool,
3113 Option<crate::usage::Snapshot>,
3114 Option<status::Stale>,
3115 )>;
3116
3117 fn rows(report: &status::Report) -> Read {
3118 report
3119 .rows
3120 .iter()
3121 .map(|row| {
3122 (
3123 row.provider,
3124 row.label.clone(),
3125 row.signed_in,
3126 row.usage.clone(),
3127 row.stale,
3128 )
3129 })
3130 .collect()
3131 }
3132
3133 #[test]
3139 #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
3140 fn a_read_where_pitboard_may_change_nothing_asks_nobody_and_says_why() {
3141 for (tool, make) in MACHINES {
3142 for refused in REFUSED {
3143 let at = format!("{tool}, {refused:?}");
3144 let m = make(&format!("gate-read-{tool}"));
3145 let later = m
3146 .ctx
3147 .clone()
3148 .with_clock(Arc::new(crate::time::FixedClock::at(
3149 crate::switch::harness::NOW + 11 * 86_400,
3150 )));
3151 let pitboard = Pitboard::new(later);
3152 refused.said_by(&m);
3153 let (before, asked) = (everything(&m), m.api.calls());
3154
3155 let read = pitboard.status(false).expect("a read");
3156 let known = pitboard.status_offline().expect("a read");
3157
3158 assert!(everything(&m) == before, "{at}: nothing is written");
3159 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
3160 assert_eq!(rows(&read.value), rows(&known.value), "{at}");
3161 let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
3162 assert_eq!(codes.last(), Some(&"read_only"), "{at}");
3163 assert_eq!(read.warnings.len(), known.warnings.len() + 1, "{at}");
3164 let said = read.warnings.last().expect("said").to_string();
3165 let words = refused.words();
3166 assert!(
3167 said.starts_with(&format!("{}, so it changes nothing", words.because)),
3168 "{at}: {said}"
3169 );
3170 assert!(said.ends_with(words.to_ask_again), "{at}: {said}");
3171
3172 Refused::lifted(&m);
3173 let live = pitboard.status(false).expect("a read");
3174 assert!(m.api.calls() > asked, "{at}: as the person, it asks");
3175 assert!(
3176 !live.warnings.iter().any(|w| w.code() == "read_only"),
3177 "{at}"
3178 );
3179 }
3180 }
3181 }
3182
3183 #[test]
3187 #[cfg_attr(
3188 windows,
3189 ignore = "W16: Pitboard writing, replacing and removing files on Windows"
3190 )]
3191 fn the_status_line_where_pitboard_may_change_nothing_draws_the_same_line_and_writes_nothing() {
3192 let session = serde_json::json!({
3193 "session_id": "a-session",
3194 "rate_limits": {
3195 "five_hour": {"used_percentage": 40.0, "resets_at": crate::switch::harness::NOW + 3_600},
3196 "seven_day": {"used_percentage": 20.0, "resets_at": crate::switch::harness::NOW + 86_400},
3197 },
3198 })
3199 .to_string();
3200 for refused in REFUSED {
3201 let m = machine("gate-statusline");
3202 let pitboard = Pitboard::new(m.ctx.clone());
3203 refused.said_by(&m);
3204 let before = everything(&m);
3205
3206 let drawn = pitboard.statusline(&session);
3207
3208 assert!(everything(&m) == before, "{refused:?}: nothing is written");
3209 Refused::lifted(&m);
3210 assert_eq!(pitboard.statusline(&session), drawn, "{refused:?}");
3211 assert!(
3212 crate::home::dir(&m.ctx).join("sessions.json").exists(),
3213 "{refused:?}: as the person, the session is kept"
3214 );
3215 }
3216 }
3217
3218 #[test]
3222 #[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
3223 fn a_renewal_where_pitboard_may_change_nothing_is_refused_and_asks_nobody() {
3224 for (tool, make) in MACHINES {
3225 for refused in REFUSED {
3226 let at = format!("{tool}, {refused:?}");
3227 let m = make(&format!("gate-renew-{tool}"));
3228 crate::switch::harness::renews(&m, "there-refresh", "there-refresh-2");
3229 let later = m
3230 .ctx
3231 .clone()
3232 .with_clock(Arc::new(crate::time::FixedClock::at(
3233 crate::switch::harness::NOW + 11 * 86_400,
3234 )));
3235 let pitboard = Pitboard::new(later);
3236 refused.said_by(&m);
3237 let (before, asked) = (everything(&m), m.api.calls());
3238
3239 let refusal = pitboard.renew().expect_err("refused");
3240
3241 assert_eq!(refusal.code(), refused.code(), "{at}");
3242 assert!(everything(&m) == before, "{at}: nothing changes");
3243 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
3244
3245 Refused::lifted(&m);
3246 let renewed = pitboard.renew().expect("as the person, it renews");
3247 let codes: Vec<&str> = renewed.iter().map(|(_, r)| r.code()).collect();
3248 assert_eq!(codes, ["renewed"], "{at}");
3249 }
3250 }
3251 }
3252
3253 #[test]
3256 #[cfg_attr(
3257 windows,
3258 ignore = "W16: Pitboard writing, replacing and removing files on Windows"
3259 )]
3260 fn doctor_says_why_pitboard_changes_nothing() {
3261 let m = machine("gate-doctor");
3262 let pitboard = Pitboard::new(m.ctx.clone());
3263 let refusing = |checks: &[doctor::Check]| -> Vec<&'static str> {
3264 checks
3265 .iter()
3266 .map(|c| c.code)
3267 .filter(|code| ["elevated", "system_too_old"].contains(code))
3268 .collect()
3269 };
3270 assert!(
3271 refusing(&pitboard.doctor().checks).is_empty(),
3272 "as the person"
3273 );
3274 for refused in REFUSED {
3275 refused.said_by(&m);
3276 let checks = pitboard.doctor().checks;
3277 let check = checks.first().expect("checks");
3278 let words = refused.words();
3279 assert_eq!(check.code, refused.code(), "{refused:?}");
3280 assert_eq!(check.level, doctor::Level::Fail, "{refused:?}");
3281 assert_eq!(check.detail, words.because, "{refused:?}");
3282 assert!(
3283 check.advice.starts_with("Pitboard changes nothing ")
3284 && check.advice.ends_with(words.way_out),
3285 "{refused:?}: {}",
3286 check.advice
3287 );
3288 Refused::lifted(&m);
3289 }
3290 m.mem.runs_on(Floor::Below { build: 22631 });
3291 m.mem.runs_with(Elevation::Unknown);
3292 assert_eq!(
3293 refusing(&pitboard.doctor().checks),
3294 ["system_too_old", "elevated"]
3295 );
3296 }
3297}