use crate::secret::Secret;
use std::path::Path;
pub fn slot_token(dir: &Path) -> Option<Secret> {
slot_token_detail(dir).ok()
}
pub fn slot_token_detail(dir: &Path) -> Result<Secret, TokenUnavailable> {
if let Ok(bytes) = std::fs::read(dir.join(".credentials.json")) {
if let Some(t) = access_token(&bytes) {
return Ok(t);
}
}
use crate::adapters::claude::KeychainReadError as K;
match crate::adapters::claude::slot_keychain_read_detail(dir) {
Ok(bytes) => access_token(&bytes).ok_or(TokenUnavailable::NoLogin),
Err(K::Locked) => Err(TokenUnavailable::KeychainLocked),
Err(K::Missing | K::NotApplicable) => Err(TokenUnavailable::NoLogin),
}
}
pub enum TokenUnavailable {
NoLogin,
KeychainLocked,
}
impl TokenUnavailable {
pub fn remedy(&self, name: &str) -> String {
match self {
Self::NoLogin => format!(
"account '{name}' has no usable login - `swapdex run {name}` once signs it in"
),
Self::KeychainLocked => format!(
"account '{name}' is signed in, but macOS will not release its login here: \
reading a Keychain secret needs an unlocked login keychain, which a remote \
or non-interactive shell does not have. Run the proxy from a terminal on \
the Mac itself (or unlock with `security unlock-keychain`)."
),
}
}
pub fn short(&self) -> &'static str {
match self {
Self::NoLogin => "no saved token",
Self::KeychainLocked => "signed in, but this shell cannot read the keychain",
}
}
}
#[cfg(test)]
mod unavailable_tests {
use super::*;
#[test]
fn a_locked_keychain_does_not_read_as_a_missing_login() {
assert_eq!(TokenUnavailable::NoLogin.short(), "no saved token");
let locked = TokenUnavailable::KeychainLocked.short();
assert!(
!locked.contains("no saved token"),
"a locked keychain must not read as an absent login: {locked}"
);
assert!(
locked.contains("signed in"),
"it says the login is fine: {locked}"
);
}
}
pub fn slot_token_expired(dir: &Path, now_ms: i64) -> bool {
const SLACK_MS: i64 = 60_000;
let blob = std::fs::read(dir.join(".credentials.json"))
.ok()
.or_else(|| crate::adapters::claude::slot_keychain_read_detail(dir).ok());
blob.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v["claudeAiOauth"]["expiresAt"].as_i64())
.is_some_and(|exp| exp - now_ms <= SLACK_MS)
}
pub fn slot_account_uuid(dir: &Path) -> Option<String> {
let bytes = std::fs::read(dir.join(".claude.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v["oauthAccount"]["accountUuid"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub fn slot_email(dir: &Path) -> Option<String> {
let bytes = std::fs::read(dir.join(".claude.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v["oauthAccount"]["emailAddress"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub fn any_slot_email(dir: &Path) -> Option<String> {
if let Some(e) = slot_email(dir) {
return Some(e);
}
let bytes = std::fs::read(dir.join("auth.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
crate::adapters::codex::decode_email_from_id_token(v["tokens"]["id_token"].as_str())
}
pub fn identity_contradicts_login(dir: &Path) -> Option<String> {
let id = std::fs::read(dir.join(".claude.json")).ok()?;
let id: serde_json::Value = serde_json::from_slice(&id).ok()?;
let email = id["oauthAccount"]["emailAddress"]
.as_str()
.filter(|s| !s.is_empty())?;
let org = id["oauthAccount"]["organizationName"]
.as_str()
.filter(|s| !s.is_empty())?;
if org.contains(email) {
return None;
}
let blob = slot_token_blob(dir)?;
let cred: serde_json::Value = serde_json::from_slice(&blob).ok()?;
let sub = cred["claudeAiOauth"]["subscriptionType"].as_str()?;
if matches!(sub, "max" | "pro") {
return Some(format!(
"recorded as {email} ({org}), and its credential is a '{sub}' plan. \
That is normal if you hold a personal plan alongside the \
organisation; if you did not expect it, the config was written by a \
different login than the one in this slot - `swapdex whoami` while \
running as this account settles it"
));
}
None
}
fn slot_token_blob(dir: &Path) -> Option<Vec<u8>> {
if let Ok(b) = std::fs::read(dir.join(".credentials.json")) {
if !b.is_empty() {
return Some(b);
}
}
crate::adapters::claude::slot_keychain_read_detail(dir).ok()
}
fn access_token(bytes: &[u8]) -> Option<Secret> {
let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
let t = v["claudeAiOauth"]["accessToken"].as_str()?;
(!t.is_empty()).then(|| Secret::new(t.as_bytes().to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slot_token_reads_the_access_token_from_the_slot_file() {
let dir = tempfile::tempdir().unwrap();
assert!(slot_token(dir.path()).is_none(), "no login yet");
std::fs::write(
dir.path().join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"AT-1","refreshToken":"RT-1","expiresAt":1}}"#,
)
.unwrap();
let t = slot_token(dir.path()).expect("token");
assert_eq!(t.expose(), b"AT-1");
}
#[test]
fn slot_account_uuid_comes_from_the_slots_oauth_account() {
let dir = tempfile::tempdir().unwrap();
assert!(slot_account_uuid(dir.path()).is_none(), "no config yet");
std::fs::write(
dir.path().join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@x.com"}}"#,
)
.unwrap();
assert_eq!(slot_account_uuid(dir.path()).as_deref(), Some("u-1"));
std::fs::write(
dir.path().join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":""}}"#,
)
.unwrap();
assert!(slot_account_uuid(dir.path()).is_none());
}
#[test]
fn an_expired_slot_is_recognised_and_an_unknown_one_is_not() {
let dir = tempfile::tempdir().unwrap();
let now = 1_800_000_000_000i64;
assert!(!slot_token_expired(dir.path(), now));
let write = |exp: i64| {
std::fs::write(
dir.path().join(".credentials.json"),
format!(r#"{{"claudeAiOauth":{{"accessToken":"A","expiresAt":{exp}}}}}"#),
)
.unwrap()
};
write(now + 3_600_000);
assert!(!slot_token_expired(dir.path(), now), "an hour left is fine");
write(now - 1);
assert!(slot_token_expired(dir.path(), now), "already lapsed");
write(now + 30_000);
assert!(
slot_token_expired(dir.path(), now),
"about to lapse mid-flight counts as expired"
);
}
#[test]
fn an_identity_that_cannot_match_its_login_is_reported() {
let dir = tempfile::tempdir().unwrap();
let id = |email: &str, org: &str| {
std::fs::write(
dir.path().join(".claude.json"),
format!(
r#"{{"oauthAccount":{{"emailAddress":"{email}","organizationName":"{org}"}}}}"#
),
)
.unwrap()
};
let cred = |sub: &str| {
std::fs::write(
dir.path().join(".credentials.json"),
format!(r#"{{"claudeAiOauth":{{"accessToken":"A","subscriptionType":"{sub}"}}}}"#),
)
.unwrap()
};
id("a@company.com", "Acme RnD");
cred("max");
let msg = identity_contradicts_login(dir.path()).expect("worth reporting");
assert!(msg.contains("a@company.com"), "{msg}");
assert!(msg.contains("Acme RnD"), "{msg}");
assert!(msg.contains("max"), "{msg}");
assert!(
!msg.contains("different accounts"),
"it states what it sees, it does not conclude: {msg}"
);
assert!(
msg.contains("normal if"),
"and it says plainly when this is an ordinary setup: {msg}"
);
cred("team");
assert!(identity_contradicts_login(dir.path()).is_none());
cred("max");
id("me@gmail.com", "");
assert!(identity_contradicts_login(dir.path()).is_none());
id("me@gmail.com", "me@gmail.com's Organization");
assert!(
identity_contradicts_login(dir.path()).is_none(),
"an account named after its own address is not an organisation"
);
std::fs::remove_file(dir.path().join(".credentials.json")).unwrap();
assert!(identity_contradicts_login(dir.path()).is_none());
}
#[test]
fn slot_token_is_none_for_an_unparseable_credential() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".credentials.json"), b"not json").unwrap();
assert!(slot_token(dir.path()).is_none());
}
}
pub fn startup_refusal(reads: &[Result<(), TokenUnavailable>]) -> Option<String> {
if reads.is_empty() || reads.iter().any(Result::is_ok) {
return None;
}
let locked = reads
.iter()
.filter(|r| matches!(r, Err(TokenUnavailable::KeychainLocked)))
.count();
Some(if locked == reads.len() {
"every account is signed in, but this shell cannot open the Keychain to read one. \
A proxy here would forward your own login on every turn and never say so, which is \
worse than no proxy. Start it from a terminal on the Mac itself, or unlock with \
`security unlock-keychain`."
.to_string()
} else {
"no account has a readable login, so there is nothing to serve turns with. \
Sign one in - `swapdex run <name>` - and start the proxy again."
.to_string()
})
}
#[cfg(test)]
mod startup_refusal_tests {
use super::*;
#[test]
fn one_readable_login_is_enough_to_start() {
assert!(startup_refusal(&[Ok(()), Err(TokenUnavailable::NoLogin)]).is_none());
assert!(startup_refusal(&[Ok(())]).is_none());
}
#[test]
fn a_locked_keychain_says_so_rather_than_blaming_the_login() {
let why = startup_refusal(&[
Err(TokenUnavailable::KeychainLocked),
Err(TokenUnavailable::KeychainLocked),
])
.expect("refused");
assert!(why.contains("Keychain"), "{why}");
assert!(
why.contains("unlock-keychain"),
"the fix comes with it: {why}"
);
assert!(!why.contains("Sign one in"), "not the wrong remedy: {why}");
}
#[test]
fn nothing_signed_in_asks_for_a_sign_in() {
let why = startup_refusal(&[Err(TokenUnavailable::NoLogin)]).expect("refused");
assert!(why.contains("swapdex run"), "{why}");
}
#[test]
fn an_empty_registry_is_left_to_the_caller() {
assert!(startup_refusal(&[]).is_none());
}
}
#[cfg(test)]
mod any_tool_email_tests {
use super::*;
#[test]
fn a_codex_slot_email_is_read_from_its_auth() {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join(".claude.json"),
br#"{"oauthAccount":{"emailAddress":"c@x.com"}}"#,
)
.unwrap();
assert_eq!(any_slot_email(d.path()).as_deref(), Some("c@x.com"));
let e = tempfile::tempdir().unwrap();
let tok = crate::adapters::codex::test_id_token("k@x.com");
std::fs::write(
e.path().join("auth.json"),
serde_json::to_vec(&serde_json::json!({"tokens":{"id_token":tok}})).unwrap(),
)
.unwrap();
assert_eq!(any_slot_email(e.path()).as_deref(), Some("k@x.com"));
let f = tempfile::tempdir().unwrap();
assert_eq!(any_slot_email(f.path()), None);
}
}