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