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.
48/// Takes the one-sign-in-at-a-time lock and prepares the private directory Claude Code will
49/// sign in to. Both the inherited and the watched sign-in start here.
50fn reserve_signin(ctx: &Context) -> Result<SignIn> {
51    let home = home::ensure(ctx).map_err(|source| Error::HomeUnwritable {
52        path: home::dir(ctx),
53        source,
54    })?;
55    let lock_path = home.join("signin.lock");
56    let one_at_a_time = OpenOptions::new()
57        .create(true)
58        .truncate(false)
59        .write(true)
60        .mode(0o600)
61        .open(&lock_path)
62        .map_err(|source| Error::HomeUnwritable {
63            path: lock_path.clone(),
64            source,
65        })?;
66    match one_at_a_time.try_lock() {
67        Ok(()) => {}
68        Err(TryLockError::WouldBlock) => return Err(Error::SignInInProgress),
69        Err(TryLockError::Error(source)) => {
70            return Err(Error::HomeUnwritable {
71                path: lock_path,
72                source,
73            });
74        }
75    }
76
77    let dir = home.join("signin");
78    // A sign-in that was killed rather than finished never ran its cleanup, so a login can
79    // be sitting in the scratch slot with nothing naming it. The directory is always the
80    // same one, so the slot is too, and this is the moment it can be cleared safely: the
81    // lock above means no other sign-in is using it.
82    let _ = store::discard_signin(ctx, &dir);
83    let _ = std::fs::remove_dir_all(&dir);
84    home::create_private(&dir).map_err(|source| Error::HomeUnwritable {
85        path: dir.clone(),
86        source,
87    })?;
88    Ok(SignIn {
89        dir,
90        document: Value::Null,
91        ctx: ctx.clone(),
92        _one_at_a_time: one_at_a_time,
93    })
94}
95
96pub fn sign_in(ctx: &Context) -> Result<SignIn> {
97    let mut pending = reserve_signin(ctx)?;
98    // pitboard never sees the sign-in; it reads the login Claude Code stores once it is done.
99    // What Claude Code prints goes to stderr, so `--json` output stays one JSON line.
100    let finished = login(ctx, &pending.dir)
101        .stdout(std::io::stderr())
102        .status()
103        .map_err(started)?
104        .success();
105    if !finished {
106        return Err(Error::SignInIncomplete);
107    }
108    pending.document = signed_in_document(ctx, &pending.dir)?;
109    Ok(pending)
110}
111
112/// Claude Code's own sign-in, pointed at a private directory so the live login is never
113/// touched. Measured in 2.1.278: it opens the browser itself and finishes through a
114/// loopback callback, printing progress with `stdout.write` and reading stdin only as the
115/// fallback for a pasted code. So it needs no terminal: pipes are enough.
116fn login(ctx: &Context, dir: &std::path::Path) -> Command {
117    let mut command = Command::new(&ctx.claude_program);
118    command
119        .args(["auth", "login"])
120        .env("CLAUDE_CONFIG_DIR", dir)
121        .env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR");
122    command
123}
124
125fn started(e: std::io::Error) -> Error {
126    match e.kind() {
127        std::io::ErrorKind::NotFound => Error::ClaudeNotFound,
128        _ => Error::SignInIncomplete,
129    }
130}
131
132fn signed_in_document(ctx: &Context, dir: &std::path::Path) -> Result<Value> {
133    let raw = store::read_signin(ctx, dir)?.ok_or(Error::SignInIncomplete)?;
134    serde_json::from_str(&raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
135        detail: e.to_string(),
136    })
137}
138
139/// The same sign-in, watched rather than inherited: an app has no terminal to hand over, so
140/// it reads what Claude Code prints and can type the fallback code back.
141pub struct WatchedSignIn {
142    child: std::process::Child,
143    said: std::sync::mpsc::Receiver<String>,
144    pending: SignIn,
145}
146
147impl WatchedSignIn {
148    /// The next thing Claude Code said, or `None` once it has finished saying anything.
149    /// Blocks, so a caller reads it on a thread of its own.
150    pub fn next_line(&self) -> Option<String> {
151        self.said.recv().ok()
152    }
153
154    /// Types a line back, for the code Claude Code asks to be pasted when the browser
155    /// cannot reach its callback.
156    pub fn paste(&mut self, line: &str) -> Result<()> {
157        use std::io::Write;
158        let stdin = self.child.stdin.as_mut().ok_or(Error::SignInIncomplete)?;
159        writeln!(stdin, "{line}").map_err(|_| Error::SignInIncomplete)?;
160        stdin.flush().map_err(|_| Error::SignInIncomplete)
161    }
162
163    /// Waits for it to finish and hands back the login it stored.
164    pub fn finish(mut self) -> Result<SignIn> {
165        let finished = self
166            .child
167            .wait()
168            .map_err(|_| Error::SignInIncomplete)?
169            .success();
170        if !finished {
171            return Err(Error::SignInIncomplete);
172        }
173        let mut pending = self.pending;
174        pending.document = signed_in_document(&pending.ctx.clone(), &pending.dir.clone())?;
175        Ok(pending)
176    }
177
178    /// Stops it. What it may have written is discarded by `SignIn`'s own cleanup.
179    pub fn cancel(mut self) {
180        let _ = self.child.kill();
181        let _ = self.child.wait();
182    }
183}
184
185/// Starts the sign-in with its output piped, for a caller that will show it.
186pub fn sign_in_watched(ctx: &Context) -> Result<WatchedSignIn> {
187    let pending = reserve_signin(ctx)?;
188    let mut child = login(ctx, &pending.dir)
189        .stdin(std::process::Stdio::piped())
190        .stdout(std::process::Stdio::piped())
191        .stderr(std::process::Stdio::piped())
192        .spawn()
193        .map_err(started)?;
194    let (say, said) = std::sync::mpsc::channel();
195    // Claude Code writes the browser URL and the paste prompt without a newline after them,
196    // so this reads by chunk rather than by line and lets the caller decide what to show.
197    for stream in [
198        child.stdout.take().map(Readable::Out),
199        child.stderr.take().map(Readable::Err),
200    ]
201    .into_iter()
202    .flatten()
203    {
204        let say = say.clone();
205        std::thread::spawn(move || {
206            use std::io::Read;
207            let mut reader: Box<dyn Read + Send> = match stream {
208                Readable::Out(o) => Box::new(o),
209                Readable::Err(e) => Box::new(e),
210            };
211            let mut buffer = [0_u8; 1024];
212            while let Ok(read) = reader.read(&mut buffer) {
213                if read == 0 {
214                    break;
215                }
216                let text = String::from_utf8_lossy(&buffer[..read]).into_owned();
217                if say.send(text).is_err() {
218                    break;
219                }
220            }
221        });
222    }
223    Ok(WatchedSignIn {
224        child,
225        said,
226        pending,
227    })
228}
229
230enum Readable {
231    Out(std::process::ChildStdout),
232    Err(std::process::ChildStderr),
233}
234
235/// Enroll the account signed in now, or with `signed_in`, the one a sign-in just produced.
236pub fn enroll(settled: Settled, label: &str, signed_in: Option<SignIn>) -> Result<Enrolled> {
237    let Settled {
238        _exclusive,
239        mut state,
240        ctx,
241    } = settled;
242    match signed_in {
243        Some(login) => park_signed_in(&ctx, label, &mut state, &login),
244        None => record_current(&ctx, label, &mut state),
245    }
246}
247
248/// A label names one account for good: its own, or one not enrolled under another label.
249fn claim(state: &State, label: &str, owner: &Owner) -> Result<()> {
250    if let Some(taken) = state.get(label)
251        && taken.account_uuid != owner.account_uuid
252    {
253        return Err(Error::LabelTaken {
254            label: label.to_string(),
255            email: taken.email.clone(),
256        });
257    }
258    if let Some(existing) = state.by_uuid(&owner.account_uuid)
259        && existing.label != label
260    {
261        return Err(Error::AlreadyEnrolled {
262            email: owner.email.clone(),
263            label: existing.label.clone(),
264        });
265    }
266    Ok(())
267}
268
269fn record_current(ctx: &Context, label: &str, state: &mut State) -> Result<Enrolled> {
270    let live = store::read(ctx, &claude::live_service(ctx))?.ok_or(Error::LiveCredentialAbsent)?;
271    let owner = identify(ctx, &access_token(&live)?)?;
272    claim(state, label, &owner)?;
273    let parked = state.get(label).and_then(|a| a.parked.clone());
274    state.upsert(account(label, &owner, parked));
275    state.active = Some(label.to_string());
276    state::save(ctx, state)?;
277    Ok(Enrolled::Current { email: owner.email })
278}
279
280fn park_signed_in(
281    ctx: &Context,
282    label: &str,
283    state: &mut State,
284    login: &SignIn,
285) -> Result<Enrolled> {
286    let owner = identify(ctx, &access_token(&login.document)?)?;
287    claim(state, label, &owner)?;
288    let service = park::reserve(ctx, &owner.account_uuid)?;
289    let fresh = park::store_at(ctx, &service, &oauth_of(&login.document)?)?;
290    let existing = state.get(label);
291    let previous = existing.and_then(|a| a.parked.clone());
292    let renewed = existing.is_some();
293    state.upsert(account(label, &owner, previous));
294    state.park(label, fresh);
295    // Unrecorded, the new login would be an item nothing refers to, never deleted.
296    state::save(ctx, state).inspect_err(|_| {
297        let _ = store::vault_delete(ctx, &service);
298    })?;
299    purge(ctx, state);
300    Ok(if renewed {
301        Enrolled::Renewed { email: owner.email }
302    } else {
303        Enrolled::SignedIn { email: owner.email }
304    })
305}
306
307/// Only what Anthropic just confirmed. Leaving the rest out makes Claude Code fetch its own
308/// profile after a switch rather than trust a copy pitboard wrote.
309fn account(label: &str, owner: &Owner, parked: Option<Park>) -> Account {
310    Account {
311        label: label.to_string(),
312        account_uuid: owner.account_uuid.clone(),
313        email: owner.email.clone(),
314        organization_uuid: owner.organization_uuid.clone(),
315        oauth_account: json!({
316            "accountUuid": owner.account_uuid,
317            "emailAddress": owner.email,
318            "organizationUuid": owner.organization_uuid,
319        }),
320        parked,
321    }
322}