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//! What is not here: a `park` method. Parking is pitboard's own bookkeeping, built out of
36//! the pieces above, and a method for it would have to hide the difference between splicing
37//! a shared document and replacing a whole file behind a flag. Also absent: Claude Code's
38//! config-file identity cache, its supervisor daemon, its status line hook. A method most
39//! implementations no-op is a sign it does not belong on a shared trait.
40
41pub(crate) mod claude;
42pub(crate) mod codex;
43pub(crate) mod jwt;
44
45use crate::context::Context;
46use crate::usage;
47use serde_json::Value;
48
49/// Which tool's login this is.
50///
51/// `non_exhaustive` from the first day it exists, while there is still only one variant, so
52/// every caller outside this crate is made to write a fallback arm before there is a second
53/// variant to catch them out.
54#[derive(
55    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
56)]
57#[serde(rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum ProviderId {
60    Claude,
61    Codex,
62}
63
64impl ProviderId {
65    /// Every provider pitboard knows, in the order a listing shows them.
66    ///
67    /// Named rather than written out at each call site, because resolving a bare label has
68    /// to look at all of them and a provider missing from one such list would simply never
69    /// be found, with nothing failing to say so.
70    pub const ALL: &'static [ProviderId] = &[ProviderId::Claude, ProviderId::Codex];
71
72    /// The one spelling used in a label prefix, in the state file, in a park's name and in
73    /// the audit log. Written once so those four cannot drift, and chosen from the command
74    /// a person types rather than the company behind it, because the command is the thing
75    /// that is stable.
76    pub fn code(self) -> &'static str {
77        match self {
78            ProviderId::Claude => "claude",
79            ProviderId::Codex => "codex",
80        }
81    }
82
83    /// The service behind the tool, as a person would name it.
84    ///
85    /// Not the same as the tool: `claude` talks to Anthropic, `codex` to OpenAI. Messages
86    /// about a failed request name this, because "could not reach OpenAI" is something
87    /// somebody can act on and "could not reach the service" is not.
88    pub fn service(self) -> &'static str {
89        match self {
90            ProviderId::Claude => "Anthropic",
91            ProviderId::Codex => "OpenAI",
92        }
93    }
94
95    /// The tool, as its own documentation names it.
96    pub fn name(self) -> &'static str {
97        match self {
98            ProviderId::Claude => "Claude Code",
99            ProviderId::Codex => "Codex",
100        }
101    }
102
103    /// The command a person types to run the tool.
104    pub fn program(self) -> &'static str {
105        match self {
106            ProviderId::Claude => "claude",
107            ProviderId::Codex => "codex",
108        }
109    }
110
111    /// The environment variable that moves where the tool keeps its login.
112    pub fn home_variable(self) -> &'static str {
113        match self {
114            ProviderId::Claude => "CLAUDE_CONFIG_DIR",
115            ProviderId::Codex => "CODEX_HOME",
116        }
117    }
118
119    /// What a person runs to sign in with the tool's own command. Claude Code signs in from
120    /// inside the program it starts; Codex has a subcommand for it.
121    pub fn login_command(self) -> &'static str {
122        match self {
123            ProviderId::Claude => "claude",
124            ProviderId::Codex => "codex login",
125        }
126    }
127
128    pub fn parse(code: &str) -> Option<ProviderId> {
129        match code {
130            "claude" => Some(ProviderId::Claude),
131            "codex" => Some(ProviderId::Codex),
132            _ => None,
133        }
134    }
135}
136
137impl std::fmt::Display for ProviderId {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.write_str(self.code())
140    }
141}
142
143/// One tool's login document, carried without being understood.
144///
145/// The provider it came from travels with it, so a value crossing this boundary can always
146/// say which shape it is, and a credential can never be handed to the wrong engine by
147/// accident. What is inside is that engine's business and nothing else's.
148#[derive(Debug, Clone, PartialEq)]
149pub struct Credential {
150    pub provider: ProviderId,
151    pub raw: Value,
152}
153
154impl Credential {
155    pub fn new(provider: ProviderId, raw: Value) -> Credential {
156        Credential { provider, raw }
157    }
158}
159
160/// Who a credential belongs to, as the tool's own service understands it.
161///
162/// How this is learned is deliberately not part of the answer. Claude Code's is a network
163/// call to Anthropic on every switch, because its local config can lag the credential by a
164/// day. Codex's is a local decode of the ID token it already holds. Gemini's is a local
165/// decode when the token carries one and a network call when it does not. The engine wants
166/// the answer, not the method.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Identity {
169    /// Stable for the life of the account. A UUID for Claude, a UUID for Codex's
170    /// `chatgpt_account_id`, Google's `sub` for Gemini.
171    pub account_id: String,
172    pub email: String,
173    /// Claude's organisation, Codex's ChatGPT workspace, Gemini's project. `None` where the
174    /// tool has no such concept or did not say, which is not the same as an empty one.
175    pub group: Option<String>,
176}
177
178/// Why an answer about a login could not be had.
179///
180/// Every variant says whether asking again later could answer differently, because that is
181/// the difference between a switch that should wait and one that should stop.
182#[derive(Debug, thiserror::Error)]
183#[non_exhaustive]
184pub enum ProviderError {
185    #[error("the session has expired")]
186    Unauthorized,
187    /// `retry_after` is what the service said to wait, in seconds, where it said anything.
188    #[error("{service} is rate limiting this request")]
189    RateLimited {
190        service: &'static str,
191        retry_after: Option<i64>,
192    },
193    #[error("could not reach {service}: {detail}")]
194    Network {
195        service: &'static str,
196        detail: String,
197    },
198    #[error("{service} answered {status}")]
199    Unexpected { service: &'static str, status: u16 },
200    #[error("{service}'s answer was not understood: {detail}")]
201    Malformed {
202        service: &'static str,
203        detail: String,
204    },
205    /// Refused for good: revoked, or already spent somewhere else.
206    #[error("{service} no longer accepts this login")]
207    InvalidGrant { service: &'static str },
208    /// The credential is not the shape this provider stores.
209    #[error("the stored login is not the shape {provider} keeps: {detail}")]
210    ShapeUnexpected {
211        provider: ProviderId,
212        detail: String,
213    },
214    /// The tool is configured to keep its login somewhere pitboard does not handle.
215    #[error("{reason}")]
216    Unsupported {
217        provider: ProviderId,
218        reason: String,
219    },
220    /// The document holds no account's login at all, which is what signing out leaves in a
221    /// document the machine also keeps other things in. Not a malformed login: none.
222    #[error("nothing is signed in")]
223    NoLogin { provider: ProviderId },
224}
225
226/// When a session that is already running picks a switch up.
227///
228/// Measured, not assumed, and it differs enough between tools that a single number would be
229/// a lie for two of the three. Claude Code serves its credential from a 30 second cache, so
230/// a session follows on its own. Codex caches for the life of the process, watches no file
231/// and refuses a reload whose account id has changed; Gemini caches its client for the
232/// process with no expiry. Neither ever notices.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum Adoption {
235    /// A session already running follows within this many seconds, with no action.
236    PollingWithin(u32),
237    /// Nothing follows until the program is started again. Never rendered as a countdown.
238    ///
239    /// `holders` names every kind of process that runs `program`, most particular first and
240    /// ending with one that is anywhere, and what makes each take the switch: a terminal
241    /// session and an app that runs the program for itself are started again differently.
242    RestartRequired {
243        program: &'static str,
244        holders: &'static [crate::holder::Holder],
245    },
246}
247
248/// Whether a parked copy may exist while the same account is still live.
249///
250/// For Claude Code it may: the live document holds the machine's other keys too, and
251/// nothing revokes for presenting either copy. For Codex it must not. `codex login` and
252/// `codex logout` both revoke the stored refresh token at OpenAI before clearing it, so a
253/// copy left live while its twin sits in the vault is a token the person's own next login
254/// can kill in both places at once.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum ParkSemantics {
257    /// A park may be a copy; the live credential can keep working.
258    CopyWhileLive,
259    /// There must never be two usable copies of one account's credential at rest on this
260    /// machine, not even between two steps of a switch.
261    MoveOnly,
262}
263
264/// Whether signing in to a second account in a private directory really leaves the live
265/// login alone.
266///
267/// The trick pitboard uses for enrolment is to point the tool's own sign-in at a scratch
268/// directory through its home variable, let it write there, and read back what it wrote.
269/// That works for `CLAUDE_CONFIG_DIR` and for `CODEX_HOME`. It does not work for Gemini
270/// when its optional keychain backend is in use: that backend's service and account names
271/// are global constants which `GEMINI_CLI_HOME` does not namespace, so the "private"
272/// sign-in would write over the live login instead of beside it.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum Isolation {
275    /// A home override fully isolates a sign-in from the live credential.
276    Isolated,
277    /// It does not, and here is what to tell the person.
278    NotIsolated { reason: String },
279}
280
281/// When a login stops working, in epoch seconds. `None` where the tool does not say.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
283pub struct Expiry {
284    /// Until then its usage can be asked without renewing it first.
285    pub access_expires_at: Option<i64>,
286    /// Until then it can be restored at all.
287    pub refresh_expires_at: Option<i64>,
288}
289
290/// Where one tool keeps its live login: the backends it reads, in the order it reads them,
291/// and the name the login is filed under in each.
292///
293/// Every tool pitboard knows keeps its login this way: Claude Code in a keychain item with
294/// a file behind it, Codex in a file or a keychain item depending on its configuration,
295/// Gemini in a file. Handing the switch the store itself, rather than a pair of read and
296/// write methods, is what lets one switch ask the questions a store answers the same way
297/// for every tool: what is there byte for byte, what a write would cost, whether it held.
298pub(crate) struct LiveStore {
299    pub(crate) chain: crate::store::Live,
300    pub(crate) service: String,
301}
302
303/// A store that could not be read, as the provider boundary reports it.
304pub(crate) fn store_error(error: crate::store::Error) -> ProviderError {
305    const STORE: &str = "this machine's credential store";
306    match error {
307        crate::store::Error::Malformed(detail) => ProviderError::Malformed {
308            service: STORE,
309            detail,
310        },
311        other => ProviderError::Network {
312            service: STORE,
313            detail: other.to_string(),
314        },
315    }
316}
317
318/// One coding tool's login, as the rest of pitboard needs to touch it.
319///
320/// Implementations live in `provider::<name>`. Nothing here knows about pitboard's state
321/// file, its lock, its journal or its audit log: those are pitboard's own bookkeeping and
322/// do not vary by tool.
323pub(crate) trait Provider: Send + Sync + std::fmt::Debug {
324    fn id(&self) -> ProviderId;
325
326    /// Where this tool's live login is kept on this machine, right now.
327    ///
328    /// Resolved on every call and never cached: which backend holds the login depends on
329    /// the tool's own configuration and home variables, and either can change between two
330    /// commands. An error means this tool keeps nothing at rest here that pitboard could
331    /// park, which is not the same as nothing being signed in.
332    fn live(&self, ctx: &Context) -> Result<LiveStore, ProviderError>;
333
334    /// The credential this tool would authenticate with right now.
335    ///
336    /// `Ok(None)` means nothing is signed in, which is an answer. A store that could not be
337    /// read is an error and must never collapse into `None`: reading one as the other tells
338    /// somebody their login is gone when it is merely unreadable.
339    fn read_live(&self, ctx: &Context) -> Result<Option<Credential>, ProviderError> {
340        let live = self.live(ctx)?;
341        crate::store::read(&live.chain, &live.service)
342            .map(|found| found.map(|raw| Credential::new(self.id(), raw)))
343            .map_err(store_error)
344    }
345
346    /// Whose credential this is.
347    fn identify(&self, ctx: &Context, credential: &Credential) -> Result<Identity, ProviderError>;
348
349    /// Whose credential this is, confirmed by the service still accepting it.
350    ///
351    /// The same answer as [`Provider::identify`] where that already asks the service, which
352    /// it does for Claude Code. A tool whose login names its own account can say whose it
353    /// is without anybody's agreement, and that is not enough before installing it: a login
354    /// the service has stopped accepting would be switched to, read back, found present,
355    /// and fail the next time the person ran the tool, with nothing parked to go back to.
356    fn verify(&self, ctx: &Context, credential: &Credential) -> Result<Identity, ProviderError> {
357        self.identify(ctx, credential)
358    }
359
360    /// What this credential has left, normalised into pitboard's own shape.
361    ///
362    /// Takes the whole credential and the context, not an access token, because what a
363    /// usage call needs is not the same everywhere: Codex sends an account id header it
364    /// reads out of the credential, and Gemini needs a project id the credential never
365    /// mentions.
366    fn usage(
367        &self,
368        ctx: &Context,
369        credential: &Credential,
370    ) -> Result<usage::Snapshot, ProviderError>;
371
372    /// Fresh tokens for a parked login.
373    ///
374    /// Only ever called on a park. Renewing what is signed in is the tool's own job, and
375    /// racing it there is how a refresh chain gets spent twice.
376    fn renew(&self, ctx: &Context, credential: &Credential) -> Result<Credential, ProviderError>;
377
378    /// A name for where this tool's live login is on this machine right now.
379    ///
380    /// One state file serves every place a tool can keep its login, and a home variable
381    /// changes which one is live, so a record of which account was switched to in one
382    /// says nothing about another. Claude Code's is the keychain item its directory hashes
383    /// to; Codex's is the file its home puts the login in.
384    fn slot(&self, ctx: &Context) -> String;
385
386    /// The lock this tool takes around its own writes to the live login, which pitboard
387    /// must hold too while it writes there. `None` for a tool that takes none, where there
388    /// is nothing to hold and nothing it could wait for.
389    fn write_lock(&self, ctx: &Context) -> Option<std::path::PathBuf>;
390
391    /// Who this tool itself says is signed in, read from its own files without asking
392    /// anybody.
393    ///
394    /// A cache for a tool that keeps one apart from its login, and can lag it; the login's
395    /// own claims for a tool whose login names its account. Good enough to decide which of
396    /// two messages to show and whether an account may be forgotten, never good enough to
397    /// file a login under.
398    fn recorded_identity(&self, ctx: &Context) -> Option<Identity>;
399
400    /// Correct whatever this tool caches about who is signed in, now that `incoming`'s
401    /// login is live in place of `outgoing`'s.
402    ///
403    /// Runs after the login has moved and cannot undo it, so a failure here is reported
404    /// and never rolled back: the tool would otherwise name an account whose login is no
405    /// longer there.
406    fn after_switch(
407        &self,
408        ctx: &Context,
409        incoming: &crate::state::Account,
410        outgoing: &Identity,
411    ) -> Result<(), crate::error::Error>;
412
413    /// The tool's own program, where it is installed. A private sign-in runs it, so its
414    /// absence is worth saying before anybody opens a browser.
415    fn program(&self, ctx: &Context) -> Option<std::path::PathBuf>;
416
417    /// The tool's own sign-in, pointed at `dir` so the live login is never touched.
418    ///
419    /// `dir` exists and is private when this is called, and it is where the tool writes the
420    /// new login: every tool pitboard handles lets a home variable move its whole store,
421    /// which is the only reason a second account can be signed in without signing the first
422    /// one out. Whether that really isolates the live login is
423    /// [`Provider::private_signin_isolation`]'s question, asked first.
424    fn sign_in(&self, ctx: &Context, dir: &std::path::Path) -> std::process::Command;
425
426    /// The login a sign-in left in `dir`, as the tool stored it.
427    fn read_signin(
428        &self,
429        ctx: &Context,
430        dir: &std::path::Path,
431    ) -> Result<Option<String>, crate::store::Error>;
432
433    /// Take away whatever a sign-in into `dir` left outside it. The directory itself is the
434    /// caller's to remove.
435    fn discard_signin(&self, ctx: &Context, dir: &std::path::Path);
436
437    /// Names of whatever on this machine makes the tool sign in with something other than
438    /// the login pitboard moves: an environment variable or a setting holding a key of its
439    /// own. Read from files as well as this process's environment, so the app, which has no
440    /// shell environment at all, gets the same answer as the command line.
441    fn overridden_by(&self, ctx: &Context) -> Vec<String>;
442
443    /// When a running session follows a switch. A fact about the tool, not a setting.
444    fn adoption(&self) -> Adoption;
445
446    /// Whether a park may coexist with the same account still live.
447    fn park_semantics(&self) -> ParkSemantics;
448
449    /// Whether a private sign-in on this machine, right now, is really private.
450    ///
451    /// Takes the context because the answer is not a constant: it depends on which backend
452    /// the tool is configured to use here.
453    fn private_signin_isolation(&self, ctx: &Context) -> Isolation;
454
455    /// The part of a live document that belongs to the account signed in.
456    ///
457    /// For Claude Code that is a slice: its credential document also holds MCP tokens and
458    /// other keys that belong to the machine, and parking those would take them away from
459    /// whoever switches in. For a tool that keeps one account per file it is the whole
460    /// document.
461    fn slice(&self, live: &Value) -> Result<Value, ProviderError>;
462
463    /// `live` with `incoming` in place of whatever account was there, and nothing of the
464    /// outgoing account left behind.
465    fn splice(&self, live: &Value, incoming: &Value) -> Result<Value, ProviderError>;
466
467    /// A short, non-secret handle on the refresh token inside a slice.
468    ///
469    /// Two slices with the same handle hold the same refresh chain. It is what lets an
470    /// interrupted switch work out which side landed without asking anybody.
471    fn fingerprint(&self, slice: &Value) -> String;
472
473    /// When a slice stops being askable and stops being restorable.
474    fn expiry(&self, slice: &Value) -> Expiry;
475}
476
477/// Where a program somebody named is: the path itself, made absolute, when it has a
478/// directory in it, or the first file on `search`, a list in `PATH`'s form, that can be run.
479///
480/// Found the way `execvp` finds one, which passes over a directory of that name and a file
481/// nobody may run, so what is found here is what starts. Only a directory named from the
482/// root is looked in: a relative one names a place relative to wherever pitboard was
483/// started, which says nothing about where a tool is installed, and a sign-in that runs
484/// from a directory of its own would read it as somewhere else again.
485pub(crate) fn find_program(
486    named: &std::path::Path,
487    search: &std::ffi::OsStr,
488) -> Option<std::path::PathBuf> {
489    find_in(named, search, runnable)
490}
491
492/// `find_program` with the question of whether a file can be run handed in, so a test can
493/// see every place it looks.
494fn find_in(
495    named: &std::path::Path,
496    search: &std::ffi::OsStr,
497    mut runnable: impl FnMut(&std::path::Path) -> bool,
498) -> Option<std::path::PathBuf> {
499    if named.components().count() > 1 {
500        let named = std::path::absolute(named).ok()?;
501        return runnable(&named).then_some(named);
502    }
503    std::env::split_paths(search)
504        .filter(|dir| dir.is_absolute())
505        .map(|dir| dir.join(named))
506        .find(|candidate| runnable(candidate))
507}
508
509/// Whether `path` is a file somebody may run.
510fn runnable(path: &std::path::Path) -> bool {
511    use std::os::unix::fs::PermissionsExt;
512    std::fs::metadata(path)
513        .is_ok_and(|found| found.is_file() && found.permissions().mode() & 0o111 != 0)
514}
515
516/// Where `tool`'s own program is, looked for the way the context says to look.
517pub(crate) fn program_of(ctx: &Context, tool: ProviderId) -> Option<std::path::PathBuf> {
518    find_program(ctx.program_for(tool), &ctx.search_path())
519}
520
521/// A command that runs `tool`'s own program, by the path it was found at, with the search
522/// path as its `PATH`, and the program's own directory in front of it when it is not on it
523/// already.
524///
525/// An npm install is a script that starts `#!/usr/bin/env node`, and npm puts it beside the
526/// `node` that installed it, under whatever prefix or version manager that was. So a
527/// program found somewhere the search path does not reach, where an app finds one its
528/// installer put there, finds its interpreter in its own directory, even for an app whose
529/// `PATH` has neither. The directory as found, never the script it links to: npm links
530/// `<prefix>/bin/codex` to a file deep inside `lib/node_modules`, where no `node` is. A
531/// program found on the search path runs with that path as it is, so `env` finds the `node`
532/// the person's own terminal would, and not an older one that happens to sit beside it.
533///
534/// A program that was not found is left to the search path, where starting it fails the
535/// way a missing program does.
536pub(crate) fn command(ctx: &Context, tool: ProviderId) -> std::process::Command {
537    let search = ctx.search_path();
538    let Some(program) = program_of(ctx, tool) else {
539        let mut command = std::process::Command::new(ctx.program_for(tool));
540        command.env("PATH", search);
541        return command;
542    };
543    let mut path = std::ffi::OsString::new();
544    if let Some(dir) = program
545        .parent()
546        .filter(|dir| !std::env::split_paths(&search).any(|entry| entry == *dir))
547    {
548        path.push(dir);
549        if !search.is_empty() {
550            path.push(":");
551        }
552    }
553    path.push(&search);
554    let mut command = std::process::Command::new(program);
555    command.env("PATH", path);
556    command
557}
558
559/// The implementation for one tool.
560///
561/// An exhaustive match rather than a lookup, so a tool added to [`ProviderId`] and not to
562/// here stops compiling instead of being silently absent.
563pub(crate) fn of(provider: ProviderId) -> &'static dyn Provider {
564    match provider {
565        ProviderId::Claude => &claude::engine::Claude,
566        ProviderId::Codex => &codex::engine::Codex,
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    /// The code is written into a label prefix, the state file, a park's name and the audit
575    /// log. If it ever stopped round-tripping, a state file would load with an account
576    /// nothing could name.
577    #[test]
578    fn every_provider_code_parses_back_to_itself() {
579        for &id in ProviderId::ALL {
580            assert_eq!(ProviderId::parse(id.code()), Some(id), "{id}");
581            assert!(
582                id.code().chars().all(|c| c.is_ascii_lowercase()),
583                "{id} is not a plain lowercase code"
584            );
585        }
586        assert_eq!(ProviderId::parse("nothing"), None);
587    }
588
589    /// `ALL` is what resolving a bare label walks. A provider missing from it would never
590    /// be found and nothing would say so.
591    #[test]
592    fn every_provider_is_in_all() {
593        // Exhaustive by construction: adding a variant without adding it here stops
594        // compiling, which is the point.
595        for &id in ProviderId::ALL {
596            match id {
597                ProviderId::Claude | ProviderId::Codex => {}
598            }
599        }
600        assert_eq!(ProviderId::ALL.len(), 2, "add the new provider to ALL");
601    }
602
603    /// A code is also what serde writes, so the two spellings must not drift.
604    #[test]
605    fn the_code_is_what_serde_writes() {
606        for &id in ProviderId::ALL {
607            let written = serde_json::to_value(id).expect("a provider id serialises");
608            assert_eq!(written, serde_json::json!(id.code()), "{id}");
609        }
610    }
611
612    /// A registry entry pointing at the wrong implementation would be silent: every
613    /// account of that tool would be handled by another tool's rules.
614    #[test]
615    fn every_implementation_agrees_about_which_tool_it_is() {
616        for &id in ProviderId::ALL {
617            assert_eq!(of(id).id(), id, "{id} is registered against another tool");
618        }
619    }
620
621    /// The three facts, asserted against what was measured, so a change to one is a change
622    /// to a test rather than a surprise on somebody's machine.
623    #[test]
624    fn claude_code_follows_a_switch_on_its_own_and_tolerates_a_copy() {
625        let claude = of(ProviderId::Claude);
626        assert_eq!(
627            claude.adoption(),
628            Adoption::PollingWithin(33),
629            "measured: a session serves its credential from a 30 second cache"
630        );
631        assert_eq!(
632            claude.park_semantics(),
633            ParkSemantics::CopyWhileLive,
634            "nothing of Claude Code's revokes for presenting either copy, and the live              document holds the machine's other keys"
635        );
636        assert_eq!(
637            claude.private_signin_isolation(&Context::from_env()),
638            Isolation::Isolated,
639            "CLAUDE_CONFIG_DIR picks the keychain item by hashing the directory, and there              is no second backend that escapes it"
640        );
641    }
642
643    /// A restart-required provider has no number of seconds to show, and a caller that
644    /// treated one as zero would render "follows in 0 seconds", which is the opposite of
645    /// what is true.
646    #[test]
647    fn a_restart_is_not_a_countdown_of_zero() {
648        let restart = of(ProviderId::Codex).adoption();
649        assert_ne!(restart, Adoption::PollingWithin(0));
650        assert!(matches!(Adoption::PollingWithin(33), Adoption::PollingWithin(s) if s == 33));
651    }
652
653    /// A scratch directory standing in for an npm prefix's `bin`, with an empty file for
654    /// each tool's program that anybody may run. Nothing here is ever run.
655    struct Prefix(std::path::PathBuf);
656
657    impl Prefix {
658        fn new(name: &str) -> Prefix {
659            use std::os::unix::fs::PermissionsExt;
660            let root = std::env::temp_dir().join(format!(
661                "pitboard-search-path-{name}-{}-{:?}",
662                std::process::id(),
663                std::thread::current().id()
664            ));
665            let _ = std::fs::remove_dir_all(&root);
666            let bin = root.join("npm/bin");
667            std::fs::create_dir_all(&bin).expect("a scratch prefix");
668            for &tool in ProviderId::ALL {
669                let program = bin.join(tool.program());
670                std::fs::write(&program, "").expect("a program");
671                std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755))
672                    .expect("a program that can be run");
673            }
674            Prefix(root)
675        }
676
677        fn bin(&self) -> std::path::PathBuf {
678            self.0.join("npm/bin")
679        }
680
681        /// A directory beside `bin` holding something named for each tool's program that
682        /// cannot be run: a directory of that name, or a file with no execute bit.
683        fn decoys(&self) -> (std::path::PathBuf, std::path::PathBuf) {
684            let (dirs, files) = (self.0.join("dirs"), self.0.join("files"));
685            for &tool in ProviderId::ALL {
686                std::fs::create_dir_all(dirs.join(tool.program())).expect("a directory");
687                std::fs::create_dir_all(&files).expect("a directory");
688                std::fs::write(files.join(tool.program()), "").expect("a file");
689            }
690            (dirs, files)
691        }
692    }
693
694    impl Drop for Prefix {
695        fn drop(&mut self) {
696            let _ = std::fs::remove_dir_all(&self.0);
697        }
698    }
699
700    fn env_of<'a>(command: &'a std::process::Command, name: &str) -> Option<&'a std::ffi::OsStr> {
701        command
702            .get_envs()
703            .find(|(key, _)| *key == name)
704            .and_then(|(_, value)| value)
705    }
706
707    /// A program is looked for where the caller says, not on this process's own `PATH`: an
708    /// app opened from Finder has only the system's directories there.
709    #[test]
710    fn a_program_is_looked_for_on_the_search_path_it_is_given() {
711        let prefix = Prefix::new("find");
712        let search = format!("/nowhere/at/all::{}", prefix.bin().display());
713        let found = find_program(std::path::Path::new("codex"), search.as_ref());
714        assert_eq!(found, Some(prefix.bin().join("codex")));
715        assert_eq!(
716            find_program(std::path::Path::new("ls"), search.as_ref()),
717            None,
718            "ls is on this process's PATH and not on the one given"
719        );
720    }
721
722    /// Only a directory named from the root is looked in. An empty entry and a relative one
723    /// both name somewhere relative to wherever pitboard was started, and a sign-in that
724    /// runs from a directory of its own would start something else from there.
725    #[test]
726    fn only_a_directory_named_from_the_root_is_looked_in() {
727        let mut looked = Vec::new();
728        let found = find_in(
729            std::path::Path::new("codex"),
730            "::bin:./node_modules/.bin:/usr/bin:".as_ref(),
731            |candidate| {
732                looked.push(candidate.to_path_buf());
733                false
734            },
735        );
736        assert_eq!(found, None);
737        assert_eq!(looked, [std::path::PathBuf::from("/usr/bin/codex")]);
738    }
739
740    /// A directory named for the program, or a file of that name nobody may run, is passed
741    /// over the way `execvp` passes over it, so what is found is what a sign-in can start.
742    #[test]
743    fn what_cannot_be_run_is_passed_over() {
744        let prefix = Prefix::new("decoys");
745        let (dirs, files) = prefix.decoys();
746        let search = format!(
747            "{}:{}:{}",
748            dirs.display(),
749            files.display(),
750            prefix.bin().display()
751        );
752        assert_eq!(
753            find_program(std::path::Path::new("codex"), search.as_ref()),
754            Some(prefix.bin().join("codex"))
755        );
756        for decoy in [dirs.join("codex"), files.join("codex")] {
757            assert_eq!(
758                find_program(&decoy, "".as_ref()),
759                None,
760                "{}",
761                decoy.display()
762            );
763        }
764    }
765
766    /// A program named with a directory relative to where pitboard was started is found as
767    /// that place, by its full path, so the sign-in that runs from a directory of its own
768    /// starts the same program.
769    #[test]
770    fn a_program_named_relative_to_here_is_found_by_its_full_path() {
771        let found =
772            find_in(std::path::Path::new("./bin/codex"), "".as_ref(), |_| true).expect("found");
773        assert!(found.is_absolute(), "{}", found.display());
774        assert_eq!(
775            found,
776            std::env::current_dir()
777                .expect("a working directory")
778                .join("bin/codex")
779        );
780    }
781
782    /// Every tool's sign-in runs the program found, by its full path. Found on the search
783    /// path, it runs with that path as it is: its directory is on it already, and putting it
784    /// first would only change which `node` an npm install's `env` finds from the one the
785    /// person's own terminal finds.
786    #[test]
787    fn a_sign_in_runs_the_program_found_with_the_search_path_as_it_is() {
788        let prefix = Prefix::new("sign-in");
789        let search = format!("/nowhere/before:{}", prefix.bin().display());
790        let ctx =
791            Context::new(std::path::PathBuf::from("/nowhere")).with_search_path(search.clone());
792        let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
793        for &tool in ProviderId::ALL {
794            let command = of(tool).sign_in(&ctx, dir);
795            let program = prefix.bin().join(tool.program());
796            assert_eq!(command.get_program(), program.as_os_str(), "{tool}");
797            assert_eq!(env_of(&command, "PATH"), Some(search.as_ref()), "{tool}");
798            assert_eq!(of(tool).program(&ctx), Some(program), "{tool}");
799        }
800    }
801
802    /// A program named outright where the search path does not reach is run with its own
803    /// directory first on `PATH`, which is how an app that found it where its installer
804    /// puts it starts it: an npm install's script names `node` through `env`, and `node` is
805    /// beside it. Named outright on the search path, it runs with that path as it is.
806    #[test]
807    fn a_program_named_outright_is_run_with_its_own_directory_on_path() {
808        let prefix = Prefix::new("named");
809        let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
810            .with_claude_program(prefix.bin().join("claude"))
811            .with_codex_program(prefix.bin().join("codex"))
812            .with_search_path("/usr/bin:/bin".into());
813        let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
814        for &tool in ProviderId::ALL {
815            let command = of(tool).sign_in(&ctx, dir);
816            assert_eq!(
817                command.get_program(),
818                prefix.bin().join(tool.program()).as_os_str(),
819                "{tool}"
820            );
821            assert_eq!(
822                env_of(&command, "PATH"),
823                Some(format!("{}:/usr/bin:/bin", prefix.bin().display()).as_ref()),
824                "{tool}"
825            );
826        }
827        let on_it = format!("/usr/bin:{}/", prefix.bin().display());
828        let ctx = ctx.with_search_path(on_it.clone());
829        for &tool in ProviderId::ALL {
830            let command = of(tool).sign_in(&ctx, dir);
831            assert_eq!(env_of(&command, "PATH"), Some(on_it.as_ref()), "{tool}");
832        }
833    }
834
835    /// A program found nowhere is left to the search path, so starting it fails the way a
836    /// missing program does rather than running whatever this process's `PATH` has.
837    #[test]
838    fn a_program_found_nowhere_is_left_to_the_search_path() {
839        let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
840            .with_search_path("/nowhere/at/all".into());
841        let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
842        for &tool in ProviderId::ALL {
843            let command = of(tool).sign_in(&ctx, dir);
844            assert_eq!(command.get_program(), tool.program(), "{tool}");
845            assert_eq!(
846                env_of(&command, "PATH"),
847                Some("/nowhere/at/all".as_ref()),
848                "{tool}"
849            );
850            assert_eq!(of(tool).program(&ctx), None, "{tool}");
851        }
852    }
853
854    /// Without a search path of its own, a context looks where this process would, which
855    /// is what the command line has always done.
856    #[test]
857    fn the_search_path_is_this_processs_own_path_unless_given() {
858        let ctx = Context::new(std::path::PathBuf::from("/nowhere"));
859        assert_eq!(
860            ctx.search_path(),
861            std::env::var_os("PATH").unwrap_or_default()
862        );
863        assert_eq!(
864            ctx.with_search_path("/opt/tools/bin".into()).search_path(),
865            "/opt/tools/bin"
866        );
867    }
868}