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::{Permit, Warning};
19use crate::state::{Account, Key, Park, State};
20use crate::{home, lock, park, state, store};
21use serde_json::{Value, json};
22use std::fs::{File, TryLockError};
23use std::path::PathBuf;
24
25#[derive(Debug)]
26pub enum Enrolled {
27 Current { email: String },
29 SignedIn { email: String },
31 Renewed { email: String },
33 InUse { email: String, again: bool },
37}
38
39pub struct SignIn {
42 provider: ProviderId,
43 dir: PathBuf,
44 document: Value,
45 ctx: Context,
46 permit: Permit,
49 one_at_a_time: File,
52}
53
54impl SignIn {
55 pub fn provider(&self) -> ProviderId {
57 self.provider
58 }
59}
60
61impl Drop for SignIn {
62 fn drop(&mut self) {
72 provider::of(self.provider).discard_signin(&self.ctx, self.permit, &self.dir);
73 let _ = crate::host::fs::remove_dir_all(self.permit, &self.dir);
74 let _ = self.one_at_a_time.unlock();
75 }
76}
77
78fn reserve_signin(ctx: &Context, permit: Permit, which: ProviderId) -> Result<SignIn> {
84 let home = home::ensure(ctx, permit).map_err(|source| Error::HomeUnwritable {
85 path: home::dir(ctx),
86 source,
87 })?;
88 let lock_path = home.join("signin.lock");
89 let one_at_a_time =
90 crate::host::fs::open_private_lock(permit, &lock_path).map_err(|source| {
91 Error::HomeUnwritable {
92 path: lock_path.clone(),
93 source,
94 }
95 })?;
96 match one_at_a_time.try_lock() {
97 Ok(()) => {}
98 Err(TryLockError::WouldBlock) => return Err(Error::SignInInProgress),
99 Err(TryLockError::Error(source)) => {
100 return Err(Error::HomeUnwritable {
101 path: lock_path,
102 source,
103 });
104 }
105 }
106
107 let dir = home.join("signin");
108 for &tool in ProviderId::ALL {
114 provider::of(tool).discard_signin(ctx, permit, &dir);
115 }
116 let _ = crate::host::fs::remove_dir_all(permit, &dir);
117 crate::host::fs::create_private_dir(permit, &dir).map_err(|source| Error::HomeUnwritable {
118 path: dir.clone(),
119 source,
120 })?;
121 Ok(SignIn {
122 provider: which,
123 dir,
124 document: Value::Null,
125 ctx: ctx.clone(),
126 permit,
127 one_at_a_time,
128 })
129}
130
131#[cfg(any(test, feature = "test-support"))]
135pub(crate) fn planted(
136 ctx: &Context,
137 permit: Permit,
138 which: ProviderId,
139 document: Value,
140) -> Result<SignIn> {
141 let mut pending = reserve_signin(ctx, permit, which)?;
142 pending.document = document;
143 Ok(pending)
144}
145
146pub fn sign_in(ctx: &Context, permit: Permit, which: ProviderId) -> Result<SignIn> {
149 let mut pending = reserve_signin(ctx, permit, which)?;
150 let finished = provider::of(which)
153 .sign_in(ctx, permit, &pending.dir)
154 .stdout(std::io::stderr())
155 .status()
156 .map_err(|e| started(which, e))?
157 .success();
158 if !finished {
159 return Err(Error::SignInIncomplete);
160 }
161 pending.document = signed_in_document(ctx, which, &pending.dir)?;
162 Ok(pending)
163}
164
165fn started(which: ProviderId, e: std::io::Error) -> Error {
166 match e.kind() {
167 std::io::ErrorKind::NotFound => Error::ProgramNotFound { tool: which },
168 _ => Error::SignInIncomplete,
169 }
170}
171
172fn signed_in_document(ctx: &Context, which: ProviderId, dir: &std::path::Path) -> Result<Value> {
173 let raw = provider::of(which)
174 .read_signin(ctx, dir)?
175 .ok_or(Error::SignInIncomplete)?;
176 serde_json::from_str(&raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
177 tool: which,
178 detail: e.to_string(),
179 })
180}
181
182pub struct WatchedSignIn {
186 tool: Running,
187 said: Said,
188 pending: SignIn,
189}
190
191enum Running {
194 Program(std::process::Child),
195 #[cfg(any(test, feature = "test-support"))]
196 Scripted(Box<dyn ScriptedSignIn>),
197}
198
199#[cfg(any(test, feature = "test-support"))]
205pub trait SignInScript: Send + Sync + std::fmt::Debug {
206 fn start(
211 &self,
212 which: ProviderId,
213 label: Option<&str>,
214 dir: &std::path::Path,
215 say: std::sync::mpsc::Sender<String>,
216 ) -> Box<dyn ScriptedSignIn>;
217}
218
219#[cfg(any(test, feature = "test-support"))]
221pub trait ScriptedSignIn: Send {
222 fn typed(&mut self, line: &str) -> bool;
224 fn wait(&mut self) -> bool;
227 fn stop(&mut self);
229}
230
231#[derive(Clone)]
238pub struct Said(std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<String>>>);
239
240impl Said {
241 pub fn next(&self) -> Option<String> {
244 self.0.lock().ok()?.recv().ok()
245 }
246}
247
248impl WatchedSignIn {
249 pub fn provider(&self) -> ProviderId {
251 self.pending.provider
252 }
253
254 pub fn said(&self) -> Said {
257 self.said.clone()
258 }
259
260 pub fn paste(&mut self, line: &str) -> Result<()> {
263 match &mut self.tool {
264 Running::Program(child) => {
265 use std::io::Write;
266 let stdin = child.stdin.as_mut().ok_or(Error::SignInIncomplete)?;
267 writeln!(stdin, "{line}").map_err(|_| Error::SignInIncomplete)?;
268 stdin.flush().map_err(|_| Error::SignInIncomplete)
269 }
270 #[cfg(any(test, feature = "test-support"))]
271 Running::Scripted(run) => {
272 if run.typed(line) {
273 Ok(())
274 } else {
275 Err(Error::SignInIncomplete)
276 }
277 }
278 }
279 }
280
281 pub fn finish(mut self) -> Result<SignIn> {
283 let finished = match &mut self.tool {
284 Running::Program(child) => child.wait().map_err(|_| Error::SignInIncomplete)?.success(),
285 #[cfg(any(test, feature = "test-support"))]
286 Running::Scripted(run) => run.wait(),
287 };
288 if !finished {
289 return Err(Error::SignInIncomplete);
290 }
291 let mut pending = self.pending;
292 pending.document =
293 signed_in_document(&pending.ctx.clone(), pending.provider, &pending.dir.clone())?;
294 Ok(pending)
295 }
296
297 pub fn cancel(mut self) {
301 match &mut self.tool {
302 Running::Program(child) => {
303 let _ = child.kill();
304 let _ = child.wait();
305 }
306 #[cfg(any(test, feature = "test-support"))]
307 Running::Scripted(run) => {
308 run.stop();
309 let _ = run.wait();
310 }
311 }
312 }
313}
314
315pub fn sign_in_watched(ctx: &Context, permit: Permit, which: ProviderId) -> Result<WatchedSignIn> {
317 start_watched(ctx, permit, which, None)
318}
319
320pub(crate) fn sign_in_watched_as(
323 ctx: &Context,
324 permit: Permit,
325 key: &Key,
326) -> Result<WatchedSignIn> {
327 start_watched(ctx, permit, key.provider, Some(&key.label))
328}
329
330fn start_watched(
331 ctx: &Context,
332 permit: Permit,
333 which: ProviderId,
334 label: Option<&str>,
335) -> Result<WatchedSignIn> {
336 let pending = reserve_signin(ctx, permit, which)?;
337 #[cfg(any(test, feature = "test-support"))]
338 if let Some(script) = ctx.sign_in_script() {
339 let (say, said) = std::sync::mpsc::channel();
340 return Ok(WatchedSignIn {
341 tool: Running::Scripted(script.start(which, label, &pending.dir, say)),
342 said: Said(std::sync::Arc::new(std::sync::Mutex::new(said))),
343 pending,
344 });
345 }
346 let _ = label;
348 let command = provider::of(which).sign_in(ctx, permit, &pending.dir);
349 watch(command, pending).map_err(|e| started(which, e))
350}
351
352fn watch(mut command: std::process::Command, pending: SignIn) -> std::io::Result<WatchedSignIn> {
354 let mut child = command
355 .stdin(std::process::Stdio::piped())
356 .stdout(std::process::Stdio::piped())
357 .stderr(std::process::Stdio::piped())
358 .spawn()?;
359 let (say, said) = std::sync::mpsc::channel();
360 for stream in [
364 child.stdout.take().map(Readable::Out),
365 child.stderr.take().map(Readable::Err),
366 ]
367 .into_iter()
368 .flatten()
369 {
370 let say = say.clone();
371 std::thread::spawn(move || {
372 use std::io::Read;
373 let mut reader: Box<dyn Read + Send> = match stream {
374 Readable::Out(o) => Box::new(o),
375 Readable::Err(e) => Box::new(e),
376 };
377 let mut buffer = [0_u8; 1024];
378 while let Ok(read) = reader.read(&mut buffer) {
379 if read == 0 {
380 break;
381 }
382 let text = String::from_utf8_lossy(&buffer[..read]).into_owned();
383 if say.send(text).is_err() {
384 break;
385 }
386 }
387 });
388 }
389 Ok(WatchedSignIn {
390 tool: Running::Program(child),
391 said: Said(std::sync::Arc::new(std::sync::Mutex::new(said))),
392 pending,
393 })
394}
395
396enum Readable {
397 Out(std::process::ChildStdout),
398 Err(std::process::ChildStderr),
399}
400
401pub fn enroll(
404 settled: Settled,
405 key: &Key,
406 signed_in: Option<SignIn>,
407) -> Result<(Enrolled, Vec<Warning>)> {
408 let Settled {
409 _exclusive,
410 mut state,
411 ctx,
412 permit,
413 } = settled;
414 match signed_in {
415 Some(login) if login.provider != key.provider => Err(Error::Usage(format!(
418 "that sign-in was {}'s, and `{key}` is a {} account",
419 login.provider.name(),
420 key.provider.name()
421 ))),
422 Some(login) => from_sign_in(&ctx, key, &mut state, &login),
423 None => record_current(&ctx, permit, key, &mut state).map(|e| (e, Vec::new())),
424 }
425}
426
427fn claim(state: &State, key: &Key, owner: &Owner) -> Result<()> {
430 if let Some(taken) = state.get(key)
431 && !taken.owned_by(owner)
432 {
433 return Err(Error::LabelTaken {
434 label: key.typed(),
435 who: crate::words::login(key.provider, &taken.email, taken.email == owner.email),
436 });
437 }
438 if let Some(existing) = state.account_of(key.provider, owner)
439 && existing.label != key.label
440 {
441 return Err(Error::AlreadyEnrolled {
442 tool: key.provider,
443 email: owner.email.clone(),
444 label: existing.key().typed(),
445 });
446 }
447 Ok(())
448}
449
450fn record_current(ctx: &Context, permit: Permit, key: &Key, state: &mut State) -> Result<Enrolled> {
451 let which = key.provider;
452 let label = key.label.as_str();
453 let store = super::live_store(ctx, which)?;
456 let live =
457 store::read(&store.chain, &store.service)?.ok_or_else(|| nothing_signed_in(ctx, which))?;
458 match provider::of(which).slice(&live) {
461 Err(provider::ProviderError::NoLogin { .. }) => return Err(nothing_signed_in(ctx, which)),
462 Err(other) => return Err(super::shape(which, other)),
463 Ok(_) => {}
464 }
465 let owner = identify_document(ctx, which, &live)?;
466 claim(state, key, &owner)?;
467 let existing = state.get(key);
468 let parked = existing.and_then(|a| a.parked.clone());
469 let last_used_at = Some(ctx.now());
471 state.upsert(account(
472 which,
473 state.id_of(which, &owner),
474 label,
475 &owner,
476 parked,
477 last_used_at,
478 &live,
479 ));
480 state.set_active(which, Some(label.to_string()));
481 state::save(ctx, permit, state)?;
482 Ok(Enrolled::Current { email: owner.email })
483}
484
485fn from_sign_in(
492 ctx: &Context,
493 key: &Key,
494 state: &mut State,
495 login: &SignIn,
496) -> Result<(Enrolled, Vec<Warning>)> {
497 let owner = identify_document(ctx, login.provider, &login.document)?;
498 claim(state, key, &owner)?;
499 match signed_in_now(ctx, key.provider, &owner) {
500 InUse::Theirs(live, first) => {
501 install_signed_in(ctx, key, state, login, &owner, &live, &first)
502 }
503 InUse::NotTheirs => park_signed_in(ctx, key, state, login, &owner),
504 InUse::Untold(why) => {
505 let last_in_use = state.active_for(key.provider) == Some(key.label.as_str());
508 let (enrolled, mut warnings) = park_signed_in(ctx, key, state, login, &owner)?;
509 if last_in_use {
510 warnings.push(Warning::SignInParkedNotInUse {
511 tool: key.provider,
512 label: state.typed(key),
513 why: untold(&why),
514 });
515 }
516 Ok((enrolled, warnings))
517 }
518 }
519}
520
521enum InUse {
526 Theirs(provider::LiveStore, Value),
528 NotTheirs,
530 Untold(Error),
532}
533
534fn signed_in_now(ctx: &Context, which: ProviderId, owner: &Owner) -> InUse {
535 let read = super::live_store(ctx, which)
536 .and_then(|live| super::read_live(ctx, which, &live).map(|(_, first)| (live, first)));
537 let (live, first) = match read {
538 Ok(read) => read,
539 Err(Error::LiveCredentialAbsent { .. }) => return InUse::NotTheirs,
540 Err(other) => return InUse::Untold(other),
541 };
542 match identify_document(ctx, which, &first) {
543 Ok(found) if found.same_login(owner) => InUse::Theirs(live, first),
544 Ok(_) => InUse::NotTheirs,
545 Err(e) => InUse::Untold(e),
546 }
547}
548
549fn untold(error: &Error) -> String {
552 match error {
553 Error::SessionExpired { tool } => format!("{} refused its access token", tool.service()),
554 Error::IdentityUnverifiable { detail, .. }
555 | Error::LiveCredentialShapeUnexpected { detail, .. } => detail.clone(),
556 Error::LiveStoreUnsupported { reason, .. } => reason.clone(),
557 Error::LiveCredentialElsewhere { email } => {
558 format!("its config names {email}, and Pitboard cannot find that login")
559 }
560 Error::Store(e) => e.to_string(),
561 other => other.code().replace('_', " "),
562 }
563}
564
565fn install_signed_in(
570 ctx: &Context,
571 key: &Key,
572 state: &mut State,
573 login: &SignIn,
574 owner: &Owner,
575 live: &provider::LiveStore,
576 first: &Value,
577) -> Result<(Enrolled, Vec<Warning>)> {
578 let permit = login.permit;
579 let which = key.provider;
580 let name = state.typed(key);
581 let not_kept = |detail: String| Error::SignInNotKept {
584 tool: which,
585 label: name.clone(),
586 detail,
587 };
588 let slice = provider::of(which)
589 .slice(&login.document)
590 .map_err(|e| super::shape(which, e))?;
591 let guard = super::write_lock(ctx, permit, which)?;
592 let Readied {
593 before_raw,
594 next,
595 on_the_command_line,
596 ..
597 } = super::ready(ctx, which, live, first, owner, &slice, &name).map_err(|e| match e {
598 Error::SignedInAccountChanged => not_kept(format!(
599 "{} was signed in to another account meanwhile",
600 which.name()
601 )),
602 other => other,
603 })?;
604
605 let written = on_the_command_line.into_iter().collect::<Vec<_>>();
606 match super::install_with(
607 which,
608 |body| store::write_raw(&live.chain, permit, &live.service, body),
609 || store::read_raw(&live.chain, &live.service),
610 &next,
611 &before_raw,
612 &name,
613 &name,
614 ) {
615 Ok(()) => {}
616 Err(Error::SwitchRolledBack { detail, .. }) => return Err(not_kept(detail)),
618 Err(Error::SwitchUnverified { detail, .. } | Error::SwitchCorrupted { detail, .. }) => {
619 return Err(not_installed(
620 ctx, key, state, login, owner, detail, written,
621 ));
622 }
623 Err(other) => return Err(other),
624 }
625 crate::fault::point("enroll.installed");
626
627 let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
629 let lost = match super::holds(which, live) {
630 Ok(true) => None,
631 Ok(false) => Some("it was gone again before Pitboard finished".to_string()),
632 Err(unreadable) => Some(unreadable.to_string()),
633 };
634 if let Some(detail) = lost {
635 return Err(not_installed(
636 ctx, key, state, login, owner, detail, written,
637 ));
638 }
639
640 let again = state.get(key).is_some();
641 let parked = state.get(key).and_then(|a| a.parked.clone());
642 state.upsert(account(
643 which,
644 state.id_of(which, owner),
645 &key.label,
646 owner,
647 parked,
648 Some(ctx.now()),
649 &login.document,
650 ));
651 state.set_active(which, Some(key.label.clone()));
652 state::save(ctx, permit, state)?;
653 crate::fault::point("enroll.recorded");
654 drop(guard);
655
656 let still_running = match super::still_holding(ctx, which) {
660 super::StillHolding::Nothing => None,
661 super::StillHolding::These(holding) => Some(Warning::SessionsKeepTheOldLogin {
662 label: name.clone(),
663 holding,
664 }),
665 super::StillHolding::Unknown => Some(Warning::SessionsUnknownAfterSignIn {
666 tool: which,
667 label: name.clone(),
668 }),
669 };
670 let warnings = written
671 .into_iter()
672 .chain(still_running)
673 .chain(lock_lost.then_some(Warning::LockCompromised { tool: which }))
674 .collect();
675 Ok((
676 Enrolled::InUse {
677 email: owner.email.clone(),
678 again,
679 },
680 warnings,
681 ))
682}
683
684fn not_installed(
693 ctx: &Context,
694 key: &Key,
695 state: &mut State,
696 login: &SignIn,
697 owner: &Owner,
698 detail: String,
699 mut warnings: Vec<Warning>,
700) -> Error {
701 let parked = match park_signed_in(ctx, key, state, login, owner) {
702 Ok((_, parking)) => {
703 warnings.extend(parking);
704 true
705 }
706 Err(_) => false,
707 };
708 Error::SignInNotInstalled {
709 tool: key.provider,
710 label: state.typed(key),
711 detail,
712 parked,
713 warnings,
714 }
715}
716
717fn park_signed_in(
718 ctx: &Context,
719 key: &Key,
720 state: &mut State,
721 login: &SignIn,
722 owner: &Owner,
723) -> Result<(Enrolled, Vec<Warning>)> {
724 let permit = login.permit;
725 let label = key.label.as_str();
726 let id = state.id_of(login.provider, owner);
727 let slice = provider::of(login.provider)
728 .slice(&login.document)
729 .map_err(|e| super::shape(login.provider, e))?;
730 let parking = park::price(
731 ctx,
732 login.provider,
733 &key.typed(),
734 &park::service_name(&id, ctx.now_millis()),
735 &slice,
736 )?;
737 let service = park::reserve(ctx, permit, &id)?;
738 let fresh = park::store_at(ctx, permit, key.provider, &service, &slice)?;
739 crate::fault::point("enroll.park_stored");
742 let existing = state.get(key);
743 let previous = existing.and_then(|a| a.parked.clone());
744 let renewed = existing.is_some();
745 let last_used_at = existing.and_then(|a| a.last_used_at);
746 state.upsert(account(
747 login.provider,
748 id,
749 label,
750 owner,
751 previous,
752 last_used_at,
753 &login.document,
754 ));
755 state.park(key, fresh);
756 state::save(ctx, permit, state).inspect_err(|_| {
758 let _ = store::vault_delete(ctx, permit, &service);
759 })?;
760 crate::fault::point("enroll.park_recorded");
761 purge(ctx, permit, state);
762 let email = owner.email.clone();
763 let enrolled = if renewed {
764 Enrolled::Renewed { email }
765 } else {
766 Enrolled::SignedIn { email }
767 };
768 Ok((enrolled, parking.into_iter().collect()))
769}
770
771fn account(
778 which: ProviderId,
779 id: String,
780 label: &str,
781 owner: &Owner,
782 parked: Option<Park>,
783 last_used_at: Option<i64>,
784 login: &Value,
785) -> Account {
786 let detail = match which {
787 ProviderId::Claude => state::Detail::Claude {
788 organization_uuid: owner.organization_uuid.clone(),
789 oauth_account: json!({
790 "accountUuid": owner.account_uuid,
791 "emailAddress": owner.email,
792 "organizationUuid": owner.organization_uuid,
793 }),
794 },
795 ProviderId::Codex => {
796 let claims = login["tokens"]["id_token"]
797 .as_str()
798 .and_then(crate::provider::jwt::claims)
799 .unwrap_or(Value::Null);
800 let openai = "https://api.openai.com/auth";
801 state::Detail::Codex {
802 workspace_id: Some(owner.organization_uuid.clone()).filter(|id| !id.is_empty()),
803 plan: crate::provider::jwt::claim(&claims, &[openai, "chatgpt_plan_type"])
804 .map(str::to_owned),
805 }
806 }
807 };
808 Account {
809 last_used_at,
810 label: label.to_string(),
811 id,
812 account_uuid: owner.account_uuid.clone(),
813 email: owner.email.clone(),
814 parked,
815 detail,
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822 use crate::api::scripted::Trouble;
823 use crate::store::memory::Fault;
824 use crate::switch::harness::{
825 self, Machine, NOW, codex_id, codex_login, codex_machine, hold, login_of, machine, oauth,
826 renews, signed_in,
827 };
828 use crate::switch::{Due, Outcome, renew_due, settle, switch};
829 use crate::time::FixedClock;
830 use std::sync::Arc;
831 use std::time::{Duration, Instant};
832
833 type Make = fn(&str) -> Machine;
834 const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
836
837 fn in_use(m: &Machine, refresh: &str) -> bool {
839 m.live().is_some_and(|live| {
840 provider::of(m.which).fingerprint(&live) == store::fingerprint(refresh)
841 })
842 }
843
844 fn enrolled_as(m: &Machine, label: &str, login: SignIn) -> Result<(Enrolled, Vec<Warning>)> {
846 let settled = settle(&m.ctx, Permit::for_a_test(), Some(m.which))
847 .expect("nothing to recover")
848 .0;
849 enroll(settled, &m.key(label), Some(login))
850 }
851
852 fn park_of(m: &Machine, label: &str) -> Option<Park> {
853 state::load(&m.ctx)
854 .expect("state")
855 .get(&m.key(label))
856 .and_then(|a| a.parked.clone())
857 }
858
859 fn here_in(m: &Machine, org: &str, refresh: &str) -> SignIn {
861 m.api.owned_by(
862 &format!("access-{refresh}"),
863 Owner {
864 account_uuid: "here".into(),
865 email: "here@example.com".into(),
866 organization_uuid: org.into(),
867 },
868 );
869 planted(
870 &m.ctx,
871 Permit::for_a_test(),
872 ProviderId::Claude,
873 harness::document(refresh),
874 )
875 .expect("a sign-in")
876 }
877
878 #[test]
882 fn one_account_in_two_organisations_is_two_logins() {
883 let m = machine("two-organisations");
884 let (enrolled, _) = enrolled_as(&m, "team", here_in(&m, "org-team", "team-refresh"))
885 .expect("the other organisation is a login of its own");
886 assert!(
887 matches!(enrolled, Enrolled::SignedIn { .. }),
888 "{enrolled:?}"
889 );
890
891 let state = state::load(&m.ctx).expect("state");
892 let here = state.get(&m.key("here")).expect("here");
893 let team = state.get(&m.key("team")).expect("team");
894 assert_eq!(here.account_uuid, team.account_uuid, "one person");
895 assert_eq!(team.id, "here_org-team");
896 assert!(
897 team.parked
898 .as_ref()
899 .is_some_and(|p| p.service.starts_with("pitboard-park-here_org-team-")),
900 "{:?}",
901 team.parked
902 );
903 assert_eq!(
904 team.claude().expect("Claude").oauth_account["accountUuid"],
905 "here",
906 "Claude Code's config is given the account uuid it knows"
907 );
908
909 let settled = settle(&m.ctx, Permit::for_a_test(), Some(m.which))
910 .expect("nothing to recover")
911 .0;
912 let (outcome, _) = switch(settled, &m.key("team")).expect("a switch");
913 assert!(matches!(outcome, Outcome::Switched { .. }), "{outcome:?}");
914 assert!(in_use(&m, "team-refresh"));
915 assert!(
916 park_of(&m, "here").is_some_and(|p| p.service.starts_with("pitboard-park-here-1")),
917 "the first organisation's login is parked under the id it was enrolled with"
918 );
919
920 let again = enrolled_as(&m, "personal", here_in(&m, "org-team", "team-again"))
921 .expect_err("the same organisation is the same login");
922 assert!(matches!(again, Error::AlreadyEnrolled { .. }), "{again}");
923 }
924
925 #[test]
928 fn a_label_another_organisation_holds_is_said_to_be_that() {
929 let m = machine("label-in-another-organisation");
930 let refused = enrolled_as(&m, "here", here_in(&m, "org-team", "team-refresh"))
931 .expect_err("`here` is the first organisation's");
932 assert_eq!(
933 refused.to_string(),
934 "`here` already refers to here@example.com in another organisation. Choose a \
935 different label."
936 );
937 }
938
939 #[test]
943 fn signing_in_again_to_the_account_in_use_puts_the_new_login_in_use() {
944 for (tool, make) in MACHINES {
945 let m = make("again-in-use");
946 let vault = m.mem.vault().services();
947
948 let (enrolled, _) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
949 .unwrap_or_else(|e| panic!("{tool}: {e}"));
950
951 assert!(
952 matches!(
953 enrolled,
954 Enrolled::InUse { ref email, again: true } if email == "here@example.com"
955 ),
956 "{tool}: {enrolled:?}"
957 );
958 assert!(
959 in_use(&m, "here-refresh-2"),
960 "{tool}: the new login is in use"
961 );
962 assert_eq!(m.mem.vault().services(), vault, "{tool}: nothing is parked");
963 assert!(park_of(&m, "here").is_none(), "{tool}");
964 let state = state::load(&m.ctx).expect("state");
965 assert_eq!(state.active_for(m.which), Some("here"), "{tool}");
966 assert_eq!(
967 state.get(&m.key("here")).and_then(|a| a.last_used_at),
968 Some(NOW),
969 "{tool}: signing in to the account in use is using it"
970 );
971 hold(&m, &format!("{tool}, after signing in again"));
972 }
973 }
974
975 #[test]
978 fn signing_in_again_to_the_account_in_use_keeps_the_park_it_holds() {
979 for (tool, make) in MACHINES {
980 let m = make("again-keeps-park");
981 let (uuid, older) = match m.which {
982 ProviderId::Claude => ("here".to_string(), oauth("here-older", 30)),
983 ProviderId::Codex => (codex_id("here"), codex_login("here", "here-older")),
984 };
985 let service = park::reserve(&m.ctx, Permit::for_a_test(), &uuid).expect("a free name");
986 let held = park::store_at(&m.ctx, Permit::for_a_test(), m.which, &service, &older)
987 .expect("parked");
988 let mut state = state::load(&m.ctx).expect("state");
989 state.park(&m.key("here"), held.clone());
990 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
991 let vault = m.mem.vault().services();
992
993 enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
994 .unwrap_or_else(|e| panic!("{tool}: {e}"));
995
996 assert!(in_use(&m, "here-refresh-2"), "{tool}");
997 assert_eq!(
998 park_of(&m, "here"),
999 Some(held),
1000 "{tool}: the park is untouched"
1001 );
1002 assert_eq!(m.mem.vault().services(), vault, "{tool}");
1003 hold(&m, &format!("{tool}, after signing in again beside a park"));
1004 }
1005 }
1006
1007 #[test]
1011 fn the_next_switch_away_parks_the_new_login_not_the_old() {
1012 for (tool, make) in MACHINES {
1013 let m = make("again-then-switch");
1014 enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
1015 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1016
1017 let settled = settle(&m.ctx, Permit::for_a_test(), Some(m.which))
1018 .expect("nothing to recover")
1019 .0;
1020 let (outcome, _) = switch(settled, &m.key("there"))
1021 .unwrap_or_else(|e| panic!("{tool}: the switch away: {e}"));
1022 assert!(matches!(outcome, Outcome::Switched { .. }), "{tool}");
1023
1024 let parked = park_of(&m, "here").expect("the outgoing login is parked");
1025 assert_eq!(
1026 parked.refresh_fingerprint,
1027 store::fingerprint("here-refresh-2"),
1028 "{tool}: the new login is parked, not the one it replaced"
1029 );
1030 hold(&m, &format!("{tool}, after the switch away"));
1031 }
1032 }
1033
1034 #[test]
1037 fn a_sign_in_of_another_account_is_parked_beside_the_one_in_use() {
1038 for (tool, make) in MACHINES {
1039 let m = make("another-parked");
1040 let live = m.live();
1041
1042 let (enrolled, _) = enrolled_as(&m, "third", signed_in(&m, "third", "third-refresh"))
1043 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1044
1045 assert!(
1046 matches!(enrolled, Enrolled::SignedIn { .. }),
1047 "{tool}: {enrolled:?}"
1048 );
1049 assert_eq!(m.live(), live, "{tool}: the login in use is untouched");
1050 assert_eq!(
1051 park_of(&m, "third").map(|p| p.refresh_fingerprint),
1052 Some(store::fingerprint("third-refresh")),
1053 "{tool}"
1054 );
1055 hold(&m, &format!("{tool}, after another account's sign-in"));
1056 }
1057 }
1058
1059 #[test]
1064 fn a_login_in_use_whose_account_cannot_be_told_is_not_written_over() {
1065 let mut cases: Vec<(String, Machine)> = Vec::new();
1066 for (name, trouble) in [
1067 ("refused", Trouble::Unauthorized),
1068 ("offline", Trouble::Offline),
1069 ] {
1070 let m = machine(&format!("untold-{name}"));
1071 m.api.token_trouble("access-here-refresh", trouble);
1072 cases.push((format!("claude, {name}"), m));
1073 }
1074 let m = codex_machine("untold");
1075 let mut unreadable = codex_login("here", "here-refresh");
1076 unreadable["tokens"]["id_token"] = "not a token".into();
1077 m.sign_in(&unreadable);
1078 cases.push(("codex".into(), m));
1079
1080 for (case, m) in cases {
1081 let live = m.live();
1082
1083 let (enrolled, warnings) =
1084 enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
1085 .unwrap_or_else(|e| panic!("{case}: {e}"));
1086
1087 assert!(
1088 matches!(enrolled, Enrolled::Renewed { .. }),
1089 "{case}: {enrolled:?}"
1090 );
1091 assert_eq!(m.live(), live, "{case}: the login in use is untouched");
1092 assert_eq!(
1093 park_of(&m, "here").map(|p| p.refresh_fingerprint),
1094 Some(store::fingerprint("here-refresh-2")),
1095 "{case}: the new login is parked"
1096 );
1097 let said = warnings
1100 .iter()
1101 .find(|w| w.code() == "sign_in_parked_not_in_use")
1102 .unwrap_or_else(|| panic!("{case}: {warnings:?}"))
1103 .to_string();
1104 assert!(
1105 said.contains("parked the new login for `"),
1106 "{case}: {said}"
1107 );
1108 assert!(
1109 said.contains("goes on with the login it has"),
1110 "{case}: {said}"
1111 );
1112 assert!(said.contains("sign in to `"), "{case}: {said}");
1113 }
1114 }
1115
1116 #[test]
1119 fn a_sign_in_to_a_parked_account_is_not_warned_about_the_login_in_use() {
1120 let m = machine("untold-parked");
1121 m.api
1122 .token_trouble("access-here-refresh", Trouble::Unauthorized);
1123
1124 let (enrolled, warnings) =
1125 enrolled_as(&m, "there", signed_in(&m, "there", "there-refresh-2"))
1126 .unwrap_or_else(|e| panic!("{e}"));
1127
1128 assert!(matches!(enrolled, Enrolled::Renewed { .. }), "{enrolled:?}");
1129 assert!(warnings.is_empty(), "{warnings:?}");
1130 }
1131
1132 #[test]
1136 fn a_first_sign_in_to_the_account_in_use_enrols_it_with_the_new_login_in_use() {
1137 for (tool, make) in MACHINES {
1138 let m = make("first-in-use");
1139 let mut state = state::load(&m.ctx).expect("state");
1140 state.accounts.retain(|a| a.label != "here");
1141 state.set_active(m.which, None);
1142 state::save(&m.ctx, Permit::for_a_test(), &state).expect("saved");
1143
1144 let (enrolled, _) =
1145 enrolled_as(&m, "personal", signed_in(&m, "here", "here-refresh-2"))
1146 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1147
1148 assert!(
1149 matches!(enrolled, Enrolled::InUse { again: false, .. }),
1150 "{tool}: {enrolled:?}"
1151 );
1152 assert!(in_use(&m, "here-refresh-2"), "{tool}");
1153 let state = state::load(&m.ctx).expect("state");
1154 assert_eq!(state.active_for(m.which), Some("personal"), "{tool}");
1155 hold(&m, &format!("{tool}, after enrolling the account in use"));
1156 }
1157 }
1158
1159 #[test]
1162 fn the_account_in_use_changing_before_the_write_is_refused_and_nothing_is_written() {
1163 for (tool, make) in MACHINES {
1164 let m = make("changed-before-write");
1165 let key = m.key("here");
1166 let login = signed_in(&m, "here", "here-refresh-2");
1167 let owner = identify_document(&m.ctx, m.which, &login.document).expect("whose");
1168 let InUse::Theirs(live, first) = signed_in_now(&m.ctx, m.which, &owner) else {
1169 panic!("{tool}: `here` is signed in");
1170 };
1171
1172 m.sign_in(&login_of(&m, "other", "other-refresh"));
1173 let vault = m.mem.vault().services();
1174 let recorded = serde_json::to_value(state::load(&m.ctx).expect("state")).unwrap();
1175 let mut state = state::load(&m.ctx).expect("state");
1176 let refused =
1177 install_signed_in(&m.ctx, &key, &mut state, &login, &owner, &live, &first)
1178 .expect_err("refused");
1179
1180 assert_eq!(refused.code(), "sign_in_not_kept", "{tool}: {refused}");
1181 assert!(
1182 refused
1183 .to_string()
1184 .contains("was signed in to another account meanwhile"),
1185 "{tool}: {refused}"
1186 );
1187 assert!(in_use(&m, "other-refresh"), "{tool}: the other login stays");
1188 assert_eq!(m.mem.vault().services(), vault, "{tool}");
1189 assert_eq!(
1190 serde_json::to_value(state::load(&m.ctx).expect("state")).unwrap(),
1191 recorded,
1192 "{tool}: and nothing is recorded"
1193 );
1194 }
1195 }
1196
1197 #[test]
1201 fn a_new_login_that_cannot_be_written_leaves_the_old_one_in_use() {
1202 for (tool, make) in MACHINES {
1203 let m = make("again-write-fails");
1204 let vault = m.mem.vault().services();
1205 let login = signed_in(&m, "here", "here-refresh-2");
1206 m.fault_live(Fault::FailWrite("refused".into()));
1207
1208 let failed = enrolled_as(&m, "here", login).expect_err("the write failed");
1209
1210 assert_eq!(failed.code(), "sign_in_not_kept", "{tool}: {failed}");
1211 let said = failed.to_string();
1212 assert!(said.contains("was not kept"), "{tool}: {said}");
1213 assert!(
1214 said.contains(&format!("pitboard enroll {} --sign-in", m.key("here"))),
1215 "{tool}: {said}"
1216 );
1217 assert!(!said.contains("nothing was lost"), "{tool}: {said}");
1218 assert!(in_use(&m, "here-refresh"), "{tool}");
1219 assert_eq!(m.mem.vault().services(), vault, "{tool}");
1220 }
1221 }
1222
1223 #[test]
1227 fn a_new_login_that_did_not_hold_is_parked_rather_than_lost() {
1228 for (tool, make) in MACHINES {
1229 let m = make("again-did-not-hold");
1230 let login = signed_in(&m, "here", "here-refresh-2");
1231 m.fault_live(Fault::DeletedAfterWrite);
1232
1233 let failed = enrolled_as(&m, "here", login).expect_err("it did not hold");
1234
1235 assert!(
1236 matches!(failed, Error::SignInNotInstalled { parked: true, .. }),
1237 "{tool}: {failed:?}"
1238 );
1239 assert!(
1240 failed.to_string().contains("parked, so it is not lost"),
1241 "{tool}: {failed}"
1242 );
1243 assert_eq!(
1244 park_of(&m, "here").map(|p| p.refresh_fingerprint),
1245 Some(store::fingerprint("here-refresh-2")),
1246 "{tool}"
1247 );
1248 hold(&m, &format!("{tool}, after a new login that did not hold"));
1249 }
1250 }
1251
1252 #[test]
1258 fn a_new_login_that_could_not_be_read_back_is_not_kept_beside_itself() {
1259 for (tool, make) in MACHINES {
1260 for locked in ["by the write", "after it"] {
1261 for next in ["renewal", "change"] {
1262 let at = format!("{tool}, locked {locked}, then a {next}");
1263 let m = make(&format!("unread-{}-{next}", locked.replace(' ', "-")));
1264 let login = signed_in(&m, "here", "here-refresh-2");
1265 let (store, service) = m.live_store();
1266 let failed = if locked == "by the write" {
1267 store.fault(&service, Fault::LocksAfterWrite);
1268 enrolled_as(&m, "here", login)
1269 } else {
1270 crate::fault::meanwhile(
1271 "enroll.installed",
1272 move || store.fault(&service, Fault::Unreadable("locked".into())),
1273 || enrolled_as(&m, "here", login),
1274 )
1275 }
1276 .expect_err("it could not be read back");
1277 assert!(
1278 matches!(failed, Error::SignInNotInstalled { parked: true, .. }),
1279 "{at}: {failed:?}"
1280 );
1281 assert_eq!(
1282 park_of(&m, "here").map(|p| p.refresh_fingerprint),
1283 Some(store::fingerprint("here-refresh-2")),
1284 "{at}: parked as well"
1285 );
1286
1287 m.live_store().0.heal_all();
1288 match next {
1289 "renewal" => {
1290 let later = m
1293 .ctx
1294 .clone()
1295 .with_clock(Arc::new(FixedClock::at(NOW + 11 * 86_400)));
1296 renews(&m, "here-refresh-2", "here-refresh-3");
1297 let renewed = renew_due(&later, Permit::for_a_test(), Due::ToBeAsked);
1298 assert!(
1299 renewed.iter().all(|(key, _)| key.label != "here"),
1300 "{at}: {renewed:?}"
1301 );
1302 }
1303 _ => {
1304 settle(&m.ctx, Permit::for_a_test(), None).expect("the next change");
1305 }
1306 }
1307 assert!(in_use(&m, "here-refresh-2"), "{at}");
1308 assert!(park_of(&m, "here").is_none(), "{at}: the copy is dropped");
1309 hold(&m, &at);
1310 }
1311 }
1312 }
1313 }
1314
1315 #[test]
1319 fn sessions_running_on_the_old_login_are_counted_and_warned_about() {
1320 for (tool, make) in MACHINES {
1321 let m = make("again-sessions");
1322 m.mem.runs("codex", 2);
1323 m.mem.runs("claude", 2);
1324
1325 let (_, warnings) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
1326 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1327
1328 let said = warnings
1329 .iter()
1330 .find(|w| w.code() == "sessions_keep_old_login");
1331 match m.which {
1332 ProviderId::Claude => assert!(said.is_none(), "{warnings:?}"),
1333 ProviderId::Codex => {
1334 let text = said.expect("warned").to_string();
1335 assert!(text.contains("2 `codex` sessions"), "{text}");
1336 assert!(text.contains("`codex/here`'s old login"), "{text}");
1337 assert!(text.contains("Quit them and start again"), "{text}");
1338 assert!(text.contains("put the old login back"), "{text}");
1339 }
1340 }
1341 assert!(
1342 warnings
1343 .iter()
1344 .all(|w| w.code() != "sessions_still_running"),
1345 "{tool}: nobody switched away from anything: {warnings:?}"
1346 );
1347 }
1348 }
1349
1350 #[test]
1354 fn a_process_list_nobody_could_read_is_said_after_a_sign_in() {
1355 for (tool, make) in MACHINES {
1356 let m = make("again-listless");
1357 m.mem.without_a_process_list();
1358
1359 let (_, warnings) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
1360 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1361
1362 let said = warnings.iter().find(|w| w.code() == "sessions_unknown");
1363 match m.which {
1364 ProviderId::Claude => assert!(said.is_none(), "{warnings:?}"),
1365 ProviderId::Codex => {
1366 let text = said
1367 .unwrap_or_else(|| panic!("warned: {warnings:?}"))
1368 .to_string();
1369 assert!(
1370 text.starts_with(
1371 "Pitboard could not tell whether Codex sessions started before \
1372 this sign-in are still running"
1373 ),
1374 "{text}"
1375 );
1376 assert!(text.contains("`codex/here`'s old login"), "{text}");
1377 assert!(text.contains("put the old login back"), "{text}");
1378 }
1379 }
1380 assert!(
1381 warnings
1382 .iter()
1383 .all(|w| w.code() != "sessions_keep_old_login"),
1384 "{tool}: nothing is said to be running: {warnings:?}"
1385 );
1386 }
1387 }
1388
1389 #[derive(Debug, Default)]
1392 struct Played {
1393 started: std::sync::Mutex<Vec<(ProviderId, Option<String>, PathBuf)>>,
1395 login: std::sync::Mutex<Option<(Arc<crate::host::memory::MemoryHost>, String)>>,
1397 typed: Arc<std::sync::Mutex<Vec<String>>>,
1399 }
1400
1401 enum Told {
1403 Typed,
1404 Stopped,
1405 }
1406
1407 struct Playing {
1408 told: std::sync::mpsc::Sender<Told>,
1409 typed: Arc<std::sync::Mutex<Vec<String>>>,
1410 ended: Option<std::thread::JoinHandle<bool>>,
1411 }
1412
1413 impl SignInScript for Played {
1414 fn start(
1415 &self,
1416 which: ProviderId,
1417 label: Option<&str>,
1418 dir: &std::path::Path,
1419 say: std::sync::mpsc::Sender<String>,
1420 ) -> Box<dyn ScriptedSignIn> {
1421 self.started.lock().expect("a test's own lock").push((
1422 which,
1423 label.map(str::to_owned),
1424 dir.to_path_buf(),
1425 ));
1426 let login = self.login.lock().expect("a test's own lock").clone();
1427 let dir = dir.to_path_buf();
1428 let (told, hears) = std::sync::mpsc::channel();
1429 let ended = std::thread::spawn(move || {
1430 let _ = say.send("Opening browser to sign in\u{2026}\n".into());
1431 match hears.recv() {
1432 Ok(Told::Typed) => {}
1433 Ok(Told::Stopped) | Err(_) => return false,
1434 }
1435 let Some((host, document)) = login else {
1436 return false;
1437 };
1438 match which {
1440 ProviderId::Claude => host.live().plant(
1441 &crate::provider::claude::slot::service_for_dir(&dir.to_string_lossy()),
1442 &document,
1443 ),
1444 ProviderId::Codex => host
1445 .file_at(dir.join("auth.json"))
1446 .plant("auth.json", &document),
1447 }
1448 let _ = say.send("Login successful.\n".into());
1449 true
1450 });
1451 Box::new(Playing {
1452 told,
1453 typed: Arc::clone(&self.typed),
1454 ended: Some(ended),
1455 })
1456 }
1457 }
1458
1459 impl ScriptedSignIn for Playing {
1460 fn typed(&mut self, line: &str) -> bool {
1461 self.typed
1462 .lock()
1463 .expect("a test's own lock")
1464 .push(line.into());
1465 self.told.send(Told::Typed).is_ok()
1466 }
1467
1468 fn wait(&mut self) -> bool {
1469 self.ended
1470 .take()
1471 .is_some_and(|ended| ended.join().unwrap_or(false))
1472 }
1473
1474 fn stop(&mut self) {
1475 let _ = self.told.send(Told::Stopped);
1476 }
1477 }
1478
1479 fn nowhere(ctx: &Context) -> Context {
1481 let missing = std::env::temp_dir().join("pitboard-no-such-directory");
1482 ctx.clone()
1483 .with_claude_program(missing.join("claude"))
1484 .with_codex_program(missing.join("codex"))
1485 }
1486
1487 #[test]
1494 fn a_scripted_sign_in_enrols_what_it_stored_without_starting_the_tool() {
1495 for (tool, make) in MACHINES {
1496 let m = make("scripted");
1497 let played = Arc::new(Played::default());
1498 *played.login.lock().expect("a test's own lock") = Some((
1499 Arc::clone(&m.mem),
1500 login_of(&m, "newcomer", "newcomer-refresh").to_string(),
1501 ));
1502 let ctx = nowhere(&m.ctx).with_sign_in_script(Arc::clone(&played) as _);
1503
1504 let mut watched = sign_in_watched_as(&ctx, Permit::for_a_test(), &m.key("newcomer"))
1505 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1506 let said = watched.said();
1507 assert_eq!(
1508 said.next().as_deref(),
1509 Some("Opening browser to sign in\u{2026}\n"),
1510 "{tool}"
1511 );
1512 watched.paste("code#state").expect("typed back");
1513 let login = watched.finish().unwrap_or_else(|e| panic!("{tool}: {e}"));
1514 assert_eq!(
1515 said.next().as_deref(),
1516 Some("Login successful.\n"),
1517 "{tool}"
1518 );
1519 assert_eq!(
1520 said.next(),
1521 None,
1522 "{tool}: the tool has stopped saying anything"
1523 );
1524
1525 assert_eq!(
1526 *played.typed.lock().expect("a test's own lock"),
1527 ["code#state"],
1528 "{tool}"
1529 );
1530 let started = played.started.lock().expect("a test's own lock").clone();
1531 assert_eq!(started.len(), 1, "{tool}");
1532 assert_eq!(
1533 (started[0].0, started[0].1.as_deref()),
1534 (m.which, Some("newcomer")),
1535 "{tool}"
1536 );
1537 assert!(
1538 started[0].2.starts_with(home::dir(&m.ctx)),
1539 "{tool}: the core's own private directory"
1540 );
1541 let (enrolled, _) =
1542 enrolled_as(&m, "newcomer", login).unwrap_or_else(|e| panic!("{tool}: {e}"));
1543 assert!(
1544 matches!(enrolled, Enrolled::SignedIn { ref email } if email == "newcomer@example.com"),
1545 "{tool}: {enrolled:?}"
1546 );
1547 assert!(park_of(&m, "newcomer").is_some(), "{tool}: parked");
1548 }
1549 }
1550
1551 #[test]
1554 fn a_stopped_scripted_sign_in_enrols_nothing() {
1555 for (tool, make) in MACHINES {
1556 let m = make("scripted-stop");
1557 let played = Arc::new(Played::default());
1558 let ctx = nowhere(&m.ctx).with_sign_in_script(Arc::clone(&played) as _);
1559 let watched = sign_in_watched(&ctx, Permit::for_a_test(), m.which)
1560 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1561 let said = watched.said();
1562 assert!(said.next().is_some(), "{tool}");
1563 watched.cancel();
1564 assert_eq!(said.next(), None, "{tool}");
1565 let started = played.started.lock().expect("a test's own lock").clone();
1566 assert_eq!(started[0].1, None, "{tool}: nobody said whom it was for");
1567 assert!(park_of(&m, "newcomer").is_none(), "{tool}");
1568 reserve_signin(&ctx, Permit::for_a_test(), m.which)
1569 .expect("a stopped sign-in lets the next one start");
1570 }
1571 }
1572
1573 #[test]
1579 #[cfg(unix)]
1580 fn a_cancelled_sign_in_lets_the_next_start_while_a_copy_of_its_lock_is_open() {
1581 for (tool, make) in MACHINES {
1582 let m = make("lock-copied");
1583 let played = Arc::new(Played::default());
1584 let ctx = nowhere(&m.ctx).with_sign_in_script(Arc::clone(&played) as _);
1585 let watched = sign_in_watched(&ctx, Permit::for_a_test(), m.which)
1586 .unwrap_or_else(|e| panic!("{tool}: {e}"));
1587 let copy = watched
1588 .pending
1589 .one_at_a_time
1590 .try_clone()
1591 .expect("a copy of the lock's descriptor");
1592 watched.cancel();
1593 let next = reserve_signin(&ctx, Permit::for_a_test(), m.which);
1594 drop(copy);
1595 assert!(
1596 next.is_ok(),
1597 "{tool}: the next sign-in refused: {:?}",
1598 next.err()
1599 );
1600 }
1601 }
1602
1603 #[test]
1607 fn a_cancel_does_not_wait_on_a_tool_that_says_nothing() {
1608 let m = codex_machine("cancel");
1609 let pending =
1610 reserve_signin(&m.ctx, Permit::for_a_test(), ProviderId::Codex).expect("reserved");
1611 let mut silent = std::process::Command::new("sleep");
1612 silent.arg("30");
1613 let watched = watch(silent, pending).expect("started");
1614 let said = watched.said();
1615 let reader = std::thread::spawn(move || said.next());
1616 std::thread::sleep(Duration::from_millis(100));
1617
1618 let asked = Instant::now();
1619 watched.cancel();
1620 assert_eq!(reader.join().expect("the reader"), None);
1621 assert!(
1622 asked.elapsed() < Duration::from_secs(10),
1623 "the cancel waited on the tool"
1624 );
1625 reserve_signin(&m.ctx, Permit::for_a_test(), ProviderId::Codex)
1626 .expect("a cancelled sign-in lets the next one start");
1627 }
1628}