Skip to main content

pitboard_core/switch/
enroll.rs

1//! Bringing an account under Pitboard's care.
2//!
3//! A parked copy is only safe if the live slot is replaced the moment it is taken; otherwise
4//! the tool keeps rotating the same token and the copy goes stale, and for a tool whose
5//! sign-out revokes what it finds, a copy left beside the live login is one the person's
6//! own next sign-out would end. So the account signed in now is recorded but not parked.
7//! Its first switch parks it at exactly that moment, and any other account is signed in
8//! inside a private directory, where the live slot is never touched and the vault is the
9//! new login's only holder.
10//!
11//! A sign-in to the account signed in now keeps the same rule. That account is not parked,
12//! so its new login is put in use the way a switch puts one there, in place of the old.
13
14use 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    /// The account signed in now, recorded without parking: its first switch parks it.
28    Current { email: String },
29    /// Another account, signed in privately and parked.
30    SignedIn { email: String },
31    /// An enrolled account signed in to again: its parked login is now the new one.
32    Renewed { email: String },
33    /// The account signed in now, signed in to: its new login is the one in use now, and
34    /// nothing was parked. `again` when it was enrolled already, and not when this enrolled
35    /// it, which a browser that signs in to the session it already has makes likely.
36    InUse { email: String, again: bool },
37}
38
39/// A login a tool stored for Pitboard in a private directory, not yet enrolled. Dropping it
40/// deletes that directory and whatever the tool kept for it elsewhere.
41pub struct SignIn {
42    provider: ProviderId,
43    dir: PathBuf,
44    document: Value,
45    ctx: Context,
46    /// What it was reserved with: what enrolling it writes with, and what lets dropping it
47    /// remove what it reserved.
48    permit: Permit,
49    /// The lock on `signin.lock` that makes this the one sign-in at a time, let go of as this
50    /// is dropped.
51    one_at_a_time: File,
52}
53
54impl SignIn {
55    /// Which tool this login is for.
56    pub fn provider(&self) -> ProviderId {
57        self.provider
58    }
59}
60
61impl Drop for SignIn {
62    /// Clears the private directory, then lets go of the lock, so the next sign-in can start
63    /// the moment this returns.
64    ///
65    /// Let go of by name rather than by closing the file: a lock taken with `flock` belongs to
66    /// the open file, which every process another thread is starting holds a copy of until
67    /// it runs its program, though std opens the file so that the program does not keep it.
68    /// Closed, the lock stayed held until the last copy went, for up to milliseconds, as
69    /// ARCHITECTURE.md's Measured facts say, and a sign-in started in that time was refused
70    /// as one already waiting. Let go of, it is free whatever copies are still open.
71    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
78/// Takes the one-sign-in-at-a-time lock and prepares the private directory the tool will
79/// sign in to. Both the inherited and the watched sign-in start here.
80///
81/// A sign-in waits on a person in a browser, so it takes no lock but its own: a switch
82/// meanwhile goes ahead, and a second sign-in is refused rather than queued.
83fn 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    // A sign-in that was killed rather than finished never ran its cleanup, so a login can
109    // be sitting in the scratch slot with nothing naming it. The directory is always the
110    // same one, so the slot is too, and this is the moment it can be cleared safely: the
111    // lock above means no other sign-in is using it. Every tool's leftovers, because the
112    // one that was killed need not be the one starting now.
113    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/// A sign-in that never ran. The crash matrix needs the state a finished sign-in leaves,
132/// and running a tool's own login inside a test is neither possible nor wanted. The app
133/// model's tests in `pitboard-ffi` need it for the same reason, through `testing`.
134#[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
146/// Run the tool's own sign-in in a private directory, where the live login is never
147/// touched, and read back the login it stored there.
148pub fn sign_in(ctx: &Context, permit: Permit, which: ProviderId) -> Result<SignIn> {
149    let mut pending = reserve_signin(ctx, permit, which)?;
150    // Pitboard never sees the sign-in; it reads the login the tool stores once it is done.
151    // What the tool prints goes to stderr, so `--json` output stays one JSON line.
152    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
182/// The same sign-in, watched rather than inherited: an app has no terminal to hand over, so
183/// it reads what the tool prints and can type a fallback code back where the tool asks for
184/// one.
185pub struct WatchedSignIn {
186    tool: Running,
187    said: Said,
188    pending: SignIn,
189}
190
191/// The tool a watched sign-in runs: its program, or, where a test or a fixture says, a script
192/// playing it.
193enum Running {
194    Program(std::process::Child),
195    #[cfg(any(test, feature = "test-support"))]
196    Scripted(Box<dyn ScriptedSignIn>),
197}
198
199/// A tool's own sign-in, played rather than run, for a test or a fixture that may start no
200/// program: everything up to the tool's program is the real core's, which reserves the
201/// private directory, reads back the login the script stores there as it reads the tool's,
202/// and enrols it. Only a context made with `Context::with_sign_in_script` has one, and only a
203/// build with `test-support` has the seam.
204#[cfg(any(test, feature = "test-support"))]
205pub trait SignInScript: Send + Sync + std::fmt::Debug {
206    /// Starts `which`'s sign-in into the private directory `dir`, as `label` names the
207    /// account somebody means to sign in to, where the caller said which: the person at the
208    /// browser, whom a program never knows of. What the tool says goes to `say`, and dropping
209    /// it is the tool having stopped saying anything.
210    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/// One sign-in a `SignInScript` started, as its program would be driven.
220#[cfg(any(test, feature = "test-support"))]
221pub trait ScriptedSignIn: Send {
222    /// A line typed back. False where nothing reads it any more, as a closed stdin is.
223    fn typed(&mut self, line: &str) -> bool;
224    /// Waits for it to end, and says whether it ended signed in, with the login stored in its
225    /// directory where the tool stores one.
226    fn wait(&mut self) -> bool;
227    /// Stops it, as a kill stops the program.
228    fn stop(&mut self);
229}
230
231/// What a watched sign-in's tool says, read apart from the sign-in itself.
232///
233/// Reading waits until the tool says something, and Codex prints its address and then
234/// nothing until the browser is done. A reader that held the sign-in while it waited held
235/// up a paste or a cancel for as long, so what it says is its own handle, and stopping the
236/// tool is what ends the reading.
237#[derive(Clone)]
238pub struct Said(std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<String>>>);
239
240impl Said {
241    /// The next thing the tool said, or `None` once it has finished saying anything.
242    /// Blocks, so a caller reads it on a thread of its own.
243    pub fn next(&self) -> Option<String> {
244        self.0.lock().ok()?.recv().ok()
245    }
246}
247
248impl WatchedSignIn {
249    /// Which tool's sign-in this is.
250    pub fn provider(&self) -> ProviderId {
251        self.pending.provider
252    }
253
254    /// What the tool says, for a reader on a thread of its own that must not hold up a
255    /// paste or a cancel while it waits.
256    pub fn said(&self) -> Said {
257        self.said.clone()
258    }
259
260    /// Types a line back, for a code the tool reads from stdin while it waits on the
261    /// browser.
262    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    /// Waits for it to finish and hands back the login it stored.
282    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    /// Stops it, and returns once its tool has ended and been waited for and the one sign-in
298    /// at a time has been let go of, so a sign-in started after this returns is not refused as
299    /// one already waiting. What it may have written is discarded by `SignIn`'s own cleanup.
300    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
315/// Starts the sign-in with its output piped, for a caller that will show it.
316pub fn sign_in_watched(ctx: &Context, permit: Permit, which: ProviderId) -> Result<WatchedSignIn> {
317    start_watched(ctx, permit, which, None)
318}
319
320/// The same, for the account `key` names, which a script playing the tool is told of: the
321/// person at the browser knows which account they mean, where the tool never does.
322pub(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    // Only a script playing the tool is told whom the sign-in is for.
347    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
352/// Runs `command` with its output piped, as the sign-in `pending` reserved.
353fn 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    // Claude Code writes the browser URL and the paste prompt without a newline after them,
361    // so this reads by chunk rather than by line and lets the caller decide what to show.
362    // Codex writes its address to stderr, which is read the same way.
363    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
401/// Enroll the account signed in to `which` now, or with `signed_in`, the one a sign-in just
402/// produced.
403pub 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        // A sign-in was run for one tool; filing its login under another would be an
416        // account of the wrong tool under the name somebody chose.
417        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
427/// A label names one account of its tool for good: its own, or one not enrolled under
428/// another label.
429fn 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    // Through the store itself rather than the provider's reading of it, so a locked
454    // keychain says so in the store's own words instead of reading as a strange login.
455    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    // A document with no account in it is nobody signed in: Claude Code's after a
459    // `/logout` still holds the machine's MCP tokens.
460    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    // Enrolling the account that is signed in is using it.
470    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
485/// Enrol the account a sign-in produced: put in use when it is the account signed in now,
486/// and parked when it is any other.
487///
488/// Somebody signs in again to the account in use because its login is broken or about to
489/// lapse. Parked, the new login left the tool on the old one, and the next switch away
490/// parked the old one over it.
491fn 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            // Somebody signing in to the account Pitboard last saw in use most likely wants
506            // its broken login replaced, and parking is not that, so it is said.
507            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
521/// Whose the tool's live login is, as far as a sign-in to `owner`'s account needs to know.
522///
523/// Read the way a switch reads it. Writing over a login whose account is not known could
524/// lose that account's only login, so only a login known to be `owner`'s is written over.
525enum InUse {
526    /// `owner`'s: where it is, and what it held.
527    Theirs(provider::LiveStore, Value),
528    /// Another account's, or nobody's.
529    NotTheirs,
530    /// It could not be read, or its account could not be told, for this reason.
531    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
549/// Why the login in use could not be told, in a few words. The warning it goes into says
550/// what to do, and an error's own advice would be about something else.
551fn 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
565/// Put the new login of the account signed in now in place of its old one, under the rules
566/// a switch writes by, and record the account as `record_current` does. Nothing is parked,
567/// and a park the account already holds is kept. The old login is dropped, which is what
568/// the tool's own sign-in does to the login it replaces.
569fn 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    // Said as a sign-in's failure rather than a switch's: the new login goes with the
582    // sign-in, so nothing was lost is not true of it, and the way on is signing in again.
583    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        // The old login is where it was, and the new one goes with the sign-in.
617        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    // Read back as a switch reads back: a write that landed has not necessarily held.
628    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    // A session of a tool that never reads its login again goes on with the old one, and
657    // writes it back over the new one when it refreshes. Where nobody could tell what is
658    // running, that is said, since one may be.
659    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
684/// A new login that could not be confirmed in use, where the tool may be left without the
685/// old one too. The new one is the one copy of the account's login known to be good, so it
686/// is parked, as a sign-in of any other account would be, before the failure is reported.
687/// Where the write landed and could not be read back, the slot holds the new login as well:
688/// no renewal spends that copy, and the next change that can read the slot drops it.
689///
690/// What writing it warned about is carried on the error with what parking it warns about,
691/// because both happened whatever became of them.
692fn 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    // The window the roadmap named: the login is in the vault and nothing on the machine
740    // says so yet.
741    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    // Unrecorded, the new login would be an item nothing refers to, never deleted.
757    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
771/// What Pitboard records about a newly enrolled account.
772///
773/// For Claude Code, only what Anthropic just confirmed: leaving the rest out makes Claude
774/// Code fetch its own profile after a switch rather than trust a copy Pitboard wrote. For
775/// Codex there is no such cache to correct, and what is kept instead is what its own login
776/// already said, which costs nothing to read and explains a limit somebody is surprised by.
777fn 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    /// A machine of each tool: `here` signed in, `there` parked and ready.
835    const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
836
837    /// Whether the login in use is the one this refresh token belongs to.
838    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    /// Enrols what a sign-in left under `label`, the way the next command would.
845    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    /// `here` in another organisation, signed in through `refresh`.
860    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    /// One Anthropic account in two organisations is two logins, each on its own plan. The
879    /// account uuid is the person in both, and identified by it alone the second was
880    /// refused as already enrolled, and a switch to it said the first was already in use.
881    #[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    /// Both logins are the same person's, so naming the one that holds a label by its email
926    /// alone names the one being enrolled too.
927    #[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    /// Somebody signs in again to the account in use, whose login is broken or about to
940    /// lapse. The new login is the one the tool uses from now on, and nothing is parked:
941    /// a park of the account in use is a copy the next switch away would only replace.
942    #[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    /// A park the account in use already holds, from before it was signed in to with the
976    /// tool itself, is kept as it is: this sign-in is about the login in use.
977    #[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    /// The failure this replaces: the new login was parked beside the old, and the next
1008    /// switch away parked the old one over it, which for Codex could be a login whose
1009    /// chain was already revoked. Now the switch parks what is in use, the new login.
1010    #[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    /// Another account's sign-in is parked, and the account in use is left exactly as it
1035    /// was, as before.
1036    #[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    /// Writing over a login whose account nobody can name could lose that account's only
1060    /// login, so a sign-in to `here` while the login in use cannot be told apart is parked
1061    /// beside it, as it always was. For Claude Code that is Anthropic refusing or not
1062    /// answering about the login in use; for Codex, a login whose ID token cannot be read.
1063    #[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            // `here` is the account Pitboard last saw in use, so the person most likely
1098            // meant to replace its login, and is told that did not happen and why.
1099            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    /// Only the account Pitboard last saw in use is warned about. Signing in again to a
1117    /// parked account renews its park whoever is signed in, as it always did.
1118    #[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    /// A browser often signs in to the session it already has, so a sign-in under a new
1133    /// label can be the account signed in now before anything enrolled it. It is enrolled
1134    /// with its new login in use, and says it was enrolled rather than signed in again.
1135    #[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    /// Somebody signs the tool in to another account between the first read and the one
1160    /// made under the tool's lock. Nothing is written over the account now signed in.
1161    #[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    /// A write that fails and changes nothing leaves the old login in use, and the new one
1198    /// goes with the sign-in. That is said as a sign-in's failure: the new login was not
1199    /// kept, and signing in again is the way on.
1200    #[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    /// The new login was written and was gone again before it was read back. The tool may
1224    /// have no login for the account now, so the new one, the one copy known to be good, is
1225    /// parked rather than thrown away, and the failure says so.
1226    #[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    /// The new login was written and could not be read back: the store locked as it took
1253    /// the write, or at the read that follows. The slot holds the new login, and it is parked
1254    /// as well, one refresh token in two places that nothing records. A renewal of the copy
1255    /// would spend the token the tool is using, so none is made: the next renewal once the
1256    /// slot can be read drops the copy instead, and so does the next change.
1257    #[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                            // Late enough that the copy's access token has lapsed, which is
1291                            // when a renewal would take it.
1292                            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    /// A running `codex` keeps the login it started with and writes it back when it
1316    /// refreshes its token, so a new login put in use is said to need them restarted.
1317    /// Claude Code sessions read the new login by themselves, and nothing is said.
1318    #[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    /// Where the process list cannot be read, nobody can say whether a running `codex` has
1351    /// the old login to write back, and the sign-in says so rather than nothing. Claude Code
1352    /// sessions read the new login by themselves, and nothing is said.
1353    #[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    /// A sign-in a script plays, as a fixture's is, standing in for whichever tool it is
1390    /// told of and for the person at the browser.
1391    #[derive(Debug, Default)]
1392    struct Played {
1393        /// Each sign-in started: its tool, whom it is for, and its private directory.
1394        started: std::sync::Mutex<Vec<(ProviderId, Option<String>, PathBuf)>>,
1395        /// The login each one stores once a line is typed back, as its tool writes it.
1396        login: std::sync::Mutex<Option<(Arc<crate::host::memory::MemoryHost>, String)>>,
1397        /// Every line typed back to any of them.
1398        typed: Arc<std::sync::Mutex<Vec<String>>>,
1399    }
1400
1401    /// What a played sign-in is told.
1402    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                // Where each tool stores the login of a sign-in into `dir`.
1439                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    /// `ctx` with each tool's program named where there is none.
1480    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    /// A test or a fixture that may start no program plays the tool's sign-in with a script,
1488    /// and everything else is the core's own: the private directory it reserves, the login
1489    /// read back from where the tool stores it there, and the enrolment. The script is told
1490    /// whom the sign-in is for, as the person at the browser knows, and hears what is typed
1491    /// back. Each tool's program is named where there is none, so a seam that let the tool's
1492    /// program start would fail to start one, never start this machine's own.
1493    #[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    /// A played sign-in that is stopped ends what it says and enrols nothing, as a killed
1552    /// tool does, and the next one can start.
1553    #[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    /// A cancel has let go of the core's one sign-in at a time by the time it returns, even
1574    /// while a copy of its lock's descriptor is open elsewhere, as one is in every process
1575    /// another thread is starting until that process runs its program. Closing the file let
1576    /// go of it only once every copy was closed, so the next sign-in was refused as one
1577    /// already waiting for as long as that took, which ARCHITECTURE.md's Measured facts give.
1578    #[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    /// Codex prints its address and then nothing until the browser is done, so somebody
1604    /// pressing Cancel nearly always finds a reader waiting. The cancel must not wait with
1605    /// it, and stopping the tool must end the reading rather than leave it waiting forever.
1606    #[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}