1use super::{Error, Readied, Result, Settled, identify_document, nothing_signed_in, purge};
15use crate::api::Owner;
16use crate::context::Context;
17use crate::provider::{self, ProviderId};
18use crate::service::Warning;
19use crate::state::{Account, Key, Park, State};
20use crate::{home, lock, park, state, store};
21use serde_json::{Value, json};
22use std::fs::{File, OpenOptions, TryLockError};
23use std::os::unix::fs::OpenOptionsExt;
24use std::path::PathBuf;
25
26#[derive(Debug)]
27pub enum Enrolled {
28 Current { email: String },
30 SignedIn { email: String },
32 Renewed { email: String },
34 InUse { email: String, again: bool },
38}
39
40pub struct SignIn {
43 provider: ProviderId,
44 dir: PathBuf,
45 document: Value,
46 ctx: Context,
47 _one_at_a_time: File,
48}
49
50impl SignIn {
51 pub fn provider(&self) -> ProviderId {
53 self.provider
54 }
55}
56
57impl Drop for SignIn {
58 fn drop(&mut self) {
59 provider::of(self.provider).discard_signin(&self.ctx, &self.dir);
60 let _ = std::fs::remove_dir_all(&self.dir);
61 }
62}
63
64fn reserve_signin(ctx: &Context, which: ProviderId) -> Result<SignIn> {
70 let home = home::ensure(ctx).map_err(|source| Error::HomeUnwritable {
71 path: home::dir(ctx),
72 source,
73 })?;
74 let lock_path = home.join("signin.lock");
75 let one_at_a_time = OpenOptions::new()
76 .create(true)
77 .truncate(false)
78 .write(true)
79 .mode(0o600)
80 .open(&lock_path)
81 .map_err(|source| Error::HomeUnwritable {
82 path: lock_path.clone(),
83 source,
84 })?;
85 match one_at_a_time.try_lock() {
86 Ok(()) => {}
87 Err(TryLockError::WouldBlock) => return Err(Error::SignInInProgress),
88 Err(TryLockError::Error(source)) => {
89 return Err(Error::HomeUnwritable {
90 path: lock_path,
91 source,
92 });
93 }
94 }
95
96 let dir = home.join("signin");
97 for &tool in ProviderId::ALL {
103 provider::of(tool).discard_signin(ctx, &dir);
104 }
105 let _ = std::fs::remove_dir_all(&dir);
106 home::create_private(&dir).map_err(|source| Error::HomeUnwritable {
107 path: dir.clone(),
108 source,
109 })?;
110 Ok(SignIn {
111 provider: which,
112 dir,
113 document: Value::Null,
114 ctx: ctx.clone(),
115 _one_at_a_time: one_at_a_time,
116 })
117}
118
119#[cfg(test)]
122pub(super) fn planted(ctx: &Context, which: ProviderId, document: Value) -> Result<SignIn> {
123 let mut pending = reserve_signin(ctx, which)?;
124 pending.document = document;
125 Ok(pending)
126}
127
128pub fn sign_in(ctx: &Context, which: ProviderId) -> Result<SignIn> {
131 let mut pending = reserve_signin(ctx, which)?;
132 let finished = provider::of(which)
135 .sign_in(ctx, &pending.dir)
136 .stdout(std::io::stderr())
137 .status()
138 .map_err(|e| started(which, e))?
139 .success();
140 if !finished {
141 return Err(Error::SignInIncomplete);
142 }
143 pending.document = signed_in_document(ctx, which, &pending.dir)?;
144 Ok(pending)
145}
146
147fn started(which: ProviderId, e: std::io::Error) -> Error {
148 match e.kind() {
149 std::io::ErrorKind::NotFound => Error::ProgramNotFound { tool: which },
150 _ => Error::SignInIncomplete,
151 }
152}
153
154fn signed_in_document(ctx: &Context, which: ProviderId, dir: &std::path::Path) -> Result<Value> {
155 let raw = provider::of(which)
156 .read_signin(ctx, dir)?
157 .ok_or(Error::SignInIncomplete)?;
158 serde_json::from_str(&raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
159 tool: which,
160 detail: e.to_string(),
161 })
162}
163
164pub struct WatchedSignIn {
168 child: std::process::Child,
169 said: Said,
170 pending: SignIn,
171}
172
173#[derive(Clone)]
180pub struct Said(std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<String>>>);
181
182impl Said {
183 pub fn next(&self) -> Option<String> {
186 self.0.lock().ok()?.recv().ok()
187 }
188}
189
190impl WatchedSignIn {
191 pub fn provider(&self) -> ProviderId {
193 self.pending.provider
194 }
195
196 pub fn said(&self) -> Said {
199 self.said.clone()
200 }
201
202 pub fn paste(&mut self, line: &str) -> Result<()> {
205 use std::io::Write;
206 let stdin = self.child.stdin.as_mut().ok_or(Error::SignInIncomplete)?;
207 writeln!(stdin, "{line}").map_err(|_| Error::SignInIncomplete)?;
208 stdin.flush().map_err(|_| Error::SignInIncomplete)
209 }
210
211 pub fn finish(mut self) -> Result<SignIn> {
213 let finished = self
214 .child
215 .wait()
216 .map_err(|_| Error::SignInIncomplete)?
217 .success();
218 if !finished {
219 return Err(Error::SignInIncomplete);
220 }
221 let mut pending = self.pending;
222 pending.document =
223 signed_in_document(&pending.ctx.clone(), pending.provider, &pending.dir.clone())?;
224 Ok(pending)
225 }
226
227 pub fn cancel(mut self) {
229 let _ = self.child.kill();
230 let _ = self.child.wait();
231 }
232}
233
234pub fn sign_in_watched(ctx: &Context, which: ProviderId) -> Result<WatchedSignIn> {
236 let pending = reserve_signin(ctx, which)?;
237 let command = provider::of(which).sign_in(ctx, &pending.dir);
238 watch(command, pending).map_err(|e| started(which, e))
239}
240
241fn watch(mut command: std::process::Command, pending: SignIn) -> std::io::Result<WatchedSignIn> {
243 let mut child = command
244 .stdin(std::process::Stdio::piped())
245 .stdout(std::process::Stdio::piped())
246 .stderr(std::process::Stdio::piped())
247 .spawn()?;
248 let (say, said) = std::sync::mpsc::channel();
249 for stream in [
253 child.stdout.take().map(Readable::Out),
254 child.stderr.take().map(Readable::Err),
255 ]
256 .into_iter()
257 .flatten()
258 {
259 let say = say.clone();
260 std::thread::spawn(move || {
261 use std::io::Read;
262 let mut reader: Box<dyn Read + Send> = match stream {
263 Readable::Out(o) => Box::new(o),
264 Readable::Err(e) => Box::new(e),
265 };
266 let mut buffer = [0_u8; 1024];
267 while let Ok(read) = reader.read(&mut buffer) {
268 if read == 0 {
269 break;
270 }
271 let text = String::from_utf8_lossy(&buffer[..read]).into_owned();
272 if say.send(text).is_err() {
273 break;
274 }
275 }
276 });
277 }
278 Ok(WatchedSignIn {
279 child,
280 said: Said(std::sync::Arc::new(std::sync::Mutex::new(said))),
281 pending,
282 })
283}
284
285enum Readable {
286 Out(std::process::ChildStdout),
287 Err(std::process::ChildStderr),
288}
289
290pub fn enroll(
293 settled: Settled,
294 key: &Key,
295 signed_in: Option<SignIn>,
296) -> Result<(Enrolled, Vec<Warning>)> {
297 let Settled {
298 _exclusive,
299 mut state,
300 ctx,
301 } = settled;
302 match signed_in {
303 Some(login) if login.provider != key.provider => Err(Error::Usage(format!(
306 "that sign-in was {}'s, and `{key}` is a {} account",
307 login.provider.name(),
308 key.provider.name()
309 ))),
310 Some(login) => from_sign_in(&ctx, key, &mut state, &login),
311 None => record_current(&ctx, key, &mut state).map(|e| (e, Vec::new())),
312 }
313}
314
315fn claim(state: &State, key: &Key, owner: &Owner) -> Result<()> {
318 if let Some(taken) = state.get(key)
319 && taken.account_uuid != owner.account_uuid
320 {
321 return Err(Error::LabelTaken {
322 label: key.typed(),
323 email: taken.email.clone(),
324 });
325 }
326 if let Some(existing) = state.by_uuid(key.provider, &owner.account_uuid)
327 && existing.label != key.label
328 {
329 return Err(Error::AlreadyEnrolled {
330 tool: key.provider,
331 email: owner.email.clone(),
332 label: existing.key().typed(),
333 });
334 }
335 Ok(())
336}
337
338fn record_current(ctx: &Context, key: &Key, state: &mut State) -> Result<Enrolled> {
339 let which = key.provider;
340 let label = key.label.as_str();
341 let store = super::live_store(ctx, which)?;
344 let live =
345 store::read(&store.chain, &store.service)?.ok_or_else(|| nothing_signed_in(ctx, which))?;
346 match provider::of(which).slice(&live) {
349 Err(provider::ProviderError::NoLogin { .. }) => return Err(nothing_signed_in(ctx, which)),
350 Err(other) => return Err(super::shape(which, other)),
351 Ok(_) => {}
352 }
353 let owner = identify_document(ctx, which, &live)?;
354 claim(state, key, &owner)?;
355 let existing = state.get(key);
356 let parked = existing.and_then(|a| a.parked.clone());
357 let last_used_at = Some(ctx.now());
359 state.upsert(account(which, label, &owner, parked, last_used_at, &live));
360 state.set_active(which, Some(label.to_string()));
361 state::save(ctx, state)?;
362 Ok(Enrolled::Current { email: owner.email })
363}
364
365fn from_sign_in(
372 ctx: &Context,
373 key: &Key,
374 state: &mut State,
375 login: &SignIn,
376) -> Result<(Enrolled, Vec<Warning>)> {
377 let owner = identify_document(ctx, login.provider, &login.document)?;
378 claim(state, key, &owner)?;
379 match signed_in_now(ctx, key.provider, &owner) {
380 InUse::Theirs(live, first) => {
381 install_signed_in(ctx, key, state, login, &owner, &live, &first)
382 }
383 InUse::NotTheirs => park_signed_in(ctx, key, state, login, &owner),
384 InUse::Untold(why) => {
385 let last_in_use = state.active_for(key.provider) == Some(key.label.as_str());
388 let (enrolled, mut warnings) = park_signed_in(ctx, key, state, login, &owner)?;
389 if last_in_use {
390 warnings.push(Warning::SignInParkedNotInUse {
391 tool: key.provider,
392 label: state.typed(key),
393 why: untold(&why),
394 });
395 }
396 Ok((enrolled, warnings))
397 }
398 }
399}
400
401enum InUse {
406 Theirs(provider::LiveStore, Value),
408 NotTheirs,
410 Untold(Error),
412}
413
414fn signed_in_now(ctx: &Context, which: ProviderId, owner: &Owner) -> InUse {
415 let read = super::live_store(ctx, which)
416 .and_then(|live| super::read_live(ctx, which, &live).map(|(_, first)| (live, first)));
417 let (live, first) = match read {
418 Ok(read) => read,
419 Err(Error::LiveCredentialAbsent { .. }) => return InUse::NotTheirs,
420 Err(other) => return InUse::Untold(other),
421 };
422 match identify_document(ctx, which, &first) {
423 Ok(found) if found.account_uuid == owner.account_uuid => InUse::Theirs(live, first),
424 Ok(_) => InUse::NotTheirs,
425 Err(e) => InUse::Untold(e),
426 }
427}
428
429fn untold(error: &Error) -> String {
432 match error {
433 Error::SessionExpired { tool } => format!("{} refused its access token", tool.service()),
434 Error::IdentityUnverifiable { detail, .. }
435 | Error::LiveCredentialShapeUnexpected { detail, .. } => detail.clone(),
436 Error::LiveStoreUnsupported { reason, .. } => reason.clone(),
437 Error::LiveCredentialElsewhere { email } => {
438 format!("its config names {email}, and pitboard cannot find that login")
439 }
440 Error::Store(e) => e.to_string(),
441 other => other.code().replace('_', " "),
442 }
443}
444
445fn install_signed_in(
450 ctx: &Context,
451 key: &Key,
452 state: &mut State,
453 login: &SignIn,
454 owner: &Owner,
455 live: &provider::LiveStore,
456 first: &Value,
457) -> Result<(Enrolled, Vec<Warning>)> {
458 let which = key.provider;
459 let name = state.typed(key);
460 let not_kept = |detail: String| Error::SignInNotKept {
463 tool: which,
464 label: name.clone(),
465 detail,
466 };
467 let slice = provider::of(which)
468 .slice(&login.document)
469 .map_err(|e| super::shape(which, e))?;
470 let Readied {
471 guard,
472 before_raw,
473 next,
474 on_the_command_line,
475 ..
476 } = super::ready(ctx, which, live, first, &owner.account_uuid, &slice, &name).map_err(|e| {
477 match e {
478 Error::SignedInAccountChanged => not_kept(format!(
479 "{} was signed in to another account meanwhile",
480 which.name()
481 )),
482 other => other,
483 }
484 })?;
485
486 let written = on_the_command_line.into_iter().collect::<Vec<_>>();
487 match super::install_with(
488 which,
489 |body| store::write_raw(&live.chain, &live.service, body),
490 || store::read_raw(&live.chain, &live.service),
491 &next,
492 &before_raw,
493 &name,
494 &name,
495 ) {
496 Ok(()) => {}
497 Err(Error::SwitchRolledBack { detail, .. }) => return Err(not_kept(detail)),
499 Err(Error::SwitchUnverified { detail, .. } | Error::SwitchCorrupted { detail, .. }) => {
500 return Err(not_installed(
501 ctx, key, state, login, owner, detail, written,
502 ));
503 }
504 Err(other) => return Err(other),
505 }
506 crate::fault::point("enroll.installed");
507
508 let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
510 let lost = match super::holds(which, live) {
511 Ok(true) => None,
512 Ok(false) => Some("it was gone again before pitboard finished".to_string()),
513 Err(unreadable) => Some(unreadable.to_string()),
514 };
515 if let Some(detail) = lost {
516 return Err(not_installed(
517 ctx, key, state, login, owner, detail, written,
518 ));
519 }
520
521 let again = state.get(key).is_some();
522 let parked = state.get(key).and_then(|a| a.parked.clone());
523 state.upsert(account(
524 which,
525 &key.label,
526 owner,
527 parked,
528 Some(ctx.now()),
529 &login.document,
530 ));
531 state.set_active(which, Some(key.label.clone()));
532 state::save(ctx, state)?;
533 crate::fault::point("enroll.recorded");
534 drop(guard);
535
536 let still_running = super::running_sessions(ctx, which).map(|(program, count)| {
539 Warning::SessionsKeepTheOldLogin {
540 program,
541 count,
542 label: name.clone(),
543 }
544 });
545 let warnings = written
546 .into_iter()
547 .chain(still_running)
548 .chain(lock_lost.then_some(Warning::LockCompromised { tool: which }))
549 .collect();
550 Ok((
551 Enrolled::InUse {
552 email: owner.email.clone(),
553 again,
554 },
555 warnings,
556 ))
557}
558
559fn not_installed(
568 ctx: &Context,
569 key: &Key,
570 state: &mut State,
571 login: &SignIn,
572 owner: &Owner,
573 detail: String,
574 mut warnings: Vec<Warning>,
575) -> Error {
576 let parked = match park_signed_in(ctx, key, state, login, owner) {
577 Ok((_, parking)) => {
578 warnings.extend(parking);
579 true
580 }
581 Err(_) => false,
582 };
583 Error::SignInNotInstalled {
584 tool: key.provider,
585 label: state.typed(key),
586 detail,
587 parked,
588 warnings,
589 }
590}
591
592fn park_signed_in(
593 ctx: &Context,
594 key: &Key,
595 state: &mut State,
596 login: &SignIn,
597 owner: &Owner,
598) -> Result<(Enrolled, Vec<Warning>)> {
599 let label = key.label.as_str();
600 let slice = provider::of(login.provider)
601 .slice(&login.document)
602 .map_err(|e| super::shape(login.provider, e))?;
603 let parking = park::price(
604 ctx,
605 login.provider,
606 &key.typed(),
607 &park::service_name(&owner.account_uuid, ctx.now_millis()),
608 &slice,
609 )?;
610 let service = park::reserve(ctx, &owner.account_uuid)?;
611 let fresh = park::store_at(ctx, key.provider, &service, &slice)?;
612 crate::fault::point("enroll.park_stored");
615 let existing = state.get(key);
616 let previous = existing.and_then(|a| a.parked.clone());
617 let renewed = existing.is_some();
618 let last_used_at = existing.and_then(|a| a.last_used_at);
619 state.upsert(account(
620 login.provider,
621 label,
622 owner,
623 previous,
624 last_used_at,
625 &login.document,
626 ));
627 state.park(key, fresh);
628 state::save(ctx, state).inspect_err(|_| {
630 let _ = store::vault_delete(ctx, &service);
631 })?;
632 crate::fault::point("enroll.park_recorded");
633 purge(ctx, state);
634 let email = owner.email.clone();
635 let enrolled = if renewed {
636 Enrolled::Renewed { email }
637 } else {
638 Enrolled::SignedIn { email }
639 };
640 Ok((enrolled, parking.into_iter().collect()))
641}
642
643fn account(
650 which: ProviderId,
651 label: &str,
652 owner: &Owner,
653 parked: Option<Park>,
654 last_used_at: Option<i64>,
655 login: &Value,
656) -> Account {
657 let detail = match which {
658 ProviderId::Claude => state::Detail::Claude {
659 organization_uuid: owner.organization_uuid.clone(),
660 oauth_account: json!({
661 "accountUuid": owner.account_uuid,
662 "emailAddress": owner.email,
663 "organizationUuid": owner.organization_uuid,
664 }),
665 },
666 ProviderId::Codex => {
667 let claims = login["tokens"]["id_token"]
668 .as_str()
669 .and_then(crate::provider::jwt::claims)
670 .unwrap_or(Value::Null);
671 let openai = "https://api.openai.com/auth";
672 state::Detail::Codex {
673 workspace_id: Some(owner.organization_uuid.clone()).filter(|id| !id.is_empty()),
674 plan: crate::provider::jwt::claim(&claims, &[openai, "chatgpt_plan_type"])
675 .map(str::to_owned),
676 }
677 }
678 };
679 Account {
680 last_used_at,
681 label: label.to_string(),
682 account_uuid: owner.account_uuid.clone(),
683 email: owner.email.clone(),
684 parked,
685 detail,
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::api::scripted::Trouble;
693 use crate::store::memory::Fault;
694 use crate::switch::harness::{
695 Machine, NOW, codex_id, codex_login, codex_machine, hold, login_of, machine, oauth, renews,
696 signed_in,
697 };
698 use crate::switch::{Due, Outcome, renew_due, settle, switch};
699 use crate::time::FixedClock;
700 use std::sync::Arc;
701 use std::time::{Duration, Instant};
702
703 type Make = fn(&str) -> Machine;
704 const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
706
707 fn in_use(m: &Machine, refresh: &str) -> bool {
709 m.live().is_some_and(|live| {
710 provider::of(m.which).fingerprint(&live) == store::fingerprint(refresh)
711 })
712 }
713
714 fn enrolled_as(m: &Machine, label: &str, login: SignIn) -> Result<(Enrolled, Vec<Warning>)> {
716 let settled = settle(&m.ctx, Some(m.which)).expect("nothing to recover").0;
717 enroll(settled, &m.key(label), Some(login))
718 }
719
720 fn park_of(m: &Machine, label: &str) -> Option<Park> {
721 state::load(&m.ctx)
722 .expect("state")
723 .get(&m.key(label))
724 .and_then(|a| a.parked.clone())
725 }
726
727 #[test]
731 fn signing_in_again_to_the_account_in_use_puts_the_new_login_in_use() {
732 for (tool, make) in MACHINES {
733 let m = make("again-in-use");
734 let vault = m.mem.vault().services();
735
736 let (enrolled, _) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
737 .unwrap_or_else(|e| panic!("{tool}: {e}"));
738
739 assert!(
740 matches!(
741 enrolled,
742 Enrolled::InUse { ref email, again: true } if email == "here@example.com"
743 ),
744 "{tool}: {enrolled:?}"
745 );
746 assert!(
747 in_use(&m, "here-refresh-2"),
748 "{tool}: the new login is in use"
749 );
750 assert_eq!(m.mem.vault().services(), vault, "{tool}: nothing is parked");
751 assert!(park_of(&m, "here").is_none(), "{tool}");
752 let state = state::load(&m.ctx).expect("state");
753 assert_eq!(state.active_for(m.which), Some("here"), "{tool}");
754 assert_eq!(
755 state.get(&m.key("here")).and_then(|a| a.last_used_at),
756 Some(NOW),
757 "{tool}: signing in to the account in use is using it"
758 );
759 hold(&m, &format!("{tool}, after signing in again"));
760 }
761 }
762
763 #[test]
766 fn signing_in_again_to_the_account_in_use_keeps_the_park_it_holds() {
767 for (tool, make) in MACHINES {
768 let m = make("again-keeps-park");
769 let (uuid, older) = match m.which {
770 ProviderId::Claude => ("here".to_string(), oauth("here-older", 30)),
771 ProviderId::Codex => (codex_id("here"), codex_login("here", "here-older")),
772 };
773 let service = park::reserve(&m.ctx, &uuid).expect("a free name");
774 let held = park::store_at(&m.ctx, m.which, &service, &older).expect("parked");
775 let mut state = state::load(&m.ctx).expect("state");
776 state.park(&m.key("here"), held.clone());
777 state::save(&m.ctx, &state).expect("saved");
778 let vault = m.mem.vault().services();
779
780 enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
781 .unwrap_or_else(|e| panic!("{tool}: {e}"));
782
783 assert!(in_use(&m, "here-refresh-2"), "{tool}");
784 assert_eq!(
785 park_of(&m, "here"),
786 Some(held),
787 "{tool}: the park is untouched"
788 );
789 assert_eq!(m.mem.vault().services(), vault, "{tool}");
790 hold(&m, &format!("{tool}, after signing in again beside a park"));
791 }
792 }
793
794 #[test]
798 fn the_next_switch_away_parks_the_new_login_not_the_old() {
799 for (tool, make) in MACHINES {
800 let m = make("again-then-switch");
801 enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
802 .unwrap_or_else(|e| panic!("{tool}: {e}"));
803
804 let settled = settle(&m.ctx, Some(m.which)).expect("nothing to recover").0;
805 let (outcome, _) = switch(settled, &m.key("there"))
806 .unwrap_or_else(|e| panic!("{tool}: the switch away: {e}"));
807 assert!(matches!(outcome, Outcome::Switched { .. }), "{tool}");
808
809 let parked = park_of(&m, "here").expect("the outgoing login is parked");
810 assert_eq!(
811 parked.refresh_fingerprint,
812 store::fingerprint("here-refresh-2"),
813 "{tool}: the new login is parked, not the one it replaced"
814 );
815 hold(&m, &format!("{tool}, after the switch away"));
816 }
817 }
818
819 #[test]
822 fn a_sign_in_of_another_account_is_parked_beside_the_one_in_use() {
823 for (tool, make) in MACHINES {
824 let m = make("another-parked");
825 let live = m.live();
826
827 let (enrolled, _) = enrolled_as(&m, "third", signed_in(&m, "third", "third-refresh"))
828 .unwrap_or_else(|e| panic!("{tool}: {e}"));
829
830 assert!(
831 matches!(enrolled, Enrolled::SignedIn { .. }),
832 "{tool}: {enrolled:?}"
833 );
834 assert_eq!(m.live(), live, "{tool}: the login in use is untouched");
835 assert_eq!(
836 park_of(&m, "third").map(|p| p.refresh_fingerprint),
837 Some(store::fingerprint("third-refresh")),
838 "{tool}"
839 );
840 hold(&m, &format!("{tool}, after another account's sign-in"));
841 }
842 }
843
844 #[test]
849 fn a_login_in_use_whose_account_cannot_be_told_is_not_written_over() {
850 let mut cases: Vec<(String, Machine)> = Vec::new();
851 for (name, trouble) in [
852 ("refused", Trouble::Unauthorized),
853 ("offline", Trouble::Offline),
854 ] {
855 let m = machine(&format!("untold-{name}"));
856 m.api.token_trouble("access-here-refresh", trouble);
857 cases.push((format!("claude, {name}"), m));
858 }
859 let m = codex_machine("untold");
860 let mut unreadable = codex_login("here", "here-refresh");
861 unreadable["tokens"]["id_token"] = "not a token".into();
862 m.sign_in(&unreadable);
863 cases.push(("codex".into(), m));
864
865 for (case, m) in cases {
866 let live = m.live();
867
868 let (enrolled, warnings) =
869 enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
870 .unwrap_or_else(|e| panic!("{case}: {e}"));
871
872 assert!(
873 matches!(enrolled, Enrolled::Renewed { .. }),
874 "{case}: {enrolled:?}"
875 );
876 assert_eq!(m.live(), live, "{case}: the login in use is untouched");
877 assert_eq!(
878 park_of(&m, "here").map(|p| p.refresh_fingerprint),
879 Some(store::fingerprint("here-refresh-2")),
880 "{case}: the new login is parked"
881 );
882 let said = warnings
885 .iter()
886 .find(|w| w.code() == "sign_in_parked_not_in_use")
887 .unwrap_or_else(|| panic!("{case}: {warnings:?}"))
888 .to_string();
889 assert!(
890 said.contains("parked the new login for `"),
891 "{case}: {said}"
892 );
893 assert!(
894 said.contains("goes on with the login it has"),
895 "{case}: {said}"
896 );
897 assert!(said.contains("sign in to `"), "{case}: {said}");
898 }
899 }
900
901 #[test]
904 fn a_sign_in_to_a_parked_account_is_not_warned_about_the_login_in_use() {
905 let m = machine("untold-parked");
906 m.api
907 .token_trouble("access-here-refresh", Trouble::Unauthorized);
908
909 let (enrolled, warnings) =
910 enrolled_as(&m, "there", signed_in(&m, "there", "there-refresh-2"))
911 .unwrap_or_else(|e| panic!("{e}"));
912
913 assert!(matches!(enrolled, Enrolled::Renewed { .. }), "{enrolled:?}");
914 assert!(warnings.is_empty(), "{warnings:?}");
915 }
916
917 #[test]
921 fn a_first_sign_in_to_the_account_in_use_enrols_it_with_the_new_login_in_use() {
922 for (tool, make) in MACHINES {
923 let m = make("first-in-use");
924 let mut state = state::load(&m.ctx).expect("state");
925 state.accounts.retain(|a| a.label != "here");
926 state.set_active(m.which, None);
927 state::save(&m.ctx, &state).expect("saved");
928
929 let (enrolled, _) =
930 enrolled_as(&m, "personal", signed_in(&m, "here", "here-refresh-2"))
931 .unwrap_or_else(|e| panic!("{tool}: {e}"));
932
933 assert!(
934 matches!(enrolled, Enrolled::InUse { again: false, .. }),
935 "{tool}: {enrolled:?}"
936 );
937 assert!(in_use(&m, "here-refresh-2"), "{tool}");
938 let state = state::load(&m.ctx).expect("state");
939 assert_eq!(state.active_for(m.which), Some("personal"), "{tool}");
940 hold(&m, &format!("{tool}, after enrolling the account in use"));
941 }
942 }
943
944 #[test]
947 fn the_account_in_use_changing_before_the_write_is_refused_and_nothing_is_written() {
948 for (tool, make) in MACHINES {
949 let m = make("changed-before-write");
950 let key = m.key("here");
951 let login = signed_in(&m, "here", "here-refresh-2");
952 let owner = identify_document(&m.ctx, m.which, &login.document).expect("whose");
953 let InUse::Theirs(live, first) = signed_in_now(&m.ctx, m.which, &owner) else {
954 panic!("{tool}: `here` is signed in");
955 };
956
957 m.sign_in(&login_of(&m, "other", "other-refresh"));
958 let vault = m.mem.vault().services();
959 let recorded = serde_json::to_value(state::load(&m.ctx).expect("state")).unwrap();
960 let mut state = state::load(&m.ctx).expect("state");
961 let refused =
962 install_signed_in(&m.ctx, &key, &mut state, &login, &owner, &live, &first)
963 .expect_err("refused");
964
965 assert_eq!(refused.code(), "sign_in_not_kept", "{tool}: {refused}");
966 assert!(
967 refused
968 .to_string()
969 .contains("was signed in to another account meanwhile"),
970 "{tool}: {refused}"
971 );
972 assert!(in_use(&m, "other-refresh"), "{tool}: the other login stays");
973 assert_eq!(m.mem.vault().services(), vault, "{tool}");
974 assert_eq!(
975 serde_json::to_value(state::load(&m.ctx).expect("state")).unwrap(),
976 recorded,
977 "{tool}: and nothing is recorded"
978 );
979 }
980 }
981
982 #[test]
986 fn a_new_login_that_cannot_be_written_leaves_the_old_one_in_use() {
987 for (tool, make) in MACHINES {
988 let m = make("again-write-fails");
989 let vault = m.mem.vault().services();
990 let login = signed_in(&m, "here", "here-refresh-2");
991 m.fault_live(Fault::FailWrite("refused".into()));
992
993 let failed = enrolled_as(&m, "here", login).expect_err("the write failed");
994
995 assert_eq!(failed.code(), "sign_in_not_kept", "{tool}: {failed}");
996 let said = failed.to_string();
997 assert!(said.contains("was not kept"), "{tool}: {said}");
998 assert!(
999 said.contains(&format!("pitboard enroll {} --sign-in", m.key("here"))),
1000 "{tool}: {said}"
1001 );
1002 assert!(!said.contains("nothing was lost"), "{tool}: {said}");
1003 assert!(in_use(&m, "here-refresh"), "{tool}");
1004 assert_eq!(m.mem.vault().services(), vault, "{tool}");
1005 }
1006 }
1007
1008 #[test]
1012 fn a_new_login_that_did_not_hold_is_parked_rather_than_lost() {
1013 for (tool, make) in MACHINES {
1014 let m = make("again-did-not-hold");
1015 let login = signed_in(&m, "here", "here-refresh-2");
1016 m.fault_live(Fault::DeletedAfterWrite);
1017
1018 let failed = enrolled_as(&m, "here", login).expect_err("it did not hold");
1019
1020 assert!(
1021 matches!(failed, Error::SignInNotInstalled { parked: true, .. }),
1022 "{tool}: {failed:?}"
1023 );
1024 assert!(
1025 failed.to_string().contains("parked, so it is not lost"),
1026 "{tool}: {failed}"
1027 );
1028 assert_eq!(
1029 park_of(&m, "here").map(|p| p.refresh_fingerprint),
1030 Some(store::fingerprint("here-refresh-2")),
1031 "{tool}"
1032 );
1033 hold(&m, &format!("{tool}, after a new login that did not hold"));
1034 }
1035 }
1036
1037 #[test]
1043 fn a_new_login_that_could_not_be_read_back_is_not_kept_beside_itself() {
1044 for (tool, make) in MACHINES {
1045 for locked in ["by the write", "after it"] {
1046 for next in ["renewal", "change"] {
1047 let at = format!("{tool}, locked {locked}, then a {next}");
1048 let m = make(&format!("unread-{}-{next}", locked.replace(' ', "-")));
1049 let login = signed_in(&m, "here", "here-refresh-2");
1050 let (store, service) = m.live_store();
1051 let failed = if locked == "by the write" {
1052 store.fault(&service, Fault::LocksAfterWrite);
1053 enrolled_as(&m, "here", login)
1054 } else {
1055 crate::fault::meanwhile(
1056 "enroll.installed",
1057 move || store.fault(&service, Fault::Unreadable("locked".into())),
1058 || enrolled_as(&m, "here", login),
1059 )
1060 }
1061 .expect_err("it could not be read back");
1062 assert!(
1063 matches!(failed, Error::SignInNotInstalled { parked: true, .. }),
1064 "{at}: {failed:?}"
1065 );
1066 assert_eq!(
1067 park_of(&m, "here").map(|p| p.refresh_fingerprint),
1068 Some(store::fingerprint("here-refresh-2")),
1069 "{at}: parked as well"
1070 );
1071
1072 m.live_store().0.heal_all();
1073 match next {
1074 "renewal" => {
1075 let later = m
1078 .ctx
1079 .clone()
1080 .with_clock(Arc::new(FixedClock::at(NOW + 11 * 86_400)));
1081 renews(&m, "here-refresh-2", "here-refresh-3");
1082 let renewed = renew_due(&later, Due::ToBeAsked);
1083 assert!(
1084 renewed.iter().all(|(key, _)| key.label != "here"),
1085 "{at}: {renewed:?}"
1086 );
1087 }
1088 _ => {
1089 settle(&m.ctx, None).expect("the next change");
1090 }
1091 }
1092 assert!(in_use(&m, "here-refresh-2"), "{at}");
1093 assert!(park_of(&m, "here").is_none(), "{at}: the copy is dropped");
1094 hold(&m, &at);
1095 }
1096 }
1097 }
1098 }
1099
1100 #[test]
1104 fn sessions_running_on_the_old_login_are_counted_and_warned_about() {
1105 for (tool, make) in MACHINES {
1106 let m = make("again-sessions");
1107 m.mem.runs("codex", 2);
1108 m.mem.runs("claude", 2);
1109
1110 let (_, warnings) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
1111 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1112
1113 let said = warnings
1114 .iter()
1115 .find(|w| w.code() == "sessions_keep_old_login");
1116 match m.which {
1117 ProviderId::Claude => assert!(said.is_none(), "{warnings:?}"),
1118 ProviderId::Codex => {
1119 let text = said.expect("warned").to_string();
1120 assert!(text.contains("2 `codex` sessions"), "{text}");
1121 assert!(text.contains("`codex/here`'s old login"), "{text}");
1122 assert!(text.contains("Quit them and start again"), "{text}");
1123 assert!(text.contains("put the old login back"), "{text}");
1124 }
1125 }
1126 assert!(
1127 warnings
1128 .iter()
1129 .all(|w| w.code() != "sessions_still_running"),
1130 "{tool}: nobody switched away from anything: {warnings:?}"
1131 );
1132 }
1133 }
1134
1135 #[test]
1139 fn a_cancel_does_not_wait_on_a_tool_that_says_nothing() {
1140 let m = codex_machine("cancel");
1141 let pending = reserve_signin(&m.ctx, ProviderId::Codex).expect("reserved");
1142 let mut silent = std::process::Command::new("sleep");
1143 silent.arg("30");
1144 let watched = watch(silent, pending).expect("started");
1145 let said = watched.said();
1146 let reader = std::thread::spawn(move || said.next());
1147 std::thread::sleep(Duration::from_millis(100));
1148
1149 let asked = Instant::now();
1150 watched.cancel();
1151 assert_eq!(reader.join().expect("the reader"), None);
1152 assert!(
1153 asked.elapsed() < Duration::from_secs(10),
1154 "the cancel waited on the tool"
1155 );
1156 reserve_signin(&m.ctx, ProviderId::Codex)
1157 .expect("a cancelled sign-in lets the next one start");
1158 }
1159}