Skip to main content

pitboard_core/switch/
enroll.rs

1//! Bringing an account under pitboard's care.
2//!
3//! A parked copy is only safe if the live slot is replaced the moment it is taken; otherwise
4//! Claude Code keeps rotating the same token and the copy goes stale. So the account signed
5//! in now is recorded but not parked. Its first switch parks it at exactly that moment,
6//! and any other account is signed in inside a private directory, where the live slot is
7//! never touched and the vault is the new login's only holder.
8
9use super::{Error, Result, Settled, access_token, identify, oauth_of, purge};
10use crate::api::Owner;
11use crate::context::Context;
12use crate::state::{Account, Park, State};
13use crate::{claude, home, park, state, store};
14use serde_json::{Value, json};
15use std::fs::{File, OpenOptions, TryLockError};
16use std::os::unix::fs::OpenOptionsExt;
17use std::path::PathBuf;
18use std::process::Command;
19
20pub enum Enrolled {
21    /// The account signed in now, recorded without parking: its first switch parks it.
22    Current { email: String },
23    /// Another account, signed in privately and parked.
24    SignedIn { email: String },
25    /// An enrolled account signed in to again: its parked login is now the new one.
26    Renewed { email: String },
27}
28
29/// A login Claude Code stored for pitboard in a private directory, not yet enrolled. Dropping
30/// it deletes that directory and the credential Claude Code kept for it.
31pub struct SignIn {
32    dir: PathBuf,
33    document: Value,
34    ctx: Context,
35    _one_at_a_time: File,
36}
37
38impl Drop for SignIn {
39    fn drop(&mut self) {
40        let _ = store::discard_signin(&self.ctx, &self.dir);
41        let _ = std::fs::remove_dir_all(&self.dir);
42    }
43}
44
45/// Run Claude Code's own sign-in in a private directory, where the live login is never
46/// touched. It waits on a person in a browser, so it takes no lock but its own: a switch
47/// meanwhile goes ahead, and a second sign-in is refused rather than queued.
48pub fn sign_in(ctx: &Context) -> Result<SignIn> {
49    let home = home::ensure(ctx).map_err(|source| Error::HomeUnwritable {
50        path: home::dir(ctx),
51        source,
52    })?;
53    let lock_path = home.join("signin.lock");
54    let one_at_a_time = OpenOptions::new()
55        .create(true)
56        .truncate(false)
57        .write(true)
58        .mode(0o600)
59        .open(&lock_path)
60        .map_err(|source| Error::HomeUnwritable {
61            path: lock_path.clone(),
62            source,
63        })?;
64    match one_at_a_time.try_lock() {
65        Ok(()) => {}
66        Err(TryLockError::WouldBlock) => return Err(Error::SignInInProgress),
67        Err(TryLockError::Error(source)) => {
68            return Err(Error::HomeUnwritable {
69                path: lock_path,
70                source,
71            });
72        }
73    }
74
75    let dir = home.join("signin");
76    let _ = std::fs::remove_dir_all(&dir);
77    home::create_private(&dir).map_err(|source| Error::HomeUnwritable {
78        path: dir.clone(),
79        source,
80    })?;
81    let mut pending = SignIn {
82        dir,
83        document: Value::Null,
84        ctx: ctx.clone(),
85        _one_at_a_time: one_at_a_time,
86    };
87
88    // pitboard never sees the sign-in; it reads the login Claude Code stores once it is done.
89    // What Claude Code prints goes to stderr, so `--json` output stays one JSON line.
90    let finished = Command::new(&ctx.claude_program)
91        .args(["auth", "login"])
92        .env("CLAUDE_CONFIG_DIR", &pending.dir)
93        .env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR")
94        .stdout(std::io::stderr())
95        .status()
96        .map_err(|e| match e.kind() {
97            std::io::ErrorKind::NotFound => Error::ClaudeNotFound,
98            _ => Error::SignInIncomplete,
99        })?
100        .success();
101    if !finished {
102        return Err(Error::SignInIncomplete);
103    }
104    let raw = store::read_signin(ctx, &pending.dir)?.ok_or(Error::SignInIncomplete)?;
105    pending.document =
106        serde_json::from_str(&raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
107            detail: e.to_string(),
108        })?;
109    Ok(pending)
110}
111
112/// Enroll the account signed in now, or with `signed_in`, the one a sign-in just produced.
113pub fn enroll(settled: Settled, label: &str, signed_in: Option<SignIn>) -> Result<Enrolled> {
114    let Settled {
115        _exclusive,
116        mut state,
117        ctx,
118    } = settled;
119    match signed_in {
120        Some(login) => park_signed_in(&ctx, label, &mut state, &login),
121        None => record_current(&ctx, label, &mut state),
122    }
123}
124
125/// A label names one account for good: its own, or one not enrolled under another label.
126fn claim(state: &State, label: &str, owner: &Owner) -> Result<()> {
127    if let Some(taken) = state.get(label)
128        && taken.account_uuid != owner.account_uuid
129    {
130        return Err(Error::LabelTaken {
131            label: label.to_string(),
132            email: taken.email.clone(),
133        });
134    }
135    if let Some(existing) = state.by_uuid(&owner.account_uuid)
136        && existing.label != label
137    {
138        return Err(Error::AlreadyEnrolled {
139            email: owner.email.clone(),
140            label: existing.label.clone(),
141        });
142    }
143    Ok(())
144}
145
146fn record_current(ctx: &Context, label: &str, state: &mut State) -> Result<Enrolled> {
147    let live = store::read(ctx, &claude::live_service(ctx))?.ok_or(Error::LiveCredentialAbsent)?;
148    let owner = identify(ctx, &access_token(&live)?)?;
149    claim(state, label, &owner)?;
150    let parked = state.get(label).and_then(|a| a.parked.clone());
151    state.upsert(account(label, &owner, parked));
152    state.active = Some(label.to_string());
153    state::save(ctx, state)?;
154    Ok(Enrolled::Current { email: owner.email })
155}
156
157fn park_signed_in(
158    ctx: &Context,
159    label: &str,
160    state: &mut State,
161    login: &SignIn,
162) -> Result<Enrolled> {
163    let owner = identify(ctx, &access_token(&login.document)?)?;
164    claim(state, label, &owner)?;
165    let service = park::reserve(ctx, &owner.account_uuid)?;
166    let fresh = park::store_at(ctx, &service, &oauth_of(&login.document)?)?;
167    let existing = state.get(label);
168    let previous = existing.and_then(|a| a.parked.clone());
169    let renewed = existing.is_some();
170    state.upsert(account(label, &owner, previous));
171    state.park(label, fresh);
172    // Unrecorded, the new login would be an item nothing refers to, never deleted.
173    state::save(ctx, state).inspect_err(|_| {
174        let _ = store::vault_delete(ctx, &service);
175    })?;
176    purge(ctx, state);
177    Ok(if renewed {
178        Enrolled::Renewed { email: owner.email }
179    } else {
180        Enrolled::SignedIn { email: owner.email }
181    })
182}
183
184/// Only what Anthropic just confirmed. Leaving the rest out makes Claude Code fetch its own
185/// profile after a switch rather than trust a copy pitboard wrote.
186fn account(label: &str, owner: &Owner, parked: Option<Park>) -> Account {
187    Account {
188        label: label.to_string(),
189        account_uuid: owner.account_uuid.clone(),
190        email: owner.email.clone(),
191        organization_uuid: owner.organization_uuid.clone(),
192        oauth_account: json!({
193            "accountUuid": owner.account_uuid,
194            "emailAddress": owner.email,
195            "organizationUuid": owner.organization_uuid,
196        }),
197        parked,
198    }
199}