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