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
9mod enroll;
10mod forget;
11mod journal;
12mod rename;
13mod renew;
14mod uninstall;
15
16pub use enroll::{Enrolled, SignIn, WatchedSignIn, enroll, sign_in, sign_in_watched};
17pub use forget::forget;
18pub use journal::{Abandoned, Recovered, pending as interrupted};
19pub use rename::rename;
20pub use renew::{Renewal, renew_parked};
21pub use uninstall::{Removed, uninstall};
22
23use crate::context::Context;
24use crate::error::{Error, Result};
25use crate::service::Warning;
26use crate::state::{Account, Park, State};
27use crate::{api, claude, configfile, home, lock, park, state, store, time};
28use journal::{Journal, clear_journal, reconcile, write_journal};
29use serde_json::Value;
30use std::os::unix::fs::OpenOptionsExt;
31use std::path::PathBuf;
32
33/// Claude Code serves the credential from a 30 second cache whose clock restarts on every
34/// read or write, so a session picks up a swap within about 30 seconds of its last read
35/// rather than of its start. Measured over three runs on one machine: swapping at t+8, t+20
36/// and t+28 seconds took effect at t+32.3, t+33.5 and t+32.95 from process start.
37pub const ADOPTION_CEILING_SECONDS: u32 = 33;
38
39pub enum Outcome {
40    Switched {
41        from: String,
42        to: String,
43        parked: Park,
44    },
45    /// Not a failure: the state the caller asked for already holds.
46    AlreadyActive { label: String },
47}
48
49fn oauth_of(document: &Value) -> Result<Value> {
50    document
51        .get("claudeAiOauth")
52        .cloned()
53        .ok_or_else(|| Error::LiveCredentialShapeUnexpected {
54            detail: "it has no claudeAiOauth block".into(),
55        })
56}
57
58/// pitboard's state, held exclusively, with any interrupted switch already finished. Every
59/// command that changes state starts from one, so none acts on what a crash left behind.
60pub struct Settled {
61    _exclusive: std::fs::File,
62    state: State,
63    ctx: Context,
64}
65
66/// Throws away a record of an interrupted switch that cannot be finished, keeping every
67/// copy it names. Takes pitboard's own lock but never Claude Code's: it installs nothing.
68pub fn abandon(ctx: &Context) -> Result<Option<Abandoned>> {
69    if ctx.custom_oauth {
70        return Err(Error::CustomOauthEndpoint);
71    }
72    let _exclusive = exclusive(ctx)?;
73    let mut state = state::load(ctx)?;
74    journal::abandon(ctx, &mut state)
75}
76
77/// What recovery found is returned apart from the `Settled`, so it can be reported whether
78/// or not the command that follows succeeds.
79pub fn settle(ctx: &Context) -> Result<(Settled, Option<Recovered>)> {
80    // Under a custom OAuth endpoint the live login is in "Claude Code-custom-oauth-
81    // credentials", not the item pitboard reads. Acting would park nothing and restore
82    // into an item nobody reads, so pitboard does not act at all.
83    if ctx.custom_oauth {
84        return Err(Error::CustomOauthEndpoint);
85    }
86    let exclusive = exclusive(ctx)?;
87    let mut state = state::load(ctx)?;
88    let recovered = reconcile(ctx, &mut state)?;
89    purge(ctx, &mut state);
90    Ok((
91        Settled {
92            _exclusive: exclusive,
93            state,
94            ctx: ctx.clone(),
95        },
96        recovered,
97    ))
98}
99
100/// Delete what no account refers to any more. A failed save only leaves deleted names
101/// listed, and deleting a missing item succeeds, so a later run clears them.
102fn purge(ctx: &Context, state: &mut State) -> usize {
103    let listed = state.discarded.len();
104    let remaining = park::purge(ctx, state);
105    if remaining != listed {
106        let _ = state::save(ctx, state);
107    }
108    remaining
109}
110
111/// Makes pitboard runs exclusive of each other. A kernel lock, unlike the directory lock
112/// Claude Code's protocol requires around its own writes: the operating system releases it
113/// when a process ends, so there is no staleness rule for two runs to both satisfy.
114fn exclusive(ctx: &Context) -> Result<std::fs::File> {
115    let (file, path) = lock_file(ctx)?;
116    file.lock()
117        .map_err(|source| Error::HomeUnwritable { path, source })?;
118    Ok(file)
119}
120
121/// `exclusive` without waiting: `None` while another pitboard run holds it.
122fn try_exclusive(ctx: &Context) -> Option<std::fs::File> {
123    let (file, _) = lock_file(ctx).ok()?;
124    file.try_lock().ok()?;
125    Some(file)
126}
127
128fn lock_file(ctx: &Context) -> Result<(std::fs::File, PathBuf)> {
129    let path = home::dir(ctx).join("state.lock");
130    let fail = |source| Error::HomeUnwritable {
131        path: path.clone(),
132        source,
133    };
134    home::ensure(ctx).map_err(fail)?;
135    let file = std::fs::OpenOptions::new()
136        .create(true)
137        .truncate(false)
138        .write(true)
139        .mode(0o600)
140        .open(&path)
141        .map_err(fail)?;
142    Ok((file, path))
143}
144
145/// Who a live access token belongs to. When this cannot be answered, nothing moves: a login
146/// filed under a guessed account takes two accounts with it.
147fn identify(ctx: &Context, access_token: &str) -> Result<api::Owner> {
148    api::owner(ctx, access_token).map_err(|e| match e {
149        api::ApiError::Unauthorized => Error::SessionExpired,
150        other => Error::IdentityUnverifiable {
151            detail: other.to_string(),
152        },
153    })
154}
155
156fn access_token(document: &Value) -> Result<String> {
157    document["claudeAiOauth"]["accessToken"]
158        .as_str()
159        .map(str::to_owned)
160        .ok_or_else(|| Error::LiveCredentialShapeUnexpected {
161            detail: "it has no access token".into(),
162        })
163}
164
165pub fn switch(settled: Settled, label: &str) -> Result<(Outcome, Vec<Warning>)> {
166    let Settled {
167        _exclusive,
168        mut state,
169        ctx,
170    } = settled;
171    let ctx = &ctx;
172    let target = state
173        .get(label)
174        .cloned()
175        .ok_or_else(|| Error::AccountUnknown {
176            label: label.to_string(),
177            enrolled: state.labels(),
178        })?;
179
180    // Asked before taking Claude Code's lock so the round trip does not hold up its writes,
181    // then confirmed under the lock.
182    let service = claude::live_service(ctx);
183    let live = store::read(ctx, &service)?.ok_or(Error::LiveCredentialAbsent)?;
184    let identified_with = access_token(&live)?;
185    let outgoing = identify(ctx, &identified_with)?;
186
187    if outgoing.account_uuid == target.account_uuid {
188        if state.active.as_deref() != Some(label) {
189            state.active = Some(label.to_string());
190            state::save(ctx, &state)?;
191        }
192        return Ok((
193            Outcome::AlreadyActive {
194                label: label.to_string(),
195            },
196            Vec::new(),
197        ));
198    }
199    let outgoing_label = state
200        .by_uuid(&outgoing.account_uuid)
201        .map(|a| a.label.clone())
202        .ok_or_else(|| Error::LiveAccountNotEnrolled {
203            email: outgoing.email.clone(),
204        })?;
205    let held = target.parked.clone().ok_or_else(|| Error::NothingParked {
206        label: label.to_string(),
207    })?;
208    if !held.restorable_at(time::now()) {
209        return Err(Error::ParkedLoginExpired {
210            label: label.to_string(),
211        });
212    }
213    let incoming = park::load(ctx, label, &held)?;
214
215    let storage = PathBuf::from(claude::storage_dir(ctx)).join(".storage-write");
216    let guard = lock::acquire(&storage)?;
217
218    let before_raw = store::read_raw(ctx, &service)?.ok_or(Error::LiveCredentialAbsent)?;
219    let before: Value =
220        serde_json::from_str(&before_raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
221            detail: e.to_string(),
222        })?;
223    // A refresh keeps the account, so an unchanged access token needs no second round trip.
224    // A sign-in between the two reads would not keep it.
225    let now_token = access_token(&before)?;
226    if now_token != identified_with
227        && identify(ctx, &now_token)?.account_uuid != outgoing.account_uuid
228    {
229        return Err(Error::SignedInAccountChanged);
230    }
231
232    // Checked before anything is parked, so a switch that could never be written changes
233    // nothing.
234    let next = splice(&before, &incoming)?;
235    if store::too_large(ctx, &service, &next) {
236        let (bytes, limit) = store::cost(ctx, &service, &next).unwrap_or_default();
237        return Err(Error::CredentialTooLarge {
238            label: label.to_string(),
239            bytes,
240            limit,
241        });
242    }
243
244    let park_service = park::reserve(ctx, &outgoing.account_uuid)?;
245    write_journal(
246        ctx,
247        &Journal {
248            started_at: time::now(),
249            from_label: outgoing_label.clone(),
250            from_uuid: outgoing.account_uuid.clone(),
251            to_label: label.to_string(),
252            to_uuid: target.account_uuid.clone(),
253            park_service: park_service.clone(),
254            incoming_service: held.service.clone(),
255        },
256    )?;
257
258    // Until the incoming login is installed there is nothing for a later run to finish, so
259    // a failure here takes the record of intent away with it. A copy that was written but
260    // could not be recorded is deleted: nothing that survives would name it.
261    let parked = match park::store_at(ctx, &park_service, &oauth_of(&before)?) {
262        Ok(parked) => parked,
263        Err(e) => {
264            clear_journal(ctx);
265            return Err(e);
266        }
267    };
268    state.park(&outgoing_label, parked.clone());
269    if let Err(e) = state::save(ctx, &state) {
270        let _ = store::vault_delete(ctx, &parked.service);
271        clear_journal(ctx);
272        return Err(e);
273    }
274
275    if let Err(e) = install(ctx, &service, &next, &before_raw, &outgoing_label, label) {
276        if !only_copy_left(&e) {
277            state.discard(&parked.service);
278        }
279        state::save(ctx, &state)?;
280        clear_journal(ctx);
281        purge(ctx, &mut state);
282        return Err(e);
283    }
284    state.discard(&held.service);
285    state.active = Some(label.to_string());
286    state::save(ctx, &state)?;
287    drop(guard);
288
289    // Claude Code does not correct a stale config on its own; the next switch rewrites it.
290    let config_warning = update_config(
291        ctx,
292        &target,
293        &outgoing.account_uuid,
294        &outgoing.organization_uuid,
295    )
296    .err()
297    .map(Warning::ConfigNotUpdated);
298    let parks_pending = purge(ctx, &mut state);
299    clear_journal(ctx);
300
301    let warnings = config_warning
302        .into_iter()
303        .chain((parks_pending > 0).then_some(Warning::ParksPendingRemoval(parks_pending)))
304        .collect();
305    Ok((
306        Outcome::Switched {
307            from: outgoing_label,
308            to: label.to_string(),
309            parked,
310        },
311        warnings,
312    ))
313}
314
315/// After a failed install, whether the copy just parked is the outgoing account's only login.
316/// Once its old login is back in place, the copy is a second holder that Claude Code will
317/// rotate past; if it could not be put back, the copy is all that is left of it.
318fn only_copy_left(failure: &Error) -> bool {
319    matches!(failure, Error::SwitchCorrupted { .. })
320}
321
322/// Keys that belong to the account rather than to the machine. Claude Code deletes all of
323/// them along with the login on logout, so leaving one behind would hand the incoming
324/// account the outgoing account's device token or its second OAuth block. Measured in
325/// 2.1.278: `delete i.claudeAiOauth, delete i.organizationUuid, delete i.trustedDeviceToken,
326/// delete i.enterpriseGateway, delete i.designOauth`.
327const ACCOUNT_SCOPED: [&str; 4] = [
328    "organizationUuid",
329    "trustedDeviceToken",
330    "enterpriseGateway",
331    "designOauth",
332];
333
334/// The live document with the incoming login in place of the outgoing one, and nothing of
335/// the outgoing account left behind. Claude Code makes these keys again as it needs them,
336/// which is the state a logout and a fresh login would leave.
337fn splice(before: &Value, incoming: &Value) -> Result<String> {
338    let mut next = before.clone();
339    let document = next
340        .as_object_mut()
341        .ok_or_else(|| Error::LiveCredentialShapeUnexpected {
342            detail: "it is not a JSON object".into(),
343        })?;
344    document.insert("claudeAiOauth".into(), incoming.clone());
345    for key in ACCOUNT_SCOPED {
346        document.remove(key);
347    }
348    Ok(serde_json::to_string(&next).expect("a credential document stays serialisable"))
349}
350
351fn install(
352    ctx: &Context,
353    service: &str,
354    next: &str,
355    before_raw: &str,
356    from: &str,
357    to: &str,
358) -> Result<()> {
359    install_with(
360        |body| store::write_raw(ctx, service, body),
361        || store::read_raw(ctx, service),
362        next,
363        before_raw,
364        from,
365        to,
366    )
367}
368
369/// Write the new login, and if that fails, leave the old one in place. A failed write
370/// often changes nothing, so the slot is read back before deciding a rollback is needed,
371/// and only a rollback that also fails is reported as a lost login.
372fn install_with(
373    write: impl Fn(&str) -> std::result::Result<(), store::Error>,
374    read: impl Fn() -> std::result::Result<Option<String>, store::Error>,
375    next: &str,
376    before_raw: &str,
377    from: &str,
378    to: &str,
379) -> Result<()> {
380    let Err(failure) = write(next) else {
381        return Ok(());
382    };
383    let rolled_back = |detail: String| Error::SwitchRolledBack {
384        from: from.to_string(),
385        to: to.to_string(),
386        detail,
387    };
388    if matches!(read(), Ok(Some(now)) if now == before_raw) {
389        return Err(rolled_back(failure.to_string()));
390    }
391    match write(before_raw) {
392        Ok(()) => Err(rolled_back(failure.to_string())),
393        Err(rollback) => Err(Error::SwitchCorrupted {
394            from: from.to_string(),
395            to: to.to_string(),
396            detail: format!("{failure}; {rollback}"),
397        }),
398    }
399}
400
401/// Record the new identity in Claude Code's config. Runs after the login is in place, so
402/// the config never names an account before its login is live.
403fn update_config(
404    ctx: &Context,
405    target: &Account,
406    outgoing_account: &str,
407    outgoing_org: &str,
408) -> Result<()> {
409    let path = claude::config_file(ctx);
410    configfile::backup(ctx, &path)?;
411    let mut config = claude::load_config(ctx)?;
412    configfile::splice_identity(
413        &mut config,
414        &target.oauth_account,
415        &[outgoing_account, outgoing_org],
416    );
417    configfile::write(&path, &config)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    /// Claude Code deletes these along with the login on logout, so they belong to the
425    /// account. Left behind, the incoming account would present the outgoing account's
426    /// device token, and hold its second OAuth block.
427    #[test]
428    fn a_switch_leaves_nothing_of_the_outgoing_account() {
429        let before = serde_json::json!({
430            "claudeAiOauth": {"refreshToken": "old"},
431            "organizationUuid": "org-a",
432            "trustedDeviceToken": "device-of-a",
433            "enterpriseGateway": {"url": "https://gateway.example"},
434            "designOauth": {"refreshToken": "design-of-a"},
435            "somethingOfThisMachine": true,
436        });
437        let after: Value = serde_json::from_str(
438            &splice(&before, &serde_json::json!({"refreshToken": "new"})).expect("spliced"),
439        )
440        .expect("valid JSON");
441        assert_eq!(after["claudeAiOauth"]["refreshToken"], "new");
442        assert_eq!(after["somethingOfThisMachine"], true);
443        for key in ACCOUNT_SCOPED {
444            assert!(after.get(key).is_none(), "{key} was left behind");
445        }
446    }
447
448    use std::cell::RefCell;
449
450    fn failing(message: &str) -> store::Error {
451        store::Error::Write(message.into())
452    }
453
454    #[test]
455    fn a_successful_write_needs_no_rollback() {
456        let written = RefCell::new(Vec::new());
457        let result = install_with(
458            |b| {
459                written.borrow_mut().push(b.to_string());
460                Ok(())
461            },
462            || unreachable!(),
463            "new",
464            "old",
465            "a",
466            "b",
467        );
468        assert!(result.is_ok());
469        assert_eq!(*written.borrow(), vec!["new"]);
470    }
471
472    #[test]
473    fn a_failed_write_that_changed_nothing_is_not_reported_as_a_lost_login() {
474        let result = install_with(
475            |_| Err(failing("keychain locked")),
476            || Ok(Some("old".into())),
477            "new",
478            "old",
479            "a",
480            "b",
481        );
482        assert!(
483            matches!(result, Err(Error::SwitchRolledBack { .. })),
484            "the old login never left, so the user must not be told to sign in again"
485        );
486    }
487
488    #[test]
489    fn a_half_write_is_rolled_back() {
490        let slot = RefCell::new("old".to_string());
491        let result = install_with(
492            |b| {
493                if b == "new" {
494                    *slot.borrow_mut() = "garbled".into();
495                    Err(failing("interrupted"))
496                } else {
497                    *slot.borrow_mut() = b.to_string();
498                    Ok(())
499                }
500            },
501            || Ok(Some(slot.borrow().clone())),
502            "new",
503            "old",
504            "a",
505            "b",
506        );
507        assert!(matches!(result, Err(Error::SwitchRolledBack { .. })));
508        assert_eq!(
509            *slot.borrow(),
510            "old",
511            "the previous login must be back in place"
512        );
513    }
514
515    #[test]
516    fn only_a_failed_rollback_after_a_change_is_reported_as_corruption() {
517        let result = install_with(
518            |_| Err(failing("disk full")),
519            || Ok(Some("garbled".into())),
520            "new",
521            "old",
522            "a",
523            "b",
524        );
525        assert!(matches!(result, Err(Error::SwitchCorrupted { .. })));
526    }
527
528    #[test]
529    fn a_copy_is_kept_after_a_failed_install_only_when_it_is_all_that_is_left() {
530        let (from, to, detail) = ("a".to_string(), "b".to_string(), String::new());
531        assert!(!only_copy_left(&Error::SwitchRolledBack {
532            from: from.clone(),
533            to: to.clone(),
534            detail: detail.clone(),
535        }));
536        assert!(only_copy_left(&Error::SwitchCorrupted { from, to, detail }));
537    }
538
539    #[test]
540    fn a_credential_without_claude_ai_oauth_is_refused() {
541        assert!(oauth_of(&serde_json::json!({"slackTag": {}})).is_err());
542        assert!(oauth_of(&serde_json::json!({"claudeAiOauth": {"accessToken": "a"}})).is_ok());
543    }
544}