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::Warning;
19use crate::state::{Account, Key, Park, State};
20use crate::{home, lock, park, state, store};
21use serde_json::{Value, json};
22use std::fs::{File, OpenOptions, TryLockError};
23use std::os::unix::fs::OpenOptionsExt;
24use std::path::PathBuf;
25
26#[derive(Debug)]
27pub enum Enrolled {
28    /// The account signed in now, recorded without parking: its first switch parks it.
29    Current { email: String },
30    /// Another account, signed in privately and parked.
31    SignedIn { email: String },
32    /// An enrolled account signed in to again: its parked login is now the new one.
33    Renewed { email: String },
34    /// The account signed in now, signed in to: its new login is the one in use now, and
35    /// nothing was parked. `again` when it was enrolled already, and not when this enrolled
36    /// it, which a browser that signs in to the session it already has makes likely.
37    InUse { email: String, again: bool },
38}
39
40/// A login a tool stored for pitboard in a private directory, not yet enrolled. Dropping it
41/// deletes that directory and whatever the tool kept for it elsewhere.
42pub struct SignIn {
43    provider: ProviderId,
44    dir: PathBuf,
45    document: Value,
46    ctx: Context,
47    _one_at_a_time: File,
48}
49
50impl SignIn {
51    /// Which tool this login is for.
52    pub fn provider(&self) -> ProviderId {
53        self.provider
54    }
55}
56
57impl Drop for SignIn {
58    fn drop(&mut self) {
59        provider::of(self.provider).discard_signin(&self.ctx, &self.dir);
60        let _ = std::fs::remove_dir_all(&self.dir);
61    }
62}
63
64/// Takes the one-sign-in-at-a-time lock and prepares the private directory the tool will
65/// sign in to. Both the inherited and the watched sign-in start here.
66///
67/// A sign-in waits on a person in a browser, so it takes no lock but its own: a switch
68/// meanwhile goes ahead, and a second sign-in is refused rather than queued.
69fn reserve_signin(ctx: &Context, which: ProviderId) -> Result<SignIn> {
70    let home = home::ensure(ctx).map_err(|source| Error::HomeUnwritable {
71        path: home::dir(ctx),
72        source,
73    })?;
74    let lock_path = home.join("signin.lock");
75    let one_at_a_time = OpenOptions::new()
76        .create(true)
77        .truncate(false)
78        .write(true)
79        .mode(0o600)
80        .open(&lock_path)
81        .map_err(|source| Error::HomeUnwritable {
82            path: lock_path.clone(),
83            source,
84        })?;
85    match one_at_a_time.try_lock() {
86        Ok(()) => {}
87        Err(TryLockError::WouldBlock) => return Err(Error::SignInInProgress),
88        Err(TryLockError::Error(source)) => {
89            return Err(Error::HomeUnwritable {
90                path: lock_path,
91                source,
92            });
93        }
94    }
95
96    let dir = home.join("signin");
97    // A sign-in that was killed rather than finished never ran its cleanup, so a login can
98    // be sitting in the scratch slot with nothing naming it. The directory is always the
99    // same one, so the slot is too, and this is the moment it can be cleared safely: the
100    // lock above means no other sign-in is using it. Every tool's leftovers, because the
101    // one that was killed need not be the one starting now.
102    for &tool in ProviderId::ALL {
103        provider::of(tool).discard_signin(ctx, &dir);
104    }
105    let _ = std::fs::remove_dir_all(&dir);
106    home::create_private(&dir).map_err(|source| Error::HomeUnwritable {
107        path: dir.clone(),
108        source,
109    })?;
110    Ok(SignIn {
111        provider: which,
112        dir,
113        document: Value::Null,
114        ctx: ctx.clone(),
115        _one_at_a_time: one_at_a_time,
116    })
117}
118
119/// A sign-in that never ran. The crash matrix needs the state a finished sign-in leaves,
120/// and running a tool's own login inside a test is neither possible nor wanted.
121#[cfg(test)]
122pub(super) fn planted(ctx: &Context, which: ProviderId, document: Value) -> Result<SignIn> {
123    let mut pending = reserve_signin(ctx, which)?;
124    pending.document = document;
125    Ok(pending)
126}
127
128/// Run the tool's own sign-in in a private directory, where the live login is never
129/// touched, and read back the login it stored there.
130pub fn sign_in(ctx: &Context, which: ProviderId) -> Result<SignIn> {
131    let mut pending = reserve_signin(ctx, which)?;
132    // pitboard never sees the sign-in; it reads the login the tool stores once it is done.
133    // What the tool prints goes to stderr, so `--json` output stays one JSON line.
134    let finished = provider::of(which)
135        .sign_in(ctx, &pending.dir)
136        .stdout(std::io::stderr())
137        .status()
138        .map_err(|e| started(which, e))?
139        .success();
140    if !finished {
141        return Err(Error::SignInIncomplete);
142    }
143    pending.document = signed_in_document(ctx, which, &pending.dir)?;
144    Ok(pending)
145}
146
147fn started(which: ProviderId, e: std::io::Error) -> Error {
148    match e.kind() {
149        std::io::ErrorKind::NotFound => Error::ProgramNotFound { tool: which },
150        _ => Error::SignInIncomplete,
151    }
152}
153
154fn signed_in_document(ctx: &Context, which: ProviderId, dir: &std::path::Path) -> Result<Value> {
155    let raw = provider::of(which)
156        .read_signin(ctx, dir)?
157        .ok_or(Error::SignInIncomplete)?;
158    serde_json::from_str(&raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
159        tool: which,
160        detail: e.to_string(),
161    })
162}
163
164/// The same sign-in, watched rather than inherited: an app has no terminal to hand over, so
165/// it reads what the tool prints and can type a fallback code back where the tool asks for
166/// one.
167pub struct WatchedSignIn {
168    child: std::process::Child,
169    said: Said,
170    pending: SignIn,
171}
172
173/// What a watched sign-in's tool says, read apart from the sign-in itself.
174///
175/// Reading waits until the tool says something, and Codex prints its address and then
176/// nothing until the browser is done. A reader that held the sign-in while it waited held
177/// up a paste or a cancel for as long, so what it says is its own handle, and stopping the
178/// tool is what ends the reading.
179#[derive(Clone)]
180pub struct Said(std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<String>>>);
181
182impl Said {
183    /// The next thing the tool said, or `None` once it has finished saying anything.
184    /// Blocks, so a caller reads it on a thread of its own.
185    pub fn next(&self) -> Option<String> {
186        self.0.lock().ok()?.recv().ok()
187    }
188}
189
190impl WatchedSignIn {
191    /// Which tool's sign-in this is.
192    pub fn provider(&self) -> ProviderId {
193        self.pending.provider
194    }
195
196    /// What the tool says, for a reader on a thread of its own that must not hold up a
197    /// paste or a cancel while it waits.
198    pub fn said(&self) -> Said {
199        self.said.clone()
200    }
201
202    /// Types a line back, for a code the tool asks to be pasted when the browser cannot
203    /// reach its callback.
204    pub fn paste(&mut self, line: &str) -> Result<()> {
205        use std::io::Write;
206        let stdin = self.child.stdin.as_mut().ok_or(Error::SignInIncomplete)?;
207        writeln!(stdin, "{line}").map_err(|_| Error::SignInIncomplete)?;
208        stdin.flush().map_err(|_| Error::SignInIncomplete)
209    }
210
211    /// Waits for it to finish and hands back the login it stored.
212    pub fn finish(mut self) -> Result<SignIn> {
213        let finished = self
214            .child
215            .wait()
216            .map_err(|_| Error::SignInIncomplete)?
217            .success();
218        if !finished {
219            return Err(Error::SignInIncomplete);
220        }
221        let mut pending = self.pending;
222        pending.document =
223            signed_in_document(&pending.ctx.clone(), pending.provider, &pending.dir.clone())?;
224        Ok(pending)
225    }
226
227    /// Stops it. What it may have written is discarded by `SignIn`'s own cleanup.
228    pub fn cancel(mut self) {
229        let _ = self.child.kill();
230        let _ = self.child.wait();
231    }
232}
233
234/// Starts the sign-in with its output piped, for a caller that will show it.
235pub fn sign_in_watched(ctx: &Context, which: ProviderId) -> Result<WatchedSignIn> {
236    let pending = reserve_signin(ctx, which)?;
237    let command = provider::of(which).sign_in(ctx, &pending.dir);
238    watch(command, pending).map_err(|e| started(which, e))
239}
240
241/// Runs `command` with its output piped, as the sign-in `pending` reserved.
242fn watch(mut command: std::process::Command, pending: SignIn) -> std::io::Result<WatchedSignIn> {
243    let mut child = command
244        .stdin(std::process::Stdio::piped())
245        .stdout(std::process::Stdio::piped())
246        .stderr(std::process::Stdio::piped())
247        .spawn()?;
248    let (say, said) = std::sync::mpsc::channel();
249    // Claude Code writes the browser URL and the paste prompt without a newline after them,
250    // so this reads by chunk rather than by line and lets the caller decide what to show.
251    // Codex writes its address to stderr, which is read the same way.
252    for stream in [
253        child.stdout.take().map(Readable::Out),
254        child.stderr.take().map(Readable::Err),
255    ]
256    .into_iter()
257    .flatten()
258    {
259        let say = say.clone();
260        std::thread::spawn(move || {
261            use std::io::Read;
262            let mut reader: Box<dyn Read + Send> = match stream {
263                Readable::Out(o) => Box::new(o),
264                Readable::Err(e) => Box::new(e),
265            };
266            let mut buffer = [0_u8; 1024];
267            while let Ok(read) = reader.read(&mut buffer) {
268                if read == 0 {
269                    break;
270                }
271                let text = String::from_utf8_lossy(&buffer[..read]).into_owned();
272                if say.send(text).is_err() {
273                    break;
274                }
275            }
276        });
277    }
278    Ok(WatchedSignIn {
279        child,
280        said: Said(std::sync::Arc::new(std::sync::Mutex::new(said))),
281        pending,
282    })
283}
284
285enum Readable {
286    Out(std::process::ChildStdout),
287    Err(std::process::ChildStderr),
288}
289
290/// Enroll the account signed in to `which` now, or with `signed_in`, the one a sign-in just
291/// produced.
292pub fn enroll(
293    settled: Settled,
294    key: &Key,
295    signed_in: Option<SignIn>,
296) -> Result<(Enrolled, Vec<Warning>)> {
297    let Settled {
298        _exclusive,
299        mut state,
300        ctx,
301    } = settled;
302    match signed_in {
303        // A sign-in was run for one tool; filing its login under another would be an
304        // account of the wrong tool under the name somebody chose.
305        Some(login) if login.provider != key.provider => Err(Error::Usage(format!(
306            "that sign-in was {}'s, and `{key}` is a {} account",
307            login.provider.name(),
308            key.provider.name()
309        ))),
310        Some(login) => from_sign_in(&ctx, key, &mut state, &login),
311        None => record_current(&ctx, key, &mut state).map(|e| (e, Vec::new())),
312    }
313}
314
315/// A label names one account of its tool for good: its own, or one not enrolled under
316/// another label.
317fn claim(state: &State, key: &Key, owner: &Owner) -> Result<()> {
318    if let Some(taken) = state.get(key)
319        && taken.account_uuid != owner.account_uuid
320    {
321        return Err(Error::LabelTaken {
322            label: key.typed(),
323            email: taken.email.clone(),
324        });
325    }
326    if let Some(existing) = state.by_uuid(key.provider, &owner.account_uuid)
327        && existing.label != key.label
328    {
329        return Err(Error::AlreadyEnrolled {
330            tool: key.provider,
331            email: owner.email.clone(),
332            label: existing.key().typed(),
333        });
334    }
335    Ok(())
336}
337
338fn record_current(ctx: &Context, key: &Key, state: &mut State) -> Result<Enrolled> {
339    let which = key.provider;
340    let label = key.label.as_str();
341    // Through the store itself rather than the provider's reading of it, so a locked
342    // keychain says so in the store's own words instead of reading as a strange login.
343    let store = super::live_store(ctx, which)?;
344    let live =
345        store::read(&store.chain, &store.service)?.ok_or_else(|| nothing_signed_in(ctx, which))?;
346    // A document with no account in it is nobody signed in: Claude Code's after a
347    // `/logout` still holds the machine's MCP tokens.
348    match provider::of(which).slice(&live) {
349        Err(provider::ProviderError::NoLogin { .. }) => return Err(nothing_signed_in(ctx, which)),
350        Err(other) => return Err(super::shape(which, other)),
351        Ok(_) => {}
352    }
353    let owner = identify_document(ctx, which, &live)?;
354    claim(state, key, &owner)?;
355    let existing = state.get(key);
356    let parked = existing.and_then(|a| a.parked.clone());
357    // Enrolling the account that is signed in is using it.
358    let last_used_at = Some(ctx.now());
359    state.upsert(account(which, label, &owner, parked, last_used_at, &live));
360    state.set_active(which, Some(label.to_string()));
361    state::save(ctx, state)?;
362    Ok(Enrolled::Current { email: owner.email })
363}
364
365/// Enrol the account a sign-in produced: put in use when it is the account signed in now,
366/// and parked when it is any other.
367///
368/// Somebody signs in again to the account in use because its login is broken or about to
369/// lapse. Parked, the new login left the tool on the old one, and the next switch away
370/// parked the old one over it.
371fn from_sign_in(
372    ctx: &Context,
373    key: &Key,
374    state: &mut State,
375    login: &SignIn,
376) -> Result<(Enrolled, Vec<Warning>)> {
377    let owner = identify_document(ctx, login.provider, &login.document)?;
378    claim(state, key, &owner)?;
379    match signed_in_now(ctx, key.provider, &owner) {
380        InUse::Theirs(live, first) => {
381            install_signed_in(ctx, key, state, login, &owner, &live, &first)
382        }
383        InUse::NotTheirs => park_signed_in(ctx, key, state, login, &owner),
384        InUse::Untold(why) => {
385            // Somebody signing in to the account pitboard last saw in use most likely wants
386            // its broken login replaced, and parking is not that, so it is said.
387            let last_in_use = state.active_for(key.provider) == Some(key.label.as_str());
388            let (enrolled, mut warnings) = park_signed_in(ctx, key, state, login, &owner)?;
389            if last_in_use {
390                warnings.push(Warning::SignInParkedNotInUse {
391                    tool: key.provider,
392                    label: state.typed(key),
393                    why: untold(&why),
394                });
395            }
396            Ok((enrolled, warnings))
397        }
398    }
399}
400
401/// Whose the tool's live login is, as far as a sign-in to `owner`'s account needs to know.
402///
403/// Read the way a switch reads it. Writing over a login whose account is not known could
404/// lose that account's only login, so only a login known to be `owner`'s is written over.
405enum InUse {
406    /// `owner`'s: where it is, and what it held.
407    Theirs(provider::LiveStore, Value),
408    /// Another account's, or nobody's.
409    NotTheirs,
410    /// It could not be read, or its account could not be told, for this reason.
411    Untold(Error),
412}
413
414fn signed_in_now(ctx: &Context, which: ProviderId, owner: &Owner) -> InUse {
415    let read = super::live_store(ctx, which)
416        .and_then(|live| super::read_live(ctx, which, &live).map(|(_, first)| (live, first)));
417    let (live, first) = match read {
418        Ok(read) => read,
419        Err(Error::LiveCredentialAbsent { .. }) => return InUse::NotTheirs,
420        Err(other) => return InUse::Untold(other),
421    };
422    match identify_document(ctx, which, &first) {
423        Ok(found) if found.account_uuid == owner.account_uuid => InUse::Theirs(live, first),
424        Ok(_) => InUse::NotTheirs,
425        Err(e) => InUse::Untold(e),
426    }
427}
428
429/// Why the login in use could not be told, in a few words. The warning it goes into says
430/// what to do, and an error's own advice would be about something else.
431fn untold(error: &Error) -> String {
432    match error {
433        Error::SessionExpired { tool } => format!("{} refused its access token", tool.service()),
434        Error::IdentityUnverifiable { detail, .. }
435        | Error::LiveCredentialShapeUnexpected { detail, .. } => detail.clone(),
436        Error::LiveStoreUnsupported { reason, .. } => reason.clone(),
437        Error::LiveCredentialElsewhere { email } => {
438            format!("its config names {email}, and pitboard cannot find that login")
439        }
440        Error::Store(e) => e.to_string(),
441        other => other.code().replace('_', " "),
442    }
443}
444
445/// Put the new login of the account signed in now in place of its old one, under the rules
446/// a switch writes by, and record the account as `record_current` does. Nothing is parked,
447/// and a park the account already holds is kept. The old login is dropped, which is what
448/// the tool's own sign-in does to the login it replaces.
449fn install_signed_in(
450    ctx: &Context,
451    key: &Key,
452    state: &mut State,
453    login: &SignIn,
454    owner: &Owner,
455    live: &provider::LiveStore,
456    first: &Value,
457) -> Result<(Enrolled, Vec<Warning>)> {
458    let which = key.provider;
459    let name = state.typed(key);
460    // Said as a sign-in's failure rather than a switch's: the new login goes with the
461    // sign-in, so nothing was lost is not true of it, and the way on is signing in again.
462    let not_kept = |detail: String| Error::SignInNotKept {
463        tool: which,
464        label: name.clone(),
465        detail,
466    };
467    let slice = provider::of(which)
468        .slice(&login.document)
469        .map_err(|e| super::shape(which, e))?;
470    let Readied {
471        guard,
472        before_raw,
473        next,
474        on_the_command_line,
475        ..
476    } = super::ready(ctx, which, live, first, &owner.account_uuid, &slice, &name).map_err(|e| {
477        match e {
478            Error::SignedInAccountChanged => not_kept(format!(
479                "{} was signed in to another account meanwhile",
480                which.name()
481            )),
482            other => other,
483        }
484    })?;
485
486    let written = on_the_command_line.into_iter().collect::<Vec<_>>();
487    match super::install_with(
488        which,
489        |body| store::write_raw(&live.chain, &live.service, body),
490        || store::read_raw(&live.chain, &live.service),
491        &next,
492        &before_raw,
493        &name,
494        &name,
495    ) {
496        Ok(()) => {}
497        // The old login is where it was, and the new one goes with the sign-in.
498        Err(Error::SwitchRolledBack { detail, .. }) => return Err(not_kept(detail)),
499        Err(Error::SwitchUnverified { detail, .. } | Error::SwitchCorrupted { detail, .. }) => {
500            return Err(not_installed(
501                ctx, key, state, login, owner, detail, written,
502            ));
503        }
504        Err(other) => return Err(other),
505    }
506    crate::fault::point("enroll.installed");
507
508    // Read back as a switch reads back: a write that landed has not necessarily held.
509    let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
510    let lost = match super::holds(which, live) {
511        Ok(true) => None,
512        Ok(false) => Some("it was gone again before pitboard finished".to_string()),
513        Err(unreadable) => Some(unreadable.to_string()),
514    };
515    if let Some(detail) = lost {
516        return Err(not_installed(
517            ctx, key, state, login, owner, detail, written,
518        ));
519    }
520
521    let again = state.get(key).is_some();
522    let parked = state.get(key).and_then(|a| a.parked.clone());
523    state.upsert(account(
524        which,
525        &key.label,
526        owner,
527        parked,
528        Some(ctx.now()),
529        &login.document,
530    ));
531    state.set_active(which, Some(key.label.clone()));
532    state::save(ctx, state)?;
533    crate::fault::point("enroll.recorded");
534    drop(guard);
535
536    // A session of a tool that never reads its login again goes on with the old one, and
537    // writes it back over the new one when it refreshes.
538    let still_running = super::running_sessions(ctx, which).map(|(program, count)| {
539        Warning::SessionsKeepTheOldLogin {
540            program,
541            count,
542            label: name.clone(),
543        }
544    });
545    let warnings = written
546        .into_iter()
547        .chain(still_running)
548        .chain(lock_lost.then_some(Warning::LockCompromised { tool: which }))
549        .collect();
550    Ok((
551        Enrolled::InUse {
552            email: owner.email.clone(),
553            again,
554        },
555        warnings,
556    ))
557}
558
559/// A new login that could not be confirmed in use, where the tool may be left without the
560/// old one too. The new one is the one copy of the account's login known to be good, so it
561/// is parked, as a sign-in of any other account would be, before the failure is reported.
562/// Where the write landed and could not be read back, the slot holds the new login as well:
563/// no renewal spends that copy, and the next change that can read the slot drops it.
564///
565/// What writing it warned about is carried on the error with what parking it warns about,
566/// because both happened whatever became of them.
567fn not_installed(
568    ctx: &Context,
569    key: &Key,
570    state: &mut State,
571    login: &SignIn,
572    owner: &Owner,
573    detail: String,
574    mut warnings: Vec<Warning>,
575) -> Error {
576    let parked = match park_signed_in(ctx, key, state, login, owner) {
577        Ok((_, parking)) => {
578            warnings.extend(parking);
579            true
580        }
581        Err(_) => false,
582    };
583    Error::SignInNotInstalled {
584        tool: key.provider,
585        label: state.typed(key),
586        detail,
587        parked,
588        warnings,
589    }
590}
591
592fn park_signed_in(
593    ctx: &Context,
594    key: &Key,
595    state: &mut State,
596    login: &SignIn,
597    owner: &Owner,
598) -> Result<(Enrolled, Vec<Warning>)> {
599    let label = key.label.as_str();
600    let slice = provider::of(login.provider)
601        .slice(&login.document)
602        .map_err(|e| super::shape(login.provider, e))?;
603    let parking = park::price(
604        ctx,
605        login.provider,
606        &key.typed(),
607        &park::service_name(&owner.account_uuid, ctx.now_millis()),
608        &slice,
609    )?;
610    let service = park::reserve(ctx, &owner.account_uuid)?;
611    let fresh = park::store_at(ctx, key.provider, &service, &slice)?;
612    // The window the roadmap named: the login is in the vault and nothing on the machine
613    // says so yet.
614    crate::fault::point("enroll.park_stored");
615    let existing = state.get(key);
616    let previous = existing.and_then(|a| a.parked.clone());
617    let renewed = existing.is_some();
618    let last_used_at = existing.and_then(|a| a.last_used_at);
619    state.upsert(account(
620        login.provider,
621        label,
622        owner,
623        previous,
624        last_used_at,
625        &login.document,
626    ));
627    state.park(key, fresh);
628    // Unrecorded, the new login would be an item nothing refers to, never deleted.
629    state::save(ctx, state).inspect_err(|_| {
630        let _ = store::vault_delete(ctx, &service);
631    })?;
632    crate::fault::point("enroll.park_recorded");
633    purge(ctx, state);
634    let email = owner.email.clone();
635    let enrolled = if renewed {
636        Enrolled::Renewed { email }
637    } else {
638        Enrolled::SignedIn { email }
639    };
640    Ok((enrolled, parking.into_iter().collect()))
641}
642
643/// What pitboard records about a newly enrolled account.
644///
645/// For Claude Code, only what Anthropic just confirmed: leaving the rest out makes Claude
646/// Code fetch its own profile after a switch rather than trust a copy pitboard wrote. For
647/// Codex there is no such cache to correct, and what is kept instead is what its own login
648/// already said, which costs nothing to read and explains a limit somebody is surprised by.
649fn account(
650    which: ProviderId,
651    label: &str,
652    owner: &Owner,
653    parked: Option<Park>,
654    last_used_at: Option<i64>,
655    login: &Value,
656) -> Account {
657    let detail = match which {
658        ProviderId::Claude => state::Detail::Claude {
659            organization_uuid: owner.organization_uuid.clone(),
660            oauth_account: json!({
661                "accountUuid": owner.account_uuid,
662                "emailAddress": owner.email,
663                "organizationUuid": owner.organization_uuid,
664            }),
665        },
666        ProviderId::Codex => {
667            let claims = login["tokens"]["id_token"]
668                .as_str()
669                .and_then(crate::provider::jwt::claims)
670                .unwrap_or(Value::Null);
671            let openai = "https://api.openai.com/auth";
672            state::Detail::Codex {
673                workspace_id: Some(owner.organization_uuid.clone()).filter(|id| !id.is_empty()),
674                plan: crate::provider::jwt::claim(&claims, &[openai, "chatgpt_plan_type"])
675                    .map(str::to_owned),
676            }
677        }
678    };
679    Account {
680        last_used_at,
681        label: label.to_string(),
682        account_uuid: owner.account_uuid.clone(),
683        email: owner.email.clone(),
684        parked,
685        detail,
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::api::scripted::Trouble;
693    use crate::store::memory::Fault;
694    use crate::switch::harness::{
695        Machine, NOW, codex_id, codex_login, codex_machine, hold, login_of, machine, oauth, renews,
696        signed_in,
697    };
698    use crate::switch::{Due, Outcome, renew_due, settle, switch};
699    use crate::time::FixedClock;
700    use std::sync::Arc;
701    use std::time::{Duration, Instant};
702
703    type Make = fn(&str) -> Machine;
704    /// A machine of each tool: `here` signed in, `there` parked and ready.
705    const MACHINES: [(&str, Make); 2] = [("claude", machine), ("codex", codex_machine)];
706
707    /// Whether the login in use is the one this refresh token belongs to.
708    fn in_use(m: &Machine, refresh: &str) -> bool {
709        m.live().is_some_and(|live| {
710            provider::of(m.which).fingerprint(&live) == store::fingerprint(refresh)
711        })
712    }
713
714    /// Enrols what a sign-in left under `label`, the way the next command would.
715    fn enrolled_as(m: &Machine, label: &str, login: SignIn) -> Result<(Enrolled, Vec<Warning>)> {
716        let settled = settle(&m.ctx, Some(m.which)).expect("nothing to recover").0;
717        enroll(settled, &m.key(label), Some(login))
718    }
719
720    fn park_of(m: &Machine, label: &str) -> Option<Park> {
721        state::load(&m.ctx)
722            .expect("state")
723            .get(&m.key(label))
724            .and_then(|a| a.parked.clone())
725    }
726
727    /// Somebody signs in again to the account in use, whose login is broken or about to
728    /// lapse. The new login is the one the tool uses from now on, and nothing is parked:
729    /// a park of the account in use is a copy the next switch away would only replace.
730    #[test]
731    fn signing_in_again_to_the_account_in_use_puts_the_new_login_in_use() {
732        for (tool, make) in MACHINES {
733            let m = make("again-in-use");
734            let vault = m.mem.vault().services();
735
736            let (enrolled, _) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
737                .unwrap_or_else(|e| panic!("{tool}: {e}"));
738
739            assert!(
740                matches!(
741                    enrolled,
742                    Enrolled::InUse { ref email, again: true } if email == "here@example.com"
743                ),
744                "{tool}: {enrolled:?}"
745            );
746            assert!(
747                in_use(&m, "here-refresh-2"),
748                "{tool}: the new login is in use"
749            );
750            assert_eq!(m.mem.vault().services(), vault, "{tool}: nothing is parked");
751            assert!(park_of(&m, "here").is_none(), "{tool}");
752            let state = state::load(&m.ctx).expect("state");
753            assert_eq!(state.active_for(m.which), Some("here"), "{tool}");
754            assert_eq!(
755                state.get(&m.key("here")).and_then(|a| a.last_used_at),
756                Some(NOW),
757                "{tool}: signing in to the account in use is using it"
758            );
759            hold(&m, &format!("{tool}, after signing in again"));
760        }
761    }
762
763    /// A park the account in use already holds, from before it was signed in to with the
764    /// tool itself, is kept as it is: this sign-in is about the login in use.
765    #[test]
766    fn signing_in_again_to_the_account_in_use_keeps_the_park_it_holds() {
767        for (tool, make) in MACHINES {
768            let m = make("again-keeps-park");
769            let (uuid, older) = match m.which {
770                ProviderId::Claude => ("here".to_string(), oauth("here-older", 30)),
771                ProviderId::Codex => (codex_id("here"), codex_login("here", "here-older")),
772            };
773            let service = park::reserve(&m.ctx, &uuid).expect("a free name");
774            let held = park::store_at(&m.ctx, m.which, &service, &older).expect("parked");
775            let mut state = state::load(&m.ctx).expect("state");
776            state.park(&m.key("here"), held.clone());
777            state::save(&m.ctx, &state).expect("saved");
778            let vault = m.mem.vault().services();
779
780            enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
781                .unwrap_or_else(|e| panic!("{tool}: {e}"));
782
783            assert!(in_use(&m, "here-refresh-2"), "{tool}");
784            assert_eq!(
785                park_of(&m, "here"),
786                Some(held),
787                "{tool}: the park is untouched"
788            );
789            assert_eq!(m.mem.vault().services(), vault, "{tool}");
790            hold(&m, &format!("{tool}, after signing in again beside a park"));
791        }
792    }
793
794    /// The failure this replaces: the new login was parked beside the old, and the next
795    /// switch away parked the old one over it, which for Codex could be a login whose
796    /// chain was already revoked. Now the switch parks what is in use, the new login.
797    #[test]
798    fn the_next_switch_away_parks_the_new_login_not_the_old() {
799        for (tool, make) in MACHINES {
800            let m = make("again-then-switch");
801            enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
802                .unwrap_or_else(|e| panic!("{tool}: {e}"));
803
804            let settled = settle(&m.ctx, Some(m.which)).expect("nothing to recover").0;
805            let (outcome, _) = switch(settled, &m.key("there"))
806                .unwrap_or_else(|e| panic!("{tool}: the switch away: {e}"));
807            assert!(matches!(outcome, Outcome::Switched { .. }), "{tool}");
808
809            let parked = park_of(&m, "here").expect("the outgoing login is parked");
810            assert_eq!(
811                parked.refresh_fingerprint,
812                store::fingerprint("here-refresh-2"),
813                "{tool}: the new login is parked, not the one it replaced"
814            );
815            hold(&m, &format!("{tool}, after the switch away"));
816        }
817    }
818
819    /// Another account's sign-in is parked, and the account in use is left exactly as it
820    /// was, as before.
821    #[test]
822    fn a_sign_in_of_another_account_is_parked_beside_the_one_in_use() {
823        for (tool, make) in MACHINES {
824            let m = make("another-parked");
825            let live = m.live();
826
827            let (enrolled, _) = enrolled_as(&m, "third", signed_in(&m, "third", "third-refresh"))
828                .unwrap_or_else(|e| panic!("{tool}: {e}"));
829
830            assert!(
831                matches!(enrolled, Enrolled::SignedIn { .. }),
832                "{tool}: {enrolled:?}"
833            );
834            assert_eq!(m.live(), live, "{tool}: the login in use is untouched");
835            assert_eq!(
836                park_of(&m, "third").map(|p| p.refresh_fingerprint),
837                Some(store::fingerprint("third-refresh")),
838                "{tool}"
839            );
840            hold(&m, &format!("{tool}, after another account's sign-in"));
841        }
842    }
843
844    /// Writing over a login whose account nobody can name could lose that account's only
845    /// login, so a sign-in to `here` while the login in use cannot be told apart is parked
846    /// beside it, as it always was. For Claude Code that is Anthropic refusing or not
847    /// answering about the login in use; for Codex, a login whose ID token cannot be read.
848    #[test]
849    fn a_login_in_use_whose_account_cannot_be_told_is_not_written_over() {
850        let mut cases: Vec<(String, Machine)> = Vec::new();
851        for (name, trouble) in [
852            ("refused", Trouble::Unauthorized),
853            ("offline", Trouble::Offline),
854        ] {
855            let m = machine(&format!("untold-{name}"));
856            m.api.token_trouble("access-here-refresh", trouble);
857            cases.push((format!("claude, {name}"), m));
858        }
859        let m = codex_machine("untold");
860        let mut unreadable = codex_login("here", "here-refresh");
861        unreadable["tokens"]["id_token"] = "not a token".into();
862        m.sign_in(&unreadable);
863        cases.push(("codex".into(), m));
864
865        for (case, m) in cases {
866            let live = m.live();
867
868            let (enrolled, warnings) =
869                enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
870                    .unwrap_or_else(|e| panic!("{case}: {e}"));
871
872            assert!(
873                matches!(enrolled, Enrolled::Renewed { .. }),
874                "{case}: {enrolled:?}"
875            );
876            assert_eq!(m.live(), live, "{case}: the login in use is untouched");
877            assert_eq!(
878                park_of(&m, "here").map(|p| p.refresh_fingerprint),
879                Some(store::fingerprint("here-refresh-2")),
880                "{case}: the new login is parked"
881            );
882            // `here` is the account pitboard last saw in use, so the person most likely
883            // meant to replace its login, and is told that did not happen and why.
884            let said = warnings
885                .iter()
886                .find(|w| w.code() == "sign_in_parked_not_in_use")
887                .unwrap_or_else(|| panic!("{case}: {warnings:?}"))
888                .to_string();
889            assert!(
890                said.contains("parked the new login for `"),
891                "{case}: {said}"
892            );
893            assert!(
894                said.contains("goes on with the login it has"),
895                "{case}: {said}"
896            );
897            assert!(said.contains("sign in to `"), "{case}: {said}");
898        }
899    }
900
901    /// Only the account pitboard last saw in use is warned about. Signing in again to a
902    /// parked account renews its park whoever is signed in, as it always did.
903    #[test]
904    fn a_sign_in_to_a_parked_account_is_not_warned_about_the_login_in_use() {
905        let m = machine("untold-parked");
906        m.api
907            .token_trouble("access-here-refresh", Trouble::Unauthorized);
908
909        let (enrolled, warnings) =
910            enrolled_as(&m, "there", signed_in(&m, "there", "there-refresh-2"))
911                .unwrap_or_else(|e| panic!("{e}"));
912
913        assert!(matches!(enrolled, Enrolled::Renewed { .. }), "{enrolled:?}");
914        assert!(warnings.is_empty(), "{warnings:?}");
915    }
916
917    /// A browser often signs in to the session it already has, so a sign-in under a new
918    /// label can be the account signed in now before anything enrolled it. It is enrolled
919    /// with its new login in use, and says it was enrolled rather than signed in again.
920    #[test]
921    fn a_first_sign_in_to_the_account_in_use_enrols_it_with_the_new_login_in_use() {
922        for (tool, make) in MACHINES {
923            let m = make("first-in-use");
924            let mut state = state::load(&m.ctx).expect("state");
925            state.accounts.retain(|a| a.label != "here");
926            state.set_active(m.which, None);
927            state::save(&m.ctx, &state).expect("saved");
928
929            let (enrolled, _) =
930                enrolled_as(&m, "personal", signed_in(&m, "here", "here-refresh-2"))
931                    .unwrap_or_else(|e| panic!("{tool}: {e}"));
932
933            assert!(
934                matches!(enrolled, Enrolled::InUse { again: false, .. }),
935                "{tool}: {enrolled:?}"
936            );
937            assert!(in_use(&m, "here-refresh-2"), "{tool}");
938            let state = state::load(&m.ctx).expect("state");
939            assert_eq!(state.active_for(m.which), Some("personal"), "{tool}");
940            hold(&m, &format!("{tool}, after enrolling the account in use"));
941        }
942    }
943
944    /// Somebody signs the tool in to another account between the first read and the one
945    /// made under the tool's lock. Nothing is written over the account now signed in.
946    #[test]
947    fn the_account_in_use_changing_before_the_write_is_refused_and_nothing_is_written() {
948        for (tool, make) in MACHINES {
949            let m = make("changed-before-write");
950            let key = m.key("here");
951            let login = signed_in(&m, "here", "here-refresh-2");
952            let owner = identify_document(&m.ctx, m.which, &login.document).expect("whose");
953            let InUse::Theirs(live, first) = signed_in_now(&m.ctx, m.which, &owner) else {
954                panic!("{tool}: `here` is signed in");
955            };
956
957            m.sign_in(&login_of(&m, "other", "other-refresh"));
958            let vault = m.mem.vault().services();
959            let recorded = serde_json::to_value(state::load(&m.ctx).expect("state")).unwrap();
960            let mut state = state::load(&m.ctx).expect("state");
961            let refused =
962                install_signed_in(&m.ctx, &key, &mut state, &login, &owner, &live, &first)
963                    .expect_err("refused");
964
965            assert_eq!(refused.code(), "sign_in_not_kept", "{tool}: {refused}");
966            assert!(
967                refused
968                    .to_string()
969                    .contains("was signed in to another account meanwhile"),
970                "{tool}: {refused}"
971            );
972            assert!(in_use(&m, "other-refresh"), "{tool}: the other login stays");
973            assert_eq!(m.mem.vault().services(), vault, "{tool}");
974            assert_eq!(
975                serde_json::to_value(state::load(&m.ctx).expect("state")).unwrap(),
976                recorded,
977                "{tool}: and nothing is recorded"
978            );
979        }
980    }
981
982    /// A write that fails and changes nothing leaves the old login in use, and the new one
983    /// goes with the sign-in. That is said as a sign-in's failure: the new login was not
984    /// kept, and signing in again is the way on.
985    #[test]
986    fn a_new_login_that_cannot_be_written_leaves_the_old_one_in_use() {
987        for (tool, make) in MACHINES {
988            let m = make("again-write-fails");
989            let vault = m.mem.vault().services();
990            let login = signed_in(&m, "here", "here-refresh-2");
991            m.fault_live(Fault::FailWrite("refused".into()));
992
993            let failed = enrolled_as(&m, "here", login).expect_err("the write failed");
994
995            assert_eq!(failed.code(), "sign_in_not_kept", "{tool}: {failed}");
996            let said = failed.to_string();
997            assert!(said.contains("was not kept"), "{tool}: {said}");
998            assert!(
999                said.contains(&format!("pitboard enroll {} --sign-in", m.key("here"))),
1000                "{tool}: {said}"
1001            );
1002            assert!(!said.contains("nothing was lost"), "{tool}: {said}");
1003            assert!(in_use(&m, "here-refresh"), "{tool}");
1004            assert_eq!(m.mem.vault().services(), vault, "{tool}");
1005        }
1006    }
1007
1008    /// The new login was written and was gone again before it was read back. The tool may
1009    /// have no login for the account now, so the new one, the one copy known to be good, is
1010    /// parked rather than thrown away, and the failure says so.
1011    #[test]
1012    fn a_new_login_that_did_not_hold_is_parked_rather_than_lost() {
1013        for (tool, make) in MACHINES {
1014            let m = make("again-did-not-hold");
1015            let login = signed_in(&m, "here", "here-refresh-2");
1016            m.fault_live(Fault::DeletedAfterWrite);
1017
1018            let failed = enrolled_as(&m, "here", login).expect_err("it did not hold");
1019
1020            assert!(
1021                matches!(failed, Error::SignInNotInstalled { parked: true, .. }),
1022                "{tool}: {failed:?}"
1023            );
1024            assert!(
1025                failed.to_string().contains("parked, so it is not lost"),
1026                "{tool}: {failed}"
1027            );
1028            assert_eq!(
1029                park_of(&m, "here").map(|p| p.refresh_fingerprint),
1030                Some(store::fingerprint("here-refresh-2")),
1031                "{tool}"
1032            );
1033            hold(&m, &format!("{tool}, after a new login that did not hold"));
1034        }
1035    }
1036
1037    /// The new login was written and could not be read back: the store locked as it took
1038    /// the write, or at the read that follows. The slot holds the new login, and it is parked
1039    /// as well, one refresh token in two places that nothing records. A renewal of the copy
1040    /// would spend the token the tool is using, so none is made: the next renewal once the
1041    /// slot can be read drops the copy instead, and so does the next change.
1042    #[test]
1043    fn a_new_login_that_could_not_be_read_back_is_not_kept_beside_itself() {
1044        for (tool, make) in MACHINES {
1045            for locked in ["by the write", "after it"] {
1046                for next in ["renewal", "change"] {
1047                    let at = format!("{tool}, locked {locked}, then a {next}");
1048                    let m = make(&format!("unread-{}-{next}", locked.replace(' ', "-")));
1049                    let login = signed_in(&m, "here", "here-refresh-2");
1050                    let (store, service) = m.live_store();
1051                    let failed = if locked == "by the write" {
1052                        store.fault(&service, Fault::LocksAfterWrite);
1053                        enrolled_as(&m, "here", login)
1054                    } else {
1055                        crate::fault::meanwhile(
1056                            "enroll.installed",
1057                            move || store.fault(&service, Fault::Unreadable("locked".into())),
1058                            || enrolled_as(&m, "here", login),
1059                        )
1060                    }
1061                    .expect_err("it could not be read back");
1062                    assert!(
1063                        matches!(failed, Error::SignInNotInstalled { parked: true, .. }),
1064                        "{at}: {failed:?}"
1065                    );
1066                    assert_eq!(
1067                        park_of(&m, "here").map(|p| p.refresh_fingerprint),
1068                        Some(store::fingerprint("here-refresh-2")),
1069                        "{at}: parked as well"
1070                    );
1071
1072                    m.live_store().0.heal_all();
1073                    match next {
1074                        "renewal" => {
1075                            // Late enough that the copy's access token has lapsed, which is
1076                            // when a renewal would take it.
1077                            let later = m
1078                                .ctx
1079                                .clone()
1080                                .with_clock(Arc::new(FixedClock::at(NOW + 11 * 86_400)));
1081                            renews(&m, "here-refresh-2", "here-refresh-3");
1082                            let renewed = renew_due(&later, Due::ToBeAsked);
1083                            assert!(
1084                                renewed.iter().all(|(key, _)| key.label != "here"),
1085                                "{at}: {renewed:?}"
1086                            );
1087                        }
1088                        _ => {
1089                            settle(&m.ctx, None).expect("the next change");
1090                        }
1091                    }
1092                    assert!(in_use(&m, "here-refresh-2"), "{at}");
1093                    assert!(park_of(&m, "here").is_none(), "{at}: the copy is dropped");
1094                    hold(&m, &at);
1095                }
1096            }
1097        }
1098    }
1099
1100    /// A running `codex` keeps the login it started with and writes it back when it
1101    /// refreshes its token, so a new login put in use is said to need them restarted.
1102    /// Claude Code sessions read the new login by themselves, and nothing is said.
1103    #[test]
1104    fn sessions_running_on_the_old_login_are_counted_and_warned_about() {
1105        for (tool, make) in MACHINES {
1106            let m = make("again-sessions");
1107            m.mem.runs("codex", 2);
1108            m.mem.runs("claude", 2);
1109
1110            let (_, warnings) = enrolled_as(&m, "here", signed_in(&m, "here", "here-refresh-2"))
1111                .unwrap_or_else(|e| panic!("{tool}: {e}"));
1112
1113            let said = warnings
1114                .iter()
1115                .find(|w| w.code() == "sessions_keep_old_login");
1116            match m.which {
1117                ProviderId::Claude => assert!(said.is_none(), "{warnings:?}"),
1118                ProviderId::Codex => {
1119                    let text = said.expect("warned").to_string();
1120                    assert!(text.contains("2 `codex` sessions"), "{text}");
1121                    assert!(text.contains("`codex/here`'s old login"), "{text}");
1122                    assert!(text.contains("Quit them and start again"), "{text}");
1123                    assert!(text.contains("put the old login back"), "{text}");
1124                }
1125            }
1126            assert!(
1127                warnings
1128                    .iter()
1129                    .all(|w| w.code() != "sessions_still_running"),
1130                "{tool}: nobody switched away from anything: {warnings:?}"
1131            );
1132        }
1133    }
1134
1135    /// Codex prints its address and then nothing until the browser is done, so somebody
1136    /// pressing Cancel nearly always finds a reader waiting. The cancel must not wait with
1137    /// it, and stopping the tool must end the reading rather than leave it waiting forever.
1138    #[test]
1139    fn a_cancel_does_not_wait_on_a_tool_that_says_nothing() {
1140        let m = codex_machine("cancel");
1141        let pending = reserve_signin(&m.ctx, ProviderId::Codex).expect("reserved");
1142        let mut silent = std::process::Command::new("sleep");
1143        silent.arg("30");
1144        let watched = watch(silent, pending).expect("started");
1145        let said = watched.said();
1146        let reader = std::thread::spawn(move || said.next());
1147        std::thread::sleep(Duration::from_millis(100));
1148
1149        let asked = Instant::now();
1150        watched.cancel();
1151        assert_eq!(reader.join().expect("the reader"), None);
1152        assert!(
1153            asked.elapsed() < Duration::from_secs(10),
1154            "the cancel waited on the tool"
1155        );
1156        reserve_signin(&m.ctx, ProviderId::Codex)
1157            .expect("a cancelled sign-in lets the next one start");
1158    }
1159}