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_for(paths: &crate::paths::Paths, dir: &Path) -> Option<Secret> {
slot_token_detail_for(paths, dir).ok()
}
pub fn slot_token_detail_for(
paths: &crate::paths::Paths,
dir: &Path,
) -> Result<Secret, TokenUnavailable> {
use crate::adapters::claude::KeychainReadError as K;
match crate::claude_authority::credential(paths, dir) {
Ok(credential) => access_token(credential.bytes()).ok_or(TokenUnavailable::NoLogin),
Err(K::Locked) => Err(TokenUnavailable::KeychainLocked),
Err(K::Missing | K::NotApplicable) => Err(TokenUnavailable::NoLogin),
}
}
pub fn slot_token_detail(dir: &Path) -> Result<Secret, TokenUnavailable> {
use crate::adapters::claude::KeychainReadError as K;
match crate::adapters::claude::slot_credential(dir) {
Ok(credential) => access_token(credential.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, tool: &str) -> String {
let flag = match tool {
"claude-code" | "claude" => String::new(),
other => format!(" --tool {other}"),
};
match self {
Self::NoLogin => format!(
"account '{name}' has no usable login - \
`swapdex run {name}{flag}` 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}"
);
}
}
const SLACK_MS: i64 = 60_000;
fn credential_expired(blob: &[u8], now_ms: i64) -> bool {
serde_json::from_slice::<serde_json::Value>(blob)
.ok()
.and_then(|value| value["claudeAiOauth"]["expiresAt"].as_i64())
.is_some_and(|expires_at| expires_at - now_ms <= SLACK_MS)
}
pub fn slot_token_expired(dir: &Path, now_ms: i64) -> bool {
crate::adapters::claude::slot_credential(dir)
.is_ok_and(|credential| credential_expired(credential.bytes(), now_ms))
}
pub fn slot_token_expired_for(paths: &crate::paths::Paths, dir: &Path, now_ms: i64) -> bool {
crate::claude_authority::credential(paths, dir)
.is_ok_and(|credential| credential_expired(credential.bytes(), now_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);
}
if let Some(e) = std::fs::read(dir.join("auth.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
crate::adapters::codex::decode_email_from_id_token(v["tokens"]["id_token"].as_str())
})
{
return Some(e);
}
let active = std::fs::read(dir.join("google_accounts.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v["active"].as_str().map(str::to_string));
active.or_else(|| {
let oauth = std::fs::read(dir.join("oauth_creds.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&oauth).ok()?;
crate::adapters::gemini_jwt_claim(v["id_token"].as_str(), "email")
})
}
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 sub = credential_subscription_type(&blob)?;
if matches!(sub.as_str(), "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 status` while \
running as this account settles it"
));
}
None
}
fn slot_token_blob(dir: &Path) -> Option<Vec<u8>> {
crate::adapters::claude::slot_credential(dir)
.ok()
.map(|credential| credential.bytes().to_vec())
}
fn credential_subscription_type(blob: &[u8]) -> Option<String> {
let credential: serde_json::Value = serde_json::from_slice(blob).ok()?;
credential["claudeAiOauth"]["subscriptionType"]
.as_str()
.map(str::to_string)
}
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@example.com", "");
assert!(identity_contradicts_login(dir.path()).is_none());
id("me@example.com", "me@example.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);
}
}
#[cfg(test)]
mod stale_file_vs_keychain_tests {
use super::*;
#[test]
fn bearer_expiry_and_plan_are_views_of_the_selected_keychain_blob() {
let stale_file =
br#"{"claudeAiOauth":{"accessToken":"OLD","expiresAt":1,"subscriptionType":"team"}}"#;
let fresh_keychain = br#"{"claudeAiOauth":{"accessToken":"NEW","expiresAt":9999999999999,"subscriptionType":"max"}}"#;
let selected = crate::adapters::claude::choose_slot_credential(
Some(stale_file.to_vec()),
Ok(fresh_keychain.to_vec()),
)
.expect("selected credential");
assert_eq!(access_token(selected.bytes()).unwrap().expose(), b"NEW");
assert!(!credential_expired(selected.bytes(), 1_800_000_000_000));
assert_eq!(
credential_subscription_type(selected.bytes()).as_deref(),
Some("max")
);
}
}