Skip to main content

pitboard_core/provider/
mod.rs

1//! The boundary between Pitboard's own machinery and one particular coding tool's login.
2//!
3//! Pitboard was written against Claude Code, and for a long time that was the whole of it:
4//! the keychain slot hashing, the five keys a logout deletes, the write lock and its
5//! constants, the config file whose identity cache has to be spliced after a switch. None
6//! of that is a fact about parking a login. It is a fact about Claude Code.
7//!
8//! This module names the small set of things that genuinely differ between one tool and
9//! the next, so the rest of the crate can stop knowing which tool it is serving.
10//!
11//! # What is here and what deliberately is not
12//!
13//! Five operations: read the live credential, learn whose it is, measure what it has left,
14//! renew it, install it. Every one of them is something the engine has to call without
15//! caring how it is done underneath, and every one was checked against all three tools'
16//! measured shapes before it was written down rather than derived from Claude Code alone.
17//! [`Provider::usage`] takes the whole credential and the context rather than a bare access
18//! token for exactly that reason: Gemini's quota call needs a project id out of a second
19//! file that has nothing to do with the token, and a signature that looked sufficient after
20//! Claude and Codex would have been wrong.
21//!
22//! Three facts, as values rather than code paths: [`Adoption`], [`ParkSemantics`],
23//! [`Isolation`]. These are things the engine and the front ends must branch on, and a
24//! value lets them branch on the fact instead of on the provider's name. Nothing anywhere
25//! should read `if provider == Claude`.
26//!
27//! Four pure functions over a login document: which part of it belongs to the account,
28//! how to put another account's part in, a non-secret handle on its refresh token, and when
29//! it stops working. These are here because Pitboard's own bookkeeping needs them and they
30//! are genuinely different per tool: Claude Code's login sits in a document the machine
31//! shares with unrelated keys, while Codex and Gemini keep one account per file. They were
32//! not in the first sketch of this trait, which is how it came to be a boundary nothing
33//! could actually park through.
34//!
35//! One pure function over what a tool's own sign-in prints, which an app shows while it
36//! runs: the address to open and whether the tool waits for a code. Each tool prints its
37//! own words, so each reads its own, and both apps get the same answer from
38//! [`sign_in_view`].
39//!
40//! What is not here: a `park` method. Parking is Pitboard's own bookkeeping, built out of
41//! the pieces above, and a method for it would have to hide the difference between splicing
42//! a shared document and replacing a whole file behind a flag. Also absent: Claude Code's
43//! config-file identity cache, its supervisor daemon, its status line hook. A method most
44//! implementations no-op is a sign it does not belong on a shared trait.
45
46pub(crate) mod claude;
47pub(crate) mod codex;
48pub(crate) mod jwt;
49pub mod names;
50mod printed;
51
52use crate::context::Context;
53use crate::service::Permit;
54use crate::usage;
55use serde_json::Value;
56
57/// Which tool's login this is.
58///
59/// `non_exhaustive` from the first day it exists, while there is still only one variant, so
60/// every caller outside this crate is made to write a fallback arm before there is a second
61/// variant to catch them out.
62#[derive(
63    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
64)]
65#[serde(rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum ProviderId {
68    Claude,
69    Codex,
70}
71
72impl ProviderId {
73    /// Every provider Pitboard knows, in the order a listing shows them.
74    ///
75    /// Named rather than written out at each call site, because resolving a bare label has
76    /// to look at all of them and a provider missing from one such list would simply never
77    /// be found, with nothing failing to say so.
78    pub const ALL: &'static [ProviderId] = &[ProviderId::Claude, ProviderId::Codex];
79
80    /// The one spelling used in a label prefix, in the state file, in a park's name and in
81    /// the audit log. Written once so those four cannot drift, and chosen from the command
82    /// a person types rather than the company behind it, because the command is the thing
83    /// that is stable.
84    pub fn code(self) -> &'static str {
85        match self {
86            ProviderId::Claude => "claude",
87            ProviderId::Codex => "codex",
88        }
89    }
90
91    /// The service behind the tool, as a person would name it.
92    ///
93    /// Not the same as the tool: `claude` talks to Anthropic, `codex` to OpenAI. Messages
94    /// about a failed request name this, because "could not reach OpenAI" is something
95    /// somebody can act on and "could not reach the service" is not.
96    pub fn service(self) -> &'static str {
97        match self {
98            ProviderId::Claude => "Anthropic",
99            ProviderId::Codex => "OpenAI",
100        }
101    }
102
103    /// The tool, as its own documentation names it.
104    pub fn name(self) -> &'static str {
105        match self {
106            ProviderId::Claude => "Claude Code",
107            ProviderId::Codex => "Codex",
108        }
109    }
110
111    /// The command a person types to run the tool.
112    pub fn program(self) -> &'static str {
113        match self {
114            ProviderId::Claude => "claude",
115            ProviderId::Codex => "codex",
116        }
117    }
118
119    /// Pitboard's own environment variable naming the tool's program outright, in place of
120    /// the one it would find: one installed where nothing looks, or a stand-in.
121    pub fn program_variable(self) -> &'static str {
122        match self {
123            ProviderId::Claude => "PITBOARD_CLAUDE",
124            ProviderId::Codex => "PITBOARD_CODEX",
125        }
126    }
127
128    /// Where the tool's own installers put its program under `home`, for an app that has no
129    /// shell's `PATH` to find it on. Each tool says its own, read from its own build.
130    pub fn install_places(self, home: &std::path::Path) -> Vec<std::path::PathBuf> {
131        match self {
132            ProviderId::Claude => claude::paths::install_places(home),
133            ProviderId::Codex => codex::paths::install_places(home),
134        }
135    }
136
137    /// The environment variable that moves where the tool keeps its login.
138    pub fn home_variable(self) -> &'static str {
139        match self {
140            ProviderId::Claude => "CLAUDE_CONFIG_DIR",
141            ProviderId::Codex => "CODEX_HOME",
142        }
143    }
144
145    /// What a person runs to sign in with the tool's own command. Claude Code signs in from
146    /// inside the program it starts; Codex has a subcommand for it.
147    pub fn login_command(self) -> &'static str {
148        match self {
149            ProviderId::Claude => "claude",
150            ProviderId::Codex => "codex login",
151        }
152    }
153
154    pub fn parse(code: &str) -> Option<ProviderId> {
155        match code {
156            "claude" => Some(ProviderId::Claude),
157            "codex" => Some(ProviderId::Codex),
158            _ => None,
159        }
160    }
161}
162
163impl std::fmt::Display for ProviderId {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str(self.code())
166    }
167}
168
169/// One tool's login document, carried without being understood.
170///
171/// The provider it came from travels with it, so a value crossing this boundary can always
172/// say which shape it is, and a credential can never be handed to the wrong engine by
173/// accident. What is inside is that engine's business and nothing else's.
174#[derive(Debug, Clone, PartialEq)]
175pub struct Credential {
176    pub provider: ProviderId,
177    pub raw: Value,
178}
179
180impl Credential {
181    pub fn new(provider: ProviderId, raw: Value) -> Credential {
182        Credential { provider, raw }
183    }
184}
185
186/// Who a credential belongs to, as the tool's own service understands it.
187///
188/// How this is learned is deliberately not part of the answer. Claude Code's is a network
189/// call to Anthropic on every switch, because its local config can lag the credential by a
190/// day. Codex's is a local decode of the ID token it already holds. Gemini's is a local
191/// decode when the token carries one and a network call when it does not. The engine wants
192/// the answer, not the method.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Identity {
195    /// Stable for the life of the account. A UUID for Claude, a UUID for Codex's
196    /// `chatgpt_account_id`, Google's `sub` for Gemini.
197    pub account_id: String,
198    pub email: String,
199    /// Claude's organisation, Codex's ChatGPT workspace, Gemini's project. `None` where the
200    /// tool has no such concept or did not say, which is not the same as an empty one.
201    pub group: Option<String>,
202}
203
204/// Why an answer about a login could not be had.
205///
206/// Every variant says whether asking again later could answer differently, because that is
207/// the difference between a switch that should wait and one that should stop.
208#[derive(Debug, thiserror::Error)]
209#[non_exhaustive]
210pub enum ProviderError {
211    #[error("the session has expired")]
212    Unauthorized,
213    /// `retry_after` is what the service said to wait, in seconds, where it said anything.
214    #[error("{service} is rate limiting this request")]
215    RateLimited {
216        service: &'static str,
217        retry_after: Option<i64>,
218    },
219    #[error("could not reach {service}: {detail}")]
220    Network {
221        service: &'static str,
222        detail: String,
223    },
224    #[error("{service} answered {status}")]
225    Unexpected { service: &'static str, status: u16 },
226    #[error("{service}'s answer was not understood: {detail}")]
227    Malformed {
228        service: &'static str,
229        detail: String,
230    },
231    /// Refused for good: revoked, or already spent somewhere else.
232    #[error("{service} no longer accepts this login")]
233    InvalidGrant { service: &'static str },
234    /// The credential is not the shape this provider stores.
235    #[error("the stored login is not the shape {provider} keeps: {detail}")]
236    ShapeUnexpected {
237        provider: ProviderId,
238        detail: String,
239    },
240    /// The tool is configured to keep its login somewhere Pitboard does not handle.
241    #[error("{reason}")]
242    Unsupported {
243        provider: ProviderId,
244        reason: String,
245    },
246    /// The document holds no account's login at all, which is what signing out leaves in a
247    /// document the machine also keeps other things in. Not a malformed login: none.
248    #[error("nothing is signed in")]
249    NoLogin { provider: ProviderId },
250}
251
252/// When a session that is already running picks a switch up.
253///
254/// Measured or read, not assumed, and it differs enough between tools that a single number
255/// would be a lie for two of the three. Claude Code serves its credential from a 30 second
256/// cache, so a session follows on its own, unless a file sits behind the keychain, which it
257/// watches in place of the store. Codex caches for the life of the process, watches no file
258/// and refuses a reload whose account id has changed; Gemini caches its client for the
259/// process with no expiry. Neither ever notices.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub enum Adoption {
262    /// A session already running follows within this many seconds, with no action.
263    PollingWithin(u32),
264    /// A session already running keeps the login it holds until that login is next renewed,
265    /// or until it is started again, because `file` sits behind the store the switch wrote.
266    /// Never rendered as a countdown: when a session's login is renewed is the session's.
267    AtRenewal { file: std::path::PathBuf },
268    /// Nothing follows until the program is started again. Never rendered as a countdown.
269    ///
270    /// `holders` names every kind of process that runs `program`, most particular first and
271    /// ending with one that is anywhere, and what makes each take the switch: a terminal
272    /// session and an app that runs the program for itself are started again differently.
273    RestartRequired {
274        program: &'static str,
275        holders: &'static [crate::holder::Holder],
276    },
277}
278
279/// A file of a tool's behind the store that holds the login in use, which no switch reaches.
280/// What it holds can matter less than that it is there: a session can watch it by a look,
281/// whatever is in it.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub(crate) struct Behind {
284    pub path: std::path::PathBuf,
285    pub held: Held,
286}
287
288/// What a file behind a tool's store holds, as far as Pitboard can read it.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum Held {
291    /// A login of the tool's, which a session that cannot read that store signs in with.
292    Login,
293    /// No login of the tool's.
294    NoLogin,
295    /// It is there, and cannot be read, as a file that is not text or that this user may
296    /// not read.
297    Unreadable,
298}
299
300/// Whether a parked copy may exist while the same account is still live.
301///
302/// For Claude Code it may: the live document holds the machine's other keys too, and
303/// nothing revokes for presenting either copy. For Codex it must not. `codex login` and
304/// `codex logout` both revoke the stored refresh token at OpenAI before clearing it, so a
305/// copy left live while its twin sits in the vault is a token the person's own next login
306/// can kill in both places at once.
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum ParkSemantics {
309    /// A park may be a copy; the live credential can keep working.
310    CopyWhileLive,
311    /// There must never be two usable copies of one account's credential at rest on this
312    /// machine, not even between two steps of a switch.
313    MoveOnly,
314}
315
316/// Whether signing in to a second account in a private directory really leaves the live
317/// login alone.
318///
319/// The trick Pitboard uses for enrolment is to point the tool's own sign-in at a scratch
320/// directory through its home variable, let it write there, and read back what it wrote.
321/// That works for `CLAUDE_CONFIG_DIR` and for `CODEX_HOME`. It does not work for Gemini
322/// when its optional keychain backend is in use: that backend's service and account names
323/// are global constants which `GEMINI_CLI_HOME` does not namespace, so the "private"
324/// sign-in would write over the live login instead of beside it.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum Isolation {
327    /// A home override fully isolates a sign-in from the live credential.
328    Isolated,
329    /// It does not, and here is what to tell the person.
330    NotIsolated { reason: String },
331}
332
333/// When a login stops working, in epoch seconds. `None` where the tool does not say.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
335pub struct Expiry {
336    /// Until then its usage can be asked without renewing it first.
337    pub access_expires_at: Option<i64>,
338    /// Until then it can be restored at all.
339    pub refresh_expires_at: Option<i64>,
340}
341
342/// Where one tool keeps its live login: the backends it reads, in the order it reads them,
343/// and the name the login is filed under in each.
344///
345/// Every tool Pitboard knows keeps its login this way: Claude Code in a keychain item with
346/// a file behind it, Codex in a file or a keychain item depending on its configuration,
347/// Gemini in a file. Handing the switch the store itself, rather than a pair of read and
348/// write methods, is what lets one switch ask the questions a store answers the same way
349/// for every tool: what is there byte for byte, what a write would cost, whether it held.
350pub(crate) struct LiveStore {
351    pub(crate) chain: crate::store::Live,
352    pub(crate) service: String,
353}
354
355/// A store that could not be read, as the provider boundary reports it.
356pub(crate) fn store_error(error: crate::store::Error) -> ProviderError {
357    const STORE: &str = "this machine's credential store";
358    match error {
359        crate::store::Error::Malformed(detail) => ProviderError::Malformed {
360            service: STORE,
361            detail,
362        },
363        other => ProviderError::Network {
364            service: STORE,
365            detail: other.to_string(),
366        },
367    }
368}
369
370/// One coding tool's login, as the rest of Pitboard needs to touch it.
371///
372/// Implementations live in `provider::<name>`. Nothing here knows about Pitboard's state
373/// file, its lock, its journal or its audit log: those are Pitboard's own bookkeeping and
374/// do not vary by tool.
375pub(crate) trait Provider: Send + Sync + std::fmt::Debug {
376    fn id(&self) -> ProviderId;
377
378    /// Where this tool's live login is kept on this machine, right now.
379    ///
380    /// Resolved on every call and never cached: which backend holds the login depends on
381    /// the tool's own configuration and home variables, and either can change between two
382    /// commands. An error means this tool keeps nothing at rest here that Pitboard could
383    /// park, which is not the same as nothing being signed in.
384    fn live(&self, ctx: &Context) -> Result<LiveStore, ProviderError>;
385
386    /// The credential this tool would authenticate with right now.
387    ///
388    /// `Ok(None)` means nothing is signed in, which is an answer. A store that could not be
389    /// read is an error and must never collapse into `None`: reading one as the other tells
390    /// somebody their login is gone when it is merely unreadable.
391    fn read_live(&self, ctx: &Context) -> Result<Option<Credential>, ProviderError> {
392        let live = self.live(ctx)?;
393        crate::store::read(&live.chain, &live.service)
394            .map(|found| found.map(|raw| Credential::new(self.id(), raw)))
395            .map_err(store_error)
396    }
397
398    /// Whose credential this is.
399    fn identify(&self, ctx: &Context, credential: &Credential) -> Result<Identity, ProviderError>;
400
401    /// Whether [`Provider::identify`] answers from the credential alone, asking nobody.
402    /// Where it does, whose a login is can be told by a read that sends no request, as
403    /// `doctor` and `status --offline` are.
404    fn identifies_by_itself(&self) -> bool;
405
406    /// Whose credential this is, confirmed by the service still accepting it.
407    ///
408    /// The same answer as [`Provider::identify`] where that already asks the service, which
409    /// it does for Claude Code. A tool whose login names its own account can say whose it
410    /// is without anybody's agreement, and that is not enough before installing it: a login
411    /// the service has stopped accepting would be switched to, read back, found present,
412    /// and fail the next time the person ran the tool, with nothing parked to go back to.
413    fn verify(&self, ctx: &Context, credential: &Credential) -> Result<Identity, ProviderError> {
414        self.identify(ctx, credential)
415    }
416
417    /// What this credential has left, normalised into Pitboard's own shape.
418    ///
419    /// Takes the whole credential and the context, not an access token, because what a
420    /// usage call needs is not the same everywhere: Codex sends an account id header it
421    /// reads out of the credential, and Gemini needs a project id the credential never
422    /// mentions.
423    fn usage(
424        &self,
425        ctx: &Context,
426        credential: &Credential,
427    ) -> Result<usage::Snapshot, ProviderError>;
428
429    /// Fresh tokens for a parked login.
430    ///
431    /// Only ever called on a park. Renewing what is signed in is the tool's own job, and
432    /// racing it there is how a refresh chain gets spent twice.
433    ///
434    /// The service spends the refresh token it is given as it answers, so this takes the
435    /// [`Permit`] only the one gate every change passes makes: nothing rotates a token that
436    /// could not then be written down.
437    fn renew(
438        &self,
439        ctx: &Context,
440        permit: Permit,
441        credential: &Credential,
442    ) -> Result<Credential, ProviderError>;
443
444    /// A name for where this tool's live login is on this machine right now.
445    ///
446    /// One state file serves every place a tool can keep its login, and a home variable
447    /// changes which one is live, so a record of which account was switched to in one
448    /// says nothing about another. Claude Code's is the keychain item its directory hashes
449    /// to; Codex's is the file its home puts the login in.
450    fn slot(&self, ctx: &Context) -> String;
451
452    /// The lock this tool takes around its own writes to the live login, which Pitboard
453    /// must hold too while it writes there. `None` for a tool that takes none, where there
454    /// is nothing to hold and nothing it could wait for.
455    fn write_lock(&self, ctx: &Context) -> Option<std::path::PathBuf>;
456
457    /// What the locks this tool takes before it renews its login guard: its own, then one
458    /// beside it that it goes without where that cannot be made. Pitboard holds them too while
459    /// it counts on the refresh token stored. `None` for a tool that takes none.
460    fn refresh_lock(&self, ctx: &Context) -> Option<(std::path::PathBuf, std::path::PathBuf)>;
461
462    /// The account a tool that keeps a record of its own apart from its login names there,
463    /// read from its files without asking anybody. `None` for a tool whose login names its
464    /// own account, and where the record names nobody.
465    ///
466    /// Claude Code's is its config, which a Claude Code process started or signed in on
467    /// another login can rewrite with its own account (the register's
468    /// `config_identity_is_the_last_writers`), so it need not be the login stored's. So it is
469    /// a sign that something signed in, the words for a message, and what a switch to the
470    /// account in use writes over: never whose a login is, which row is in use, whose a
471    /// session's numbers are, or whether an account may be forgotten.
472    fn own_record(&self, ctx: &Context) -> Option<Identity>;
473
474    /// Correct whatever this tool caches about who is signed in, now that `incoming`'s
475    /// login is live and the tool still names `outgoing`: the account switched away from, or
476    /// the one its own record names in place of the account already in use.
477    ///
478    /// Runs after the login has moved and cannot undo it, so a failure here is reported
479    /// and never rolled back: the tool would otherwise name an account whose login is no
480    /// longer there.
481    fn after_switch(
482        &self,
483        ctx: &Context,
484        permit: Permit,
485        incoming: &crate::state::Account,
486        outgoing: &Identity,
487    ) -> Result<(), crate::error::Error>;
488
489    /// The tool's own program, where it is installed. A private sign-in runs it, so its
490    /// absence is worth saying before anybody opens a browser.
491    fn program(&self, ctx: &Context) -> Option<std::path::PathBuf>;
492
493    /// The tool's own sign-in, pointed at `dir` so the live login is never touched.
494    ///
495    /// `dir` exists and is private when this is called, and it is where the tool writes the
496    /// new login: every tool Pitboard handles lets a home variable move its whole store,
497    /// which is the only reason a second account can be signed in without signing the first
498    /// one out. Whether that really isolates the live login is
499    /// [`Provider::private_signin_isolation`]'s question, asked first.
500    ///
501    /// Starting it changes things a run that may change nothing must not: `codex login`
502    /// revokes whatever login is in the home it is given before it signs in. So the command
503    /// is made only with the [`Permit`] only the one gate every change passes makes.
504    fn sign_in(
505        &self,
506        ctx: &Context,
507        permit: Permit,
508        dir: &std::path::Path,
509    ) -> std::process::Command;
510
511    /// The login a sign-in left in `dir`, as the tool stored it.
512    fn read_signin(
513        &self,
514        ctx: &Context,
515        dir: &std::path::Path,
516    ) -> Result<Option<String>, crate::store::Error>;
517
518    /// Take away whatever a sign-in into `dir` left outside it. The directory itself is the
519    /// caller's to remove.
520    fn discard_signin(&self, ctx: &Context, permit: Permit, dir: &std::path::Path);
521
522    /// What the tool's own sign-in has printed so far, `said`, comes to: the address it gave
523    /// for a browser that did not open by itself, and whether it waits for a code typed
524    /// back. Everything said so far, because a tool's output arrives in pieces that need
525    /// not end where a line does.
526    ///
527    /// Whether a code was typed back already is Pitboard's to know, not the tool's, and
528    /// [`sign_in_view`] adds it.
529    fn read_sign_in(&self, said: &str) -> SignInView;
530
531    /// Whether what the tool's own sign-in said after a code was typed back, `since`,
532    /// refuses that code and reads another in the same sign-in, so a person may type one
533    /// again. Everything said since, for the same reason as [`Provider::read_sign_in`]. A
534    /// tool that reads no code refuses none.
535    fn refused_code(&self, since: &str) -> bool;
536
537    /// Names of whatever on this machine makes the tool sign in with something other than
538    /// the login Pitboard moves: an environment variable or a setting holding a key of its
539    /// own. Read from files as well as this process's environment, so the app, which has no
540    /// shell environment at all, gets the same answer as the command line.
541    fn overridden_by(&self, ctx: &Context) -> Vec<String>;
542
543    /// The file behind the store in use, wherever a look says it is there, whatever it
544    /// holds. `None` where there is none, and where whether there is one cannot be told: the
545    /// store in use cannot be read, which reading the login in it says as well, or the file
546    /// cannot even be looked at, which the tool's own look cannot either.
547    fn behind(&self, ctx: &Context) -> Option<Behind>;
548
549    /// When a running session follows a switch made with `behind` behind the store the
550    /// switch writes. A fact about the tool, not a setting.
551    fn adoption(&self, behind: Option<&Behind>) -> Adoption;
552
553    /// Whether a park may coexist with the same account still live.
554    fn park_semantics(&self) -> ParkSemantics;
555
556    /// Whether a private sign-in on this machine, right now, is really private.
557    ///
558    /// Takes the context because the answer is not a constant: it depends on which backend
559    /// the tool is configured to use here.
560    fn private_signin_isolation(&self, ctx: &Context) -> Isolation;
561
562    /// The part of a live document that belongs to the account signed in.
563    ///
564    /// For Claude Code that is a slice: its credential document also holds MCP tokens and
565    /// other keys that belong to the machine, and parking those would take them away from
566    /// whoever switches in. For a tool that keeps one account per file it is the whole
567    /// document.
568    fn slice(&self, live: &Value) -> Result<Value, ProviderError>;
569
570    /// The plan a login or a park of it says, as a label: "Max 20x", "Pro".
571    fn plan(&self, document: &Value) -> Option<String>;
572
573    /// `live` with `incoming` in place of whatever account was there, and nothing of the
574    /// outgoing account left behind.
575    fn splice(&self, live: &Value, incoming: &Value) -> Result<Value, ProviderError>;
576
577    /// A short, non-secret handle on the refresh token inside a slice.
578    ///
579    /// Two slices with the same handle hold the same refresh chain. It is what lets an
580    /// interrupted switch work out which side landed without asking anybody.
581    fn fingerprint(&self, slice: &Value) -> String;
582
583    /// When a slice stops being askable and stops being restorable.
584    fn expiry(&self, slice: &Value) -> Expiry;
585}
586
587/// What a tool's sign-in has printed so far comes to, for an app showing it while it runs.
588#[derive(Debug, Clone, PartialEq, Eq, Default)]
589pub struct SignInView {
590    /// The address the tool printed for a person to open when the browser did not open by
591    /// itself, as printed, for the app to make a link of.
592    pub url: Option<String>,
593    /// Whether to offer a field for the code the browser shows, which the tool is waiting
594    /// to have typed back.
595    pub wants_code: bool,
596}
597
598/// What `provider`'s sign-in has printed so far, `said`, comes to. `pasted` is whether a
599/// code has been typed back already, after which none is asked for.
600pub fn sign_in_view(provider: ProviderId, said: &str, pasted: bool) -> SignInView {
601    let read = of(provider).read_sign_in(said);
602    SignInView {
603        wants_code: read.wants_code && !pasted,
604        ..read
605    }
606}
607
608/// Whether `provider`'s sign-in refused the code typed back last, by what it said after it,
609/// `since`, and reads another in its place.
610pub fn refused_code(provider: ProviderId, since: &str) -> bool {
611    of(provider).refused_code(since)
612}
613
614/// The first `https` address in what a tool printed, read as a terminal reads it: where a
615/// hyperlink goes, or an address printed as text, up to where an address printed bare
616/// cannot go on: white space, a quote or an angle bracket. No byte of an escape sequence is
617/// ever part of one, and a hyperlink's text is read only after where it goes.
618///
619/// Only `https`: the address each tool prints for a person to open is one, and a loopback
620/// address it prints, where the browser comes back to, is `http`. The registers record
621/// both.
622pub(crate) fn https_address(said: &str) -> Option<String> {
623    printed::read(said).find_map(|piece| match piece {
624        printed::Printed::Text(text) => bare_https_address(text),
625        printed::Printed::Link(target) => target
626            .strip_prefix(HTTPS)
627            .is_some_and(|rest| !rest.is_empty() && !rest.contains(ends_an_address))
628            .then(|| target.to_owned()),
629    })
630}
631
632const HTTPS: &str = "https://";
633
634/// The first `https` address in `text`, which has no escape sequence in it.
635fn bare_https_address(text: &str) -> Option<String> {
636    text.match_indices(HTTPS).find_map(|(at, _)| {
637        let rest = &text[at + HTTPS.len()..];
638        let length = rest.find(ends_an_address).unwrap_or(rest.len());
639        (length > 0).then(|| text[at..at + HTTPS.len() + length].to_owned())
640    })
641}
642
643/// White space is Unicode's, as `char::is_whitespace` has it, which is what the app's own
644/// pattern ended an address at before this moved here. The pattern also ended one at ESC,
645/// which never reaches here: `printed` takes every escape sequence out first.
646fn ends_an_address(c: char) -> bool {
647    c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>')
648}
649
650/// Where `tool`'s own program is, looked for the way the context says to look.
651pub(crate) fn program_of(ctx: &Context, tool: ProviderId) -> Option<std::path::PathBuf> {
652    crate::host::program::find(ctx.program_for(tool), &ctx.search_path())
653}
654
655/// A command that runs `tool`'s own program, by the path it was found at, with the search
656/// path as its `PATH`, and the program's own directory in front of it when it is not on it
657/// already.
658///
659/// An npm install is a script that starts `#!/usr/bin/env node`, and npm puts it beside the
660/// `node` that installed it, under whatever prefix or version manager that was. So a
661/// program found somewhere the search path does not reach, where an app finds one its
662/// installer put there, finds its interpreter in its own directory, even for an app whose
663/// `PATH` has neither. The directory as found, never the script it links to: npm links
664/// `<prefix>/bin/codex` to a file deep inside `lib/node_modules`, where no `node` is. A
665/// program found on the search path runs with that path as it is, so `env` finds the `node`
666/// the person's own terminal would, and not an older one that happens to sit beside it.
667///
668/// A program that was not found is left to the search path, where starting it fails the
669/// way a missing program does.
670///
671/// What it runs is a tool's sign-in, which changes what that tool keeps, so it is made only
672/// with the [`Permit`] the one gate every change passes makes.
673pub(crate) fn command(ctx: &Context, _: Permit, tool: ProviderId) -> std::process::Command {
674    let search = ctx.search_path();
675    let Some(program) = program_of(ctx, tool) else {
676        let mut command = std::process::Command::new(ctx.program_for(tool));
677        command.env("PATH", search);
678        return command;
679    };
680    // An empty search path has no entries. Split, it would give one empty entry, and an
681    // empty entry is the current directory.
682    let entries: Vec<std::path::PathBuf> = if search.is_empty() {
683        Vec::new()
684    } else {
685        std::env::split_paths(&search).collect()
686    };
687    let path = match program
688        .parent()
689        .filter(|dir| !entries.iter().any(|entry| entry == dir))
690    {
691        // Each entry was read out of a search path, so they join again as they were; only
692        // the program's own directory could hold the separator, and then it is left off.
693        Some(dir) => std::env::join_paths(std::iter::once(dir.to_path_buf()).chain(entries))
694            .unwrap_or_else(|_| search.clone()),
695        None => search.clone(),
696    };
697    let mut command = std::process::Command::new(program);
698    command.env("PATH", path);
699    command
700}
701
702/// The implementation for one tool.
703///
704/// An exhaustive match rather than a lookup, so a tool added to [`ProviderId`] and not to
705/// here stops compiling instead of being silently absent.
706pub(crate) fn of(provider: ProviderId) -> &'static dyn Provider {
707    match provider {
708        ProviderId::Claude => &claude::engine::Claude,
709        ProviderId::Codex => &codex::engine::Codex,
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    /// The code is written into a label prefix, the state file, a park's name and the audit
718    /// log. If it ever stopped round-tripping, a state file would load with an account
719    /// nothing could name.
720    #[test]
721    fn every_provider_code_parses_back_to_itself() {
722        for &id in ProviderId::ALL {
723            assert_eq!(ProviderId::parse(id.code()), Some(id), "{id}");
724            assert!(
725                id.code().chars().all(|c| c.is_ascii_lowercase()),
726                "{id} is not a plain lowercase code"
727            );
728        }
729        assert_eq!(ProviderId::parse("nothing"), None);
730    }
731
732    /// `ALL` is what resolving a bare label walks. A provider missing from it would never
733    /// be found and nothing would say so.
734    #[test]
735    fn every_provider_is_in_all() {
736        // Exhaustive by construction: adding a variant without adding it here stops
737        // compiling, which is the point.
738        for &id in ProviderId::ALL {
739            match id {
740                ProviderId::Claude | ProviderId::Codex => {}
741            }
742        }
743        assert_eq!(ProviderId::ALL.len(), 2, "add the new provider to ALL");
744    }
745
746    /// A code is also what serde writes, so the two spellings must not drift.
747    #[test]
748    fn the_code_is_what_serde_writes() {
749        for &id in ProviderId::ALL {
750            let written = serde_json::to_value(id).expect("a provider id serialises");
751            assert_eq!(written, serde_json::json!(id.code()), "{id}");
752        }
753    }
754
755    /// A registry entry pointing at the wrong implementation would be silent: every
756    /// account of that tool would be handled by another tool's rules.
757    #[test]
758    fn every_implementation_agrees_about_which_tool_it_is() {
759        for &id in ProviderId::ALL {
760            assert_eq!(of(id).id(), id, "{id} is registered against another tool");
761        }
762    }
763
764    /// The three facts, asserted against what was measured, so a change to one is a change
765    /// to a test rather than a surprise on somebody's machine.
766    #[test]
767    fn claude_code_follows_a_switch_on_its_own_and_tolerates_a_copy() {
768        let claude = of(ProviderId::Claude);
769        assert_eq!(
770            claude.adoption(None),
771            Adoption::PollingWithin(33),
772            "measured: a session serves its credential from a 30 second cache"
773        );
774        assert_eq!(
775            claude.park_semantics(),
776            ParkSemantics::CopyWhileLive,
777            "nothing of Claude Code's revokes for presenting either copy, and the live              document holds the machine's other keys"
778        );
779        assert_eq!(
780            claude.private_signin_isolation(&Context::for_unit_test()),
781            Isolation::Isolated,
782            "CLAUDE_CONFIG_DIR picks the keychain item by hashing the directory, and there              is no second backend that escapes it"
783        );
784    }
785
786    /// A restart-required provider has no number of seconds to show, and a caller that
787    /// treated one as zero would render "follows in 0 seconds", which is the opposite of
788    /// what is true.
789    #[test]
790    fn a_restart_is_not_a_countdown_of_zero() {
791        let restart = of(ProviderId::Codex).adoption(None);
792        assert_ne!(restart, Adoption::PollingWithin(0));
793        assert!(matches!(Adoption::PollingWithin(33), Adoption::PollingWithin(s) if s == 33));
794    }
795
796    /// A scratch directory standing in for an npm prefix's `bin`, with an empty file for
797    /// each tool's program that anybody may run. Nothing here is ever run.
798    struct Prefix(std::path::PathBuf);
799
800    impl Prefix {
801        fn new(name: &str) -> Prefix {
802            let root = std::env::temp_dir().join(format!(
803                "pitboard-search-path-{name}-{}-{:?}",
804                std::process::id(),
805                std::thread::current().id()
806            ));
807            let _ = std::fs::remove_dir_all(&root);
808            let bin = root.join("npm/bin");
809            std::fs::create_dir_all(&bin).expect("a scratch prefix");
810            for &tool in ProviderId::ALL {
811                let program = bin.join(tool.program());
812                std::fs::write(&program, "").expect("a program");
813                crate::host::fs::testing::make_runnable(&program).expect("runnable");
814            }
815            Prefix(root)
816        }
817
818        fn bin(&self) -> std::path::PathBuf {
819            self.0.join("npm/bin")
820        }
821
822        /// A directory beside `bin` holding something named for each tool's program that
823        /// cannot be run: a directory of that name, or a file with no execute bit.
824        fn decoys(&self) -> (std::path::PathBuf, std::path::PathBuf) {
825            let (dirs, files) = (self.0.join("dirs"), self.0.join("files"));
826            for &tool in ProviderId::ALL {
827                std::fs::create_dir_all(dirs.join(tool.program())).expect("a directory");
828                std::fs::create_dir_all(&files).expect("a directory");
829                std::fs::write(files.join(tool.program()), "").expect("a file");
830            }
831            (dirs, files)
832        }
833    }
834
835    impl Drop for Prefix {
836        fn drop(&mut self) {
837            let _ = std::fs::remove_dir_all(&self.0);
838        }
839    }
840
841    fn env_of<'a>(command: &'a std::process::Command, name: &str) -> Option<&'a std::ffi::OsStr> {
842        command
843            .get_envs()
844            .find(|(key, _)| *key == name)
845            .and_then(|(_, value)| value)
846    }
847
848    /// A program is looked for where the caller says, not on this process's own `PATH`: an
849    /// app opened from Finder has only the system's directories there.
850    #[test]
851    #[cfg_attr(windows, ignore = "W17: finding programs on Windows")]
852    fn a_program_is_looked_for_on_the_search_path_it_is_given() {
853        let prefix = Prefix::new("find");
854        let search = format!("/nowhere/at/all::{}", prefix.bin().display());
855        let found = crate::host::program::find(std::path::Path::new("codex"), search.as_ref());
856        assert_eq!(found, Some(prefix.bin().join("codex")));
857        assert_eq!(
858            crate::host::program::find(std::path::Path::new("ls"), search.as_ref()),
859            None,
860            "ls is on this process's PATH and not on the one given"
861        );
862    }
863
864    /// A directory named for the program, or a file of that name nobody may run, is passed
865    /// over the way `execvp` passes over it, so what is found is what a sign-in can start.
866    #[test]
867    #[cfg_attr(windows, ignore = "W17: finding programs on Windows")]
868    fn what_cannot_be_run_is_passed_over() {
869        let prefix = Prefix::new("decoys");
870        let (dirs, files) = prefix.decoys();
871        let search = format!(
872            "{}:{}:{}",
873            dirs.display(),
874            files.display(),
875            prefix.bin().display()
876        );
877        assert_eq!(
878            crate::host::program::find(std::path::Path::new("codex"), search.as_ref()),
879            Some(prefix.bin().join("codex"))
880        );
881        for decoy in [dirs.join("codex"), files.join("codex")] {
882            assert_eq!(
883                crate::host::program::find(&decoy, "".as_ref()),
884                None,
885                "{}",
886                decoy.display()
887            );
888        }
889    }
890
891    /// Every tool's sign-in runs the program found, by its full path. Found on the search
892    /// path, it runs with that path as it is: its directory is on it already, and putting it
893    /// first would only change which `node` an npm install's `env` finds from the one the
894    /// person's own terminal finds.
895    #[test]
896    #[cfg_attr(windows, ignore = "W17: finding programs on Windows")]
897    fn a_sign_in_runs_the_program_found_with_the_search_path_as_it_is() {
898        let prefix = Prefix::new("sign-in");
899        let search = format!("/nowhere/before:{}", prefix.bin().display());
900        let ctx =
901            Context::new(std::path::PathBuf::from("/nowhere")).with_search_path(search.clone());
902        let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
903        for &tool in ProviderId::ALL {
904            let command = of(tool).sign_in(&ctx, Permit::for_a_test(), dir);
905            let program = prefix.bin().join(tool.program());
906            assert_eq!(command.get_program(), program.as_os_str(), "{tool}");
907            assert_eq!(env_of(&command, "PATH"), Some(search.as_ref()), "{tool}");
908            assert_eq!(of(tool).program(&ctx), Some(program), "{tool}");
909        }
910    }
911
912    /// A program named outright where the search path does not reach is run with its own
913    /// directory first on `PATH`, which is how an app that found it where its installer
914    /// puts it starts it: an npm install's script names `node` through `env`, and `node` is
915    /// beside it. Named outright on the search path, it runs with that path as it is.
916    #[test]
917    #[cfg_attr(windows, ignore = "W17: finding programs on Windows")]
918    fn a_program_named_outright_is_run_with_its_own_directory_on_path() {
919        let prefix = Prefix::new("named");
920        let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
921            .with_claude_program(prefix.bin().join("claude"))
922            .with_codex_program(prefix.bin().join("codex"))
923            .with_search_path("/usr/bin:/bin".into());
924        let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
925        for &tool in ProviderId::ALL {
926            let command = of(tool).sign_in(&ctx, Permit::for_a_test(), dir);
927            assert_eq!(
928                command.get_program(),
929                prefix.bin().join(tool.program()).as_os_str(),
930                "{tool}"
931            );
932            assert_eq!(
933                env_of(&command, "PATH"),
934                Some(format!("{}:/usr/bin:/bin", prefix.bin().display()).as_ref()),
935                "{tool}"
936            );
937        }
938        let on_it = format!("/usr/bin:{}/", prefix.bin().display());
939        let ctx = ctx.with_search_path(on_it.clone());
940        for &tool in ProviderId::ALL {
941            let command = of(tool).sign_in(&ctx, Permit::for_a_test(), dir);
942            assert_eq!(env_of(&command, "PATH"), Some(on_it.as_ref()), "{tool}");
943        }
944    }
945
946    /// A program found nowhere is left to the search path, so starting it fails the way a
947    /// missing program does rather than running whatever this process's `PATH` has.
948    #[test]
949    fn a_program_found_nowhere_is_left_to_the_search_path() {
950        let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
951            .with_search_path("/nowhere/at/all".into());
952        let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
953        for &tool in ProviderId::ALL {
954            let command = of(tool).sign_in(&ctx, Permit::for_a_test(), dir);
955            assert_eq!(command.get_program(), tool.program(), "{tool}");
956            assert_eq!(
957                env_of(&command, "PATH"),
958                Some("/nowhere/at/all".as_ref()),
959                "{tool}"
960            );
961            assert_eq!(of(tool).program(&ctx), None, "{tool}");
962        }
963    }
964
965    /// The address to open is the first `https` one. Codex prints its loopback address
966    /// first, and that is where the browser comes back to, not where a person goes. Codex
967    /// reads nothing typed back, whatever it prints; Claude Code waits for a code once it
968    /// asks for one.
969    #[test]
970    fn the_address_to_open_is_the_one_the_person_goes_to() {
971        let mut said = String::from(
972            "Starting local login server on http://localhost:1455.\n\
973             If your browser did not open, navigate to this URL to authenticate:\n\n\
974             https://auth.openai.com/oauth/authorize?response_type=code&state=x\u{1b}[0m\n",
975        );
976        assert_eq!(
977            sign_in_view(ProviderId::Codex, &said, false).url.as_deref(),
978            Some("https://auth.openai.com/oauth/authorize?response_type=code&state=x")
979        );
980        said.push_str("Paste code here if prompted > ");
981        assert!(!sign_in_view(ProviderId::Codex, &said, false).wants_code);
982
983        let said = "Paste code here if prompted > ";
984        assert!(sign_in_view(ProviderId::Claude, said, false).wants_code);
985    }
986
987    /// A code field is offered only by a sign-in whose tool reads one, whatever the tool
988    /// prints, and once a code has been typed back it is not asked for again.
989    #[test]
990    fn a_code_is_asked_for_only_where_the_tool_takes_one() {
991        let said = "Paste code here if prompted > ";
992        for (tool, takes) in [(ProviderId::Claude, true), (ProviderId::Codex, false)] {
993            assert_eq!(sign_in_view(tool, said, false).wants_code, takes, "{tool}");
994            assert!(
995                !sign_in_view(tool, said, true).wants_code,
996                "{tool}: asked once"
997            );
998        }
999    }
1000
1001    /// Before a tool has said anything there is nothing to open and nothing to type.
1002    #[test]
1003    fn a_sign_in_that_has_said_nothing_offers_nothing() {
1004        for &tool in ProviderId::ALL {
1005            assert_eq!(
1006                sign_in_view(tool, "", false),
1007                SignInView::default(),
1008                "{tool}"
1009            );
1010        }
1011    }
1012
1013    /// An address printed bare ends where one cannot go on: white space, a quote, an angle
1014    /// bracket, or the escape that starts a terminal's colour. White space is Unicode's, as
1015    /// the app's own pattern had it: on 5 October 2026 that pattern ended an address at each
1016    /// of the first characters here, and went on past each of the others.
1017    #[test]
1018    fn an_address_ends_where_an_address_printed_bare_cannot_go_on() {
1019        let ends = [
1020            '\t', '\n', '\u{b}', '\u{c}', '\r', ' ', '"', '\'', '<', '>', '\u{1b}', '\u{85}',
1021            '\u{a0}', '\u{1680}', '\u{2000}', '\u{200a}', '\u{2028}', '\u{2029}', '\u{202f}',
1022            '\u{205f}', '\u{3000}',
1023        ];
1024        for end in ends {
1025            let said = format!("x https://a.b/c{end}d e");
1026            assert_eq!(
1027                https_address(&said).as_deref(),
1028                Some("https://a.b/c"),
1029                "{:?}",
1030                end
1031            );
1032        }
1033        for kept in ['\u{1c}', '\u{7f}', '\u{180e}', '\u{200b}', '\u{feff}'] {
1034            let said = format!("x https://a.b/c{kept}d e");
1035            assert_eq!(
1036                https_address(&said),
1037                Some(format!("https://a.b/c{kept}d")),
1038                "{:?}",
1039                kept
1040            );
1041        }
1042    }
1043
1044    /// A scheme with nothing after it is no address, and the next one is looked for. An
1045    /// address that is not `https` is never the one to open.
1046    #[test]
1047    fn only_an_https_address_with_something_in_it_is_one_to_open() {
1048        assert_eq!(
1049            https_address("https:// then https://x.y").as_deref(),
1050            Some("https://x.y")
1051        );
1052        assert_eq!(https_address("http://localhost:1455/auth/callback"), None);
1053        assert_eq!(https_address("HTTPS://x.y"), None);
1054    }
1055
1056    /// A hyperlink's address is where it goes, whatever its text says, and the escape
1057    /// sequences around it are no part of it. A hyperlink that goes somewhere other than an
1058    /// `https` address offers its text, as a terminal shows it.
1059    #[test]
1060    fn a_hyperlinks_address_is_where_it_goes() {
1061        let cases = [
1062            (
1063                "\u{1b}]8;;https://a.b/c\u{7}sign in\u{1b}]8;;\u{7}",
1064                "https://a.b/c",
1065            ),
1066            (
1067                "\u{1b}]8;id=1;https://a.b/c\u{1b}\\https://x.y\u{1b}]8;;\u{1b}\\",
1068                "https://a.b/c",
1069            ),
1070            (
1071                "\u{1b}]8;;http://localhost:1455\u{7}\u{1b}[94mhttps://x.y\u{1b}[39m\u{1b}]8;;\u{7}",
1072                "https://x.y",
1073            ),
1074            (
1075                "\u{1b}]8;;https://a.b/c d\u{7}https://x.y\u{1b}]8;;\u{7}",
1076                "https://x.y",
1077            ),
1078        ];
1079        for (said, address) in cases {
1080            assert_eq!(
1081                https_address(&format!("visit: {said}\n")).as_deref(),
1082                Some(address),
1083                "{said:?}"
1084            );
1085        }
1086    }
1087
1088    /// What a terminal does not show is not an address to open, such as a window's title.
1089    #[test]
1090    fn an_address_a_terminal_does_not_show_is_none_to_open() {
1091        assert_eq!(https_address("\u{1b}]0;https://a.b/c\u{7}ready"), None);
1092    }
1093
1094    /// Read part way through, as it arrives, a hyperlink offers nothing until where it goes
1095    /// has arrived whole, and then offers all of it.
1096    #[test]
1097    fn a_hyperlink_read_part_way_through_offers_all_of_its_address_or_none() {
1098        let said = "visit: \u{1b}]8;;https://a.b/c?d=e\u{7}https://a.b/c?d=e\u{1b}]8;;\u{7}\n";
1099        let whole = said.find('\u{7}').expect("the end of where it goes") + 1;
1100        for (at, _) in said.char_indices().chain([(said.len(), ' ')]) {
1101            let offered = https_address(&said[..at]);
1102            if at < whole {
1103                assert_eq!(offered, None, "{:?}", &said[..at]);
1104            } else {
1105                assert_eq!(offered.as_deref(), Some("https://a.b/c?d=e"), "{at}");
1106            }
1107        }
1108    }
1109
1110    /// Without a search path of its own, a context looks where this process would, which
1111    /// is what the command line has always done.
1112    #[test]
1113    #[allow(
1114        clippy::disallowed_methods,
1115        reason = "this process's own PATH is what the context is meant to read"
1116    )]
1117    fn the_search_path_is_this_processs_own_path_unless_given() {
1118        let ctx = Context::new(std::path::PathBuf::from("/nowhere"));
1119        assert_eq!(
1120            ctx.search_path(),
1121            std::env::var_os("PATH").unwrap_or_default()
1122        );
1123        assert_eq!(
1124            ctx.with_search_path("/opt/tools/bin".into()).search_path(),
1125            "/opt/tools/bin"
1126        );
1127    }
1128}