Skip to main content

pitboard_core/switch/
mod.rs

1//! Moving the signed-in identity from one enrolled account to another.
2//!
3//! Two rules set the order of every step. Which account the outgoing login belongs to is
4//! asked of Anthropic, never read from Claude Code's config, which can lag the login by a
5//! day: a login filed under the wrong account takes both accounts with it. And additive
6//! writes become durable before destructive ones, so a run that dies midway leaves a spare
7//! copy, never a missing one.
8
9use crate::provider;
10use crate::provider::ProviderId;
11mod adopt;
12mod auto;
13#[cfg(test)]
14mod crash;
15mod enroll;
16#[cfg(test)]
17mod foreign;
18mod forget;
19#[cfg(test)]
20pub(crate) mod harness;
21mod journal;
22#[cfg(test)]
23mod refusals;
24mod rename;
25pub(crate) mod renew;
26#[cfg(test)]
27mod two_tools;
28mod uninstall;
29
30pub use crate::pending::Reclaimed;
31pub use adopt::{Adopted, adopt};
32pub(crate) use auto::automatically;
33#[cfg(feature = "test-support")]
34pub(crate) use enroll::planted;
35pub(crate) use enroll::sign_in_watched_as;
36pub use enroll::{Enrolled, Said, SignIn, WatchedSignIn, enroll, sign_in, sign_in_watched};
37#[cfg(any(test, feature = "test-support"))]
38pub use enroll::{ScriptedSignIn, SignInScript};
39pub use forget::forget;
40pub use journal::{Abandoned, Recovered, pending as interrupted};
41pub(crate) use journal::{Asking, interrupted_tool};
42pub use rename::rename;
43pub use renew::{Due, Renewal, renew_due, renew_parked};
44pub use uninstall::{Removed, uninstall};
45
46use crate::context::Context;
47use crate::error::{Error, Result};
48use crate::service::{Permit, Warning};
49use crate::state::{Account, Key, Park, State};
50use crate::{api, fault, holder, home, lock, park, pending, state, store};
51use journal::{Journal, clear_journal, reconcile, write_journal};
52use serde_json::Value;
53use std::path::PathBuf;
54
55/// Claude Code serves the credential from a 30 second cache whose clock restarts on every
56/// read or write, so a session picks up a swap within about 30 seconds of its last read
57/// rather than of its start. Measured over three runs on one machine: swapping at t+8, t+20
58/// and t+28 seconds took effect at t+32.3, t+33.5 and t+32.95 from process start.
59pub const ADOPTION_CEILING_SECONDS: u32 = 33;
60
61#[derive(Debug)]
62pub enum Outcome {
63    Switched {
64        /// Which tool's login moved.
65        provider: ProviderId,
66        from: String,
67        to: String,
68        parked: Park,
69        /// When a session already running will be using the incoming login, as this tool
70        /// answers it. Carried rather than read from a constant, because the honest answer
71        /// for two of the three tools is that nothing follows until they are restarted.
72        adoption: provider::Adoption,
73    },
74    /// Not a failure: the state the caller asked for already holds.
75    AlreadyActive { label: String },
76}
77
78/// Pitboard's state, held exclusively, with any interrupted switch already finished. Every
79/// command that changes state starts from one, so none acts on what a crash left behind,
80/// and it carries the [`Permit`] it was settled with, which is what lets the change write.
81pub struct Settled {
82    _exclusive: std::fs::File,
83    state: State,
84    ctx: Context,
85    permit: Permit,
86}
87
88/// Throws away a record of an interrupted switch that cannot be finished, keeping every
89/// copy it names. Takes Pitboard's own lock but never Claude Code's: it installs nothing.
90///
91/// Under a custom Claude Code endpoint it gives up on another tool's switch, as a change to
92/// that tool settles one there, and refuses a Claude Code switch, which is settled nowhere.
93/// It refused every switch there, so a Codex switch that read as stuck could be neither
94/// finished nor given up on.
95pub fn abandon(ctx: &Context, permit: Permit) -> Result<Option<Abandoned>> {
96    refuse_custom_oauth(ctx, journal::interrupted_tool(ctx))?;
97    let _exclusive = exclusive(ctx, permit)?;
98    let mut state = state::load(ctx)?;
99    journal::abandon(ctx, permit, &mut state)
100}
101
102/// Under a custom OAuth endpoint Claude Code's live login is in "Claude Code-custom-oauth-
103/// credentials", not the item Pitboard reads. Acting on it would park nothing and restore
104/// into an item nobody reads, so Pitboard does not act on Claude Code at all: not a change
105/// to one of its accounts, not a change that could touch every tool's, and not the
106/// recovery of an interrupted Claude Code switch. A change to another tool's account goes
107/// ahead; its login is somewhere this setting does not move.
108fn refuse_custom_oauth(ctx: &Context, tool: Option<ProviderId>) -> Result<()> {
109    if !crate::settings::custom_oauth(ctx) {
110        return Ok(());
111    }
112    let claude = Some(ProviderId::Claude);
113    if tool.is_none() || tool == claude || journal::interrupted_tool(ctx) == claude {
114        return Err(Error::CustomOauthEndpoint);
115    }
116    Ok(())
117}
118
119/// What recovery found is returned apart from the `Settled`, so it can be reported whether
120/// or not the command that follows succeeds.
121///
122/// `tool` is the tool the change that follows is about, where it is about one.
123pub fn settle(
124    ctx: &Context,
125    permit: Permit,
126    tool: Option<ProviderId>,
127) -> Result<(Settled, Option<Recovered>)> {
128    refuse_custom_oauth(ctx, tool)?;
129    let exclusive = exclusive(ctx, permit)?;
130    let mut state = state::load(ctx)?;
131    let recovered = reconcile(ctx, permit, &mut state)?;
132    // After the journal has had its say, so a switch's own park is already accounted for.
133    pending::sweep(ctx, permit, &mut state)?;
134    drop_live_twins(ctx, permit, &mut state)?;
135    purge(ctx, permit, &mut state);
136    Ok((
137        Settled {
138            _exclusive: exclusive,
139            state,
140            ctx: ctx.clone(),
141            permit,
142        },
143        recovered,
144    ))
145}
146
147/// Why the next change would stop at an interrupted switch it cannot finish: the refusal
148/// that change would make, in its words. `None` where no switch is waiting, where the next
149/// change finishes or undoes it by itself, and where telling which would need the service
150/// and `asking` says not to ask it.
151///
152/// Worked out the way [`settle`] works it out, from the same reads and the same decision,
153/// and only read: no lock is taken, nothing is written, and the record stays for whatever
154/// finishes it or gives up on it. A switch under way in another run has a record too, and
155/// what it leaves at every step is what the next change could settle, ordinarily from the
156/// fingerprints alone, so it does not read as stuck.
157pub(crate) fn stuck(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
158    if !journal::pending(ctx) {
159        return None;
160    }
161    // Under a custom Claude Code endpoint no change comes to a Claude Code switch: each is
162    // refused over the endpoint first, as settling it and giving up on it are. A Codex
163    // switch is settled there, and given up on, as anywhere.
164    refuse_custom_oauth(ctx, journal::interrupted_tool(ctx)).ok()?;
165    journal::refusal(ctx, state, asking)
166}
167
168/// Ask the store itself what parked logins are on this machine, and resolve every one the
169/// state does not name. `settle` already does this from Pitboard's own list of names on
170/// every change; this is the thorough version, for a machine whose list was lost with its
171/// state file, or written by a version that kept no list.
172pub fn repair(settled: Settled) -> Result<pending::Reclaimed> {
173    let Settled {
174        _exclusive,
175        mut state,
176        ctx,
177        permit,
178    } = settled;
179    let reclaimed = pending::reclaim(&ctx, permit, &mut state)?;
180    purge(&ctx, permit, &mut state);
181    Ok(reclaimed)
182}
183
184/// Drop every park that is a copy of the login its tool has in use now, and say so in the
185/// state before anything deletes it. The journal records the one copy a switch makes on
186/// purpose; this finds the one nothing records, which a new login leaves when it was put in
187/// use and could not be read back, and was parked as well.
188fn drop_live_twins(ctx: &Context, permit: Permit, state: &mut State) -> Result<()> {
189    let twins = park::live_twins(ctx, state);
190    if twins.is_empty() {
191        return Ok(());
192    }
193    for service in &twins {
194        state.discard(service);
195    }
196    state::save(ctx, permit, state)
197}
198
199/// Delete what no account refers to any more. A failed save only leaves deleted names
200/// listed, and deleting a missing item succeeds, so a later run clears them.
201fn purge(ctx: &Context, permit: Permit, state: &mut State) -> usize {
202    let listed = state.discarded.len();
203    let remaining = park::purge(ctx, permit, state);
204    if remaining != listed {
205        let _ = state::save(ctx, permit, state);
206    }
207    remaining
208}
209
210/// Makes Pitboard runs exclusive of each other. A kernel lock, unlike the directory lock
211/// Claude Code's protocol requires around its own writes: the operating system releases it
212/// when a process ends, so there is no staleness rule for two runs to both satisfy.
213fn exclusive(ctx: &Context, permit: Permit) -> Result<std::fs::File> {
214    let (file, path) = lock_file(ctx, permit)?;
215    file.lock()
216        .map_err(|source| Error::HomeUnwritable { path, source })?;
217    Ok(file)
218}
219
220/// `exclusive` without waiting: `None` while another Pitboard run holds it.
221fn try_exclusive(ctx: &Context, permit: Permit) -> Option<std::fs::File> {
222    let (file, _) = lock_file(ctx, permit).ok()?;
223    file.try_lock().ok()?;
224    Some(file)
225}
226
227fn lock_file(ctx: &Context, permit: Permit) -> Result<(std::fs::File, PathBuf)> {
228    let path = home::dir(ctx).join("state.lock");
229    let fail = |source| Error::HomeUnwritable {
230        path: path.clone(),
231        source,
232    };
233    home::ensure(ctx, permit).map_err(fail)?;
234    let file = crate::host::fs::open_private_lock(permit, &path).map_err(fail)?;
235    Ok((file, path))
236}
237
238/// Whose login this document holds. When this cannot be answered, nothing moves: a login
239/// filed under a guessed account takes two accounts with it.
240///
241/// Through the provider, because the answer costs a network round trip for Claude Code and
242/// nothing at all for Codex, whose login carries a signed token naming the account. The
243/// caller wants the answer and should not have to know which.
244pub(super) fn identify_document(
245    ctx: &Context,
246    which: ProviderId,
247    document: &Value,
248) -> Result<api::Owner> {
249    let credential = provider::Credential::new(which, document.clone());
250    provider::of(which)
251        .identify(ctx, &credential)
252        .map(api::Owner::from)
253        .map_err(|e| match e {
254            provider::ProviderError::Unauthorized => Error::SessionExpired { tool: which },
255            other @ (provider::ProviderError::ShapeUnexpected { .. }
256            | provider::ProviderError::Unsupported { .. }) => shape(which, other),
257            other => Error::IdentityUnverifiable {
258                tool: which,
259                cause: crate::error::Cause::of_provider(&other),
260                detail: other.to_string(),
261            },
262        })
263}
264
265/// Nothing is signed in to this tool, said the way the tool's own files explain it.
266///
267/// Nothing in any store Pitboard reads, and the tool's own record naming somebody as signed
268/// in, are two different situations. The second means Pitboard is looking in the wrong
269/// place, and writing a login there would put it where nobody reads.
270pub(super) fn nothing_signed_in(ctx: &Context, which: ProviderId) -> Error {
271    match provider::of(which).recorded_identity(ctx) {
272        Some(found) => Error::LiveCredentialElsewhere { email: found.email },
273        None => Error::LiveCredentialAbsent { tool: which },
274    }
275}
276
277/// Where this tool's live login is, or why Pitboard cannot act on it here.
278pub(super) fn live_store(ctx: &Context, which: ProviderId) -> Result<provider::LiveStore> {
279    provider::of(which).live(ctx).map_err(|e| shape(which, e))
280}
281
282/// The live login as it is stored, byte for byte, and as a document.
283///
284/// The bytes are kept because a failed write is judged by whether they changed, and a
285/// document read back through a parser would compare equal to one whose bytes had moved.
286fn read_live(
287    ctx: &Context,
288    which: ProviderId,
289    live: &provider::LiveStore,
290) -> Result<(String, Value)> {
291    let raw = store::read_raw(&live.chain, &live.service)?
292        .ok_or_else(|| nothing_signed_in(ctx, which))?;
293    let document = serde_json::from_str(&raw)
294        .map_err(|e| Error::Store(store::Error::Malformed(e.to_string())))?;
295    match provider::of(which).slice(&document) {
296        Err(provider::ProviderError::NoLogin { .. }) => Err(nothing_signed_in(ctx, which)),
297        Err(other) => Err(shape(which, other)),
298        Ok(_) => Ok((raw, document)),
299    }
300}
301
302pub fn switch(settled: Settled, key: &Key) -> Result<(Outcome, Vec<Warning>)> {
303    let Settled {
304        _exclusive,
305        state,
306        ctx,
307        permit,
308    } = settled;
309    switch_held(state, &ctx, permit, key, None)
310}
311
312/// [`switch`], for a caller that holds Pitboard's lock itself. Where `expected` names an
313/// account by its id, the switch is made only away from that account: one decided from what
314/// was known before the lock was held is refused, with nothing changed, once somebody else
315/// has switched since.
316fn switch_held(
317    mut state: State,
318    ctx: &Context,
319    permit: Permit,
320    key: &Key,
321    expected: Option<&str>,
322) -> Result<(Outcome, Vec<Warning>)> {
323    let label = &key.label;
324    let tool = provider::of(key.provider);
325    let target = state
326        .get(key)
327        .cloned()
328        .ok_or_else(|| Error::AccountUnknown {
329            label: key.typed(),
330            enrolled: state.labels(key.provider),
331        })?;
332    let live = live_store(ctx, key.provider)?;
333
334    // Asked before taking the tool's own lock so a round trip does not hold up its writes,
335    // then confirmed under the lock.
336    let (_, first) = read_live(ctx, key.provider, &live)?;
337    let outgoing = identify_document(ctx, key.provider, &first)?;
338
339    if target.owned_by(&outgoing) {
340        if state.active_for(key.provider) != Some(label.as_str()) {
341            state.set_active(key.provider, Some(label.to_string()));
342            state.used(key, ctx.now());
343            state::save(ctx, permit, &state)?;
344        }
345        return Ok((
346            Outcome::AlreadyActive {
347                label: state.typed(key),
348            },
349            Vec::new(),
350        ));
351    }
352    if expected.is_some_and(|expected| expected != state.id_of(key.provider, &outgoing)) {
353        return Err(Error::SwitchOvertaken);
354    }
355    let (outgoing_key, outgoing_id) = state
356        .account_of(key.provider, &outgoing)
357        .map(|account| (account.key(), account.id.clone()))
358        .ok_or_else(|| Error::LiveAccountNotEnrolled {
359            tool: key.provider,
360            who: crate::words::login(
361                key.provider,
362                &outgoing.email,
363                state
364                    .accounts
365                    .iter()
366                    .any(|a| a.provider() == key.provider && a.email == outgoing.email),
367            ),
368        })?;
369    let (from, to) = (state.typed(&outgoing_key), state.typed(key));
370    let held = target.parked.clone().ok_or_else(|| Error::NothingParked {
371        tool: key.provider,
372        label: to.clone(),
373    })?;
374    if !held.restorable_at(ctx.now()) {
375        return Err(Error::ParkedLoginExpired { label: to.clone() });
376    }
377    let incoming = park::load(ctx, key, &held)?;
378    // Asked before the tool's lock is taken, like the outgoing question, so the round trip
379    // does not hold up its writes.
380    let (held, incoming) = prove_incoming(ctx, permit, &mut state, key, &target, held, incoming)?;
381
382    let guard = write_lock(ctx, permit, key.provider)?;
383    let Readied {
384        before_raw,
385        before,
386        next,
387        on_the_command_line,
388    } = ready(ctx, key.provider, &live, &first, &outgoing, &incoming, &to)?;
389
390    // The outgoing login's park has a ceiling of its own on a machine whose vault is the
391    // keychain, and it is asked about now, while refusing still changes nothing. The name
392    // is the one `reserve` is about to make, give or take the millisecond, which is all
393    // the price depends on.
394    let parking = park::price(
395        ctx,
396        key.provider,
397        &from,
398        &park::service_name(&outgoing_id, ctx.now_millis()),
399        &tool.slice(&before).map_err(|e| shape(key.provider, e))?,
400    )?;
401    let park_service = park::reserve(ctx, permit, &outgoing_id)?;
402    write_journal(
403        ctx,
404        permit,
405        &Journal {
406            provider: key.provider,
407            started_at: ctx.now(),
408            from_label: outgoing_key.label.clone(),
409            from_id: outgoing_id,
410            to_label: label.to_string(),
411            to_id: target.id.clone(),
412            park_service: park_service.clone(),
413            incoming_service: held.service.clone(),
414            // Which side the live credential came from, answerable without asking anyone.
415            from_fingerprint: tool.fingerprint(&before),
416            to_fingerprint: held.refresh_fingerprint.clone(),
417            slot: Some(tool.slot(ctx)),
418        },
419    )?;
420    fault::point("switch.journal_written");
421
422    // Until the incoming login is installed there is nothing for a later run to finish, so
423    // a failure here takes the record of intent away with it. A copy that was written but
424    // could not be recorded is deleted: nothing that survives would name it.
425    let slice = tool.slice(&before).map_err(|e| shape(key.provider, e))?;
426    let parked = match park::store_at(ctx, permit, key.provider, &park_service, &slice) {
427        Ok(parked) => parked,
428        Err(e) => {
429            clear_journal(ctx, permit);
430            return Err(e);
431        }
432    };
433    fault::point("switch.park_stored");
434    state.park(&outgoing_key, parked.clone());
435    if let Err(e) = state::save(ctx, permit, &state) {
436        let _ = store::vault_delete(ctx, permit, &parked.service);
437        clear_journal(ctx, permit);
438        return Err(e);
439    }
440    fault::point("switch.park_recorded");
441
442    // For a tool whose own sign-out revokes whatever it finds stored, there must never be
443    // two usable copies of one account's login at rest. The copy is read back before
444    // anything overwrites the original, so a park that did not survive the write is found
445    // here, while the login it copies is still where it was.
446    if tool.park_semantics() == provider::ParkSemantics::MoveOnly
447        && store::vault_read(ctx, &parked.service)?.is_none()
448    {
449        state.release(&parked.service);
450        state::save(ctx, permit, &state)?;
451        clear_journal(ctx, permit);
452        return Err(Error::ParkedCredentialMissing { label: from });
453    }
454
455    // The live login is read once more before it is replaced. A tool that takes no write
456    // lock, which is Codex, can have rewritten it since it was read under nothing at all:
457    // a session still running from before the switch refreshing its token, which spends
458    // the chain just parked and leaves the only live copy of it in the file this write is
459    // about to replace. Nothing is installed over a login that moved. Where the one there
460    // now is still the outgoing account's, the park is a spent copy of it and is dropped;
461    // where it is anybody else's, or cannot be told, the park may be the outgoing account's
462    // only login and is kept. The window left is the rename itself.
463    let now = store::read_raw(&live.chain, &live.service);
464    if !matches!(&now, Ok(Some(now)) if *now == before_raw) {
465        let still_outgoing = now
466            .ok()
467            .flatten()
468            .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
469            .and_then(|document| identify_document(ctx, key.provider, &document).ok())
470            .is_some_and(|found| found.same_login(&outgoing));
471        if still_outgoing {
472            state.discard(&parked.service);
473        }
474        state::save(ctx, permit, &state)?;
475        clear_journal(ctx, permit);
476        purge(ctx, permit, &mut state);
477        return Err(Error::SignedInAccountChanged);
478    }
479
480    if let Err(e) = install_with(
481        key.provider,
482        |body| store::write_raw(&live.chain, permit, &live.service, body),
483        || store::read_raw(&live.chain, &live.service),
484        &next,
485        &before_raw,
486        &from,
487        &to,
488    ) {
489        // Nobody could say what the slot holds. Keep every copy, and keep the record of
490        // intent, so the next run with a store that answers finishes this or undoes it.
491        // Everything below deletes something or forgets something, and neither is a thing
492        // to do without knowing.
493        if matches!(e, Error::SwitchUnverified { .. }) {
494            return Err(e);
495        }
496        if !only_copy_left(&e) {
497            state.discard(&parked.service);
498        }
499        state::save(ctx, permit, &state)?;
500        clear_journal(ctx, permit);
501        purge(ctx, permit, &mut state);
502        return Err(e);
503    }
504    fault::point("switch.installed");
505
506    // The write landed. That is not the same as it having held. Measured in Claude Code
507    // 2.1.278, a `/logout` that has given up waiting deletes the credential with no lock
508    // held at all, which is the one write its lock does not exclude; and a lock that aged
509    // out while the machine slept lets the tool reclaim it and write underneath. Both cost
510    // one read to notice here, and cost a browser sign-in to discover later.
511    //
512    // Checked before the incoming copy is discarded, so finding it did not hold leaves both
513    // logins parked rather than neither.
514    let lock_lost = guard.as_ref().is_some_and(lock::Guard::compromised);
515    match holds(key.provider, &live) {
516        Ok(true) => {}
517        Ok(false) => {
518            clear_journal(ctx, permit);
519            return Err(Error::SwitchDidNotHold {
520                tool: key.provider,
521                from,
522                to,
523            });
524        }
525        Err(unreadable) => {
526            return Err(Error::SwitchUnverified {
527                tool: key.provider,
528                from,
529                to,
530                detail: unreadable.to_string(),
531            });
532        }
533    }
534
535    state.discard(&held.service);
536    state.set_active(key.provider, Some(label.to_string()));
537    state.used(key, ctx.now());
538    state::save(ctx, permit, &state)?;
539    fault::point("switch.recorded");
540    drop(guard);
541
542    // The tool does not correct what it caches about who is signed in on its own; the next
543    // switch rewrites it.
544    let outgoing_identity = provider::Identity {
545        account_id: outgoing.account_uuid.clone(),
546        email: outgoing.email.clone(),
547        group: Some(outgoing.organization_uuid.clone()).filter(|g| !g.is_empty()),
548    };
549    let cache_warning = tool
550        .after_switch(ctx, permit, &target, &outgoing_identity)
551        .err()
552        .map(Warning::ConfigNotUpdated);
553    fault::point("switch.config_updated");
554    let parks_pending = purge(ctx, permit, &mut state);
555    clear_journal(ctx, permit);
556
557    // A tool that never follows a switch on its own goes on using the outgoing account in
558    // everything of it already running. Said with what is running, because "restart it"
559    // means nothing to somebody who does not know one is open, with what makes each kind
560    // take the switch, and with the one thing not to do in any of them. Where nobody could
561    // tell what is running, that is said, with the same thing not to do.
562    let still_running = match still_holding(ctx, key.provider) {
563        StillHolding::Nothing => None,
564        StillHolding::These(holding) => Some(Warning::SessionsStillRunning {
565            from: from.clone(),
566            holding,
567        }),
568        StillHolding::Unknown => Some(Warning::SessionsUnknown {
569            tool: key.provider,
570            from: from.clone(),
571        }),
572    };
573    let warnings = on_the_command_line
574        .into_iter()
575        .chain(parking)
576        .chain(still_running)
577        .chain(lock_lost.then_some(Warning::LockCompromised { tool: key.provider }))
578        .chain(cache_warning)
579        .chain((parks_pending > 0).then_some(Warning::ParksPendingRemoval(parks_pending)))
580        .collect();
581    Ok((
582        Outcome::Switched {
583            provider: key.provider,
584            adoption: tool.adoption(),
585            from,
586            to,
587            parked,
588        },
589        warnings,
590    ))
591}
592
593/// A write to a tool's live login, made ready under the tool's own lock.
594struct Readied {
595    /// The login there now, byte for byte and as a document.
596    before_raw: String,
597    before: Value,
598    /// What goes in its place.
599    next: String,
600    on_the_command_line: Option<Warning>,
601}
602
603/// The lock `which`'s tool takes around its own writes to its live login, taken the way it
604/// takes it, where it takes one. The caller holds it from before it readies a write until it
605/// has recorded what it wrote.
606fn write_lock(ctx: &Context, permit: Permit, which: ProviderId) -> Result<Option<lock::Guard>> {
607    Ok(provider::of(which)
608        .write_lock(ctx)
609        .map(|dir| lock::acquire(permit, &dir))
610        .transpose()?)
611}
612
613/// Readies `incoming` to go in place of the login read earlier as `first`, which was
614/// `signed_in`'s, with the tool's write lock already held. `label` is the account
615/// `incoming` is.
616///
617/// Refuses, having written nothing, when somebody else is signed in by now, or when what
618/// would be written could never be.
619fn ready(
620    ctx: &Context,
621    which: ProviderId,
622    live: &provider::LiveStore,
623    first: &Value,
624    signed_in: &api::Owner,
625    incoming: &Value,
626    label: &str,
627) -> Result<Readied> {
628    let tool = provider::of(which);
629    let (before_raw, before) = read_live(ctx, which, live)?;
630    // A refresh keeps the account, so an unchanged share of the document needs no second
631    // question. A sign-in between the two reads would not keep it.
632    if tool.slice(&before).ok() != tool.slice(first).ok()
633        && !identify_document(ctx, which, &before)?.same_login(signed_in)
634    {
635        return Err(Error::SignedInAccountChanged);
636    }
637
638    // Checked before anything is parked or written, so a change that could never be written
639    // changes nothing.
640    let next = to_body(
641        tool.splice(&before, incoming)
642            .map_err(|e| shape(which, e))?,
643    );
644    // Asked once. The answer is about the backend that would take this write, so a login
645    // living in a fallback file is not told it has the keychain's ceiling.
646    let price = store::cost(&live.chain, &live.service, &next);
647    if price.is_some_and(store::Cost::refused) {
648        let price = price.expect("refused implies a ceiling");
649        return Err(Error::CredentialTooLarge {
650            tool: which,
651            label: label.to_string(),
652            bytes: price.needs,
653            limit: price.limit,
654        });
655    }
656    // Said once per write rather than hidden: the same bytes are visible to `ps` for the
657    // length of one `security` call, which is the only way to write a login this size.
658    let on_the_command_line =
659        price
660            .filter(|p| p.on_the_second_route())
661            .map(|p| Warning::WrittenOnTheCommandLine {
662                tool: which,
663                bytes: p.needs,
664                limit: p.limit,
665            });
666    Ok(Readied {
667        before_raw,
668        before,
669        next,
670        on_the_command_line,
671    })
672}
673
674/// Whether a login of the tool's shape is in its live slot, read back after a write.
675///
676/// The tool may have rotated the token it was just given, which keeps the account and
677/// changes the bytes: a login of its shape being there at all is the fact.
678fn holds(which: ProviderId, live: &provider::LiveStore) -> std::result::Result<bool, store::Error> {
679    let tool = provider::of(which);
680    store::read_raw(&live.chain, &live.service).map(|now| {
681        now.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
682            .is_some_and(|document| tool.slice(&document).is_ok())
683    })
684}
685
686/// What is running a tool whose running sessions keep the login they started with, which a
687/// change of that login leaves on the login they had.
688#[derive(Debug, Clone, PartialEq, Eq)]
689pub(crate) enum StillHolding {
690    /// Nothing is: no process runs the tool, or its sessions follow a change by themselves.
691    Nothing,
692    /// These are, by kind. Never empty.
693    These(Vec<holder::Holding>),
694    /// The process list could not be read, so nobody can say whether anything is. Said as
695    /// such, because taken for nothing it would tell somebody with sessions open that none
696    /// are.
697    Unknown,
698}
699
700/// What is running `which`'s tool with the login it started with, by kind, as the process
701/// list says.
702pub(crate) fn still_holding(ctx: &Context, which: ProviderId) -> StillHolding {
703    match provider::of(which).adoption() {
704        provider::Adoption::RestartRequired { program, holders } => {
705            match holder::find(ctx, program, holders) {
706                None => StillHolding::Unknown,
707                Some(holding) if holding.is_empty() => StillHolding::Nothing,
708                Some(holding) => StillHolding::These(holding),
709            }
710        }
711        provider::Adoption::PollingWithin(_) => StillHolding::Nothing,
712    }
713}
714
715/// Ask the service about the login going in, not only about the one coming out.
716///
717/// A switch used to ask twice about the login it was throwing away and never once about
718/// the one it was installing. If that account's refresh chain had been revoked, signed out
719/// elsewhere or refused by the service, the switch installed it, read it back, found a login
720/// there and reported success; the person discovered they were signed out the next time
721/// they ran the tool, with no login to go back to on either side.
722///
723/// A lapsed park is renewed rather than refused, which is the whole point of parking one.
724/// That is a write, so it happens here, before anything is parked, while both copies are
725/// still where they were.
726fn prove_incoming(
727    ctx: &Context,
728    permit: Permit,
729    state: &mut State,
730    key: &Key,
731    target: &Account,
732    held: Park,
733    incoming: Value,
734) -> Result<(Park, Value)> {
735    let label = state.typed(key);
736    if held.askable_at(ctx.now()) {
737        let credential = provider::Credential::new(key.provider, incoming.clone());
738        match provider::of(key.provider)
739            .verify(ctx, &credential)
740            .map(api::Owner::from)
741        {
742            Ok(found) if target.owned_by(&found) => return Ok((held, incoming)),
743            Ok(other) => {
744                return Err(Error::ParkedLoginBelongsElsewhere {
745                    label,
746                    who: crate::words::login(
747                        key.provider,
748                        &other.email,
749                        other.email == target.email,
750                    ),
751                });
752            }
753            // The access token has lapsed earlier than the recorded expiry said it would.
754            // Renewing settles it either way.
755            Err(provider::ProviderError::Unauthorized) => {}
756            Err(provider::ProviderError::ShapeUnexpected { detail, .. }) => {
757                return Err(Error::ParkedCredentialCorrupt { label, detail });
758            }
759            Err(e) => {
760                return Err(Error::IdentityUnverifiable {
761                    tool: key.provider,
762                    cause: crate::error::Cause::of_provider(&e),
763                    detail: e.to_string(),
764                });
765            }
766        }
767    }
768
769    // Lapsed, or refused as lapsed. Renew it and switch to what comes back.
770    let Some(fresh) = renew::renew_one(ctx, permit, state, key, &held)? else {
771        return Err(Error::IdentityUnverifiable {
772            tool: key.provider,
773            cause: crate::error::Cause::Unreachable,
774            detail: format!(
775                "`{label}`'s parked login needs renewing and {} did not answer",
776                key.provider.service()
777            ),
778        });
779    };
780    let document = park::load(ctx, key, &fresh)?;
781    Ok((fresh, document))
782}
783
784/// After a failed install, whether the copy just parked is the outgoing account's only login.
785/// Once its old login is back in place, the copy is a second holder that Claude Code will
786/// rotate past; if it could not be put back, the copy is all that is left of it.
787fn only_copy_left(failure: &Error) -> bool {
788    matches!(failure, Error::SwitchCorrupted { .. })
789}
790
791/// A provider saying a login is not the shape it keeps.
792///
793/// Only the shape errors reach the switch this way. Anything about the network keeps the
794/// code the step it happened in gives it, because "could not reach Anthropic while proving
795/// who the incoming login belongs to" and "could not reach Anthropic for usage" are the
796/// same failure and not the same problem.
797pub(super) fn shape(tool: ProviderId, error: provider::ProviderError) -> Error {
798    match error {
799        provider::ProviderError::ShapeUnexpected { detail, .. } => {
800            Error::LiveCredentialShapeUnexpected { tool, detail }
801        }
802        provider::ProviderError::Unsupported { reason, .. } => {
803            Error::LiveStoreUnsupported { tool, reason }
804        }
805        other => Error::LiveCredentialShapeUnexpected {
806            tool,
807            detail: other.to_string(),
808        },
809    }
810}
811
812/// A spliced document as bytes to write.
813fn to_body(document: Value) -> String {
814    serde_json::to_string(&document).expect("a credential document stays serialisable")
815}
816
817/// Write the new login, and if that fails, leave the old one in place.
818///
819/// A failed write often changes nothing, so the slot is read back before deciding a
820/// rollback is needed. That read-back has three answers, not two. It used to have two, and
821/// the missing one is the likeliest failure there is: on a machine whose keychain is
822/// locked, the write fails, the read-back fails too, "could not read" was taken to mean
823/// "the slot changed", a rollback was attempted, that failed as well, and the person was
824/// told Pitboard could not put their login back and they should sign in again. Nothing had
825/// been written and their login had never moved.
826fn install_with(
827    tool: ProviderId,
828    write: impl Fn(&str) -> std::result::Result<(), store::Error>,
829    read: impl Fn() -> std::result::Result<Option<String>, store::Error>,
830    next: &str,
831    before_raw: &str,
832    from: &str,
833    to: &str,
834) -> Result<()> {
835    let Err(failure) = write(next) else {
836        return Ok(());
837    };
838    let rolled_back = |detail: String| Error::SwitchRolledBack {
839        from: from.to_string(),
840        to: to.to_string(),
841        detail,
842    };
843    match read() {
844        // Unchanged. The write never landed and there is nothing to undo.
845        Ok(Some(now)) if now == before_raw => return Err(rolled_back(failure.to_string())),
846        // Could not tell. Change nothing further and keep every copy: the caller leaves its
847        // record of intent in place so a later run, with a store that answers, decides.
848        Err(unreadable) => {
849            return Err(Error::SwitchUnverified {
850                tool,
851                from: from.to_string(),
852                to: to.to_string(),
853                detail: format!("{failure}; {unreadable}"),
854            });
855        }
856        // Changed, or gone. Put back what was there.
857        _ => {}
858    }
859    match write(before_raw) {
860        Ok(()) => Err(rolled_back(failure.to_string())),
861        Err(rollback) => Err(Error::SwitchCorrupted {
862            tool,
863            from: from.to_string(),
864            to: to.to_string(),
865            detail: format!("{failure}; {rollback}"),
866        }),
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    use std::cell::RefCell;
875
876    fn failing(message: &str) -> store::Error {
877        store::Error::Write(message.into())
878    }
879
880    #[test]
881    fn a_successful_write_needs_no_rollback() {
882        let written = RefCell::new(Vec::new());
883        let result = install_with(
884            ProviderId::Claude,
885            |b| {
886                written.borrow_mut().push(b.to_string());
887                Ok(())
888            },
889            || unreachable!(),
890            "new",
891            "old",
892            "a",
893            "b",
894        );
895        assert!(result.is_ok());
896        assert_eq!(*written.borrow(), vec!["new"]);
897    }
898
899    #[test]
900    fn a_failed_write_that_changed_nothing_is_not_reported_as_a_lost_login() {
901        let result = install_with(
902            ProviderId::Claude,
903            |_| Err(failing("keychain locked")),
904            || Ok(Some("old".into())),
905            "new",
906            "old",
907            "a",
908            "b",
909        );
910        assert!(
911            matches!(result, Err(Error::SwitchRolledBack { .. })),
912            "the old login never left, so the user must not be told to sign in again"
913        );
914    }
915
916    #[test]
917    fn a_half_write_is_rolled_back() {
918        let slot = RefCell::new("old".to_string());
919        let result = install_with(
920            ProviderId::Claude,
921            |b| {
922                if b == "new" {
923                    *slot.borrow_mut() = "garbled".into();
924                    Err(failing("interrupted"))
925                } else {
926                    *slot.borrow_mut() = b.to_string();
927                    Ok(())
928                }
929            },
930            || Ok(Some(slot.borrow().clone())),
931            "new",
932            "old",
933            "a",
934            "b",
935        );
936        assert!(matches!(result, Err(Error::SwitchRolledBack { .. })));
937        assert_eq!(
938            *slot.borrow(),
939            "old",
940            "the previous login must be back in place"
941        );
942    }
943
944    /// The likeliest failure of all, and the one that used to produce the most alarming
945    /// message Pitboard has. A locked keychain fails the write, fails the read-back, and
946    /// would have failed the rollback too; "could not read" was taken to mean "the slot
947    /// changed", so the person was told their login could not be put back. Nothing had been
948    /// written and it had never moved.
949    #[test]
950    fn a_store_that_cannot_be_read_back_is_not_a_lost_login() {
951        let writes = RefCell::new(0);
952        let result = install_with(
953            ProviderId::Claude,
954            |_| {
955                *writes.borrow_mut() += 1;
956                Err(failing("the keychain is locked"))
957            },
958            || Err(store::Error::Unreadable("the keychain is locked".into())),
959            "new",
960            "old",
961            "a",
962            "b",
963        );
964        assert!(
965            matches!(result, Err(Error::SwitchUnverified { .. })),
966            "not knowing is its own answer, and must not read as a lost login"
967        );
968        assert_eq!(
969            *writes.borrow(),
970            1,
971            "and nothing further is written into a store that cannot be read"
972        );
973    }
974
975    #[test]
976    fn only_a_failed_rollback_after_a_change_is_reported_as_corruption() {
977        let result = install_with(
978            ProviderId::Claude,
979            |_| Err(failing("disk full")),
980            || Ok(Some("garbled".into())),
981            "new",
982            "old",
983            "a",
984            "b",
985        );
986        assert!(matches!(result, Err(Error::SwitchCorrupted { .. })));
987    }
988
989    #[test]
990    fn a_copy_is_kept_after_a_failed_install_only_when_it_is_all_that_is_left() {
991        let (from, to, detail) = ("a".to_string(), "b".to_string(), String::new());
992        assert!(!only_copy_left(&Error::SwitchRolledBack {
993            from: from.clone(),
994            to: to.clone(),
995            detail: detail.clone(),
996        }));
997        assert!(only_copy_left(&Error::SwitchCorrupted {
998            tool: ProviderId::Claude,
999            from,
1000            to,
1001            detail
1002        }));
1003    }
1004}