use crate::{paths::Paths, secret::Secret};
use std::path::Path;
pub fn token_url() -> String {
if std::env::var_os("SWAPDEX_ROOT").is_some() {
if let Some(u) = std::env::var_os("SWAPDEX_OAUTH_URL") {
return u.to_string_lossy().into_owned();
}
}
"https://console.anthropic.com/v1/oauth/token".to_string()
}
pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
#[derive(Debug, PartialEq)]
pub enum RefreshError {
NoCredential,
InUse,
Expired,
Busy,
AlreadyRefreshing,
Refused(String),
Offline(String),
}
impl RefreshError {
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::NoCredential => {
format!("'{name}' has no login yet - `swapdex run {name}{flag}` signs it in")
}
Self::InUse => format!(
"'{name}' {tool} renewal deferred - refresh unverified while a {tool} session \
is using this account; retry after the session exits"
),
Self::Expired => format!(
"'{name}' has been idle too long to renew - \
`swapdex run {name}{flag}` signs it in again"
),
Self::Busy => format!(
"the login server is busy - '{name}' is fine, renewing again shortly will work"
),
Self::AlreadyRefreshing => format!(
"'{name}' is already being renewed by another turn - nothing to do; \
spending its refresh token twice is what logs an account out"
),
Self::Refused(why) => format!("'{name}' could not be renewed: {why}"),
Self::Offline(why) => format!("could not reach the login server: {why}"),
}
}
}
fn refresh_token_expired(blob: &[u8], now_ms: i64) -> bool {
serde_json::from_slice::<serde_json::Value>(blob)
.ok()
.and_then(|v| v["claudeAiOauth"]["refreshTokenExpiresAt"].as_i64())
.is_some_and(|exp| exp <= now_ms)
}
pub fn merge_response(old: &[u8], response: &str, now_ms: i64) -> Option<Vec<u8>> {
let mut blob: serde_json::Value = serde_json::from_slice(old).ok()?;
let r: serde_json::Value = serde_json::from_str(response).ok()?;
let access = r["access_token"].as_str().filter(|s| !s.is_empty())?;
let o = blob.get_mut("claudeAiOauth")?.as_object_mut()?;
o.insert("accessToken".into(), access.into());
if let Some(rt) = r["refresh_token"].as_str().filter(|s| !s.is_empty()) {
o.insert("refreshToken".into(), rt.into());
match r["refresh_token_expires_in"].as_i64() {
Some(secs) => {
o.insert(
"refreshTokenExpiresAt".into(),
(now_ms + secs * 1000).into(),
);
}
None => {
o.remove("refreshTokenExpiresAt");
}
}
}
if let Some(secs) = r["expires_in"].as_i64() {
o.insert("expiresAt".into(), (now_ms + secs * 1000).into());
}
serde_json::to_vec(&blob).ok()
}
pub fn request_body(refresh_token: &str) -> String {
serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
})
.to_string()
}
pub const BURST_SECS: i64 = 30;
#[derive(Default)]
pub struct RefreshGate {
last: std::sync::Mutex<std::collections::HashMap<std::path::PathBuf, i64>>,
}
impl RefreshGate {
pub fn claim(&self, dir: &Path, now_secs: i64) -> bool {
let mut m = self.last.lock().unwrap_or_else(|e| e.into_inner());
match m.get(dir) {
Some(&t) if now_secs - t <= BURST_SECS => false,
_ => {
m.insert(dir.to_path_buf(), now_secs);
true
}
}
}
}
fn gate() -> &'static RefreshGate {
static GATE: std::sync::OnceLock<RefreshGate> = std::sync::OnceLock::new();
GATE.get_or_init(RefreshGate::default)
}
fn identity_file(dir: &Path, name: &str) -> Option<Vec<u8>> {
let path = dir.join(name);
let meta = std::fs::symlink_metadata(&path).ok()?;
if meta.file_type().is_symlink() || !meta.is_file() {
return None;
}
std::fs::read(path).ok()
}
fn account_of(dir: &Path, tool: &str) -> Option<String> {
let (file, field, prefix) = match tool {
"claude-code" => (
".claude.json",
&["oauthAccount", "accountUuid"][..],
"claude",
),
"codex" => ("auth.json", &["tokens", "account_id"][..], "codex"),
_ => return None,
};
let bytes = identity_file(dir, file)?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
field
.iter()
.try_fold(&v, |value, key| value.get(key))?
.as_str()
.filter(|s| !s.is_empty())
.map(|id| format!("{prefix}:{id}"))
}
fn claim_key(dir: &Path, tool: &str) -> std::path::PathBuf {
match account_of(dir, tool) {
Some(a) => std::path::PathBuf::from(format!("account:{a}")),
None => std::path::PathBuf::from(format!("{tool}:{}", dir.display())),
}
}
fn claim_refresh_at(dir: &Path, tool: &str, now_secs: i64) -> bool {
gate().claim(&claim_key(dir, tool), now_secs)
}
pub fn refresh_slot(paths: &Paths, dir: &Path, now_ms: i64) -> Result<(), RefreshError> {
if !claim_refresh_at(dir, "claude-code", now_ms / 1000) {
return Err(RefreshError::AlreadyRefreshing);
}
if slot_in_use(paths, dir, "claude-code") {
return Err(RefreshError::InUse);
}
let blob = read_credential(dir).ok_or(RefreshError::NoCredential)?;
if refresh_token_expired(blob.expose(), now_ms) {
return Err(RefreshError::Expired);
}
let token = serde_json::from_slice::<serde_json::Value>(blob.expose())
.ok()
.and_then(|v| {
v["claudeAiOauth"]["refreshToken"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
})
.ok_or(RefreshError::NoCredential)?;
if slot_in_use(paths, dir, "claude-code") {
return Err(RefreshError::InUse);
}
if read_credential(dir).is_none_or(|current| current.expose() != blob.expose()) {
return Err(RefreshError::AlreadyRefreshing);
}
let (body, status) = post(&token)?;
if status == 429 {
return Err(RefreshError::Busy);
}
if status == 401 || status == 400 {
return Err(RefreshError::Refused(short_reason(&body)));
}
if !(200..300).contains(&status) {
return Err(RefreshError::Refused(format!("HTTP {status}")));
}
let merged = merge_response(blob.expose(), &body, now_ms)
.ok_or_else(|| RefreshError::Refused("the server's answer had no access token".into()))?;
write_credential(dir, &merged).map_err(|e| RefreshError::Refused(e.to_string()))
}
fn short_reason(body: &str) -> String {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
v["error_description"]
.as_str()
.or_else(|| v["error"].as_str())
.or_else(|| v["error"]["message"].as_str())
.map(str::to_string)
})
.unwrap_or_else(|| "the login server refused it".into())
}
fn same_dir(a: &Path, b: &Path) -> bool {
a == b
|| std::fs::canonicalize(a)
.ok()
.zip(std::fs::canonicalize(b).ok())
.is_some_and(|(a, b)| a == b)
}
fn inside_paths(paths: &Paths, dir: &Path) -> bool {
std::fs::canonicalize(paths.home())
.ok()
.zip(std::fs::canonicalize(dir).ok())
.is_some_and(|(root, dir)| dir.starts_with(root))
}
fn slot_in_use(paths: &Paths, dir: &Path, tool: &str) -> bool {
let running = crate::proc::running_config_dirs(tool);
if running.iter().any(|active| same_dir(active, dir)) {
return true;
}
let Some(candidate) = account_of(dir, tool) else {
return false;
};
running.into_iter().any(|active| {
(!paths.sandboxed() || inside_paths(paths, &active))
&& account_of(&active, tool).is_some_and(|id| id == candidate)
})
}
fn read_credential(dir: &Path) -> Option<Secret> {
if let Ok(bytes) = std::fs::read(dir.join(".credentials.json")) {
if !bytes.is_empty() {
return Some(Secret::new(bytes));
}
}
crate::adapters::claude::slot_keychain_read_detail(dir)
.ok()
.map(Secret::new)
}
fn write_credential(dir: &Path, blob: &[u8]) -> anyhow::Result<()> {
let file = dir.join(".credentials.json");
if file.exists() {
return crate::atomic::write_secret(&file, blob);
}
crate::adapters::claude::slot_keychain_write(dir, blob)
}
fn post(refresh_token: &str) -> Result<(String, u32), RefreshError> {
let body = request_body(refresh_token);
let cfg = format!(
"url = \"{}\"\n\
request = POST\n\
header = \"content-type: application/json\"\n\
header = \"Accept: application/json\"\n\
header = \"User-Agent: swapdex\"\n\
data = \"{}\"\n\
silent\n\
show-error\n\
connect-timeout = 6\n\
max-time = 15\n\
write-out = \"\\n%{{http_code}}\"\n",
token_url(),
body.replace('\\', "\\\\").replace('"', "\\\"")
);
let out = crate::quota::run_curl_cfg(&cfg).map_err(RefreshError::Offline)?;
Ok(out)
}
pub const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub fn codex_token_url() -> String {
if std::env::var_os("SWAPDEX_ROOT").is_some() {
if let Some(u) = std::env::var_os("SWAPDEX_CODEX_OAUTH_URL") {
return u.to_string_lossy().into_owned();
}
}
"https://auth.openai.com/oauth/token".to_string()
}
pub fn codex_request_body(refresh_token: &str) -> String {
let enc = |s: &str| -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
};
format!(
"grant_type=refresh_token&refresh_token={}&client_id={}",
enc(refresh_token),
enc(CODEX_CLIENT_ID)
)
}
pub fn merge_codex_response(old: &[u8], response: &str, now_rfc3339: &str) -> Option<Vec<u8>> {
let mut blob: serde_json::Value = serde_json::from_slice(old).ok()?;
let r: serde_json::Value = serde_json::from_str(response).ok()?;
let access = r["access_token"].as_str().filter(|s| !s.is_empty())?;
let t = blob.get_mut("tokens")?.as_object_mut()?;
t.insert("access_token".into(), access.into());
for (from, to) in [("refresh_token", "refresh_token"), ("id_token", "id_token")] {
if let Some(v) = r[from].as_str().filter(|s| !s.is_empty()) {
t.insert(to.into(), v.into());
}
}
blob.as_object_mut()?
.insert("last_refresh".into(), now_rfc3339.into());
serde_json::to_vec_pretty(&blob).ok()
}
pub fn rfc3339_utc(secs: i64) -> String {
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3_600,
(rem % 3_600) / 60,
rem % 60
)
}
pub fn refresh_codex_slot(paths: &Paths, dir: &Path, now_ms: i64) -> Result<(), RefreshError> {
if !claim_refresh_at(dir, "codex", now_ms / 1000) {
return Err(RefreshError::AlreadyRefreshing);
}
if slot_in_use(paths, dir, "codex") {
return Err(RefreshError::InUse);
}
let path = dir.join("auth.json");
let blob = std::fs::read(&path)
.ok()
.filter(|b| !b.is_empty())
.map(Secret::new)
.ok_or(RefreshError::NoCredential)?;
let token = serde_json::from_slice::<serde_json::Value>(blob.expose())
.ok()
.and_then(|v| {
v["tokens"]["refresh_token"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
})
.ok_or(RefreshError::NoCredential)?;
let fingerprint = crate::refresh_health::codex_credential_fingerprint_from_blob(blob.expose())
.ok_or(RefreshError::NoCredential)?;
if slot_in_use(paths, dir, "codex") {
return Err(RefreshError::InUse);
}
if !std::fs::read(&path).is_ok_and(|current| current.as_slice() == blob.expose()) {
return Err(RefreshError::AlreadyRefreshing);
}
let (body, status) = post_codex(&token)?;
if status == 429 {
return Err(RefreshError::Busy);
}
if matches!(status, 400 | 401 | 403) {
let _ = crate::refresh_health::record_codex_rejection(dir, &fingerprint, now_ms);
return Err(RefreshError::Expired);
}
if !(200..300).contains(&status) {
return Err(RefreshError::Refused(format!("HTTP {status}")));
}
if !std::fs::read(&path).is_ok_and(|current| current.as_slice() == blob.expose()) {
return Err(RefreshError::AlreadyRefreshing);
}
let merged = merge_codex_response(blob.expose(), &body, &rfc3339_utc(now_ms / 1000))
.ok_or_else(|| RefreshError::Refused("the server's answer had no access token".into()))?;
crate::atomic::write_secret(&path, &merged)
.map_err(|e| RefreshError::Refused(e.to_string()))?;
let _ = crate::refresh_health::clear_codex_rejection_before(dir, &fingerprint, now_ms);
Ok(())
}
fn post_codex(refresh_token: &str) -> Result<(String, u32), RefreshError> {
let body = codex_request_body(refresh_token);
let cfg = format!(
"url = \"{}\"\n\
request = POST\n\
header = \"content-type: application/x-www-form-urlencoded\"\n\
header = \"Accept: application/json\"\n\
header = \"User-Agent: swapdex\"\n\
data = \"{}\"\n\
silent\n\
show-error\n\
connect-timeout = 6\n\
max-time = 15\n\
write-out = \"\\n%{{http_code}}\"\n",
codex_token_url(),
body.replace('\\', "\\\\").replace('"', "\\\"")
);
crate::quota::run_curl_cfg(&cfg).map_err(RefreshError::Offline)
}
#[cfg(test)]
mod tests {
use super::*;
const BLOB: &str = r#"{"claudeAiOauth":{"accessToken":"OLD-AT","refreshToken":"OLD-RT",
"expiresAt":1000,"refreshTokenExpiresAt":9999999999999,"subscriptionType":"max"},
"mcpOAuth":{"keep":"me"}}"#;
#[test]
fn a_renewal_replaces_the_refresh_token_the_server_retired() {
let now = 1_800_000_000_000i64;
let resp = r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#;
let merged = merge_response(BLOB.as_bytes(), resp, now).expect("merged");
let v: serde_json::Value = serde_json::from_slice(&merged).unwrap();
let o = &v["claudeAiOauth"];
assert_eq!(o["accessToken"], "NEW-AT");
assert_eq!(
o["refreshToken"], "NEW-RT",
"keeping the old one would leave a spent token on disk"
);
assert_eq!(o["expiresAt"], now + 3_600_000, "an absolute moment");
assert_eq!(o["subscriptionType"], "max");
assert_eq!(v["mcpOAuth"]["keep"], "me");
}
#[test]
fn a_response_without_a_new_refresh_token_keeps_the_old_one() {
let resp = r#"{"access_token":"NEW-AT","expires_in":3600}"#;
let merged = merge_response(BLOB.as_bytes(), resp, 0).expect("merged");
let v: serde_json::Value = serde_json::from_slice(&merged).unwrap();
assert_eq!(v["claudeAiOauth"]["refreshToken"], "OLD-RT");
}
#[test]
fn an_answer_with_no_access_token_is_not_merged() {
assert!(merge_response(BLOB.as_bytes(), r#"{"error":"invalid_grant"}"#, 0).is_none());
assert!(merge_response(BLOB.as_bytes(), "not json", 0).is_none());
assert!(merge_response(b"not json", r#"{"access_token":"X"}"#, 0).is_none());
}
#[test]
fn an_expired_refresh_token_is_named_rather_than_retried() {
let now = 1_800_000_000_000i64;
let blob = format!(
r#"{{"claudeAiOauth":{{"refreshToken":"R","refreshTokenExpiresAt":{}}}}}"#,
now - 1
);
assert!(refresh_token_expired(blob.as_bytes(), now));
let msg = RefreshError::Expired.remedy("work", "claude-code");
assert!(msg.contains("swapdex run work"), "names the way out: {msg}");
assert!(!refresh_token_expired(br#"{"claudeAiOauth":{}}"#, now));
}
#[test]
fn the_request_names_the_grant_and_the_client() {
let b = request_body("RT-1");
let v: serde_json::Value = serde_json::from_str(&b).unwrap();
assert_eq!(v["grant_type"], "refresh_token");
assert_eq!(v["refresh_token"], "RT-1");
assert_eq!(v["client_id"], CLIENT_ID);
}
#[test]
fn a_refusal_is_reported_in_the_users_terms() {
assert_eq!(
short_reason(r#"{"error":"invalid_grant","error_description":"token revoked"}"#),
"token revoked"
);
assert_eq!(
short_reason(r#"{"error":{"type":"rate_limit_error","message":"Rate limited."}}"#),
"Rate limited."
);
assert_eq!(
short_reason("<html>502</html>"),
"the login server refused it"
);
let busy = RefreshError::Busy.remedy("work", "claude-code");
assert!(busy.contains("is fine"), "{busy}");
assert!(!busy.contains("swapdex run"), "not a sign-in: {busy}");
let msg = RefreshError::InUse.remedy("work", "claude-code");
assert!(msg.contains("renewal deferred"), "name the outcome: {msg}");
assert!(
msg.contains("refresh unverified"),
"name uncertainty: {msg}"
);
assert!(!msg.contains("re-login"), "not a rejection verdict: {msg}");
}
}
pub const KEEP_ALIVE_WINDOW_MS: i64 = 6 * 60 * 60 * 1000;
pub fn wants_keep_alive(blob: &[u8], now_ms: i64) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(blob) else {
return false;
};
let oauth = &v["claudeAiOauth"];
if oauth["refreshToken"].as_str().is_none_or(str::is_empty) {
return false;
}
if refresh_token_expired(blob, now_ms) {
return false;
}
oauth["expiresAt"]
.as_i64()
.is_some_and(|exp| exp - now_ms <= KEEP_ALIVE_WINDOW_MS)
}
pub const KEEP_ALIVE_CODEX_WINDOW_SECS: i64 = 2 * 24 * 60 * 60;
pub fn wants_keep_alive_codex(blob: &[u8], now_secs: i64) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(blob) else {
return false;
};
if v["tokens"]["refresh_token"]
.as_str()
.is_none_or(str::is_empty)
{
return false;
}
v["tokens"]["access_token"]
.as_str()
.and_then(crate::proxy::codex::jwt_expiry)
.is_some_and(|exp| exp - now_secs <= KEEP_ALIVE_CODEX_WINDOW_SECS)
}
#[derive(Debug, Default)]
pub(crate) struct KeepAliveReport {
pub(crate) renewed: Vec<String>,
pub(crate) deferred: Vec<String>,
pub(crate) failed: Vec<(String, RefreshError)>,
}
pub(crate) fn codex_renewal_deferred(paths: &Paths, dir: &Path, now_secs: i64) -> bool {
std::fs::read(dir.join("auth.json")).is_ok_and(|blob| {
wants_keep_alive_codex(&blob, now_secs) && slot_in_use(paths, dir, "codex")
})
}
pub fn keep_alive_sweep_codex(
paths: &Paths,
slots: &[(String, std::path::PathBuf)],
now_ms: i64,
) -> (Vec<String>, Vec<(String, RefreshError)>) {
let report = keep_alive_sweep_codex_report(paths, slots, now_ms);
(report.renewed, report.failed)
}
pub(crate) fn keep_alive_sweep_codex_report(
paths: &Paths,
slots: &[(String, std::path::PathBuf)],
now_ms: i64,
) -> KeepAliveReport {
let mut report = KeepAliveReport::default();
for (name, dir) in slots {
let Ok(blob) = std::fs::read(dir.join("auth.json")) else {
continue;
};
if !wants_keep_alive_codex(&blob, now_ms / 1000) {
continue;
}
if codex_renewal_deferred(paths, dir, now_ms / 1000) {
report.deferred.push(name.clone());
continue;
}
match refresh_codex_slot(paths, dir, now_ms) {
Ok(()) => report.renewed.push(name.clone()),
Err(RefreshError::InUse) => report.deferred.push(name.clone()),
Err(e) => report.failed.push((name.clone(), e)),
}
}
report
}
#[cfg(test)]
mod remedy_tool_tests {
use super::*;
#[test]
fn a_codex_remedy_names_codex() {
for e in [RefreshError::Expired, RefreshError::NoCredential] {
let msg = e.remedy("cxwork", "codex");
assert!(
msg.contains("swapdex run cxwork"),
"the remedy stopped naming `run`, so this test measures nothing: {msg}"
);
assert!(
msg.contains("swapdex run cxwork --tool codex"),
"a Codex remedy names a command that runs Claude: {msg}"
);
}
}
#[test]
fn a_claude_remedy_carries_no_tool_flag() {
for e in [RefreshError::Expired, RefreshError::NoCredential] {
let msg = e.remedy("work", "claude-code");
assert!(
msg.contains("swapdex run work"),
"the remedy stopped naming `run`: {msg}"
);
assert!(
!msg.contains("--tool"),
"Claude is run's default; spelling it out is noise: {msg}"
);
}
}
}
#[cfg(test)]
mod codex_keep_alive_tests {
use super::*;
fn jwt(exp: i64) -> String {
use base64::Engine;
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
format!(
"{}.{}.sig",
b64(br#"{"alg":"none"}"#),
b64(format!(r#"{{"exp":{exp}}}"#).as_bytes())
)
}
fn blob(exp: i64, refresh: &str) -> Vec<u8> {
format!(
r#"{{"tokens":{{"access_token":"{}","account_id":"a","refresh_token":"{refresh}"}}}}"#,
jwt(exp)
)
.into_bytes()
}
#[test]
fn a_codex_token_near_its_deadline_wants_renewing() {
let now = 1_788_743_000i64;
assert!(
wants_keep_alive_codex(&blob(now + 3600, "R"), now),
"an hour left"
);
assert!(
wants_keep_alive_codex(&blob(now - 86_400, "R"), now),
"already lapsed"
);
assert!(
!wants_keep_alive_codex(&blob(now + 9 * 86_400, "R"), now),
"nine days left is not the sweep's business yet"
);
}
#[test]
fn the_sweep_leaves_alone_what_it_cannot_renew() {
let now = 1_788_743_000i64;
assert!(
!wants_keep_alive_codex(&blob(now + 60, ""), now),
"no refresh token"
);
assert!(
!wants_keep_alive_codex(
br#"{"tokens":{"access_token":"not-a-jwt","refresh_token":"R"}}"#,
now
),
"an unreadable deadline is not an expired one"
);
assert!(!wants_keep_alive_codex(b"not json", now));
}
}
#[cfg(test)]
mod keep_alive_tests {
use super::*;
fn blob(expires_in_ms: i64, refresh: &str, refresh_expiry: Option<i64>) -> Vec<u8> {
let now = 1_700_000_000_000i64;
let mut o = serde_json::json!({
"claudeAiOauth": {
"accessToken": "a",
"refreshToken": refresh,
"expiresAt": now + expires_in_ms,
}
});
if let Some(r) = refresh_expiry {
o["claudeAiOauth"]["refreshTokenExpiresAt"] = (now + r).into();
}
serde_json::to_vec(&o).unwrap()
}
const NOW: i64 = 1_700_000_000_000;
#[test]
fn an_idle_account_is_renewed_before_it_lapses() {
let hour = 60 * 60 * 1000;
assert!(
wants_keep_alive(&blob(2 * hour, "r", None), NOW),
"two hours left is inside the window"
);
assert!(
!wants_keep_alive(&blob(12 * hour, "r", None), NOW),
"half a day left needs nothing yet"
);
}
#[test]
fn there_is_nothing_to_sweep_without_a_live_refresh_token() {
let hour = 60 * 60 * 1000;
assert!(
!wants_keep_alive(&blob(hour, "", None), NOW),
"no refresh token"
);
assert!(
!wants_keep_alive(&blob(hour, "r", Some(-1)), NOW),
"the refresh token itself has expired"
);
}
#[test]
fn nonsense_is_never_swept() {
assert!(!wants_keep_alive(b"not json", NOW));
assert!(!wants_keep_alive(br#"{"claudeAiOauth":{}}"#, NOW));
}
}
pub fn keep_alive_sweep(
paths: &Paths,
slots: &[(String, std::path::PathBuf)],
now_ms: i64,
) -> (Vec<String>, Vec<(String, RefreshError)>) {
let report = keep_alive_sweep_report(paths, slots, now_ms);
(report.renewed, report.failed)
}
pub(crate) fn keep_alive_sweep_report(
paths: &Paths,
slots: &[(String, std::path::PathBuf)],
now_ms: i64,
) -> KeepAliveReport {
let mut report = KeepAliveReport::default();
for (name, dir) in slots {
let Some(blob) = read_credential(dir) else {
continue;
};
if !wants_keep_alive(blob.expose(), now_ms) {
continue;
}
match refresh_slot(paths, dir, now_ms) {
Ok(()) => report.renewed.push(name.clone()),
Err(RefreshError::InUse) => report.deferred.push(name.clone()),
Err(e) => report.failed.push((name.clone(), e)),
}
}
report
}
#[cfg(test)]
mod one_refresh_per_burst_tests {
use super::*;
#[test]
fn a_burst_of_refreshes_collapses_to_one() {
let g = RefreshGate::default();
let dir = std::path::Path::new("/s/a");
assert!(g.claim(dir, 1_000));
assert!(!g.claim(dir, 1_000));
assert!(!g.claim(dir, 1_002));
assert!(g.claim(std::path::Path::new("/s/b"), 1_000));
assert!(g.claim(dir, 1_000 + BURST_SECS + 1));
}
}
#[cfg(test)]
mod point_of_effect_tests {
use super::*;
#[test]
fn dedupe_uses_provider_qualified_account_identity() {
let root = tempfile::tempdir().unwrap();
let a = root.path().join("a");
let b = root.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
for dir in [&a, &b] {
std::fs::write(
dir.join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":"same-id"}}"#,
)
.unwrap();
std::fs::write(
dir.join("auth.json"),
br#"{"tokens":{"account_id":"same-id"}}"#,
)
.unwrap();
}
assert_eq!(
claim_key(&a, "claude-code"),
claim_key(&b, "claude-code"),
"two directories holding one Claude account share a claim"
);
assert_ne!(
claim_key(&a, "claude-code"),
claim_key(&b, "codex"),
"equal provider-local ids are not the same account"
);
}
#[test]
fn a_second_refresh_of_the_same_slot_in_a_burst_stands_down() {
let a = std::path::Path::new("/tmp/swapdex-gate-a");
let b = std::path::Path::new("/tmp/swapdex-gate-b");
assert!(
claim_refresh_at(a, "claude-code", 10_000),
"the first caller goes ahead"
);
assert!(
!claim_refresh_at(a, "claude-code", 10_000),
"a second in the same burst stands down"
);
assert!(
!claim_refresh_at(a, "claude-code", 10_000 + BURST_SECS),
"still inside the window"
);
assert!(
claim_refresh_at(a, "claude-code", 10_001 + BURST_SECS),
"a later refresh is a new event, not the same burst"
);
assert!(
claim_refresh_at(b, "claude-code", 10_000),
"another account is never blocked"
);
assert!(
claim_refresh_at(a, "codex", 10_000),
"provider namespaces are independent"
);
}
#[test]
fn standing_down_is_its_own_answer() {
let e = RefreshError::AlreadyRefreshing;
let r = e.remedy("rnd", "claude-code");
assert!(
!r.to_lowercase().contains("sign in"),
"nobody needs to sign in: {r}"
);
assert!(r.contains("rnd"), "name the account: {r}");
}
}
#[cfg(all(test, target_os = "linux"))]
mod codex_in_use_tests {
use super::*;
#[test]
fn a_running_session_refuses_the_renewal() {
let root = std::env::temp_dir().join(format!("swapdex_cx_ref_{}", std::process::id()));
let slot = root.join("slot");
std::fs::create_dir_all(&slot).unwrap();
let bin = root.join("codex");
let sleep = if std::path::Path::new("/bin/sleep").exists() {
"/bin/sleep"
} else {
"/usr/bin/sleep"
};
std::os::unix::fs::symlink(sleep, &bin).unwrap();
let mut child = std::process::Command::new(&bin)
.arg("30")
.env("CODEX_HOME", &slot)
.spawn()
.unwrap();
let environ = format!("/proc/{}/environ", child.id());
for _ in 0..300 {
if std::fs::read(&environ).is_ok_and(|b| !b.is_empty()) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let paths = Paths::rooted(&root);
let verdict = refresh_codex_slot(&paths, &slot, 1_700_000_000_000);
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&root);
assert!(
matches!(verdict, Err(RefreshError::InUse)),
"renewed a slot a live session holds: {verdict:?}"
);
}
}
#[cfg(test)]
mod refresh_deadline_tests {
use super::*;
fn blob(refresh_exp: i64) -> String {
format!(
r#"{{"claudeAiOauth":{{"accessToken":"OLD-AT","refreshToken":"OLD-RT",
"expiresAt":1000,"refreshTokenExpiresAt":{refresh_exp},
"subscriptionType":"max"}}}}"#
)
}
#[test]
fn a_rotated_refresh_token_does_not_inherit_the_old_deadline() {
let now = 1_800_000_000_000i64;
let dead_soon = now + 86_400_000; let out = merge_response(
blob(dead_soon).as_bytes(),
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#,
now,
)
.expect("merged");
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
let o = &v["claudeAiOauth"];
assert_eq!(o["refreshToken"], "NEW-RT", "the token rotated");
assert!(
!refresh_token_expired(&out, now + 2 * 86_400_000),
"the new token inherited the retired one's deadline: {o}"
);
}
#[test]
fn a_stated_refresh_lifetime_is_recorded() {
let now = 1_800_000_000_000i64;
let out = merge_response(
blob(now + 1000).as_bytes(),
r#"{"access_token":"A","refresh_token":"R","expires_in":3600,
"refresh_token_expires_in":604800}"#,
now,
)
.expect("merged");
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(
v["claudeAiOauth"]["refreshTokenExpiresAt"].as_i64(),
Some(now + 604_800 * 1000),
"the stated lifetime was not written"
);
}
#[test]
fn an_account_renewed_past_its_original_deadline_is_still_swept() {
let now = 1_800_000_000_000i64;
let signed_in_deadline = now + 86_400_000; let renewed = merge_response(
blob(signed_in_deadline).as_bytes(),
r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#,
now,
)
.expect("merged");
let later = now + 7 * 86_400_000;
assert!(
!refresh_token_expired(&renewed, later),
"the sweep would call a freshly rotated token expired"
);
assert!(
wants_keep_alive(&renewed, later),
"the sweep stopped renewing an account that is still renewable"
);
}
#[test]
fn a_renewal_without_rotation_keeps_the_deadline() {
let now = 1_800_000_000_000i64;
let deadline = now + 86_400_000;
let out = merge_response(
blob(deadline).as_bytes(),
r#"{"access_token":"NEW-AT","expires_in":3600}"#,
now,
)
.expect("merged");
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(
v["claudeAiOauth"]["refreshTokenExpiresAt"].as_i64(),
Some(deadline),
"the live token's deadline was discarded"
);
}
}