pub(crate) mod claude;
pub(crate) mod codex;
pub(crate) mod jwt;
use crate::context::Context;
use crate::usage;
use serde_json::Value;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProviderId {
Claude,
Codex,
}
impl ProviderId {
pub const ALL: &'static [ProviderId] = &[ProviderId::Claude, ProviderId::Codex];
pub fn code(self) -> &'static str {
match self {
ProviderId::Claude => "claude",
ProviderId::Codex => "codex",
}
}
pub fn service(self) -> &'static str {
match self {
ProviderId::Claude => "Anthropic",
ProviderId::Codex => "OpenAI",
}
}
pub fn name(self) -> &'static str {
match self {
ProviderId::Claude => "Claude Code",
ProviderId::Codex => "Codex",
}
}
pub fn program(self) -> &'static str {
match self {
ProviderId::Claude => "claude",
ProviderId::Codex => "codex",
}
}
pub fn home_variable(self) -> &'static str {
match self {
ProviderId::Claude => "CLAUDE_CONFIG_DIR",
ProviderId::Codex => "CODEX_HOME",
}
}
pub fn login_command(self) -> &'static str {
match self {
ProviderId::Claude => "claude",
ProviderId::Codex => "codex login",
}
}
pub fn parse(code: &str) -> Option<ProviderId> {
match code {
"claude" => Some(ProviderId::Claude),
"codex" => Some(ProviderId::Codex),
_ => None,
}
}
}
impl std::fmt::Display for ProviderId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Credential {
pub provider: ProviderId,
pub raw: Value,
}
impl Credential {
pub fn new(provider: ProviderId, raw: Value) -> Credential {
Credential { provider, raw }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub account_id: String,
pub email: String,
pub group: Option<String>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ProviderError {
#[error("the session has expired")]
Unauthorized,
#[error("{service} is rate limiting this request")]
RateLimited {
service: &'static str,
retry_after: Option<i64>,
},
#[error("could not reach {service}: {detail}")]
Network {
service: &'static str,
detail: String,
},
#[error("{service} answered {status}")]
Unexpected { service: &'static str, status: u16 },
#[error("{service}'s answer was not understood: {detail}")]
Malformed {
service: &'static str,
detail: String,
},
#[error("{service} no longer accepts this login")]
InvalidGrant { service: &'static str },
#[error("the stored login is not the shape {provider} keeps: {detail}")]
ShapeUnexpected {
provider: ProviderId,
detail: String,
},
#[error("{reason}")]
Unsupported {
provider: ProviderId,
reason: String,
},
#[error("nothing is signed in")]
NoLogin { provider: ProviderId },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Adoption {
PollingWithin(u32),
RestartRequired { program: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParkSemantics {
CopyWhileLive,
MoveOnly,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Isolation {
Isolated,
NotIsolated { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Expiry {
pub access_expires_at: Option<i64>,
pub refresh_expires_at: Option<i64>,
}
pub(crate) struct LiveStore {
pub(crate) chain: crate::store::Live,
pub(crate) service: String,
}
pub(crate) fn store_error(error: crate::store::Error) -> ProviderError {
const STORE: &str = "this machine's credential store";
match error {
crate::store::Error::Malformed(detail) => ProviderError::Malformed {
service: STORE,
detail,
},
other => ProviderError::Network {
service: STORE,
detail: other.to_string(),
},
}
}
pub(crate) trait Provider: Send + Sync + std::fmt::Debug {
fn id(&self) -> ProviderId;
fn live(&self, ctx: &Context) -> Result<LiveStore, ProviderError>;
fn read_live(&self, ctx: &Context) -> Result<Option<Credential>, ProviderError> {
let live = self.live(ctx)?;
crate::store::read(&live.chain, &live.service)
.map(|found| found.map(|raw| Credential::new(self.id(), raw)))
.map_err(store_error)
}
fn identify(&self, ctx: &Context, credential: &Credential) -> Result<Identity, ProviderError>;
fn verify(&self, ctx: &Context, credential: &Credential) -> Result<Identity, ProviderError> {
self.identify(ctx, credential)
}
fn usage(
&self,
ctx: &Context,
credential: &Credential,
) -> Result<usage::Snapshot, ProviderError>;
fn renew(&self, ctx: &Context, credential: &Credential) -> Result<Credential, ProviderError>;
fn slot(&self, ctx: &Context) -> String;
fn write_lock(&self, ctx: &Context) -> Option<std::path::PathBuf>;
fn recorded_identity(&self, ctx: &Context) -> Option<Identity>;
fn after_switch(
&self,
ctx: &Context,
incoming: &crate::state::Account,
outgoing: &Identity,
) -> Result<(), crate::error::Error>;
fn program(&self, ctx: &Context) -> Option<std::path::PathBuf>;
fn sign_in(&self, ctx: &Context, dir: &std::path::Path) -> std::process::Command;
fn read_signin(
&self,
ctx: &Context,
dir: &std::path::Path,
) -> Result<Option<String>, crate::store::Error>;
fn discard_signin(&self, ctx: &Context, dir: &std::path::Path);
fn overridden_by(&self, ctx: &Context) -> Vec<String>;
fn adoption(&self) -> Adoption;
fn park_semantics(&self) -> ParkSemantics;
fn private_signin_isolation(&self, ctx: &Context) -> Isolation;
fn slice(&self, live: &Value) -> Result<Value, ProviderError>;
fn splice(&self, live: &Value, incoming: &Value) -> Result<Value, ProviderError>;
fn fingerprint(&self, slice: &Value) -> String;
fn expiry(&self, slice: &Value) -> Expiry;
}
pub(crate) fn find_program(
named: &std::path::Path,
search: &std::ffi::OsStr,
) -> Option<std::path::PathBuf> {
find_in(named, search, runnable)
}
fn find_in(
named: &std::path::Path,
search: &std::ffi::OsStr,
mut runnable: impl FnMut(&std::path::Path) -> bool,
) -> Option<std::path::PathBuf> {
if named.components().count() > 1 {
let named = std::path::absolute(named).ok()?;
return runnable(&named).then_some(named);
}
std::env::split_paths(search)
.filter(|dir| dir.is_absolute())
.map(|dir| dir.join(named))
.find(|candidate| runnable(candidate))
}
fn runnable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.is_ok_and(|found| found.is_file() && found.permissions().mode() & 0o111 != 0)
}
pub(crate) fn program_of(ctx: &Context, tool: ProviderId) -> Option<std::path::PathBuf> {
find_program(ctx.program_for(tool), &ctx.search_path())
}
pub(crate) fn command(ctx: &Context, tool: ProviderId) -> std::process::Command {
let search = ctx.search_path();
let Some(program) = program_of(ctx, tool) else {
let mut command = std::process::Command::new(ctx.program_for(tool));
command.env("PATH", search);
return command;
};
let mut path = std::ffi::OsString::new();
if let Some(dir) = program
.parent()
.filter(|dir| !std::env::split_paths(&search).any(|entry| entry == *dir))
{
path.push(dir);
if !search.is_empty() {
path.push(":");
}
}
path.push(&search);
let mut command = std::process::Command::new(program);
command.env("PATH", path);
command
}
pub(crate) fn of(provider: ProviderId) -> &'static dyn Provider {
match provider {
ProviderId::Claude => &claude::engine::Claude,
ProviderId::Codex => &codex::engine::Codex,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_provider_code_parses_back_to_itself() {
for &id in ProviderId::ALL {
assert_eq!(ProviderId::parse(id.code()), Some(id), "{id}");
assert!(
id.code().chars().all(|c| c.is_ascii_lowercase()),
"{id} is not a plain lowercase code"
);
}
assert_eq!(ProviderId::parse("nothing"), None);
}
#[test]
fn every_provider_is_in_all() {
for &id in ProviderId::ALL {
match id {
ProviderId::Claude | ProviderId::Codex => {}
}
}
assert_eq!(ProviderId::ALL.len(), 2, "add the new provider to ALL");
}
#[test]
fn the_code_is_what_serde_writes() {
for &id in ProviderId::ALL {
let written = serde_json::to_value(id).expect("a provider id serialises");
assert_eq!(written, serde_json::json!(id.code()), "{id}");
}
}
#[test]
fn every_implementation_agrees_about_which_tool_it_is() {
for &id in ProviderId::ALL {
assert_eq!(of(id).id(), id, "{id} is registered against another tool");
}
}
#[test]
fn claude_code_follows_a_switch_on_its_own_and_tolerates_a_copy() {
let claude = of(ProviderId::Claude);
assert_eq!(
claude.adoption(),
Adoption::PollingWithin(33),
"measured: a session serves its credential from a 30 second cache"
);
assert_eq!(
claude.park_semantics(),
ParkSemantics::CopyWhileLive,
"nothing of Claude Code's revokes for presenting either copy, and the live document holds the machine's other keys"
);
assert_eq!(
claude.private_signin_isolation(&Context::from_env()),
Isolation::Isolated,
"CLAUDE_CONFIG_DIR picks the keychain item by hashing the directory, and there is no second backend that escapes it"
);
}
#[test]
fn a_restart_is_not_a_countdown_of_zero() {
let restart = Adoption::RestartRequired { program: "codex" };
assert_ne!(restart, Adoption::PollingWithin(0));
assert!(matches!(Adoption::PollingWithin(33), Adoption::PollingWithin(s) if s == 33));
}
struct Prefix(std::path::PathBuf);
impl Prefix {
fn new(name: &str) -> Prefix {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join(format!(
"pitboard-search-path-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
let bin = root.join("npm/bin");
std::fs::create_dir_all(&bin).expect("a scratch prefix");
for &tool in ProviderId::ALL {
let program = bin.join(tool.program());
std::fs::write(&program, "").expect("a program");
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755))
.expect("a program that can be run");
}
Prefix(root)
}
fn bin(&self) -> std::path::PathBuf {
self.0.join("npm/bin")
}
fn decoys(&self) -> (std::path::PathBuf, std::path::PathBuf) {
let (dirs, files) = (self.0.join("dirs"), self.0.join("files"));
for &tool in ProviderId::ALL {
std::fs::create_dir_all(dirs.join(tool.program())).expect("a directory");
std::fs::create_dir_all(&files).expect("a directory");
std::fs::write(files.join(tool.program()), "").expect("a file");
}
(dirs, files)
}
}
impl Drop for Prefix {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn env_of<'a>(command: &'a std::process::Command, name: &str) -> Option<&'a std::ffi::OsStr> {
command
.get_envs()
.find(|(key, _)| *key == name)
.and_then(|(_, value)| value)
}
#[test]
fn a_program_is_looked_for_on_the_search_path_it_is_given() {
let prefix = Prefix::new("find");
let search = format!("/nowhere/at/all::{}", prefix.bin().display());
let found = find_program(std::path::Path::new("codex"), search.as_ref());
assert_eq!(found, Some(prefix.bin().join("codex")));
assert_eq!(
find_program(std::path::Path::new("ls"), search.as_ref()),
None,
"ls is on this process's PATH and not on the one given"
);
}
#[test]
fn only_a_directory_named_from_the_root_is_looked_in() {
let mut looked = Vec::new();
let found = find_in(
std::path::Path::new("codex"),
"::bin:./node_modules/.bin:/usr/bin:".as_ref(),
|candidate| {
looked.push(candidate.to_path_buf());
false
},
);
assert_eq!(found, None);
assert_eq!(looked, [std::path::PathBuf::from("/usr/bin/codex")]);
}
#[test]
fn what_cannot_be_run_is_passed_over() {
let prefix = Prefix::new("decoys");
let (dirs, files) = prefix.decoys();
let search = format!(
"{}:{}:{}",
dirs.display(),
files.display(),
prefix.bin().display()
);
assert_eq!(
find_program(std::path::Path::new("codex"), search.as_ref()),
Some(prefix.bin().join("codex"))
);
for decoy in [dirs.join("codex"), files.join("codex")] {
assert_eq!(
find_program(&decoy, "".as_ref()),
None,
"{}",
decoy.display()
);
}
}
#[test]
fn a_program_named_relative_to_here_is_found_by_its_full_path() {
let found =
find_in(std::path::Path::new("./bin/codex"), "".as_ref(), |_| true).expect("found");
assert!(found.is_absolute(), "{}", found.display());
assert_eq!(
found,
std::env::current_dir()
.expect("a working directory")
.join("bin/codex")
);
}
#[test]
fn a_sign_in_runs_the_program_found_with_the_search_path_as_it_is() {
let prefix = Prefix::new("sign-in");
let search = format!("/nowhere/before:{}", prefix.bin().display());
let ctx =
Context::new(std::path::PathBuf::from("/nowhere")).with_search_path(search.clone());
let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
for &tool in ProviderId::ALL {
let command = of(tool).sign_in(&ctx, dir);
let program = prefix.bin().join(tool.program());
assert_eq!(command.get_program(), program.as_os_str(), "{tool}");
assert_eq!(env_of(&command, "PATH"), Some(search.as_ref()), "{tool}");
assert_eq!(of(tool).program(&ctx), Some(program), "{tool}");
}
}
#[test]
fn a_program_named_outright_is_run_with_its_own_directory_on_path() {
let prefix = Prefix::new("named");
let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
.with_claude_program(prefix.bin().join("claude"))
.with_codex_program(prefix.bin().join("codex"))
.with_search_path("/usr/bin:/bin".into());
let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
for &tool in ProviderId::ALL {
let command = of(tool).sign_in(&ctx, dir);
assert_eq!(
command.get_program(),
prefix.bin().join(tool.program()).as_os_str(),
"{tool}"
);
assert_eq!(
env_of(&command, "PATH"),
Some(format!("{}:/usr/bin:/bin", prefix.bin().display()).as_ref()),
"{tool}"
);
}
let on_it = format!("/usr/bin:{}/", prefix.bin().display());
let ctx = ctx.with_search_path(on_it.clone());
for &tool in ProviderId::ALL {
let command = of(tool).sign_in(&ctx, dir);
assert_eq!(env_of(&command, "PATH"), Some(on_it.as_ref()), "{tool}");
}
}
#[test]
fn a_program_found_nowhere_is_left_to_the_search_path() {
let ctx = Context::new(std::path::PathBuf::from("/nowhere"))
.with_search_path("/nowhere/at/all".into());
let dir = std::path::Path::new("/tmp/pitboard-signin-scratch");
for &tool in ProviderId::ALL {
let command = of(tool).sign_in(&ctx, dir);
assert_eq!(command.get_program(), tool.program(), "{tool}");
assert_eq!(
env_of(&command, "PATH"),
Some("/nowhere/at/all".as_ref()),
"{tool}"
);
assert_eq!(of(tool).program(&ctx), None, "{tool}");
}
}
#[test]
fn the_search_path_is_this_processs_own_path_unless_given() {
let ctx = Context::new(std::path::PathBuf::from("/nowhere"));
assert_eq!(
ctx.search_path(),
std::env::var_os("PATH").unwrap_or_default()
);
assert_eq!(
ctx.with_search_path("/opt/tools/bin".into()).search_path(),
"/opt/tools/bin"
);
}
}