1use crate::context::Context;
6use crate::doctor::{self, Diagnosis};
7use crate::duty::autoswitch::{Auto, Look, Threshold};
8use crate::error::{Error, Result};
9use crate::holder::{self, capitalised};
10use crate::host::{Elevation, Floor, Os};
11use crate::in_use::{self, Doubt, InUse, Known};
12use crate::provider::{Held, LoginKind, Provider, ProviderId};
13use crate::state::{self, Account, Key, new_id};
14use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
15use crate::usage::readings;
16use crate::{audit, schedule, status, statusline};
17use std::fmt;
18
19#[derive(Debug, Clone, Copy)]
29pub struct Permit {
30 _only_the_gate_makes_one: (),
31}
32
33#[cfg(test)]
34impl Permit {
35 pub(crate) fn for_a_test() -> Permit {
38 Permit {
39 _only_the_gate_makes_one: (),
40 }
41 }
42}
43
44pub(crate) fn gate(ctx: &Context) -> Result<Permit> {
59 crate::release::check()?;
60 match ctx.host().floor() {
61 Floor::Met => {}
62 Floor::Below { build } => return Err(Error::SystemTooOld { build: Some(build) }),
63 Floor::Unknown => return Err(Error::SystemTooOld { build: None }),
64 }
65 match ctx.host().elevation(ctx) {
66 Elevation::Normal => {}
67 Elevation::Elevated { why } => return Err(Error::Elevated { why: Some(why) }),
68 Elevation::Unknown => return Err(Error::Elevated { why: None }),
69 }
70 crate::home::check_absolute(ctx)?;
71 Ok(Permit {
72 _only_the_gate_makes_one: (),
73 })
74}
75
76#[derive(Debug)]
78#[non_exhaustive]
79pub enum Warning {
80 Recovered(Recovered),
82 ConfigNotUpdated(Error),
85 ParksPendingRemoval(usize),
87 ParkedLoginRefused {
89 tool: ProviderId,
90 label: String,
91 },
92 RenewalFailed(Error),
93 LockCompromised {
96 tool: ProviderId,
97 },
98 AuthOverridden {
101 tool: ProviderId,
102 names: Vec<String>,
103 },
104 FallbackLogin {
109 tool: ProviderId,
110 path: std::path::PathBuf,
111 held: Held,
112 },
113 WrittenOnTheCommandLine {
115 tool: ProviderId,
116 bytes: usize,
117 limit: usize,
118 },
119 SessionsStillRunning {
123 from: String,
124 holding: Vec<holder::Holding>,
125 },
126 SessionsKeepTheOldLogin {
129 label: String,
130 holding: Vec<holder::Holding>,
131 },
132 SessionsUnknown {
136 tool: ProviderId,
137 from: String,
138 },
139 SessionsUnknownAfterSignIn {
143 tool: ProviderId,
144 label: String,
145 },
146 SignInParkedNotInUse {
149 tool: ProviderId,
150 label: String,
151 why: String,
152 },
153 SwitchStuck(Error),
157 LoginReplaced {
162 tool: ProviderId,
163 id: String,
164 label: String,
165 now: Stored,
166 },
167 InUseUnconfirmed {
174 tool: ProviderId,
175 last: Stored,
176 named: Stored,
177 asked: bool,
178 },
179 ConfigNamesAnother {
183 tool: ProviderId,
184 config: Stored,
185 in_use: Stored,
186 },
187 TakenAsNamed {
190 tool: ProviderId,
191 label: String,
192 recorded: Option<String>,
193 },
194 SetAside {
196 tool: ProviderId,
197 path: std::path::PathBuf,
198 label: Option<String>,
199 why: state::SetAside,
200 },
201 KeysSetAside {
204 tool: ProviderId,
205 path: std::path::PathBuf,
206 label: Option<String>,
207 },
208 LooksSignedOut {
210 tool: ProviderId,
211 label: String,
212 },
213 MoveSettled(switch::MoveSettled),
215 MoveWaiting {
217 tool: ProviderId,
218 label: String,
219 },
220 MoveNotGivenUp {
222 tool: ProviderId,
223 label: String,
224 why: Error,
225 },
226 MoveNotSettled {
228 tool: ProviderId,
229 label: String,
230 why: Error,
231 },
232 SignInAwaited {
234 tool: ProviderId,
235 label: String,
236 put_back: String,
237 since: i64,
238 again: bool,
240 },
241 ItemGone {
243 tool: ProviderId,
244 label: String,
245 item: String,
246 },
247 ReadOnly {
252 why: Option<&'static str>,
253 },
254 ReadOnlyBelowTheFloor {
255 build: Option<u32>,
256 },
257}
258
259impl Warning {
260 fn read_only(refused: &Error) -> Option<Warning> {
261 match *refused {
262 Error::Elevated { why } => Some(Warning::ReadOnly { why }),
263 Error::SystemTooOld { build } => Some(Warning::ReadOnlyBelowTheFloor { build }),
264 _ => None,
265 }
266 }
267
268 pub fn code(&self) -> &'static str {
270 match self {
271 Warning::Recovered(r) => r.code(),
272 Warning::LockCompromised { .. } => "lock_compromised",
273 Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
274 e.code()
275 }
276 Warning::ParksPendingRemoval(_) => "parks_pending_removal",
277 Warning::ParkedLoginRefused { .. } => "parked_login_refused",
278 Warning::AuthOverridden { .. } => "auth_overridden",
279 Warning::FallbackLogin { .. } => "fallback_login",
280 Warning::WrittenOnTheCommandLine { .. } => "written_on_the_command_line",
281 Warning::SessionsStillRunning { .. } => "sessions_still_running",
282 Warning::SessionsKeepTheOldLogin { .. } => "sessions_keep_old_login",
283 Warning::SessionsUnknown { .. } | Warning::SessionsUnknownAfterSignIn { .. } => {
284 "sessions_unknown"
285 }
286 Warning::SignInParkedNotInUse { .. } => "sign_in_parked_not_in_use",
287 Warning::LoginReplaced { .. } => "login_replaced",
288 Warning::InUseUnconfirmed { .. } => "in_use_unconfirmed",
289 Warning::ConfigNamesAnother { .. } => "config_names_another",
290 Warning::TakenAsNamed { .. } => "login_taken_as_named",
291 Warning::SetAside { .. } | Warning::KeysSetAside { .. } => "set_aside",
292 Warning::LooksSignedOut { .. } => "looks_signed_out",
293 Warning::MoveSettled(moved) => match moved.ended {
294 switch::Ended::Undone => "interrupted_move_undone",
295 switch::Ended::Finished => "interrupted_move_finished",
296 },
297 Warning::MoveWaiting { .. } => "move_waiting",
298 Warning::MoveNotGivenUp { .. } => "move_not_given_up",
299 Warning::MoveNotSettled { .. } => "move_not_settled",
300 Warning::SignInAwaited { .. } => "sign_in_awaited",
301 Warning::ItemGone { .. } => "login_item_gone",
302 Warning::ReadOnly { .. } | Warning::ReadOnlyBelowTheFloor { .. } => "read_only",
303 }
304 }
305
306 pub fn tool(&self) -> Option<ProviderId> {
307 match self {
308 Warning::ParkedLoginRefused { tool, .. }
309 | Warning::LockCompromised { tool }
310 | Warning::AuthOverridden { tool, .. }
311 | Warning::FallbackLogin { tool, .. }
312 | Warning::WrittenOnTheCommandLine { tool, .. }
313 | Warning::SessionsUnknown { tool, .. }
314 | Warning::SessionsUnknownAfterSignIn { tool, .. }
315 | Warning::SignInParkedNotInUse { tool, .. }
316 | Warning::LoginReplaced { tool, .. }
317 | Warning::InUseUnconfirmed { tool, .. }
318 | Warning::ConfigNamesAnother { tool, .. }
319 | Warning::TakenAsNamed { tool, .. }
320 | Warning::SetAside { tool, .. }
321 | Warning::KeysSetAside { tool, .. }
322 | Warning::LooksSignedOut { tool, .. }
323 | Warning::MoveWaiting { tool, .. }
324 | Warning::MoveNotGivenUp { tool, .. }
325 | Warning::MoveNotSettled { tool, .. }
326 | Warning::SignInAwaited { tool, .. }
327 | Warning::ItemGone { tool, .. } => Some(*tool),
328 Warning::MoveSettled(moved) => Some(moved.tool),
329 Warning::Recovered(_)
330 | Warning::ConfigNotUpdated(_)
331 | Warning::ParksPendingRemoval(_)
332 | Warning::RenewalFailed(_)
333 | Warning::SessionsStillRunning { .. }
334 | Warning::SessionsKeepTheOldLogin { .. }
335 | Warning::SwitchStuck(_)
336 | Warning::ReadOnly { .. }
337 | Warning::ReadOnlyBelowTheFloor { .. } => None,
338 }
339 }
340}
341
342impl fmt::Display for Warning {
343 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344 match self {
345 Warning::Recovered(r) => write!(f, "{r}"),
346 Warning::LockCompromised { tool } => write!(
347 f,
348 "{} reclaimed a credential lock while this change was under way, so it may \
349 have written or renewed a login at the same time. Pitboard read the slot back \
350 and the change stood, but check with `pitboard` that the right account is \
351 signed in. A login Pitboard parked meanwhile may need signing in to again \
352 when it is next used.",
353 tool.name()
354 ),
355 Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) | Warning::SwitchStuck(e) => {
356 write!(f, "{e}")
357 }
358 Warning::ParksPendingRemoval(count) => write!(
359 f,
360 "{count} parked login(s) no longer in use could not be removed yet; Pitboard \
361 tries again on its next change"
362 ),
363 Warning::ParkedLoginRefused { tool, label } => write!(
364 f,
365 "{} no longer accepts the parked login for `{label}`. Run `pitboard enroll \
366 {label} --sign-in` to sign in to it again.",
367 tool.service()
368 ),
369 Warning::WrittenOnTheCommandLine { tool, bytes, limit } => {
370 write!(
371 f,
372 "this login needs {bytes} bytes and `security` reads {limit} from stdin, \
373 so it was written on the argument line, where a process running as you \
374 could have read it while the call lasted."
375 )?;
376 if *tool == ProviderId::Claude {
377 write!(
378 f,
379 " Claude Code writes this same login the same way whenever it \
380 refreshes the token."
381 )?;
382 }
383 Ok(())
384 }
385 Warning::AuthOverridden { tool, names } => write!(
386 f,
387 "{} is set, so {} signs in with it and not with the login Pitboard moved. \
388 Unset it for the switch to take effect.",
389 names.join(" and "),
390 tool.name()
391 ),
392 Warning::FallbackLogin {
393 tool,
394 path,
395 held: Held::Login,
396 } => write!(
397 f,
398 "{path} holds another {tool} login, which {reader} signs in with. No switch \
399 reaches it. While it is there, {tool} sessions already running at a switch \
400 {running}. `pitboard stow` keeps that login for its account, where Pitboard \
401 holds no other it can switch to, then deletes the file.",
402 path = path.display(),
403 tool = tool.name(),
404 reader = crate::words::reads_only_the_file(crate::host::OS),
405 running = crate::words::kept_until_renewed(),
406 ),
407 Warning::FallbackLogin {
408 tool,
409 path,
410 held: Held::NoLogin,
411 } => write!(
412 f,
413 "{path} is there with no {tool} login in it. While it is, {tool} sessions \
414 already running at a switch {running}. `pitboard stow` deletes it, which lets \
415 them follow a switch.",
416 path = path.display(),
417 tool = tool.name(),
418 running = crate::words::kept_until_renewed(),
419 ),
420 Warning::FallbackLogin {
421 tool,
422 path,
423 held: Held::Unreadable,
424 } => write!(
425 f,
426 "{path} is there, and Pitboard could not read it. While it is, {tool} sessions \
427 already running at a switch {running}. `pitboard doctor` says why, and what to \
428 do.",
429 path = path.display(),
430 tool = tool.name(),
431 running = crate::words::kept_until_renewed(),
432 ),
433 Warning::SessionsStillRunning { from, holding } => write!(
434 f,
435 "{} started before this switch {} still running and still using `{from}`. {} \
436 Do not sign out in {}: signing out there revokes `{from}`'s login, which \
437 Pitboard has just parked.",
438 capitalised(&holder::described(holding)),
439 if holder::plural(holding) { "are" } else { "is" },
440 holder::remedies(holding, "to use the new account"),
441 if holder::plural(holding) {
442 "any of them"
443 } else {
444 "it"
445 },
446 ),
447 Warning::SessionsKeepTheOldLogin { label, holding } => write!(
448 f,
449 "{} started before this sign-in {} still running and still using `{label}`'s \
450 old login. {} Otherwise one of them can put the old login back in place of \
451 the new one when it refreshes its token.",
452 capitalised(&holder::described(holding)),
453 if holder::plural(holding) { "are" } else { "is" },
454 holder::remedies(holding, "to use the new one"),
455 ),
456 Warning::SessionsUnknown { tool, from } => write!(
457 f,
458 "Pitboard could not tell whether {} sessions started before this switch are \
459 still running, because it could not read the list of processes. Do not sign \
460 out in one that is: signing out there revokes `{from}`'s login, which \
461 Pitboard has just parked.",
462 tool.name()
463 ),
464 Warning::SessionsUnknownAfterSignIn { tool, label } => write!(
465 f,
466 "Pitboard could not tell whether {} sessions started before this sign-in are \
467 still running, because it could not read the list of processes. Quit any \
468 that are still using `{label}`'s old login and start them again. Otherwise \
469 one of them can put the old login back in place of the new one when it \
470 refreshes its token.",
471 tool.name()
472 ),
473 Warning::SignInParkedNotInUse { tool, label, why } => write!(
474 f,
475 "{} goes on with the login it has: Pitboard could not tell whose it is \
476 ({why}), so it parked the new login for `{label}` rather than write over \
477 that one. If that login no longer works, {} and sign in to `{label}` \
478 there.",
479 tool.name(),
480 crate::words::sign_in_yourself(*tool, false)
481 ),
482 Warning::LoginReplaced {
483 tool, label, now, ..
484 } => match (tool, now) {
485 (ProviderId::Desktop, Stored::Nothing) => write!(
486 f,
487 "`{label}` was signed out in Claude, which ends its login at claude.ai, and \
488 Pitboard holds no login for it. Run `pitboard enroll {label} --sign-in` to \
489 sign in to it again."
490 ),
491 (ProviderId::Claude | ProviderId::Codex | ProviderId::Desktop, now) => {
492 write!(
493 f,
494 "`{label}`'s login was replaced by a sign-in outside Pitboard"
495 )?;
496 let tool = tool.name();
497 match now {
498 Stored::Account(now) => {
499 write!(f, ": {tool} now has `{now}`'s login stored,")?;
500 }
501 Stored::Unenrolled(email) if email.is_empty() => write!(
502 f,
503 ": {tool} now has the login of an account Pitboard has not enrolled,"
504 )?,
505 Stored::Unenrolled(email) => {
506 write!(f, ": {tool} now has {email}'s login stored,")?;
507 }
508 Stored::Nothing => write!(f, ": {tool} now has no login stored,")?,
509 Stored::Unknown => write!(f, ",")?,
510 }
511 write!(
512 f,
513 " and Pitboard holds no login for `{label}`. Run `pitboard enroll \
514 {label} --sign-in` to sign in to it again."
515 )
516 }
517 },
518 Warning::InUseUnconfirmed {
519 tool,
520 last,
521 named,
522 asked,
523 } => match tool {
524 ProviderId::Desktop => desktop_named(f, named, last),
525 ProviderId::Claude | ProviderId::Codex if !asked => write!(
526 f,
527 "{name}'s config names {named}, and Pitboard has not asked {service} whose \
528 login {name} has stored since Pitboard was updated, so another login may be \
529 stored. The account it last switched to is {last}. `pitboard status` asks.",
530 name = tool.name(),
531 named = named.said("no account"),
532 service = tool.service(),
533 last = last.said("none"),
534 ),
535 ProviderId::Claude | ProviderId::Codex => {
536 write!(
537 f,
538 "{name}'s config has named {named} since Pitboard last asked {service} \
539 whose login {name} has stored, so another login may be stored.",
540 name = tool.name(),
541 named = named.said("no account"),
542 service = tool.service(),
543 )?;
544 match last {
545 Stored::Account(_) | Stored::Unenrolled(_) => {
546 write!(f, " The one stored then was {}'s.", last.said(""))?;
547 }
548 Stored::Nothing => write!(f, " None was stored then.")?,
549 Stored::Unknown => {}
550 }
551 write!(f, " `pitboard status` asks again.")
552 }
553 },
554 Warning::ConfigNamesAnother {
555 tool,
556 config,
557 in_use,
558 } => match tool {
559 ProviderId::Desktop => desktop_named(f, config, in_use),
560 ProviderId::Claude | ProviderId::Codex => {
561 let config = config.said("no account");
562 write!(
563 f,
564 "{name}'s config names {config}, and the login {name} has stored is \
565 {}'s, as {} said. `/status` in {name} shows {config}.",
566 in_use.said("nobody"),
567 tool.service(),
568 name = tool.name(),
569 )?;
570 write!(f, " {}", in_use.how_to_name())
571 }
572 },
573 Warning::TakenAsNamed {
574 label,
575 recorded: None,
576 ..
577 } => write!(
578 f,
579 "Pitboard had not seen the login Claude held, and took it as `{label}`'s because \
580 Claude names that account. If Claude showed another account, run `pitboard use \
581 {label}`, check in Claude, quit it, and enrol it under its own name."
582 ),
583 Warning::TakenAsNamed {
584 label,
585 recorded: Some(recorded),
586 ..
587 } => write!(
588 f,
589 "Pitboard had recorded the login Claude held as `{recorded}`'s, and filed it as \
590 `{label}`'s because Claude names that account. Open Claude and check which \
591 account it shows. If it is another, quit Claude with Command-Q, then run \
592 `pitboard enroll desktop/<label>` under that account's name."
593 ),
594 Warning::SetAside {
595 path, label, why, ..
596 } => {
597 let path = path.display();
598 match (why, label) {
599 (state::SetAside::Superseded, Some(label)) => write!(
600 f,
601 "Pitboard kept `{label}`'s earlier parked login in {path} and deleted \
602 nothing. `pitboard doctor` lists it."
603 ),
604 (state::SetAside::Superseded, None) => write!(
605 f,
606 "Pitboard kept an earlier parked login in {path} and deleted nothing. \
607 `pitboard doctor` lists it."
608 ),
609 (state::SetAside::GivenUp, Some(label)) => write!(
610 f,
611 "Pitboard moved the parts of `{label}`'s login left in Claude's folder \
612 into {path} and deleted nothing. `pitboard doctor` lists them."
613 ),
614 (state::SetAside::GivenUp, None) => write!(
615 f,
616 "Pitboard moved the parts of a login left in Claude's folder into {path} \
617 and deleted nothing. `pitboard doctor` lists them."
618 ),
619 (
620 state::SetAside::InTheWay
621 | state::SetAside::AnotherMachine
622 | state::SetAside::Orphan,
623 _,
624 ) => write!(
625 f,
626 "Pitboard moved what it found in the way into {path} and deleted nothing."
627 ),
628 }
629 }
630 Warning::KeysSetAside { path, label, .. } => {
631 let whose = label
632 .as_ref()
633 .map_or_else(|| "a login".to_string(), |label| format!("`{label}`"));
634 write!(
635 f,
636 "Claude rewrote {whose}'s keys. Pitboard kept the earlier ones in {} and \
637 deleted nothing. `pitboard doctor` lists them.",
638 path.display()
639 )
640 }
641 Warning::LooksSignedOut { label, .. } => write!(
642 f,
643 "Claude looks signed out since Pitboard last read `{label}` in use. The next \
644 change to a Claude Desktop account checks."
645 ),
646 Warning::MoveSettled(moved) => {
647 let label = Key::new(moved.tool, moved.label.clone()).typed();
648 f.write_str(&crate::words::move_settled(&label, moved.half, moved.ended))
649 }
650 Warning::MoveWaiting { label, .. } => f.write_str(&crate::words::move_waiting(label)),
651 Warning::MoveNotGivenUp { label, why, .. } => {
652 f.write_str(&crate::words::move_not_given_up(label, why))
653 }
654 Warning::MoveNotSettled { label, why, .. } => {
655 f.write_str(&crate::words::move_not_settled(label, why))
656 }
657 Warning::SignInAwaited {
658 label,
659 put_back,
660 again,
661 ..
662 } => f.write_str(&crate::words::sign_in_awaited(label, put_back, *again)),
663 Warning::ItemGone { label, item, .. } => {
664 f.write_str(&crate::words::item_gone(label, item))
665 }
666 Warning::ReadOnly { why } => {
667 read_only(f, &crate::words::elevated(crate::host::OS, *why))
668 }
669 Warning::ReadOnlyBelowTheFloor { build } => {
670 read_only(f, &crate::words::too_old(*build))
671 }
672 }
673 }
674}
675
676#[derive(Debug, Clone, PartialEq, Eq)]
679pub enum Stored {
680 Account(String),
682 Unenrolled(String),
684 Nothing,
686 Unknown,
688}
689
690impl Stored {
691 pub(crate) fn of(
693 state: &state::State,
694 tool: ProviderId,
695 owner: Option<&crate::provider::Owner>,
696 ) -> Stored {
697 match owner {
698 None => Stored::Nothing,
699 Some(owner) => state.account_of(tool, owner).map_or_else(
700 || Stored::Unenrolled(owner.email.clone()),
701 |account| Stored::Account(state.typed(&account.key())),
702 ),
703 }
704 }
705
706 pub(crate) fn said(&self, nobody: &str) -> String {
708 match self {
709 Stored::Account(name) => format!("`{name}`"),
710 Stored::Unenrolled(email) if email.is_empty() => {
711 "an account Pitboard has not enrolled".into()
712 }
713 Stored::Unenrolled(email) => email.clone(),
714 Stored::Nothing | Stored::Unknown => nobody.to_string(),
715 }
716 }
717
718 pub(crate) fn how_to_name(&self) -> String {
722 match self {
723 Stored::Account(name) => {
724 format!("`pitboard use {name}` writes `{name}` into the config.")
725 }
726 _ => "A switch writes the account it puts in use into the config.".into(),
727 }
728 }
729}
730
731fn replaced(state: &state::State) -> Vec<Warning> {
734 state
735 .accounts
736 .iter()
737 .filter(|account| account.replaced_at.is_some() && account.parked.is_none())
738 .map(|account| {
739 let tool = account.provider();
740 let now = state.in_use(tool).map_or(Stored::Unknown, |record| {
741 Stored::of(state, tool, record.owner.as_ref())
742 });
743 Warning::LoginReplaced {
744 tool,
745 id: account.id.clone(),
746 label: state.typed(&account.key()),
747 now,
748 }
749 })
750 .collect()
751}
752
753fn in_doubt(state: &state::State, tool: ProviderId, known: &Known) -> Option<Warning> {
757 let named = Stored::of(state, tool, known.own_record.as_ref());
758 let unconfirmed = |asked| {
759 let last = known.last.as_ref().map_or(Stored::Unknown, |record| {
760 Stored::of(state, tool, record.owner.as_ref())
761 });
762 Some(Warning::InUseUnconfirmed {
763 tool,
764 last,
765 named: named.clone(),
766 asked,
767 })
768 };
769 match known.doubt {
770 Some(Doubt::NamedMoved) => return unconfirmed(true),
771 Some(Doubt::NeverEstablished) => {
772 let owner = known.owner()?;
773 let config = known.own_record.as_ref()?;
774 return if new_id(tool, config) == new_id(tool, owner) {
775 None
776 } else {
777 unconfirmed(false)
778 };
779 }
780 Some(Doubt::LoginUnreadable) | None => {}
781 }
782 known
783 .named_another(tool)
784 .map(|_| Warning::ConfigNamesAnother {
785 tool,
786 config: named,
787 in_use: Stored::of(state, tool, known.owner()),
788 })
789}
790
791fn desktop_named(f: &mut fmt::Formatter<'_>, named: &Stored, last: &Stored) -> fmt::Result {
792 write!(
793 f,
794 "{}. Open Claude, check which account it shows, quit it with Command-Q, then run \
795 `pitboard enroll desktop/<label>` under that account's name.",
796 crate::words::desktop_named(named, last)
797 )
798}
799
800fn read_only(f: &mut fmt::Formatter<'_>, said: &crate::words::ChangesNothing) -> fmt::Result {
801 write!(
802 f,
803 "{}, so it changes nothing: these are the numbers it last measured, and it renews no \
804 parked login and asks nobody. {}",
805 said.because, said.to_ask_again
806 )
807}
808
809#[derive(Debug)]
810pub struct Done<T> {
811 pub value: T,
812 pub warnings: Vec<Warning>,
813}
814
815fn sign_in_awaited(state: &state::State, tool: ProviderId) -> Option<Warning> {
817 let awaited = state.awaiting(tool)?;
818 let put_back = state.by_id(tool, &awaited.put_back)?.key();
819 Some(Warning::SignInAwaited {
820 tool,
821 label: state.typed(&Key::new(tool, awaited.label.clone())),
822 put_back: state.typed(&put_back),
823 since: awaited.since,
824 again: awaited.account.is_some(),
825 })
826}
827
828#[derive(Debug)]
831pub struct Failed {
832 pub error: Error,
833 pub warnings: Vec<Warning>,
834 pub said_first: usize,
836}
837
838impl Failed {
839 fn of(mut error: Error, found: Vec<Warning>) -> Failed {
842 let (mut warnings, found): (Vec<Warning>, Vec<Warning>) = found
843 .into_iter()
844 .partition(|warning| matches!(warning, Warning::Recovered(_)));
845 warnings.extend(error.take_said_first());
846 let said_first = warnings.len();
847 warnings.extend(found);
848 warnings.extend(error.take_warnings());
849 Failed {
850 error,
851 warnings,
852 said_first,
853 }
854 }
855}
856
857pub type Changing<T> = std::result::Result<Done<T>, Failed>;
858
859#[derive(Debug)]
860pub struct Pitboard {
861 ctx: Context,
862}
863
864impl Pitboard {
865 pub fn new(ctx: Context) -> Pitboard {
866 Pitboard { ctx }
867 }
868
869 pub fn permit(&self) -> Result<Permit> {
877 gate(&self.ctx)
878 }
879
880 pub fn hold_open(&self) -> Option<crate::app::Open> {
882 crate::app::Open::hold(&self.ctx, self.permit().ok()?)
883 }
884
885 pub fn check_homes(&self) -> Result<()> {
891 crate::home::check_absolute(&self.ctx)
892 }
893
894 fn permitted(&self) -> std::result::Result<Permit, Failed> {
896 self.permit().map_err(|error| Failed {
897 error,
898 warnings: Vec::new(),
899 said_first: 0,
900 })
901 }
902
903 pub fn status(&self, fresh: bool) -> Result<Done<status::Report>> {
937 let permit = match self.permit() {
938 Ok(permit) => permit,
939 Err(refused) => {
940 let Some(warning) = Warning::read_only(&refused) else {
941 return Err(refused);
942 };
943 let mut read = self.status_offline()?;
944 read.warnings.push(warning);
945 return Ok(read);
946 }
947 };
948 let renewed = switch::renew_parked(&self.ctx, permit);
949 let state = state::load(&self.ctx)?;
952 let (stuck, answers) = std::thread::scope(|scope| {
956 let stuck = scope.spawn(|| switch::stuck(&self.ctx, &state, switch::Asking::Service));
957 let answers = status::ask(&self.ctx, permit, &state, fresh);
958 (stuck.join().ok().flatten(), answers)
959 });
960 switch::identify::record_read(&self.ctx, permit, &answers.found, &answers.plans);
961 let found = answers.found.clone();
962 let state = state::load(&self.ctx)?;
965 let report = status::report(&self.ctx, permit, &state, answers);
966 let mut warnings: Vec<Warning> = stuck.map(Warning::SwitchStuck).into_iter().collect();
967 for (key, outcome) in renewed {
968 audit::record(&self.ctx, permit, "renew", &key.typed(), outcome.code());
969 match outcome {
970 Renewal::Refused => warnings.push(Warning::ParkedLoginRefused {
971 tool: key.provider,
972 label: state.typed(&key),
973 }),
974 Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
975 Renewal::Renewed | Renewal::Deferred => {}
976 }
977 }
978 warnings.extend(
979 ProviderId::ALL
980 .iter()
981 .filter_map(|&tool| self.left_behind(tool)),
982 );
983 warnings.extend(self.standing(&state, &found));
984 Ok(Done {
985 value: report,
986 warnings,
987 })
988 }
989
990 fn standing(&self, state: &state::State, found: &[(ProviderId, InUse)]) -> Vec<Warning> {
999 let mut warnings = replaced(state);
1000 for &tool in ProviderId::ALL
1002 .iter()
1003 .filter(|&&tool| status::shown(state, tool))
1004 {
1005 let (known, signed_out) = match crate::provider::of(tool) {
1006 LoginKind::Document(driver) if driver.identifies_by_itself() => continue,
1007 LoginKind::Document(_) => {
1008 let known = match found.iter().find(|(which, _)| *which == tool) {
1009 Some((_, found)) => in_use::judged(&self.ctx, tool, Some(found.clone())),
1010 None => in_use::known(&self.ctx, state, tool),
1011 };
1012 (known, None)
1013 }
1014 LoginKind::Folder(app) => {
1015 warnings.extend(sign_in_awaited(state, tool));
1016 if let Some(moving) = state.moving(tool) {
1018 warnings.push(Warning::MoveWaiting {
1019 tool,
1020 label: state.typed(&Key::new(tool, moving.label.clone())),
1021 });
1022 continue;
1023 }
1024 switch::folder::read(&self.ctx, state, app)
1026 }
1027 };
1028 warnings.extend(in_doubt(state, tool, &known));
1029 warnings.extend(signed_out.map(|key| Warning::LooksSignedOut {
1030 tool,
1031 label: state.typed(&key),
1032 }));
1033 }
1034 warnings
1035 }
1036
1037 pub fn doctor(&self) -> Diagnosis {
1038 self.doctor_with(&crate::time::moment)
1039 }
1040
1041 pub fn doctor_with(&self, moment: &dyn Fn(i64, i64) -> String) -> Diagnosis {
1045 doctor::run(&self.ctx, moment)
1046 }
1047
1048 pub fn status_offline(&self) -> Result<Done<status::Report>> {
1066 let state = state::load(&self.ctx)?;
1067 let warnings = switch::stuck(&self.ctx, &state, switch::Asking::Nobody)
1068 .map(Warning::SwitchStuck)
1069 .into_iter()
1070 .chain(self.standing(&state, &[]))
1071 .collect();
1072 Ok(Done {
1073 value: status::gather_offline(&self.ctx, &state),
1074 warnings,
1075 })
1076 }
1077
1078 pub fn statusline(&self, session: &str) -> statusline::StatusLine {
1084 statusline::read(&self.ctx, self.permit().ok(), session)
1085 }
1086
1087 pub fn account(&self, typed: &str) -> Option<Account> {
1089 let state = state::load(&self.ctx).ok()?;
1090 crate::label::resolve(&state, typed).ok().cloned()
1091 }
1092
1093 pub fn switch_to(&self, typed: &str) -> Changing<Outcome> {
1094 let permit = self.permitted()?;
1095 let key = self.named(permit, "use", typed)?;
1096 self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
1097 switch::switch(settled, &key)
1098 })
1099 }
1100
1101 pub fn replace_with(&self, typed: &str) -> Changing<Outcome> {
1107 let permit = self.permitted()?;
1108 let key = self.named(permit, "use", typed)?;
1109 self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
1110 switch::replace(settled, &key)
1111 })
1112 }
1113
1114 pub fn update_config(&self, typed: &str) -> Changing<Outcome> {
1120 let permit = self.permitted()?;
1121 let key = self.named(permit, "use", typed)?;
1122 self.changing(permit, "use", &key.typed(), Some(key.provider), |settled| {
1123 switch::update_config(settled, &key)
1124 })
1125 }
1126
1127 pub fn auto_switch(&self, threshold: Threshold) -> Changing<Auto> {
1139 let permit = self.permitted()?;
1140 let looked = self.auto_look(threshold).map_err(|error| Failed {
1141 error,
1142 warnings: Vec::new(),
1143 said_first: 0,
1144 })?;
1145 if let Look::Stands(value) = looked {
1146 return Ok(Done {
1147 value: *value,
1148 warnings: Vec::new(),
1149 });
1150 }
1151 let tool = ProviderId::Claude;
1152 let (settled, mut warnings) = self.settled_for(permit, "auto-switch", "", Some(tool))?;
1153 match switch::automatically(settled, threshold) {
1154 Ok((value, more)) => {
1155 warnings.extend(more);
1156 Ok(Done { value, warnings })
1157 }
1158 Err(error) => Err(Failed::of(error, warnings)),
1159 }
1160 }
1161
1162 pub fn auto_look(&self, threshold: Threshold) -> Result<Look> {
1170 let state = state::load(&self.ctx)?;
1171 Ok(crate::duty::autoswitch::look(&self.ctx, &state, threshold))
1172 }
1173
1174 fn named(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
1180 let state = state::load(&self.ctx).map_err(|error| Failed {
1181 error,
1182 warnings: Vec::new(),
1183 said_first: 0,
1184 })?;
1185 crate::label::resolve(&state, typed)
1186 .map(Account::key)
1187 .map_err(|error| self.refused(permit, verb, typed, error.code(), error))
1188 }
1189
1190 pub fn enroll_current(&self, typed: &str) -> Changing<Enrolled> {
1192 let permit = self.permitted()?;
1193 let key = self.chosen(permit, "enroll", typed)?;
1194 self.changing(
1195 permit,
1196 "enroll",
1197 &key.typed(),
1198 Some(key.provider),
1199 |settled| switch::enroll(settled, &key, None),
1200 )
1201 }
1202
1203 fn chosen(&self, permit: Permit, verb: &str, typed: &str) -> std::result::Result<Key, Failed> {
1209 self.enrolling(typed)
1210 .map_err(|error| self.refused(permit, verb, typed, "label_unusable", error))
1211 }
1212
1213 fn refused(
1223 &self,
1224 permit: Permit,
1225 verb: &str,
1226 subject: &str,
1227 code: &str,
1228 error: Error,
1229 ) -> Failed {
1230 let recovered = if switch::interrupted(&self.ctx) {
1231 switch::settle(&self.ctx, permit, switch::interrupted_tool(&self.ctx))
1232 .ok()
1233 .and_then(|(_, recovered)| recovered)
1234 } else {
1235 None
1236 };
1237 let mut warnings = Vec::new();
1238 if let Some(r) = recovered {
1239 audit::record(&self.ctx, permit, "recover", &r.to, r.code());
1240 warnings.push(Warning::Recovered(r));
1241 }
1242 audit::record(&self.ctx, permit, verb, subject, code);
1243 Failed::of(error, warnings)
1244 }
1245
1246 fn enrolling(&self, typed: &str) -> Result<Key> {
1249 if let Ok(state) = state::load(&self.ctx) {
1250 let whole = state
1251 .accounts
1252 .iter()
1253 .find(|a| typed.contains(crate::label::SEPARATOR) && a.label == typed);
1254 let named = match crate::label::parse(typed) {
1255 Ok(crate::label::Spec::Bare(label)) => {
1256 state.get(&Key::new(crate::label::DEFAULT, label))
1257 }
1258 Ok(crate::label::Spec::Qualified(provider, label)) => {
1259 state.get(&Key::new(provider, label))
1260 }
1261 Err(_) => None,
1262 };
1263 if let Some(existing) = whole.or(named) {
1264 return Ok(existing.key());
1265 }
1266 }
1267 crate::label::choose(typed)
1268 .map(|chosen| Key::new(chosen.provider, chosen.label))
1269 .map_err(Error::Usage)
1270 }
1271
1272 pub fn account_to_enroll(&self, typed: &str) -> Option<Account> {
1275 let key = self.enrolling(typed).ok()?;
1276 state::load(&self.ctx).ok()?.get(&key).cloned()
1277 }
1278
1279 pub fn name_to_type(&self, typed: &str) -> String {
1282 let Ok(key) = self.enrolling(typed) else {
1283 return typed.to_string();
1284 };
1285 state::load(&self.ctx).map_or_else(|_| key.typed(), |state| state.typed(&key))
1286 }
1287
1288 pub fn running_before_sign_in(&self, typed: &str) -> Option<String> {
1291 let which = crate::label::choose(typed)
1292 .map(|chosen| chosen.provider)
1293 .unwrap_or(crate::label::DEFAULT);
1294 switch::running_already(&self.ctx, which)
1295 }
1296
1297 pub fn sign_in(&self, typed: &str) -> std::result::Result<SignIn, Failed> {
1299 let permit = self.permitted()?;
1300 let key = self.chosen(permit, "enroll", typed)?;
1301 let driver = match crate::provider::of(key.provider) {
1302 LoginKind::Document(driver) => driver,
1303 LoginKind::Folder(_) => {
1305 return self
1306 .sign_in_watched(typed)?
1307 .finish()
1308 .map_err(|error| self.not_started(permit, typed, error));
1309 }
1310 };
1311 self.ready_to_sign_in(driver)
1312 .and_then(|()| switch::sign_in(&self.ctx, permit, driver))
1313 .map_err(|error| self.not_started(permit, typed, error))
1314 }
1315
1316 pub fn sign_in_watched(
1318 &self,
1319 typed: &str,
1320 ) -> std::result::Result<switch::WatchedSignIn, Failed> {
1321 let permit = self.permitted()?;
1322 let key = self.chosen(permit, "enroll", typed)?;
1323 let driver = match crate::provider::of(key.provider) {
1324 LoginKind::Document(driver) => driver,
1325 LoginKind::Folder(app) => {
1328 let one_at_a_time = switch::one_at_a_time(&self.ctx, permit)
1329 .map_err(|error| self.not_started(permit, typed, error))?;
1330 let subject = key.typed();
1331 let settling = self.settled_for(permit, "park", &subject, Some(key.provider))?;
1332 let first = settling
1334 .1
1335 .iter()
1336 .map(|warning| format!("{warning}\n"))
1337 .collect();
1338 return self
1339 .change(permit, "park", &subject, settling, |settled| {
1340 switch::folder::awaited::start(settled, &key, app, one_at_a_time, first)
1341 })
1342 .map(|started| started.value);
1343 }
1344 };
1345 self.ready_to_sign_in(driver)
1346 .and_then(|()| switch::sign_in_watched_as(&self.ctx, permit, driver, &key))
1347 .map_err(|error| self.not_started(permit, typed, error))
1348 }
1349
1350 pub fn installed(&self) -> Vec<ProviderId> {
1352 crate::provider::installed_among(&self.ctx, crate::host::proc::can_run)
1353 }
1354
1355 fn not_started(&self, permit: Permit, typed: &str, error: Error) -> Failed {
1357 audit::record(&self.ctx, permit, "enroll", typed, error.code());
1358 Failed {
1359 error,
1360 warnings: Vec::new(),
1361 said_first: 0,
1362 }
1363 }
1364
1365 fn ready_to_sign_in(&self, driver: &'static dyn Provider) -> Result<()> {
1369 let tool = driver.id();
1370 if tool == ProviderId::Claude && self.ctx.custom_oauth() {
1371 return Err(Error::CustomOauthEndpoint);
1372 }
1373 state::load(&self.ctx)?;
1374 switch::live_store(&self.ctx, driver)?;
1377 if let crate::provider::Isolation::NotIsolated { reason } =
1382 driver.private_signin_isolation(&self.ctx)
1383 {
1384 return Err(Error::SignInNotIsolated { reason });
1385 }
1386 if driver.program(&self.ctx).is_none() {
1387 return Err(Error::ProgramMissing {
1388 tool,
1389 program: self
1390 .ctx
1391 .program_for(tool)
1392 .unwrap_or(std::path::Path::new(tool.program()))
1393 .display()
1394 .to_string(),
1395 });
1396 }
1397 Ok(())
1398 }
1399
1400 pub fn enroll_signed_in(&self, typed: &str, login: SignIn) -> Changing<Enrolled> {
1401 let permit = self.permitted()?;
1402 let key = self.chosen(permit, "enroll", typed)?;
1403 self.changing(
1404 permit,
1405 "enroll",
1406 &key.typed(),
1407 Some(key.provider),
1408 |settled| switch::enroll(settled, &key, Some(login)),
1409 )
1410 }
1411
1412 pub fn forget(&self, typed: &str) -> Changing<String> {
1414 let permit = self.permitted()?;
1415 let key = self.named(permit, "forget", typed)?;
1416 self.changing(
1417 permit,
1418 "forget",
1419 &key.typed(),
1420 Some(key.provider),
1421 |settled| switch::forget(settled, &key),
1422 )
1423 }
1424
1425 pub fn abandon_recovery(&self) -> Result<switch::GaveUp> {
1427 let permit = self.permit()?;
1428 let outcome = switch::abandon(&self.ctx, permit);
1429 audit::record(
1430 &self.ctx,
1431 permit,
1432 "abandon",
1433 "",
1434 match &outcome {
1435 Ok(_) => "ok",
1436 Err(e) => e.code(),
1437 },
1438 );
1439 outcome
1440 }
1441
1442 pub fn renew(&self) -> Result<Vec<(Key, Renewal)>> {
1452 let ctx = schedule::for_its_run(&self.ctx);
1453 let permit = gate(&ctx)?;
1454 let outcomes = switch::renew_due(&ctx, permit, switch::Due::ToStayAlive);
1455 for (key, outcome) in &outcomes {
1456 audit::record(&ctx, permit, "renew", &key.typed(), outcome.code());
1457 }
1458 Ok(outcomes)
1459 }
1460
1461 pub fn schedule(&self) -> schedule::Installed {
1464 schedule::status(&self.ctx)
1465 }
1466
1467 pub fn schedule_renews_this_home(&self) -> bool {
1471 schedule::serves(&self.ctx)
1472 }
1473
1474 pub fn schedule_install(&self) -> Result<schedule::Location> {
1478 schedule::install(&self.ctx, self.permit()?)
1479 }
1480
1481 pub fn schedule_uninstall(&self) -> Result<bool> {
1483 schedule::uninstall(&self.ctx, self.permit()?)
1484 }
1485
1486 pub fn schedule_repair(&self) -> Result<bool> {
1490 schedule::repair(&self.ctx, self.permit()?)
1491 }
1492
1493 pub fn adopt(&self) -> Result<Option<switch::Adopted>> {
1499 switch::adopt(&self.ctx, self.permit()?)
1500 }
1501
1502 pub fn repair(&self) -> Changing<switch::Reclaimed> {
1507 let permit = self.permitted()?;
1508 self.changing(permit, "repair", "", None, switch::repair)
1509 }
1510
1511 pub fn holding(&self, which: ProviderId) -> Vec<holder::Holding> {
1516 match switch::still_holding(&self.ctx, which) {
1517 switch::StillHolding::These(holding) => holding,
1518 switch::StillHolding::Nothing | switch::StillHolding::Unknown => Vec::new(),
1519 }
1520 }
1521
1522 pub fn whole(&self, which: ProviderId) -> bool {
1526 match crate::provider::of(which) {
1527 LoginKind::Document(_) => true,
1528 LoginKind::Folder(_) => {
1529 let Ok(state) = state::load(&self.ctx) else {
1530 return false;
1531 };
1532 let parked = state.accounts.iter().any(|a| {
1533 a.provider() == which
1534 && a.parked
1535 .as_ref()
1536 .is_some_and(|park| park.kind == state::ParkKind::Folder)
1537 });
1538 let nobody = state
1539 .in_use(which)
1540 .is_some_and(|record| record.owner.is_none());
1541 let left_signed_out = nobody && parked && state.awaiting(which).is_none();
1542 state.moving(which).is_none() && !left_signed_out
1543 }
1544 }
1545 }
1546
1547 pub fn moving(&self, which: ProviderId) -> bool {
1550 match crate::provider::of(which) {
1551 LoginKind::Document(_) => false,
1552 LoginKind::Folder(_) => {
1553 state::load(&self.ctx).map_or(true, |state| state.moving(which).is_some())
1554 }
1555 }
1556 }
1557
1558 pub fn parks_folder(&self) -> std::path::PathBuf {
1560 crate::store::folder::parks_dir(&self.ctx)
1561 }
1562
1563 pub fn directory(&self) -> std::path::PathBuf {
1565 crate::home::dir(&self.ctx)
1566 }
1567
1568 pub fn default_directory(&self) -> std::path::PathBuf {
1570 self.ctx.default_pitboard_home()
1571 }
1572
1573 pub fn shown(&self, folder: &std::path::Path) -> String {
1575 let home = self.ctx.home();
1576 match folder.strip_prefix(home) {
1577 Ok(rest) if self.ctx.os != Os::Windows && !home.as_os_str().is_empty() => {
1578 std::path::Path::new("~").join(rest).display().to_string()
1579 }
1580 _ => folder.display().to_string(),
1581 }
1582 }
1583
1584 pub fn changed_at(&self) -> i64 {
1588 state::changed_at(&self.ctx)
1589 }
1590
1591 pub fn readings_changed_at(&self) -> i64 {
1594 readings::changed_at(&self.ctx)
1595 }
1596
1597 pub fn app_file(&self, file: crate::app::AppFile) -> std::io::Result<Option<String>> {
1600 crate::app::read_app_file(&self.ctx, file)
1601 }
1602
1603 pub fn keep_app_file(&self, file: crate::app::AppFile, body: &str) -> Result<()> {
1606 crate::app::write_app_file(&self.ctx, self.permit()?, file, body)
1607 }
1608
1609 pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
1611 audit::read(&self.ctx, limit)
1612 }
1613
1614 pub fn uninstall(&self) -> Changing<switch::Removed> {
1618 let permit = self.permitted()?;
1619 self.changing(permit, "uninstall", "", None, |settled| {
1620 switch::uninstall(settled).map(|mut removed| {
1621 let said = std::mem::take(&mut removed.warnings);
1622 (removed, said)
1623 })
1624 })
1625 }
1626
1627 pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
1631 let permit = self.permitted()?;
1632 let from = self.named(permit, "rename", from)?;
1633 let chosen = crate::label::choose(to)
1634 .map(|chosen| Key::new(chosen.provider, chosen.label))
1635 .map_err(|refusal| {
1636 self.refused(
1637 permit,
1638 "rename",
1639 to,
1640 "label_unusable",
1641 Error::Usage(refusal),
1642 )
1643 })?;
1644 if to.contains(crate::label::SEPARATOR) && chosen.provider != from.provider {
1647 let error = Error::Usage(format!(
1648 "`{from}` is a {} account, and a rename cannot move it to {}. Sign in to that \
1649 tool and enrol the account there instead.",
1650 from.provider, chosen.provider
1651 ));
1652 return Err(self.refused(permit, "rename", &from.typed(), error.code(), error));
1653 }
1654 let to = chosen.label;
1655 self.changing(
1656 permit,
1657 "rename",
1658 &format!("{from} -> {to}"),
1659 Some(from.provider),
1660 |settled| switch::rename(settled, &from, &to).map(|email| (email, Vec::new())),
1661 )
1662 }
1663
1664 pub fn warm_overview(&self) -> Result<crate::duty::warm::Overview> {
1667 let state = state::load(&self.ctx)?;
1668 let setting = crate::duty::warm::load(&self.ctx)?;
1669 Ok(crate::duty::warm::overview(&self.ctx, &state, &setting))
1670 }
1671
1672 pub fn warm_now(&self, typed: &str) -> Changing<switch::warm::WarmUp> {
1677 let permit = self.permitted()?;
1678 let key = self.named(permit, "warm-up", typed)?;
1679 let settled = self.settled_to_warm(permit, "warm-up", &key.typed())?;
1680 Self::warming(settled, switch::warm::Target::Account(key))
1681 }
1682
1683 pub fn warm_up(&self) -> Changing<switch::warm::WarmUp> {
1687 let permit = self.permitted()?;
1688 let looked = self.warm_look().map_err(|error| Failed {
1689 error,
1690 warnings: Vec::new(),
1691 said_first: 0,
1692 })?;
1693 if let crate::duty::Look::Stands(overview) = looked {
1694 return Ok(Done {
1695 value: switch::warm::WarmUp::Stands(overview),
1696 warnings: Vec::new(),
1697 });
1698 }
1699 let settled = self.settled_to_warm(permit, "warm-up", "")?;
1700 Self::warming(settled, switch::warm::Target::Due)
1701 }
1702
1703 pub(crate) fn warm_look(&self) -> Result<crate::duty::Look<crate::duty::warm::Overview>> {
1707 let state = state::load(&self.ctx)?;
1708 crate::duty::warm::look(&self.ctx, &state)
1709 }
1710
1711 fn settled_to_warm(
1716 &self,
1717 permit: Permit,
1718 verb: &str,
1719 subject: &str,
1720 ) -> std::result::Result<(Settled, Vec<Warning>), Failed> {
1721 let (settled, recovered) = self.settled(permit, verb, subject, Some(ProviderId::Claude))?;
1722 Ok((settled, recovered.into_iter().collect()))
1723 }
1724
1725 fn warming(
1726 (settled, mut warnings): (Settled, Vec<Warning>),
1727 target: switch::warm::Target,
1728 ) -> Changing<switch::warm::WarmUp> {
1729 match switch::warm::warm_up(settled, target) {
1730 Ok((value, more)) => {
1731 warnings.extend(more);
1732 Ok(Done { value, warnings })
1733 }
1734 Err(error) => Err(Failed::of(error, warnings)),
1735 }
1736 }
1737
1738 pub fn warm_turn(&self, typed: &str, on: bool) -> Changing<bool> {
1742 let verb = if on { "warm-on" } else { "warm-off" };
1743 let permit = self.permitted()?;
1744 let key = self.named(permit, verb, typed)?;
1745 let subject = key.typed();
1746 let settled = self.settled_to_warm(permit, verb, &subject)?;
1747 self.change(permit, verb, &subject, settled, |settled| {
1748 switch::warm::turn(settled, &key, on).map(|changed| (changed, Vec::new()))
1749 })
1750 }
1751
1752 pub fn warm_range(
1755 &self,
1756 range: crate::duty::warm::Range,
1757 ) -> Changing<crate::duty::warm::Range> {
1758 let permit = self.permitted()?;
1759 let subject = format!(
1760 "{}-{}",
1761 crate::duty::warm::clock(range.from()),
1762 crate::duty::warm::clock(range.to())
1763 );
1764 let settled = self.settled_to_warm(permit, "warm-range", &subject)?;
1765 self.change(permit, "warm-range", &subject, settled, |settled| {
1766 switch::warm::range(settled, range).map(|range| (range, Vec::new()))
1767 })
1768 }
1769
1770 pub fn left_login(&self) -> Result<Option<switch::Left>> {
1778 let state = state::load(&self.ctx)?;
1779 switch::left_login(&self.ctx, &state)
1780 }
1781
1782 pub fn stow(&self, seen: &str) -> Changing<switch::Stowed> {
1788 let permit = self.permitted()?;
1789 let tool = ProviderId::Claude;
1790 let (settled, recovered) = self.settled(permit, "stow", "", Some(tool))?;
1791 let warnings = self.overridden(tool).into_iter().chain(recovered).collect();
1792 self.change(permit, "stow", "", (settled, warnings), |settled| {
1793 switch::stow(settled, seen)
1794 })
1795 }
1796
1797 fn overridden(&self, tool: ProviderId) -> Option<Warning> {
1801 let driver = match crate::provider::of(tool) {
1802 LoginKind::Document(driver) => driver,
1803 LoginKind::Folder(_) => return None,
1804 };
1805 let names = driver.overridden_by(&self.ctx);
1806 (!names.is_empty()).then_some(Warning::AuthOverridden { tool, names })
1807 }
1808
1809 fn left_behind(&self, tool: ProviderId) -> Option<Warning> {
1812 let driver = match crate::provider::of(tool) {
1813 LoginKind::Document(driver) => driver,
1814 LoginKind::Folder(_) => return None,
1815 };
1816 let behind = driver.behind(&self.ctx)?;
1817 Some(Warning::FallbackLogin {
1818 tool,
1819 path: behind.path,
1820 held: behind.held,
1821 })
1822 }
1823
1824 fn changing<T: Audited>(
1830 &self,
1831 permit: Permit,
1832 verb: &str,
1833 subject: &str,
1834 tool: Option<ProviderId>,
1835 run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
1836 ) -> Changing<T> {
1837 let settled = self.settled_for(permit, verb, subject, tool)?;
1838 self.change(permit, verb, subject, settled, run)
1839 }
1840
1841 fn change<T: Audited>(
1845 &self,
1846 permit: Permit,
1847 verb: &str,
1848 subject: &str,
1849 (settled, mut warnings): (Settled, Vec<Warning>),
1850 run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
1851 ) -> Changing<T> {
1852 match run(settled) {
1853 Ok((value, more)) => {
1854 let subject = value.audit_subject().unwrap_or(subject);
1855 audit::record(&self.ctx, permit, verb, subject, value.audit_code());
1856 warnings.extend(more);
1857 Ok(Done { value, warnings })
1858 }
1859 Err(error) => {
1860 audit::record(&self.ctx, permit, verb, subject, error.code());
1861 Err(Failed::of(error, warnings))
1862 }
1863 }
1864 }
1865
1866 fn settled_for(
1870 &self,
1871 permit: Permit,
1872 verb: &str,
1873 subject: &str,
1874 tool: Option<ProviderId>,
1875 ) -> std::result::Result<(Settled, Vec<Warning>), Failed> {
1876 let (settled, recovered) = self.settled(permit, verb, subject, tool)?;
1877 let mut warnings = Vec::new();
1878 if let Some(tool) = tool {
1879 warnings.extend(self.overridden(tool));
1880 warnings.extend(self.left_behind(tool));
1881 }
1882 warnings.extend(recovered);
1883 Ok((settled, warnings))
1884 }
1885
1886 fn settled(
1890 &self,
1891 permit: Permit,
1892 verb: &str,
1893 subject: &str,
1894 tool: Option<ProviderId>,
1895 ) -> std::result::Result<(Settled, Option<Warning>), Failed> {
1896 let (settled, recovered) = switch::settle(&self.ctx, permit, tool).map_err(|error| {
1897 audit::record(&self.ctx, permit, verb, subject, error.code());
1898 Failed {
1899 error,
1900 warnings: Vec::new(),
1901 said_first: 0,
1902 }
1903 })?;
1904 let recovered = recovered.map(|r| {
1905 audit::record(&self.ctx, permit, "recover", &r.to, r.code());
1906 Warning::Recovered(r)
1907 });
1908 Ok((settled, recovered))
1909 }
1910}
1911
1912trait Audited {
1914 fn audit_code(&self) -> &'static str {
1915 "ok"
1916 }
1917
1918 fn audit_subject(&self) -> Option<&str> {
1920 None
1921 }
1922}
1923
1924impl Audited for Outcome {
1925 fn audit_code(&self) -> &'static str {
1926 match self {
1927 Outcome::Switched { .. } | Outcome::Installed { .. } => "ok",
1928 Outcome::AlreadyActive {
1929 config_updated: true,
1930 ..
1931 } => "config_updated",
1932 Outcome::AlreadyActive { .. } => "already_active",
1933 }
1934 }
1935}
1936
1937impl Audited for switch::Reclaimed {}
1938impl Audited for switch::WatchedSignIn {}
1939impl Audited for Enrolled {}
1940impl Audited for String {}
1941impl Audited for bool {}
1942impl Audited for crate::duty::warm::Range {}
1943
1944impl Audited for switch::Stowed {
1945 fn audit_code(&self) -> &'static str {
1946 self.kept.code()
1947 }
1948
1949 fn audit_subject(&self) -> Option<&str> {
1950 self.kept.account()
1951 }
1952}
1953
1954impl Audited for switch::Removed {
1955 fn audit_code(&self) -> &'static str {
1956 if self.pending > 0 {
1957 "parks_pending_removal"
1958 } else {
1959 "ok"
1960 }
1961 }
1962}
1963
1964#[cfg(test)]
1965#[allow(
1966 clippy::disallowed_methods,
1967 reason = "a test sets up its scratch files as a person or a tool would"
1968)]
1969mod tests {
1970 use super::*;
1971 use crate::switch::harness::{
1972 Machine, Tool, audit_lines, codex_machine, hold, machine, signed_in_outside, state_file,
1973 };
1974 use std::collections::BTreeMap;
1975 use std::sync::Arc;
1976
1977 type Make = fn(&str) -> Machine;
1978
1979 const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
1981
1982 #[test]
1983 #[cfg_attr(
1984 windows,
1985 ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
1986 )]
1987 fn a_read_opens_no_jar_and_lists_no_process() {
1988 let m = switch::harness::desktop_machine("reads");
1989 let pitboard = Pitboard::new(m.ctx.clone());
1990 let (jars, listings) = (m.mem.jars_asked().len(), m.mem.listings());
1991 pitboard.status(true).expect("read");
1993 let before = state_file(&m);
1994 let online = pitboard.status(true).expect("read");
1995 let offline = pitboard.status_offline().expect("read");
1996 let doctor = pitboard.doctor();
1997 pitboard.statusline("{}");
1998 assert_eq!(m.mem.jars_asked().len(), jars, "no jar was opened");
1999 assert_eq!(m.mem.listings(), listings, "no process was listed");
2000 assert_eq!(state_file(&m), before, "nothing was recorded");
2001
2002 let data = m.ctx_home().join("claude/data");
2004 let rows: [(&str, &dyn Fn()); 3] = [
2005 ("no session", &|| {
2006 crate::desktop_testing::write_stand_in_jar(&data.join("Cookies"), 24, &[]);
2007 }),
2008 ("another name", &|| {
2009 let settings = data.join("config.json");
2010 let written = std::fs::read_to_string(&settings).expect("settings");
2011 let renamed = written.replace(
2012 switch::harness::WORK.uuid,
2013 "0b7e2c44-2222-4d1f-8a35-91c0de77aa01",
2014 );
2015 std::fs::write(&settings, renamed).expect("renamed");
2016 }),
2017 ("no jar", &|| {
2018 std::fs::remove_file(data.join("Cookies")).expect("the jar taken away");
2019 }),
2020 ];
2021 for (row, change) in rows {
2022 change();
2023 pitboard.status(true).expect("read");
2024 pitboard.status_offline().expect("read");
2025 pitboard.doctor();
2026 pitboard.statusline("{}");
2027 assert_eq!(m.mem.jars_asked().len(), jars, "{row}: no jar was opened");
2028 assert_eq!(m.mem.listings(), listings, "{row}: no process was listed");
2029 assert_eq!(state_file(&m), before, "{row}: nothing was recorded");
2030 }
2031
2032 for report in [&online.value, &offline.value] {
2033 let row = report
2034 .rows
2035 .iter()
2036 .find(|row| row.provider == ProviderId::Desktop)
2037 .expect("a row");
2038 assert!(row.signed_in);
2039 assert_eq!(row.read_at, Some(switch::harness::NOW));
2040 }
2041 for warnings in [&online.warnings, &offline.warnings] {
2042 assert!(
2043 warnings
2044 .iter()
2045 .all(|w| !w.to_string().contains("Claude Desktop")
2046 && w.code() != "in_use_unconfirmed"),
2047 "{warnings:?}"
2048 );
2049 }
2050 let codes: Vec<&str> = doctor.checks.iter().map(|c| c.code).collect();
2051 for code in [
2052 "desktop_data_folder",
2053 "desktop_settings",
2054 "desktop_layout",
2055 "desktop_login",
2056 "desktop_parked_login",
2057 "desktop_build",
2058 ] {
2059 assert!(codes.contains(&code), "{code}: {codes:?}");
2060 }
2061 assert!(
2062 doctor
2063 .checks
2064 .iter()
2065 .filter(|c| c.code.starts_with("desktop_"))
2066 .all(|c| c.level == doctor::Level::Ok),
2067 "{:?}",
2068 doctor
2069 .checks
2070 .iter()
2071 .map(|c| (c.code, &c.detail))
2072 .collect::<Vec<_>>()
2073 );
2074 }
2075
2076 #[test]
2078 #[cfg_attr(
2079 windows,
2080 ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2081 )]
2082 fn a_mac_with_no_claude_desktop_account_reads_nothing_of_claude() {
2083 let plain = machine("no-desktop-plain");
2084 let m = machine("no-desktop");
2085 let scratch = m.ctx_home().join("claude");
2086 std::fs::create_dir_all(scratch.join("data")).expect("a data folder");
2087 std::fs::write(scratch.join("data/config.json"), "{").expect("settings");
2088 crate::host::fs::testing::deny_reading(&scratch.join("data/config.json")).expect("000");
2089 let read = |m: &Machine, ctx: Context| {
2090 let pitboard = Pitboard::new(ctx);
2091 let codes = |done: Done<status::Report>| {
2092 let warnings: Vec<&str> = done.warnings.iter().map(Warning::code).collect();
2093 (done.value.rows.len(), format!("{warnings:?}"))
2094 };
2095 let doctor: Vec<&str> = pitboard.doctor().checks.iter().map(|c| c.code).collect();
2096 assert_eq!(m.mem.listings(), 0);
2097 assert!(m.mem.jars_asked().is_empty());
2098 (
2099 codes(pitboard.status(true).expect("read")),
2100 codes(pitboard.status_offline().expect("read")),
2101 format!("{doctor:?}"),
2102 )
2103 };
2104 let with_claude = crate::fault::killing("settings.read", || {
2105 read(&m, m.ctx.clone().with_scratch_claude(scratch))
2106 })
2107 .expect("no read opened Claude's settings");
2108 assert_eq!(
2109 with_claude,
2110 read(&plain, plain.ctx.clone()),
2111 "as with no Claude at all"
2112 );
2113 assert!(!with_claude.2.contains("desktop_"), "{}", with_claude.2);
2114 }
2115
2116 #[test]
2118 #[cfg_attr(
2119 windows,
2120 ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2121 )]
2122 fn forgetting_the_last_claude_desktop_account_stops_every_read_of_claude() {
2123 let m = switch::harness::desktop_machine("forgot-last");
2124 let claude = m.claude();
2125 claude.opens();
2126 claude.logs_out();
2127 claude.quits();
2128 let pitboard = Pitboard::new(m.ctx.clone());
2129 pitboard.forget("desktop/work").expect("forgotten");
2130 claude.signed_in_as(&switch::harness::STRANGER);
2131 let state = state::load(&m.ctx).expect("state");
2132 assert!(state.accounts.is_empty());
2133 assert!(state.in_use(ProviderId::Desktop).is_some(), "{state:?}");
2134
2135 let (listings, jars) = (m.mem.listings(), m.mem.jars_asked().len());
2136 let said = crate::fault::killing("settings.read", || {
2137 let codes = |done: Done<status::Report>| -> Vec<&'static str> {
2138 done.warnings.iter().map(Warning::code).collect()
2139 };
2140 let doctor: Vec<&str> = pitboard
2141 .doctor()
2142 .checks
2143 .iter()
2144 .map(|c| c.code)
2145 .filter(|code| code.starts_with("desktop_"))
2146 .collect();
2147 (
2148 codes(pitboard.status(true).expect("read")),
2149 codes(pitboard.status_offline().expect("read")),
2150 doctor,
2151 )
2152 })
2153 .expect("no read opened Claude's settings");
2154 assert_eq!(said, (Vec::new(), Vec::new(), Vec::new()));
2155 assert_eq!(m.mem.listings(), listings);
2156 assert_eq!(m.mem.jars_asked().len(), jars);
2157 }
2158
2159 #[test]
2160 #[cfg_attr(
2161 windows,
2162 ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2163 )]
2164 fn what_claudes_settings_say_since_is_a_warning_of_every_read() {
2165 let m = switch::harness::desktop_machine("standing");
2166 let pitboard = Pitboard::new(m.ctx.clone());
2167 let settings = m.ctx_home().join("claude/data/config.json");
2168 let said = |pitboard: &Pitboard| -> Vec<String> {
2169 pitboard
2170 .status_offline()
2171 .expect("read")
2172 .warnings
2173 .iter()
2174 .map(|w| format!("{}: {w}", w.code()))
2175 .collect()
2176 };
2177 assert!(said(&pitboard).is_empty());
2178
2179 let written = std::fs::read_to_string(&settings).expect("settings");
2180 std::fs::write(
2181 &settings,
2182 written.replace(
2183 switch::harness::WORK.uuid,
2184 "0b7e2c44-2222-4d1f-8a35-91c0de77aa01",
2185 ),
2186 )
2187 .expect("renamed");
2188 let warned = said(&pitboard);
2189 assert_eq!(warned.len(), 1, "{warned:?}");
2190 assert!(
2191 warned[0].starts_with(
2192 "in_use_unconfirmed: Claude's settings have named an account Pitboard has not \
2193 enrolled since Pitboard last read `desktop/work` in use, so Claude may hold \
2194 another login. Open Claude,"
2195 ),
2196 "{warned:?}"
2197 );
2198
2199 let emptied: String = written
2200 .lines()
2201 .map(|line| match line.split_once(": ") {
2202 Some((key, _)) if key.contains("oauth:tokenCache") => {
2203 format!("{key}: {},\n", crate::desktop_testing::EMPTIED)
2204 }
2205 _ => format!("{line}\n"),
2206 })
2207 .collect();
2208 std::fs::write(&settings, emptied).expect("emptied");
2209 let warned = said(&pitboard);
2210 assert_eq!(
2211 warned,
2212 [
2213 "looks_signed_out: Claude looks signed out since Pitboard last read `desktop/work` \
2214 in use. The next change to a Claude Desktop account checks."
2215 ]
2216 );
2217 assert_eq!(m.mem.listings(), 1, "the enrolment's look, and no read's");
2218 }
2219
2220 #[test]
2221 #[cfg_attr(
2222 windows,
2223 ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2224 )]
2225 fn what_stands_is_judged_from_one_read_of_claudes_settings() {
2226 let m = switch::harness::desktop_machine("one-read");
2227 let pitboard = Pitboard::new(m.ctx.clone());
2228 let state = state::load(&m.ctx).expect("state");
2229 let (warnings, reads) =
2230 crate::fault::counting("settings.read", || pitboard.standing(&state, &[]));
2231 assert!(warnings.is_empty(), "{warnings:?}");
2232 assert_eq!(reads, 1);
2233 let (known, reads) = crate::fault::counting("settings.read", || {
2234 in_use::known(&m.ctx, &state, ProviderId::Desktop)
2235 });
2236 assert_eq!((known.doubt, reads), (None, 1));
2237 }
2238
2239 #[test]
2240 fn a_folder_set_aside_is_said_with_its_account_or_with_no_name() {
2241 use state::SetAside;
2242 let said = |why, label: Option<&str>| {
2243 Warning::SetAside {
2244 tool: ProviderId::Desktop,
2245 path: "/p/parks/x".into(),
2246 label: label.map(str::to_owned),
2247 why,
2248 }
2249 .to_string()
2250 };
2251 assert_eq!(
2252 said(SetAside::Superseded, Some("desktop/home")),
2253 "Pitboard kept `desktop/home`'s earlier parked login in /p/parks/x and deleted \
2254 nothing. `pitboard doctor` lists it."
2255 );
2256 assert_eq!(
2257 said(SetAside::Superseded, None),
2258 "Pitboard kept an earlier parked login in /p/parks/x and deleted nothing. `pitboard \
2259 doctor` lists it."
2260 );
2261 assert_eq!(
2262 said(SetAside::GivenUp, Some("desktop/work")),
2263 "Pitboard moved the parts of `desktop/work`'s login left in Claude's folder into \
2264 /p/parks/x and deleted nothing. `pitboard doctor` lists them."
2265 );
2266 assert_eq!(
2267 said(SetAside::GivenUp, None),
2268 "Pitboard moved the parts of a login left in Claude's folder into /p/parks/x and \
2269 deleted nothing. `pitboard doctor` lists them."
2270 );
2271 for why in [
2272 SetAside::InTheWay,
2273 SetAside::AnotherMachine,
2274 SetAside::Orphan,
2275 ] {
2276 for label in [None, Some("desktop/work")] {
2277 assert_eq!(
2278 said(why, label),
2279 "Pitboard moved what it found in the way into /p/parks/x and deleted nothing."
2280 );
2281 }
2282 }
2283 let keys = |label: Option<&str>| Warning::KeysSetAside {
2284 tool: ProviderId::Desktop,
2285 path: "/p/parks/x".into(),
2286 label: label.map(str::to_owned),
2287 };
2288 assert_eq!(keys(None).code(), "set_aside");
2289 assert_eq!(
2290 keys(Some("desktop/home")).to_string(),
2291 "Claude rewrote `desktop/home`'s keys. Pitboard kept the earlier ones in /p/parks/x \
2292 and deleted nothing. `pitboard doctor` lists them."
2293 );
2294 assert_eq!(
2295 keys(None).to_string(),
2296 "Claude rewrote a login's keys. Pitboard kept the earlier ones in /p/parks/x and \
2297 deleted nothing. `pitboard doctor` lists them."
2298 );
2299 }
2300
2301 #[test]
2304 fn a_refused_change_counts_what_it_did_first() {
2305 let found = || Warning::LooksSignedOut {
2306 tool: ProviderId::Claude,
2307 label: "work".into(),
2308 };
2309 let refusal = || Error::AccountNotInUse {
2310 tool: ProviderId::Desktop,
2311 label: "desktop/work".into(),
2312 };
2313 let waiting = || Warning::MoveWaiting {
2314 tool: ProviderId::Desktop,
2315 label: "desktop/work".into(),
2316 };
2317 let recovered = || {
2318 Warning::Recovered(Recovered {
2319 from: "home".into(),
2320 to: "work".into(),
2321 finished: true,
2322 })
2323 };
2324 let codes = |failed: &Failed| -> Vec<&'static str> {
2325 failed.warnings.iter().map(Warning::code).collect()
2326 };
2327 let failed = Failed::of(refusal().after(vec![waiting()]), vec![found()]);
2328 assert_eq!(codes(&failed), ["move_waiting", "looks_signed_out"]);
2329 assert_eq!(failed.said_first, 1);
2330 assert_eq!(failed.error.code(), "account_not_in_use");
2331 let failed = Failed::of(refusal(), vec![found()]);
2332 assert_eq!(codes(&failed), ["looks_signed_out"]);
2333 assert_eq!(failed.said_first, 0);
2334 let failed = Failed::of(refusal(), vec![found(), recovered()]);
2335 assert_eq!(
2336 codes(&failed),
2337 ["interrupted_switch_finished", "looks_signed_out"]
2338 );
2339 assert_eq!(failed.said_first, 1);
2340 let failed = Failed::of(refusal().after(vec![waiting()]), vec![found(), recovered()]);
2341 assert_eq!(
2342 codes(&failed),
2343 [
2344 "interrupted_switch_finished",
2345 "move_waiting",
2346 "looks_signed_out"
2347 ]
2348 );
2349 assert_eq!(failed.said_first, 2);
2350 }
2351
2352 #[test]
2353 #[cfg_attr(
2354 windows,
2355 ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2356 )]
2357 fn what_holds_claude_desktops_login_is_said() {
2358 let m = switch::harness::desktop_machine("holding");
2359 let pitboard = Pitboard::new(m.ctx.clone());
2360 let claude = m.claude();
2361 assert!(pitboard.holding(ProviderId::Desktop).is_empty());
2362 claude.opens();
2363 let holding = pitboard.holding(ProviderId::Desktop);
2364 assert_eq!(holding.len(), 1, "{holding:?}");
2365 assert_eq!(holding[0].holder.kind, "claude_desktop");
2366 assert_eq!(holding[0].pids.len(), 2, "its own program and a helper");
2367 claude.crashes();
2368 assert!(pitboard.holding(ProviderId::Desktop).is_empty());
2369 m.mem.without_a_process_list();
2370 assert!(matches!(
2371 switch::still_holding(&m.ctx, ProviderId::Desktop),
2372 switch::StillHolding::Unknown
2373 ));
2374 assert!(pitboard.holding(ProviderId::Desktop).is_empty());
2375 }
2376
2377 #[test]
2378 #[cfg_attr(
2379 windows,
2380 ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
2381 )]
2382 fn a_desktop_row_says_no_usage_and_asks_nobody() {
2383 use crate::status::Stale;
2384 let m = switch::harness::desktop_machine("rows");
2385 let now = switch::harness::NOW;
2386 let mut state = state::load(&m.ctx).expect("state");
2387 let another = |label: &str, parked, replaced_at| Account {
2388 label: label.into(),
2389 id: format!("desktop_{label}"),
2390 account_uuid: label.into(),
2391 email: String::new(),
2392 parked,
2393 last_used_at: None,
2394 replaced_at,
2395 plan: None,
2396 detail: state::Detail::Desktop {},
2397 };
2398 let park = state::Park {
2399 service: "pitboard-park-desktop_home-1".into(),
2400 kind: state::ParkKind::Folder,
2401 parked_at: now,
2402 login: "0a1b2c3d4e5f6071".into(),
2403 access_expires_at: None,
2404 refresh_expires_at: None,
2405 session_expires_at: Some(now + 20 * 86_400),
2406 };
2407 state.accounts.push(another("home", Some(park), None));
2408 state.accounts.push(another("gone", None, Some(now - 5)));
2409 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2410
2411 let pitboard = Pitboard::new(m.ctx.clone());
2412 for report in [
2413 pitboard.status(true).expect("read").value,
2414 pitboard.status_offline().expect("read").value,
2415 ] {
2416 let row = |label: &str| {
2417 report
2418 .rows
2419 .iter()
2420 .find(|row| {
2421 row.provider == ProviderId::Desktop && row.label.as_deref() == Some(label)
2422 })
2423 .unwrap_or_else(|| panic!("a row for {label}"))
2424 };
2425 let (work, home, gone) = (row("work"), row("home"), row("gone"));
2426 assert!(work.signed_in && !home.signed_in && !gone.signed_in);
2427 assert_eq!(work.read_at, Some(now));
2428 assert_eq!((home.read_at, gone.read_at), (None, None));
2429 for row in [work, home] {
2430 assert_eq!(row.stale, Some(Stale::NoUsage));
2431 assert!(row.usage.is_none() && row.plan.is_none() && row.lasts(now).is_none());
2432 assert_eq!(row.email, "");
2433 assert_eq!(row.organization_uuid, None);
2434 }
2435 assert!(home.switchable(now), "no deadline is known");
2436 assert_eq!(gone.stale, Some(Stale::LoginReplaced));
2437 assert_eq!(
2438 gone.stale
2439 .and_then(|stale| stale.explanation_for(gone.provider)),
2440 Some("its login was signed out or replaced in Claude; sign in again")
2441 );
2442 assert_eq!(
2443 Stale::NoUsage.explanation_for(ProviderId::Desktop),
2444 Some("Pitboard reads no usage for Claude Desktop")
2445 );
2446 assert_eq!(
2447 Stale::Unreachable.explanation_for(ProviderId::Desktop),
2448 None
2449 );
2450 }
2451 assert!(m.api.asked().is_empty(), "{:?}", m.api.asked());
2452 assert!(
2453 m.mem
2454 .trail()
2455 .iter()
2456 .all(|touch| !touch.service.starts_with("pitboard-park-desktop_")),
2457 "{:?}",
2458 m.mem.trail()
2459 );
2460 assert!(
2461 m.mem.jars_asked().len() == 1 && m.mem.listings() == 1,
2462 "the enrolment's own"
2463 );
2464 }
2465
2466 #[test]
2468 fn an_account_named_before_names_were_one_word_keeps_its_name() {
2469 let m = machine("one-word");
2470 let mut state = state::load(&m.ctx).expect("state");
2471 state
2472 .accounts
2473 .iter_mut()
2474 .find(|a| a.label == "there")
2475 .expect("there")
2476 .label = "over there".into();
2477 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2478 let pitboard = Pitboard::new(m.ctx.clone());
2479
2480 assert_eq!(
2481 pitboard.account_to_enroll("over there").map(|a| a.label),
2482 Some("over there".to_owned())
2483 );
2484 let refused = pitboard
2485 .enroll_current("new one")
2486 .expect_err("a new name of two words");
2487 assert_eq!(refused.error.code(), "usage", "{}", refused.error);
2488 assert_eq!(
2489 audit_lines(&m, "enroll"),
2490 [("new one".to_owned(), "label_unusable".to_owned())]
2491 );
2492 }
2493
2494 #[test]
2496 fn a_rename_to_a_name_another_tool_has_follows_the_rules_for_a_new_one() {
2497 let m = codex_machine("rename-one-word");
2498 let mut state = state::load(&m.ctx).expect("state");
2499 state
2500 .accounts
2501 .push(switch::harness::account("my work", "elsewhere", None));
2502 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
2503 let pitboard = Pitboard::new(m.ctx.clone());
2504
2505 let refused = pitboard
2506 .rename("codex/there", "my work")
2507 .expect_err("a name of two words");
2508 assert_eq!(refused.error.code(), "usage", "{}", refused.error);
2509 assert!(
2510 state::load(&m.ctx)
2511 .expect("state")
2512 .get(&m.key("there"))
2513 .is_some(),
2514 "the Codex account keeps its name"
2515 );
2516 }
2517
2518 type Refuse = fn(&Pitboard, ProviderId) -> Option<Failed>;
2519
2520 const REFUSALS: [(&str, &str, &str, &str, Refuse); 4] = [
2526 (
2527 "use",
2528 "use",
2529 "account_unknown",
2530 "account_unknown",
2531 |p, _| p.switch_to("nobody").err(),
2532 ),
2533 ("enroll", "enroll", "usage", "label_unusable", |p, _| {
2534 p.enroll_current("codx/work").err()
2535 }),
2536 ("sign-in", "enroll", "usage", "label_unusable", |p, _| {
2537 p.sign_in("codx/work").err()
2538 }),
2539 ("rename", "rename", "usage", "usage", |p, tool| {
2540 let other = if tool == ProviderId::Claude {
2541 ProviderId::Codex
2542 } else {
2543 ProviderId::Claude
2544 };
2545 p.rename(
2546 &Key::new(tool, "here").qualified(),
2547 &Key::new(other, "moved").qualified(),
2548 )
2549 .err()
2550 }),
2551 ];
2552
2553 fn interrupted(make: Make, name: &str) -> Machine {
2556 let m = make(name);
2557 let settled = switch::settle(&m.ctx, Permit::for_a_test(), None)
2558 .expect("nothing to recover yet")
2559 .0;
2560 let died = crate::fault::killing("switch.park_recorded", || {
2561 switch::switch(settled, &m.key("there"))
2562 });
2563 assert_eq!(died.unwrap_err(), "switch.park_recorded");
2564 assert!(switch::interrupted(&m.ctx));
2565 m
2566 }
2567
2568 fn stuck(make: Make, name: &str) -> Machine {
2572 let m = interrupted(make, name);
2573 m.fault_live(crate::store::memory::Fault::Vanish);
2574 m
2575 }
2576
2577 fn stuck_unless_asked(name: &str) -> Machine {
2583 let m = interrupted(machine, name);
2584 m.sign_in(&switch::harness::document("renewed-since"));
2585 m
2586 }
2587
2588 fn said(read: &Done<status::Report>) -> Vec<(&'static str, String)> {
2590 read.warnings
2591 .iter()
2592 .map(|w| (w.code(), w.to_string()))
2593 .collect()
2594 }
2595
2596 type Untouched = (
2600 BTreeMap<String, Vec<u8>>,
2601 Vec<(String, Option<String>)>,
2602 Option<serde_json::Value>,
2603 );
2604
2605 fn untouched(m: &Machine) -> Untouched {
2606 let kept_by_a_read = ["usage.json", "usage.lock", "asking.json"];
2607 let mut files = files(m);
2608 files.retain(|path, _| !kept_by_a_read.iter().any(|kept| path.ends_with(kept)));
2609 let vault = m.mem.vault();
2610 let parked = vault
2611 .services()
2612 .into_iter()
2613 .map(|service| {
2614 let held = vault.peek(&service);
2615 (service, held)
2616 })
2617 .collect();
2618 (files, parked, m.live())
2619 }
2620
2621 #[test]
2625 fn a_switch_says_a_login_is_left_where_it_does_not_reach() {
2626 let m = machine("switch-fallback-login");
2627 let left = plant_behind(
2628 &m,
2629 &switch::harness::document("left-by-a-sign-in").to_string(),
2630 );
2631 let switched = Pitboard::new(m.ctx.clone())
2632 .switch_to("there")
2633 .expect("a switch");
2634 let said: Vec<String> = switched
2635 .warnings
2636 .iter()
2637 .filter(|w| w.code() == "fallback_login")
2638 .map(ToString::to_string)
2639 .collect();
2640 assert_eq!(said.len(), 1, "{:?}", switched.warnings);
2641 assert!(
2642 said[0].starts_with(&left.display().to_string()),
2643 "{}",
2644 said[0]
2645 );
2646
2647 for (tool, make) in MACHINES {
2648 let m = make(&format!("switch-nothing-left-{tool}"));
2649 let switched = Pitboard::new(m.ctx.clone())
2650 .switch_to(&m.key("there").typed())
2651 .expect("a switch");
2652 assert!(
2653 switched
2654 .warnings
2655 .iter()
2656 .all(|w| w.code() != "fallback_login"),
2657 "{tool}: {:?}",
2658 switched.warnings
2659 );
2660 }
2661 }
2662
2663 fn plant_behind(m: &Machine, contents: &str) -> std::path::PathBuf {
2666 let left = crate::provider::claude::live::credential_file(&m.ctx);
2667 m.mem.file_at(left.clone()).plant(
2668 &crate::provider::claude::paths::live_service(&m.ctx),
2669 contents,
2670 );
2671 left
2672 }
2673
2674 #[test]
2680 fn a_read_says_a_file_is_behind_the_keychain() {
2681 let m = machine("read-fallback-login");
2682 let left = plant_behind(
2683 &m,
2684 &switch::harness::document("left-by-a-sign-in").to_string(),
2685 );
2686 let pitboard = Pitboard::new(m.ctx.clone());
2687
2688 let read = pitboard.status(false).expect("a read");
2689 let said: Vec<String> = read
2690 .warnings
2691 .iter()
2692 .filter(|w| w.code() == "fallback_login")
2693 .map(ToString::to_string)
2694 .collect();
2695 assert_eq!(said.len(), 1, "{:?}", read.warnings);
2696 assert!(
2697 said[0].starts_with(&format!(
2698 "{} holds another Claude Code login",
2699 left.display()
2700 )),
2701 "{}",
2702 said[0]
2703 );
2704 assert!(
2705 said[0].contains(
2706 "sessions already running at a switch keep the account they are on until \
2707 their login is next renewed, or until they are started again"
2708 ),
2709 "{}",
2710 said[0]
2711 );
2712 assert!(
2713 said[0].ends_with(
2714 "`pitboard stow` keeps that login for its account, where Pitboard holds no \
2715 other it can switch to, then deletes the file."
2716 ),
2717 "{}",
2718 said[0]
2719 );
2720 let offline = pitboard.status_offline().expect("a read of what is known");
2721 assert!(
2722 offline
2723 .warnings
2724 .iter()
2725 .all(|w| w.code() != "fallback_login"),
2726 "{:?}",
2727 offline.warnings
2728 );
2729
2730 for (tool, make) in MACHINES {
2731 let m = make(&format!("read-nothing-left-{tool}"));
2732 let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
2733 assert!(
2734 read.warnings.iter().all(|w| w.code() != "fallback_login"),
2735 "{tool}: {:?}",
2736 read.warnings
2737 );
2738 }
2739 }
2740
2741 #[test]
2745 fn an_empty_file_behind_the_keychain_is_said_too() {
2746 let m = machine("read-empty-fallback");
2747 let left = plant_behind(&m, "{}");
2748 let pitboard = Pitboard::new(m.ctx.clone());
2749
2750 for warnings in [
2751 pitboard.status(false).expect("a read").warnings,
2752 pitboard.switch_to("there").expect("a switch").warnings,
2753 ] {
2754 let said: Vec<String> = warnings
2755 .iter()
2756 .filter(|w| w.code() == "fallback_login")
2757 .map(ToString::to_string)
2758 .collect();
2759 assert_eq!(
2760 said,
2761 [format!(
2762 "{path} is there with no Claude Code login in it. While it is, Claude \
2763 Code sessions already running at a switch keep the account they are on \
2764 until their login is next renewed, or until they are started again. \
2765 `pitboard stow` deletes it, which lets them follow a switch.",
2766 path = left.display()
2767 )],
2768 "{warnings:?}"
2769 );
2770 }
2771 }
2772
2773 #[test]
2777 fn a_file_behind_the_keychain_that_cannot_be_read_is_said_too() {
2778 let m = machine("read-unreadable-fallback");
2779 let left = plant_behind(&m, "{}");
2780 m.mem.file_at(left.clone()).fault(
2781 &crate::provider::claude::paths::live_service(&m.ctx),
2782 crate::store::memory::Fault::UnreadableContents(
2783 "stream did not contain valid UTF-8".into(),
2784 ),
2785 );
2786 let pitboard = Pitboard::new(m.ctx.clone());
2787
2788 for warnings in [
2789 pitboard.status(false).expect("a read").warnings,
2790 pitboard.switch_to("there").expect("a switch").warnings,
2791 ] {
2792 let said: Vec<String> = warnings
2793 .iter()
2794 .filter(|w| w.code() == "fallback_login")
2795 .map(ToString::to_string)
2796 .collect();
2797 assert_eq!(
2798 said,
2799 [format!(
2800 "{} is there, and Pitboard could not read it. While it is, Claude Code \
2801 sessions already running at a switch keep the account they are on until \
2802 their login is next renewed, or until they are started again. `pitboard \
2803 doctor` says why, and what to do.",
2804 left.display()
2805 )],
2806 "{warnings:?}"
2807 );
2808 }
2809 }
2810
2811 #[test]
2816 fn a_read_says_an_interrupted_switch_it_cannot_finish_is_waiting() {
2817 for (tool, make) in MACHINES {
2818 let m = stuck(make, &format!("read-stuck-{tool}"));
2819 let pitboard = Pitboard::new(m.ctx.clone());
2820 let before = untouched(&m);
2821
2822 let read = pitboard
2823 .status(false)
2824 .expect("a read is not refused over it");
2825
2826 assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
2827 assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
2828 let refused = pitboard
2829 .switch_to(&m.key("there").typed())
2830 .expect_err("every change stops at it");
2831 assert_eq!(refused.error.code(), "recovery_undetermined", "{tool}");
2832 assert_eq!(
2833 said(&read),
2834 [("recovery_undetermined", refused.error.to_string())],
2835 "{tool}: said in the refusal's own words"
2836 );
2837 }
2838 }
2839
2840 #[test]
2843 fn a_read_asks_whose_the_login_is_where_only_the_service_can_say() {
2844 let m = stuck_unless_asked("read-stuck-asked");
2845 let pitboard = Pitboard::new(m.ctx.clone());
2846 let before = untouched(&m);
2847
2848 let read = pitboard.status(false).expect("a read");
2849
2850 assert_eq!(untouched(&m), before, "a read changes nothing");
2851 let refused = pitboard.switch_to("there").expect_err("it stops at it");
2852 assert_eq!(
2853 said(&read),
2854 [("recovery_undetermined", refused.error.to_string())]
2855 );
2856 let said = refused.error.to_string();
2858 assert!(said.contains("session has expired"), "{said}");
2859 assert!(!said.contains("sign in"), "{said}");
2860 }
2861
2862 #[test]
2866 fn a_read_says_nothing_of_a_switch_the_next_change_finishes() {
2867 for (tool, make) in MACHINES {
2868 let m = interrupted(make, &format!("read-settles-{tool}"));
2869 let before = untouched(&m);
2870
2871 let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
2872
2873 assert_eq!(said(&read), [], "{tool}");
2874 assert_eq!(untouched(&m), before, "{tool}: a read finishes nothing");
2875 assert!(switch::interrupted(&m.ctx), "{tool}");
2876 }
2877 }
2878
2879 #[test]
2886 fn a_read_says_a_switch_is_waiting_only_where_a_change_would_stop_at_it() {
2887 for (tool, make) in MACHINES {
2888 let m = stuck(make, &format!("read-stuck-custom-{tool}"));
2889 let mut ctx = m.ctx.clone();
2890 ctx.custom_oauth = true;
2891 let pitboard = Pitboard::new(ctx);
2892
2893 let read = pitboard.status(false).expect("a read");
2894 let known = pitboard.status_offline().expect("a read");
2895 let refused = pitboard
2896 .switch_to(&m.key("there").typed())
2897 .expect_err("refused");
2898
2899 let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
2900 let expected: &[&str] = match Tool::of(m.which) {
2901 Tool::Claude => &[],
2902 Tool::Codex => &["recovery_undetermined"],
2903 };
2904 assert_eq!(codes, expected, "{tool}: {}", refused.error);
2905 let codes: Vec<&str> = known.warnings.iter().map(Warning::code).collect();
2906 assert_eq!(codes, expected, "{tool}: the read that asks nobody too");
2907 assert_eq!(
2908 refused.error.code() == "recovery_undetermined",
2909 !expected.is_empty(),
2910 "{tool}"
2911 );
2912
2913 let gave_up = pitboard.abandon_recovery();
2914 match Tool::of(m.which) {
2915 Tool::Claude => assert_eq!(
2916 gave_up.expect_err("refused").code(),
2917 "custom_oauth_endpoint",
2918 "{tool}"
2919 ),
2920 Tool::Codex => {
2921 assert!(gave_up.expect("given up").switch.is_some(), "{tool}");
2922 assert!(!switch::interrupted(&m.ctx), "{tool}: nothing waits");
2923 }
2924 }
2925 }
2926 }
2927
2928 #[test]
2934 fn a_read_that_asks_nobody_says_a_switch_is_waiting_wherever_it_can_tell() {
2935 for (tool, make) in MACHINES {
2936 let m = stuck(make, &format!("known-stuck-{tool}"));
2937 let pitboard = Pitboard::new(m.ctx.clone());
2938 let (before, asked) = (untouched(&m), m.api.calls());
2939
2940 let read = pitboard.status_offline().expect("a read");
2941
2942 assert_eq!(untouched(&m), before, "{tool}: a read changes nothing");
2943 assert_eq!(m.api.calls(), asked, "{tool}: and asks nobody");
2944 assert!(switch::interrupted(&m.ctx), "{tool}: the record is kept");
2945 let refused = pitboard
2946 .switch_to(&m.key("there").typed())
2947 .expect_err("every change stops at it");
2948 assert_eq!(
2949 said(&read),
2950 [("recovery_undetermined", refused.error.to_string())],
2951 "{tool}"
2952 );
2953 }
2954
2955 let m = stuck_unless_asked("known-stuck-unasked");
2956 let asked = m.api.calls();
2957 let read = Pitboard::new(m.ctx.clone())
2958 .status_offline()
2959 .expect("a read");
2960 assert_eq!(m.api.calls(), asked, "asks nobody");
2961 assert_eq!(said(&read), [], "only Anthropic could tell");
2962 }
2963
2964 #[test]
2969 fn what_claude_code_cached_is_never_an_accounts_reading() {
2970 use crate::switch::harness::{NOW, cache_usage, usage_answer};
2971 let m = machine("cached-usage");
2972 readings::answered(
2973 &m.ctx,
2974 Permit::for_a_test(),
2975 &[(
2976 "here".into(),
2977 crate::usage::from_usage_object(&usage_answer(10.0), NOW),
2978 )],
2979 );
2980 cache_usage(&m, usage_answer(100.0));
2981 let pitboard = Pitboard::new(m.ctx.clone());
2982 for (how, read) in [
2983 ("offline", pitboard.status_offline()),
2984 ("online", pitboard.status(false)),
2985 ] {
2986 let read = read.expect("a read");
2987 let here = read
2988 .value
2989 .rows
2990 .iter()
2991 .find(|row| row.label.as_deref() == Some("here"))
2992 .expect("here's row");
2993 let shares: Vec<f64> = here
2994 .usage
2995 .iter()
2996 .flat_map(|usage| &usage.windows)
2997 .map(|window| window.percent)
2998 .collect();
2999 assert_eq!(shares, [10.0], "{how}");
3000 }
3001 }
3002
3003 #[test]
3007 fn a_login_already_identified_is_not_asked_about_again() {
3008 use crate::api::scripted::Asked;
3009 let m = machine("read-known-login");
3010
3011 Pitboard::new(m.ctx.clone()).status(false).expect("a read");
3012
3013 let asked = m.api.asked();
3014 assert!(
3015 !asked.contains(&Asked::Owner("access-here-refresh".into())),
3016 "{asked:?}"
3017 );
3018 assert!(
3019 asked.contains(&Asked::Usage("access-here-refresh".into())),
3020 "what it has left is still asked: {asked:?}"
3021 );
3022 }
3023
3024 #[test]
3030 fn a_sign_in_outside_pitboard_is_said_recorded_and_shown_on_its_row() {
3031 use crate::switch::harness::NOW;
3032 let m = signed_in_outside("read-signed-in-outside");
3033 let pitboard = Pitboard::new(m.ctx.clone());
3034 let replaced = "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code \
3035 now has `elsewhere`'s login stored, and Pitboard holds no login for \
3036 `here`. Run `pitboard enroll here --sign-in` to sign in to it again.";
3037 let row = |read: &Done<status::Report>, label: &str| {
3038 read.value
3039 .rows
3040 .iter()
3041 .find(|row| row.label.as_deref() == Some(label))
3042 .map(|row| (row.signed_in, row.stale))
3043 };
3044
3045 let read = pitboard.status(false).expect("a read");
3046
3047 let state = state::load(&m.ctx).expect("state");
3048 assert_eq!(
3049 state
3050 .account_in_use(ProviderId::Claude)
3051 .map(|account| account.label.as_str()),
3052 Some("elsewhere")
3053 );
3054 let account = |label: &str| state.get(&m.key(label)).expect("enrolled");
3055 assert_eq!(account("here").replaced_at, Some(NOW));
3056 assert_eq!(account("elsewhere").last_used_at, Some(NOW));
3057 assert!(account("there").parked.is_some(), "`there` keeps its park");
3058 assert_eq!(
3059 row(&read, "elsewhere").map(|(signed_in, _)| signed_in),
3060 Some(true)
3061 );
3062 assert_eq!(
3063 row(&read, "here"),
3064 Some((false, Some(status::Stale::LoginReplaced)))
3065 );
3066 assert_eq!(said(&read), [("login_replaced", replaced.to_string())]);
3067 let noticed = [
3068 ("elsewhere".to_string(), "signed_in_outside".to_string()),
3069 ("here".to_string(), "login_replaced".to_string()),
3070 ];
3071 assert_eq!(audit_lines(&m, "in-use"), noticed);
3072
3073 let recorded = state_file(&m);
3074 let again = pitboard.status(false).expect("a second read");
3075 assert_eq!(said(&again), [("login_replaced", replaced.to_string())]);
3076 assert_eq!(audit_lines(&m, "in-use"), noticed, "recorded once");
3077 assert_eq!(state_file(&m), recorded, "and written once");
3078
3079 let offline = pitboard.status_offline().expect("a read of what is known");
3080 assert_eq!(said(&offline), [("login_replaced", replaced.to_string())]);
3081 }
3082
3083 #[test]
3087 fn a_replaced_login_says_what_the_store_holds_instead_as_far_as_it_is_known() {
3088 use crate::in_use::InUse;
3089 use crate::switch::harness::{account, owner};
3090 let mut state = state::State::default();
3091 state.accounts.push(account("here", "here", None));
3092 state.accounts[0].replaced_at = Some(1);
3093 let said = |state: &state::State| -> Vec<String> {
3094 replaced(state).iter().map(ToString::to_string).collect()
3095 };
3096 let then = "and Pitboard holds no login for `here`. Run `pitboard enroll here \
3097 --sign-in` to sign in to it again.";
3098
3099 let somebody = InUse {
3100 owner: Some(owner("new")),
3101 login: "new-login".into(),
3102 known_at: 1,
3103 named: None,
3104 };
3105 state
3106 .in_use
3107 .insert(ProviderId::Claude.code().into(), somebody);
3108 assert_eq!(
3109 said(&state),
3110 [format!(
3111 "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code now has \
3112 new@example.com's login stored, {then}"
3113 )]
3114 );
3115
3116 state.in_use.insert(
3117 ProviderId::Claude.code().into(),
3118 switch::identify::nobody(None, 1).expect("a store holding no login"),
3119 );
3120 assert_eq!(
3121 said(&state),
3122 [format!(
3123 "`here`'s login was replaced by a sign-in outside Pitboard: Claude Code now has \
3124 no login stored, {then}"
3125 )]
3126 );
3127
3128 state.in_use.clear();
3129 assert_eq!(
3130 said(&state),
3131 [format!(
3132 "`here`'s login was replaced by a sign-in outside Pitboard, {then}"
3133 )]
3134 );
3135
3136 state.accounts[0].parked = Some(state::Park {
3137 service: "pitboard-park-here-1".into(),
3138 parked_at: 1,
3139 kind: state::ParkKind::Vault,
3140 login: "f".into(),
3141 access_expires_at: None,
3142 refresh_expires_at: None,
3143 session_expires_at: None,
3144 });
3145 assert!(
3146 said(&state).is_empty(),
3147 "a login parked for it is a way back"
3148 );
3149 }
3150
3151 #[test]
3154 fn a_read_that_finds_nothing_new_writes_nothing() {
3155 let m = machine("read-nothing-new");
3156 let pitboard = Pitboard::new(m.ctx.clone());
3157 let before = state_file(&m);
3158
3159 pitboard.status(false).expect("a read");
3160 pitboard.status(false).expect("another read");
3161
3162 assert_eq!(state_file(&m), before);
3163 assert!(audit_lines(&m, "in-use").is_empty());
3164 }
3165
3166 #[test]
3171 fn a_read_under_another_claude_config_dir_keeps_this_ones_record() {
3172 use crate::switch::harness::{NOW, account, document, owner};
3173 let m = machine("read-two-slots");
3174 m.api
3175 .owned_by("access-elsewhere-refresh", owner("elsewhere"));
3176 m.api.owned_by("access-there-other", owner("there"));
3177 let mut state = state::load(&m.ctx).expect("state");
3178 state.accounts.push(account("elsewhere", "elsewhere", None));
3179 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
3180 let other = m
3181 .ctx
3182 .clone()
3183 .with_claude_config_dir(m.ctx_home().join("other").to_string_lossy().into_owned());
3184 m.mem.live().plant(
3185 &crate::provider::claude::paths::live_service(&other),
3186 &document("there-other").to_string(),
3187 );
3188
3189 Pitboard::new(other.clone())
3190 .status(false)
3191 .expect("a read under the other");
3192 m.sign_in(&document("elsewhere-refresh"));
3193 Pitboard::new(m.ctx.clone())
3194 .status(false)
3195 .expect("a read here");
3196
3197 let state = state::load(&m.ctx).expect("state");
3198 assert_eq!(
3199 state.get(&m.key("here")).expect("enrolled").replaced_at,
3200 Some(NOW)
3201 );
3202 assert_eq!(
3203 audit_lines(&m, "in-use"),
3204 [
3205 ("elsewhere".to_string(), "signed_in_outside".to_string()),
3206 ("here".to_string(), "login_replaced".to_string()),
3207 ]
3208 );
3209
3210 let recorded = state_file(&m);
3211 for ctx in [&other, &m.ctx, &other] {
3212 Pitboard::new(ctx.clone()).status(false).expect("a read");
3213 }
3214 assert_eq!(state_file(&m), recorded, "reads taking turns write nothing");
3215 }
3216
3217 #[test]
3222 fn a_login_still_stored_under_another_claude_config_dir_is_not_said_replaced() {
3223 use crate::switch::harness::{NOW, account, document, owner};
3224 let m = machine("read-two-slots-one-account");
3225 m.api.owned_by("access-here-other", owner("here"));
3226 m.api
3227 .owned_by("access-elsewhere-refresh", owner("elsewhere"));
3228 let mut state = state::load(&m.ctx).expect("state");
3229 state.accounts.push(account("elsewhere", "elsewhere", None));
3230 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
3231 let other = m
3232 .ctx
3233 .clone()
3234 .with_claude_config_dir(m.ctx_home().join("other").to_string_lossy().into_owned());
3235 let plant_there = |login: &str| {
3236 m.mem.live().plant(
3237 &crate::provider::claude::paths::live_service(&other),
3238 &document(login).to_string(),
3239 );
3240 };
3241 let replaced = |read: &Done<status::Report>| {
3242 said(read)
3243 .into_iter()
3244 .filter(|(code, _)| *code == "login_replaced")
3245 .count()
3246 };
3247
3248 plant_there("here-other");
3249 Pitboard::new(other.clone())
3250 .status(false)
3251 .expect("a read under the other");
3252 plant_there("elsewhere-refresh");
3253 let there = Pitboard::new(other.clone())
3254 .status(false)
3255 .expect("a read under the other after a sign-in there");
3256 let here = Pitboard::new(m.ctx.clone())
3257 .status(false)
3258 .expect("a read here");
3259
3260 assert_eq!((replaced(&there), replaced(&here)), (0, 0));
3261 assert_eq!(
3262 state::load(&m.ctx)
3263 .expect("state")
3264 .get(&m.key("here"))
3265 .expect("enrolled")
3266 .replaced_at,
3267 None
3268 );
3269 assert_eq!(
3270 audit_lines(&m, "in-use"),
3271 [("elsewhere".to_string(), "signed_in_outside".to_string())]
3272 );
3273
3274 m.sign_in(&document("elsewhere-refresh"));
3275 let gone = Pitboard::new(m.ctx.clone())
3276 .status(false)
3277 .expect("a read here after a sign-in here");
3278
3279 assert_eq!(replaced(&gone), 1);
3280 assert_eq!(
3281 state::load(&m.ctx)
3282 .expect("state")
3283 .get(&m.key("here"))
3284 .expect("enrolled")
3285 .replaced_at,
3286 Some(NOW)
3287 );
3288 assert_eq!(
3289 audit_lines(&m, "in-use")[1..],
3290 [
3291 ("elsewhere".to_string(), "signed_in_outside".to_string()),
3292 ("here".to_string(), "login_replaced".to_string()),
3293 ]
3294 );
3295 }
3296
3297 #[test]
3301 fn a_read_that_cannot_find_the_login_claude_code_names_records_nothing() {
3302 let m = machine("read-login-elsewhere");
3303 m.mem.live().delete_everything();
3304 let before = state_file(&m);
3305
3306 let read = Pitboard::new(m.ctx.clone()).status(false).expect("a read");
3307
3308 assert_eq!(state_file(&m), before);
3309 assert!(audit_lines(&m, "in-use").is_empty());
3310 let said = said(&read);
3311 assert!(
3312 !said.iter().any(|(code, _)| *code == "login_replaced"),
3313 "{said:?}"
3314 );
3315 }
3316
3317 fn in_use(read: &Done<status::Report>) -> Vec<&str> {
3319 read.value
3320 .rows
3321 .iter()
3322 .filter(|row| row.signed_in)
3323 .filter_map(|row| row.label.as_deref())
3324 .collect()
3325 }
3326
3327 #[test]
3332 fn an_offline_read_names_the_account_anthropic_named_whatever_the_config_says() {
3333 let m = machine("offline-config-moved");
3334 switch::harness::config_names(&m, "there");
3335
3336 let read = Pitboard::new(m.ctx.clone())
3337 .status_offline()
3338 .expect("a read");
3339
3340 assert_eq!(in_use(&read), ["here"]);
3341 assert_eq!(
3342 said(&read),
3343 [(
3344 "in_use_unconfirmed",
3345 "Claude Code's config has named `there` since Pitboard last asked Anthropic \
3346 whose login Claude Code has stored, so another login may be stored. The one \
3347 stored then was `here`'s. `pitboard status` asks again."
3348 .to_string()
3349 )]
3350 );
3351 assert_eq!(m.api.calls(), 0);
3352 }
3353
3354 #[test]
3358 fn an_offline_read_of_a_record_never_asked_about_says_the_config_names_another() {
3359 let m = machine("offline-never-asked");
3360 let mut state = state::load(&m.ctx).expect("state");
3361 let here = state.get(&m.key("here")).expect("here").owner();
3362 state.in_use.insert(
3363 ProviderId::Claude.code().into(),
3364 InUse {
3365 owner: Some(here),
3366 login: String::new(),
3367 known_at: 0,
3368 named: None,
3369 },
3370 );
3371 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
3372 switch::harness::config_names(&m, "there");
3373
3374 let read = Pitboard::new(m.ctx.clone())
3375 .status_offline()
3376 .expect("a read");
3377
3378 assert_eq!(in_use(&read), ["here"]);
3379 assert_eq!(
3380 said(&read),
3381 [(
3382 "in_use_unconfirmed",
3383 "Claude Code's config names `there`, and Pitboard has not asked Anthropic whose \
3384 login Claude Code has stored since Pitboard was updated, so another login may \
3385 be stored. The account it last switched to is `here`. `pitboard status` asks."
3386 .to_string()
3387 )]
3388 );
3389 assert_eq!(m.api.calls(), 0);
3390 }
3391
3392 #[test]
3395 fn an_offline_read_of_a_record_never_asked_about_says_nothing_where_the_config_agrees() {
3396 let m = machine("offline-never-asked-agrees");
3397 let mut state = state::load(&m.ctx).expect("state");
3398 let here = state.get(&m.key("here")).expect("here").owner();
3399 state.in_use.insert(
3400 ProviderId::Claude.code().into(),
3401 InUse {
3402 owner: Some(here),
3403 login: String::new(),
3404 known_at: 0,
3405 named: None,
3406 },
3407 );
3408 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
3409 switch::harness::config_names(&m, "here");
3410
3411 let read = Pitboard::new(m.ctx.clone())
3412 .status_offline()
3413 .expect("a read");
3414
3415 assert_eq!(in_use(&read), ["here"]);
3416 assert_eq!(said(&read), []);
3417 assert_eq!(m.api.calls(), 0);
3418 }
3419
3420 #[test]
3425 fn a_read_settles_it_and_says_the_config_names_another() {
3426 use crate::api::scripted::Asked;
3427 let m = machine("read-config-names-another");
3428 switch::harness::config_names(&m, "there");
3429 let pitboard = Pitboard::new(m.ctx.clone());
3430 let names_another = "Claude Code's config names `there`, and the login Claude Code has \
3431 stored is `here`'s, as Anthropic said. `/status` in Claude Code \
3432 shows `there`. `pitboard use here` writes `here` into the config.";
3433
3434 let read = pitboard.status(false).expect("a read");
3435
3436 assert!(
3437 !m.api
3438 .asked()
3439 .contains(&Asked::Owner("access-here-refresh".into())),
3440 "{:?}",
3441 m.api.asked()
3442 );
3443 assert_eq!(in_use(&read), ["here"]);
3444 assert_eq!(
3445 said(&read),
3446 [("config_names_another", names_another.to_string())]
3447 );
3448
3449 let offline = pitboard.status_offline().expect("a read of what is known");
3450 assert_eq!(in_use(&offline), ["here"]);
3451 assert_eq!(
3452 said(&offline),
3453 [("config_names_another", names_another.to_string())]
3454 );
3455 }
3456
3457 #[test]
3464 fn whose_a_codex_login_is_is_told_without_asking_anyone() {
3465 let m = interrupted(codex_machine, "codex-renewed-since");
3466 m.sign_in(&switch::harness::codex_login("here", "renewed-since"));
3467 m.mem.vault().fault(
3468 &reserved(&m),
3469 crate::store::memory::Fault::Unreadable("locked".into()),
3470 );
3471 let pitboard = Pitboard::new(m.ctx.clone());
3472 let asked = m.api.calls();
3473
3474 let read = pitboard.status_offline().expect("a read");
3475 let checks = pitboard.doctor().checks;
3476
3477 assert_eq!(m.api.calls(), asked, "asks nobody");
3478 let refused = pitboard
3479 .switch_to("codex/there")
3480 .expect_err("it stops at it");
3481 assert_eq!(refused.error.code(), "recovery_undetermined");
3482 assert_eq!(
3483 said(&read),
3484 [("recovery_undetermined", refused.error.to_string())]
3485 );
3486 let check = checks
3487 .iter()
3488 .find(|c| c.code == "interrupted_switch")
3489 .expect("said");
3490 assert_eq!(check.detail, refused.error.to_string());
3491 }
3492
3493 #[test]
3499 fn a_codex_store_a_requirement_pins_is_refused_before_a_sign_in() {
3500 let m = codex_machine("codex-pinned");
3501 let ctx = m.ctx.clone().with_codex_program("/nowhere/codex".into());
3504 let required = crate::provider::codex::layers::system_file(&ctx, "requirements.toml");
3505 m.mem.administers(
3506 required.clone(),
3507 "cli_auth_credentials_store = \"keyring\"\n",
3508 );
3509 let pitboard = Pitboard::new(ctx);
3510 for refused in [
3511 pitboard.sign_in("codex/new").expect_err("refused"),
3512 pitboard.sign_in_watched("codex/new").expect_err("refused"),
3513 ] {
3514 assert_eq!(refused.error.code(), "live_store_unsupported");
3515 let said = refused.error.to_string();
3516 assert!(
3517 said.contains(&format!("pinned to `keyring` by {}", required.display())),
3518 "{said}"
3519 );
3520 assert!(
3521 said.contains("No line in your own config.toml can change it"),
3522 "{said}"
3523 );
3524 }
3525 let switched = pitboard.switch_to("codex/there").expect_err("refused too");
3526 assert_eq!(switched.error.code(), "live_store_unsupported");
3527 let file = m
3528 .mem
3529 .file_at(crate::provider::codex::paths::auth_file(&m.ctx));
3530 let kept = crate::store::RawStore::read(&file, "auth.json")
3531 .expect("readable")
3532 .expect("there");
3533 let kept: serde_json::Value = serde_json::from_str(&kept).expect("JSON");
3534 assert_eq!(
3535 kept["tokens"]["refresh_token"], "here-refresh",
3536 "the auth.json Codex does not use is left as it was"
3537 );
3538 }
3539
3540 fn reserved(m: &Machine) -> String {
3542 let raw = std::fs::read_to_string(crate::home::dir(&m.ctx).join("journal.json"))
3543 .expect("a record");
3544 serde_json::from_str::<serde_json::Value>(&raw).expect("a record is JSON")["park_service"]
3545 .as_str()
3546 .expect("a park")
3547 .to_owned()
3548 }
3549
3550 #[test]
3554 fn doctor_says_a_switch_is_stuck_where_it_can_tell_without_asking() {
3555 for (tool, make) in MACHINES {
3556 let m = stuck(make, &format!("doctor-stuck-{tool}"));
3557 let pitboard = Pitboard::new(m.ctx.clone());
3558 let before = untouched(&m);
3559
3560 let checks = pitboard.doctor().checks;
3561
3562 assert_eq!(untouched(&m), before, "{tool}: doctor changes nothing");
3563 let refused = pitboard
3564 .switch_to(&m.key("there").typed())
3565 .expect_err("refused");
3566 let check = checks
3567 .iter()
3568 .find(|c| c.code == "interrupted_switch")
3569 .expect("said");
3570 assert_eq!(check.level, doctor::Level::Warn, "{tool}");
3571 assert_eq!(check.detail, refused.error.to_string(), "{tool}");
3572 assert!(check.advice.contains("pitboard abandon"), "{tool}");
3573 }
3574
3575 let m = stuck_unless_asked("doctor-stuck-unasked");
3576 let asked = m.api.calls();
3577 let checks = Pitboard::new(m.ctx.clone()).doctor().checks;
3578 assert_eq!(m.api.calls(), asked, "doctor asks nobody");
3579 let check = checks
3580 .iter()
3581 .find(|c| c.code == "interrupted_switch")
3582 .expect("said");
3583 assert_eq!(check.detail, "a switch did not finish");
3584 }
3585
3586 fn files(m: &Machine) -> BTreeMap<String, Vec<u8>> {
3588 std::fs::read_dir(crate::home::dir(&m.ctx))
3589 .expect("a Pitboard home")
3590 .map(|entry| entry.expect("an entry").path())
3591 .filter(|path| path.file_name() != Some("audit.log".as_ref()))
3592 .map(|path| {
3593 let body = std::fs::read(&path).unwrap_or_default();
3594 (path.display().to_string(), body)
3595 })
3596 .collect()
3597 }
3598
3599 fn last_audited(m: &Machine) -> Vec<(String, String)> {
3601 audit::read(&m.ctx, 2)
3602 .into_iter()
3603 .map(|entry| (entry.verb, entry.outcome))
3604 .collect()
3605 }
3606
3607 #[test]
3612 fn a_change_refused_over_its_name_still_recovers_an_interrupted_switch() {
3613 for (tool, make) in MACHINES {
3614 for (change, verb, code, audited, refuse) in REFUSALS {
3615 let at = format!("{tool}, {change}");
3616 let m = interrupted(make, &format!("refused-{tool}-{change}"));
3617
3618 let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
3619 .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
3620
3621 assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
3622 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
3623 assert_eq!(said, ["interrupted_switch_undone"], "{at}");
3624 assert!(!switch::interrupted(&m.ctx), "{at}: the record is resolved");
3625 hold(&m, &at);
3626 assert_eq!(
3627 last_audited(&m),
3628 [
3629 (
3630 "recover".to_string(),
3631 "interrupted_switch_undone".to_string()
3632 ),
3633 (verb.to_string(), audited.to_string()),
3634 ],
3635 "{at}"
3636 );
3637 }
3638 }
3639 }
3640
3641 #[test]
3644 fn a_change_refused_over_its_name_with_nothing_interrupted_changes_nothing() {
3645 for (tool, make) in MACHINES {
3646 for (change, verb, code, audited, refuse) in REFUSALS {
3647 let at = format!("{tool}, {change}");
3648 let m = make(&format!("refused-quietly-{tool}-{change}"));
3649 let (before, parked, live) = (files(&m), m.mem.vault().services(), m.live());
3650
3651 let failed = refuse(&Pitboard::new(m.ctx.clone()), m.which)
3652 .unwrap_or_else(|| panic!("{at}: the name must still be refused"));
3653
3654 assert_eq!(failed.error.code(), code, "{at}: {}", failed.error);
3655 assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
3656 assert_eq!(files(&m), before, "{at}: not even the lock file is made");
3657 assert_eq!(m.mem.vault().services(), parked, "{at}");
3658 assert_eq!(m.live(), live, "{at}");
3659 assert_eq!(
3660 last_audited(&m).last(),
3661 Some(&(verb.to_string(), audited.to_string())),
3662 "{at}"
3663 );
3664 }
3665 }
3666 }
3667
3668 #[test]
3672 fn a_new_login_parked_after_it_did_not_hold_says_how_it_was_parked() {
3673 for (tool, make) in MACHINES {
3674 let m = make(&format!("not-installed-said-{tool}"));
3675 m.mem.vault().takes_on_stdin(64);
3676 let login = switch::harness::signed_in(&m, "here", "here-refresh-2");
3677 m.fault_live(crate::store::memory::Fault::DeletedAfterWrite);
3678
3679 let failed = Pitboard::new(m.ctx.clone())
3680 .enroll_signed_in(&m.key("here").typed(), login)
3681 .expect_err("it did not hold");
3682
3683 assert_eq!(failed.error.code(), "sign_in_not_installed", "{tool}");
3684 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
3685 assert_eq!(said, ["written_on_the_command_line"], "{tool}");
3686 }
3687 }
3688
3689 #[test]
3694 fn a_custom_claude_endpoint_stops_only_the_recovery_of_a_claude_code_switch() {
3695 let codex = interrupted(codex_machine, "refused-custom-codex");
3696 let mut ctx = codex.ctx.clone();
3697 ctx.custom_oauth = true;
3698 let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
3699 assert_eq!(failed.error.code(), "account_unknown");
3700 let said: Vec<&str> = failed.warnings.iter().map(Warning::code).collect();
3701 assert_eq!(said, ["interrupted_switch_undone"]);
3702 assert!(!switch::interrupted(&codex.ctx));
3703
3704 let claude = interrupted(machine, "refused-custom-claude");
3705 let mut ctx = claude.ctx.clone();
3706 ctx.custom_oauth = true;
3707 let failed = Pitboard::new(ctx).switch_to("nobody").expect_err("refused");
3708 assert_eq!(
3709 failed.error.code(),
3710 "account_unknown",
3711 "the refusal, not the recovery that could not run"
3712 );
3713 assert!(failed.warnings.is_empty(), "{:?}", failed.warnings);
3714 assert!(
3715 switch::interrupted(&claude.ctx),
3716 "the record is kept for a run that can finish it"
3717 );
3718 }
3719
3720 #[derive(Debug, Clone, Copy)]
3721 enum Refused {
3722 As(Elevation),
3723 On(Floor),
3724 }
3725
3726 const AS_ROOT: Refused = Refused::As(Elevation::Elevated { why: "as root" });
3727 const WITH_SUDO: Refused = Refused::As(Elevation::Elevated { why: "with sudo" });
3728 const SERVICE_ACCOUNT: Refused = Refused::As(Elevation::Elevated {
3729 why: crate::host::token::AS_A_SERVICE_ACCOUNT,
3730 });
3731 const ELEVATION_UNKNOWN: Refused = Refused::As(Elevation::Unknown);
3732 const BELOW_THE_FLOOR: Refused = Refused::On(Floor::Below { build: 22631 });
3733 const FLOOR_UNKNOWN: Refused = Refused::On(Floor::Unknown);
3734
3735 const REFUSED: [Refused; 6] = [
3736 AS_ROOT,
3737 WITH_SUDO,
3738 SERVICE_ACCOUNT,
3739 ELEVATION_UNKNOWN,
3740 BELOW_THE_FLOOR,
3741 FLOOR_UNKNOWN,
3742 ];
3743
3744 impl Refused {
3745 fn said_by(self, m: &Machine) {
3746 match self {
3747 Refused::As(elevation) => m.mem.runs_with(elevation),
3748 Refused::On(floor) => m.mem.runs_on(floor),
3749 }
3750 }
3751
3752 fn lifted(m: &Machine) {
3753 m.mem.runs_with(Elevation::Normal);
3754 m.mem.runs_on(Floor::Met);
3755 }
3756
3757 fn code(self) -> &'static str {
3758 match self {
3759 Refused::As(_) => "elevated",
3760 Refused::On(_) => "system_too_old",
3761 }
3762 }
3763
3764 fn words(self) -> crate::words::ChangesNothing {
3765 match self {
3766 Refused::As(Elevation::Elevated { why }) => {
3767 crate::words::elevated(crate::host::OS, Some(why))
3768 }
3769 Refused::As(_) => crate::words::elevated(crate::host::OS, None),
3770 Refused::On(Floor::Below { build }) => crate::words::too_old(Some(build)),
3771 Refused::On(_) => crate::words::too_old(None),
3772 }
3773 }
3774 }
3775
3776 fn failed<T>(outcome: Result<T>) -> Option<Failed> {
3779 outcome.err().map(|error| Failed {
3780 error,
3781 warnings: Vec::new(),
3782 said_first: 0,
3783 })
3784 }
3785
3786 type Change = fn(&Pitboard, &Machine) -> Option<Failed>;
3787
3788 const CHANGES: [(&str, Change); 16] = [
3791 ("switch_to", |p, m| {
3792 p.switch_to(&m.key("there").typed()).err()
3793 }),
3794 ("enroll_current", |p, m| {
3795 p.enroll_current(&m.key("here").typed()).err()
3796 }),
3797 ("sign_in", |p, m| p.sign_in(&m.key("new").typed()).err()),
3798 ("sign_in_watched", |p, m| {
3799 p.sign_in_watched(&m.key("new").typed()).err()
3800 }),
3801 ("forget", |p, m| p.forget(&m.key("there").typed()).err()),
3802 ("rename", |p, m| {
3803 p.rename(&m.key("there").typed(), "elsewhere").err()
3804 }),
3805 ("abandon_recovery", |p, _| failed(p.abandon_recovery())),
3806 ("renew", |p, _| failed(p.renew())),
3807 ("schedule_install", |p, _| failed(p.schedule_install())),
3808 ("schedule_uninstall", |p, _| failed(p.schedule_uninstall())),
3809 ("schedule_repair", |p, _| failed(p.schedule_repair())),
3810 ("adopt", |p, _| failed(p.adopt())),
3811 ("repair", |p, _| p.repair().err()),
3812 ("keep_app_file", |p, _| {
3813 failed(p.keep_app_file(crate::app::AppFile::Preferences, "{}"))
3814 }),
3815 ("uninstall", |p, _| p.uninstall().err()),
3816 ("auto_switch", |p, _| {
3817 p.auto_switch(Threshold::DEFAULT).err()
3818 }),
3819 ];
3820
3821 type Everything = (
3828 BTreeMap<String, Option<Vec<u8>>>,
3829 Vec<(String, Option<String>)>,
3830 Vec<(String, Option<String>)>,
3831 Option<serde_json::Value>,
3832 );
3833
3834 fn everything(m: &Machine) -> Everything {
3835 fn walk(dir: &std::path::Path, into: &mut BTreeMap<String, Option<Vec<u8>>>) {
3836 for entry in std::fs::read_dir(dir).expect("a directory to read") {
3837 let path = entry.expect("an entry").path();
3838 if path.ends_with(".pitboard/signin") {
3839 continue;
3840 }
3841 if path.is_dir() {
3842 into.insert(path.display().to_string(), None);
3843 walk(&path, into);
3844 } else if path.ends_with(".pitboard/signin.lock") {
3845 let len = std::fs::metadata(&path).expect("the lock's length").len();
3846 into.insert(path.display().to_string(), Some(len.to_le_bytes().to_vec()));
3847 } else {
3848 let body = std::fs::read(&path)
3849 .unwrap_or_else(|e| panic!("{} to read: {e}", path.display()));
3850 into.insert(path.display().to_string(), Some(body));
3851 }
3852 }
3853 }
3854 let mut files = BTreeMap::new();
3855 walk(&m.ctx_home(), &mut files);
3856 let items = |store: &crate::store::memory::MemoryStore| {
3857 store
3858 .services()
3859 .into_iter()
3860 .map(|service| {
3861 let held = store.peek(&service);
3862 (service, held)
3863 })
3864 .collect()
3865 };
3866 (files, items(m.mem.live()), items(m.mem.vault()), m.live())
3867 }
3868
3869 mod every_change_is_refused_where_pitboard_may_change_nothing_and_changes_nothing {
3877 use super::*;
3878
3879 macro_rules! cases {
3880 ($($case:ident: $make:ident, $refused:ident;)*) => {
3881 const _: () =
3882 assert!([$(stringify!($case)),*].len() == MACHINES.len() * REFUSED.len());
3883 $(
3884 #[test]
3885 fn $case() {
3886 every_change_is_refused(stringify!($case), $make, $refused);
3887 }
3888 )*
3889 };
3890 }
3891
3892 cases! {
3893 claude_as_root: machine, AS_ROOT;
3894 claude_with_sudo: machine, WITH_SUDO;
3895 claude_as_a_service_account: machine, SERVICE_ACCOUNT;
3896 claude_elevation_unknown: machine, ELEVATION_UNKNOWN;
3897 claude_below_the_floor: machine, BELOW_THE_FLOOR;
3898 claude_floor_unknown: machine, FLOOR_UNKNOWN;
3899 codex_as_root: codex_machine, AS_ROOT;
3900 codex_with_sudo: codex_machine, WITH_SUDO;
3901 codex_as_a_service_account: codex_machine, SERVICE_ACCOUNT;
3902 codex_elevation_unknown: codex_machine, ELEVATION_UNKNOWN;
3903 codex_below_the_floor: codex_machine, BELOW_THE_FLOOR;
3904 codex_floor_unknown: codex_machine, FLOOR_UNKNOWN;
3905 }
3906 }
3907
3908 fn every_change_is_refused(case: &str, make: Make, refused: Refused) {
3909 for (change, run) in CHANGES {
3910 let at = format!("{case}, {change}");
3911 let m = interrupted(make, &format!("gate-{case}-{change}"));
3912 refused.said_by(&m);
3913 let (before, asked) = (everything(&m), m.api.calls());
3914
3915 let failed =
3916 run(&Pitboard::new(m.ctx.clone()), &m).unwrap_or_else(|| panic!("{at}: refused"));
3917
3918 assert_eq!(
3919 failed.error.code(),
3920 refused.code(),
3921 "{at}: {}",
3922 failed.error
3923 );
3924 assert!(failed.warnings.is_empty(), "{at}: {:?}", failed.warnings);
3925 assert!(everything(&m) == before, "{at}: nothing changes");
3926 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
3927 }
3928
3929 let at = format!("{case}, enroll_signed_in");
3930 let m = interrupted(make, &format!("gate-{case}-enroll-signed-in"));
3931 let login = switch::harness::signed_in(&m, "new", "new-refresh");
3932 refused.said_by(&m);
3933 let before = everything(&m);
3934 let failed = Pitboard::new(m.ctx.clone())
3935 .enroll_signed_in(&m.key("new").typed(), login)
3936 .expect_err("refused");
3937 assert_eq!(failed.error.code(), refused.code(), "{at}");
3938 assert!(everything(&m) == before, "{at}: nothing changes");
3939 }
3940
3941 #[test]
3942 fn the_gate_says_why_it_refuses_and_lets_a_person_through() {
3943 let m = machine("gate-words");
3944 let pitboard = Pitboard::new(m.ctx.clone());
3945 assert!(pitboard.permit().is_ok(), "as the person");
3946 for refused in REFUSED {
3947 refused.said_by(&m);
3948 let refusal = pitboard.permit().expect_err("refused");
3949 assert_eq!(refusal.code(), refused.code(), "{refused:?}");
3950 assert_eq!(refusal.exit_code(), 1, "{refused:?}");
3951 assert_eq!(refusal.to_string(), refused.words().refusal, "{refused:?}");
3952 Refused::lifted(&m);
3953 assert!(pitboard.permit().is_ok(), "{refused:?}: lifted");
3954 }
3955 }
3956
3957 #[test]
3958 fn the_gate_lets_windows_11_24h2_through_and_says_an_older_one_first() {
3959 let m = machine("gate-floor");
3960 let pitboard = Pitboard::new(m.ctx.clone());
3961 for build in [26100, 26200] {
3962 m.mem
3963 .runs_on(crate::host::windows_floor(Some((10, 0, build))));
3964 assert!(pitboard.permit().is_ok(), "{build}");
3965 }
3966 m.mem
3967 .runs_on(crate::host::windows_floor(Some((10, 0, 22631))));
3968 let refused = pitboard.permit().expect_err("refused");
3969 assert_eq!(refused.code(), "system_too_old");
3970 assert_eq!(
3971 refused.to_string(),
3972 crate::words::too_old(Some(22631)).refusal
3973 );
3974 m.mem.runs_with(Elevation::Elevated { why: "as root" });
3975 let first = pitboard.permit().expect_err("refused");
3976 assert_eq!(
3977 first.code(),
3978 "system_too_old",
3979 "the system before how it runs"
3980 );
3981 }
3982
3983 #[test]
3989 fn every_change_and_read_is_refused_where_the_home_is_not_a_full_path() {
3990 for home in ["", "relative"] {
3991 for (tool, make) in MACHINES {
3992 let at = format!("{tool}, HOME={home:?}");
3993 let m = make(&format!("unplaced-{tool}-{}", home.len()));
3994 let mut ctx = m.ctx.clone();
3995 ctx.home = std::path::PathBuf::from(home);
3996 let pitboard = Pitboard::new(ctx);
3997 let refused = |error: Error| {
3998 assert_eq!(error.code(), "home_not_absolute", "{at}: {error}");
3999 assert!(
4000 error
4001 .to_string()
4002 .starts_with(&format!("{} is ", crate::host::OS.home_variable())),
4003 "{at}: {error}"
4004 );
4005 };
4006
4007 refused(pitboard.permit().expect_err("the gate refuses"));
4008 refused(pitboard.status_offline().expect_err("refused"));
4009 refused(pitboard.status(false).expect_err("refused"));
4010 let before = (everything(&m), m.api.calls());
4011 for (change, run) in CHANGES {
4012 let failed = run(&pitboard, &m).unwrap_or_else(|| panic!("{at}: {change}"));
4013 refused(failed.error);
4014 assert!(failed.warnings.is_empty(), "{at}, {change}");
4015 }
4016 assert!(
4017 (everything(&m), m.api.calls()) == before,
4018 "{at}: nothing changes and nobody is asked"
4019 );
4020 }
4021 }
4022 }
4023
4024 type Read = Vec<(
4027 ProviderId,
4028 Option<String>,
4029 bool,
4030 Option<crate::usage::Snapshot>,
4031 Option<status::Stale>,
4032 )>;
4033
4034 fn rows(report: &status::Report) -> Read {
4035 report
4036 .rows
4037 .iter()
4038 .map(|row| {
4039 (
4040 row.provider,
4041 row.label.clone(),
4042 row.signed_in,
4043 row.usage.clone(),
4044 row.stale,
4045 )
4046 })
4047 .collect()
4048 }
4049
4050 #[test]
4056 fn a_read_where_pitboard_may_change_nothing_asks_nobody_and_says_why() {
4057 for (tool, make) in MACHINES {
4058 for refused in REFUSED {
4059 let at = format!("{tool}, {refused:?}");
4060 let m = make(&format!("gate-read-{tool}"));
4061 let later = m
4062 .ctx
4063 .clone()
4064 .with_clock(Arc::new(crate::time::FixedClock::at(
4065 switch::harness::NOW + 11 * 86_400,
4066 )));
4067 let pitboard = Pitboard::new(later);
4068 refused.said_by(&m);
4069 let (before, asked) = (everything(&m), m.api.calls());
4070
4071 let read = pitboard.status(false).expect("a read");
4072 let known = pitboard.status_offline().expect("a read");
4073
4074 assert!(everything(&m) == before, "{at}: nothing is written");
4075 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
4076 assert_eq!(rows(&read.value), rows(&known.value), "{at}");
4077 let codes: Vec<&str> = read.warnings.iter().map(Warning::code).collect();
4078 assert_eq!(codes.last(), Some(&"read_only"), "{at}");
4079 assert_eq!(read.warnings.len(), known.warnings.len() + 1, "{at}");
4080 let said = read.warnings.last().expect("said").to_string();
4081 let words = refused.words();
4082 assert!(
4083 said.starts_with(&format!("{}, so it changes nothing", words.because)),
4084 "{at}: {said}"
4085 );
4086 assert!(said.ends_with(words.to_ask_again), "{at}: {said}");
4087
4088 Refused::lifted(&m);
4089 let live = pitboard.status(false).expect("a read");
4090 assert!(m.api.calls() > asked, "{at}: as the person, it asks");
4091 assert!(
4092 !live.warnings.iter().any(|w| w.code() == "read_only"),
4093 "{at}"
4094 );
4095 }
4096 }
4097 }
4098
4099 #[test]
4103 fn the_status_line_where_pitboard_may_change_nothing_draws_the_same_line_and_writes_nothing() {
4104 let session = serde_json::json!({
4105 "session_id": "a-session",
4106 "rate_limits": {
4107 "five_hour": {"used_percentage": 40.0, "resets_at": switch::harness::NOW + 3_600},
4108 "seven_day": {"used_percentage": 20.0, "resets_at": switch::harness::NOW + 86_400},
4109 },
4110 })
4111 .to_string();
4112 for refused in REFUSED {
4113 let m = machine("gate-statusline");
4114 let pitboard = Pitboard::new(m.ctx.clone());
4115 refused.said_by(&m);
4116 let before = everything(&m);
4117
4118 let drawn = pitboard.statusline(&session);
4119
4120 assert!(everything(&m) == before, "{refused:?}: nothing is written");
4121 Refused::lifted(&m);
4122 assert_eq!(pitboard.statusline(&session), drawn, "{refused:?}");
4123 assert!(
4124 crate::home::dir(&m.ctx).join("sessions.json").exists(),
4125 "{refused:?}: as the person, the session is kept"
4126 );
4127 }
4128 }
4129
4130 #[test]
4134 fn a_renewal_where_pitboard_may_change_nothing_is_refused_and_asks_nobody() {
4135 for (tool, make) in MACHINES {
4136 for refused in REFUSED {
4137 let at = format!("{tool}, {refused:?}");
4138 let m = make(&format!("gate-renew-{tool}"));
4139 switch::harness::renews(&m, "there-refresh", "there-refresh-2");
4140 let later = m
4141 .ctx
4142 .clone()
4143 .with_clock(Arc::new(crate::time::FixedClock::at(
4144 switch::harness::NOW + 11 * 86_400,
4145 )));
4146 let pitboard = Pitboard::new(later);
4147 refused.said_by(&m);
4148 let (before, asked) = (everything(&m), m.api.calls());
4149
4150 let refusal = pitboard.renew().expect_err("refused");
4151
4152 assert_eq!(refusal.code(), refused.code(), "{at}");
4153 assert!(everything(&m) == before, "{at}: nothing changes");
4154 assert_eq!(m.api.calls(), asked, "{at}: nobody is asked");
4155
4156 Refused::lifted(&m);
4157 let renewed = pitboard.renew().expect("as the person, it renews");
4158 let codes: Vec<&str> = renewed.iter().map(|(_, r)| r.code()).collect();
4159 assert_eq!(codes, ["renewed"], "{at}");
4160 }
4161 }
4162 }
4163
4164 #[test]
4167 fn doctor_says_why_pitboard_changes_nothing() {
4168 let m = machine("gate-doctor");
4169 let pitboard = Pitboard::new(m.ctx.clone());
4170 let refusing = |checks: &[doctor::Check]| -> Vec<&'static str> {
4171 checks
4172 .iter()
4173 .map(|c| c.code)
4174 .filter(|code| ["elevated", "system_too_old"].contains(code))
4175 .collect()
4176 };
4177 assert!(
4178 refusing(&pitboard.doctor().checks).is_empty(),
4179 "as the person"
4180 );
4181 for refused in REFUSED {
4182 refused.said_by(&m);
4183 let checks = pitboard.doctor().checks;
4184 let check = checks.first().expect("checks");
4185 let words = refused.words();
4186 assert_eq!(check.code, refused.code(), "{refused:?}");
4187 assert_eq!(check.level, doctor::Level::Fail, "{refused:?}");
4188 assert_eq!(check.detail, words.because, "{refused:?}");
4189 assert!(
4190 check.advice.starts_with("Pitboard changes nothing ")
4191 && check.advice.ends_with(words.way_out),
4192 "{refused:?}: {}",
4193 check.advice
4194 );
4195 Refused::lifted(&m);
4196 }
4197 m.mem.runs_on(Floor::Below { build: 22631 });
4198 m.mem.runs_with(Elevation::Unknown);
4199 assert_eq!(
4200 refusing(&pitboard.doctor().checks),
4201 ["system_too_old", "elevated"]
4202 );
4203 }
4204}