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