use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use anyhow::Result;
use serde::Deserialize;
use crate::claude::{LinkState, classify_credentials_link};
use crate::lock::with_state_lock;
use crate::profile::{AppConfig, OAuthToken, save_app_state, save_profile};
use crate::runtime::{RotationGuard, has_live_session};
use crate::usage::{
ActivityKind, ActivityStore, LastRotatedWindow, OpResult, OpResultSender, ProfileActivity,
RefetchQueue, UsageStore, clear_activity, mark_activity, now_ms,
};
const TOKEN_ENDPOINT: &str = "https://api.anthropic.com/v1/oauth/token";
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const MESSAGES_ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
const KICK_MODEL: &str = "claude-haiku-4-5-20251001";
const KICK_SYSTEM_PROMPT: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
const AUTO_START_COOLDOWN_MS: u64 = 4 * 3600 * 1000 + 30 * 60 * 1000;
const STARTUP_DEDUP_MS: u64 = 60 * 1000;
#[derive(Deserialize)]
pub(crate) struct TokenResponse {
pub(crate) access_token: String,
pub(crate) refresh_token: String,
pub(crate) expires_in: u64,
#[serde(default)]
pub(crate) scope: Option<String>,
}
static AGENT: LazyLock<ureq::Agent> = LazyLock::new(|| {
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(4)))
.timeout_recv_response(Some(Duration::from_secs(15)))
.build()
.into()
});
pub(crate) fn refresh(refresh_token: &str) -> Result<TokenResponse> {
let body = serde_json::to_string(&serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
}))?;
let text = AGENT
.post(TOKEN_ENDPOINT)
.header("Content-Type", "application/json")
.send(&body)
.map_err(crate::ureq_error::into_anyhow)?
.body_mut()
.read_to_string()
.map_err(crate::ureq_error::into_anyhow)?;
serde_json::from_str(&text).map_err(|e| anyhow::anyhow!("{e}: {text}"))
}
fn kick(access_token: &str) -> Result<()> {
let body = serde_json::to_string(&serde_json::json!({
"model": KICK_MODEL,
"max_tokens": 1,
"system": [{ "type": "text", "text": KICK_SYSTEM_PROMPT }],
"messages": [{ "role": "user", "content": "x" }],
}))?;
AGENT
.post(MESSAGES_ENDPOINT)
.header("Content-Type", "application/json")
.header("Authorization", &format!("Bearer {access_token}"))
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", "oauth-2025-04-20")
.send(&body)
.map_err(crate::ureq_error::into_anyhow)?;
Ok(())
}
pub(crate) fn rotate_one(
config: &Arc<Mutex<AppConfig>>,
name: &str,
activity: &ActivityStore,
sender: &OpResultSender,
) -> bool {
let Ok(_rotation_guard) = RotationGuard::acquire(name) else {
return false;
};
let token = {
let cfg = config.lock().expect("config mutex poisoned");
with_state_lock(|| {
if has_live_session(name) {
return Ok::<_, anyhow::Error>(None);
}
let rt = cfg
.find(name)
.and_then(|p| p.refresh_token().map(str::to_string));
if rt.is_some() {
mark_activity(activity, name, ProfileActivity::Refreshing);
}
Ok(rt)
})
.ok()
.flatten()
};
let Some(rt) = token else {
return false;
};
let refreshed = refresh(&rt);
let (outcome, applied) = match refreshed {
Ok(tok) => {
let saved = apply_rotated_tokens_locked(config, name, tok, None);
if saved {
(Ok(()), true)
} else {
(
Err(anyhow::anyhow!("failed to persist rotated tokens")),
false,
)
}
}
Err(e) => (Err(e), false),
};
clear_activity(activity, name);
let _ = sender.send(OpResult {
name: name.to_string(),
kind: ActivityKind::Refreshing,
outcome,
});
applied
}
pub(crate) fn rotate_one_for_window(
config: &Arc<Mutex<AppConfig>>,
name: &str,
activity: &ActivityStore,
sender: &OpResultSender,
lrw: &LastRotatedWindow,
resets_at: i64,
) -> bool {
let Ok(_rotation_guard) = RotationGuard::acquire(name) else {
return false;
};
let token = {
let cfg = config.lock().expect("config mutex poisoned");
with_state_lock(|| {
if has_live_session(name) {
return Ok::<_, anyhow::Error>(None);
}
let rt = cfg
.find(name)
.and_then(|p| p.refresh_token().map(str::to_string));
if rt.is_some() {
mark_activity(activity, name, ProfileActivity::Refreshing);
}
Ok(rt)
})
.ok()
.flatten()
};
let Some(rt) = token else {
return false;
};
let refreshed = refresh(&rt);
let (outcome, applied) = match refreshed {
Ok(tok) => {
let saved = apply_rotated_tokens_locked(config, name, tok, Some((lrw, resets_at)));
if saved {
(Ok(()), true)
} else {
(
Err(anyhow::anyhow!("failed to persist rotated tokens")),
false,
)
}
}
Err(e) => (Err(e), false),
};
clear_activity(activity, name);
let _ = sender.send(OpResult {
name: name.to_string(),
kind: ActivityKind::Refreshing,
outcome,
});
applied
}
pub(crate) fn rotation_candidates(config: &AppConfig, force: bool) -> Vec<(String, String)> {
let skip_active = !force && active_link_diverged(config);
config
.profiles
.iter()
.filter_map(|p| {
if skip_active && config.is_active(&p.name) {
return None;
}
if !force && has_live_session(&p.name) {
return None;
}
Some((p.name.clone(), p.refresh_token()?.to_string()))
})
.collect()
}
pub(crate) fn refresh_all(
config: &Arc<Mutex<AppConfig>>,
force: bool,
refetch: &RefetchQueue,
activity: &ActivityStore,
sender: &OpResultSender,
) -> Vec<String> {
let snapshots = {
let cfg = config.lock().expect("config mutex poisoned");
rotation_candidates(&cfg, force)
};
if snapshots.is_empty() {
return Vec::new();
}
for (name, _) in &snapshots {
mark_activity(activity, name, ProfileActivity::Refreshing);
}
let handles: Vec<(String, _)> = snapshots
.into_iter()
.map(|(name, rt)| {
let config = Arc::clone(config);
let activity = Arc::clone(activity);
let sender = sender.clone();
let name_for_handle = name.clone();
let h = std::thread::spawn(move || {
let Ok(_rotation_guard) = RotationGuard::acquire(&name) else {
clear_activity(&activity, &name);
let _ = sender.send(OpResult {
name: name.clone(),
kind: ActivityKind::Refreshing,
outcome: Err(anyhow::anyhow!("failed to acquire rotation lock")),
});
return (name, false);
};
if !force && has_live_session(&name) {
clear_activity(&activity, &name);
return (name, false);
}
let refreshed = refresh(&rt);
let (outcome, saved) = match refreshed {
Ok(tok) => {
let ok = apply_rotated_tokens_locked(&config, &name, tok, None);
if ok {
(Ok(()), true)
} else {
(
Err(anyhow::anyhow!("failed to persist rotated tokens")),
false,
)
}
}
Err(e) => (Err(e), false),
};
clear_activity(&activity, &name);
let _ = sender.send(OpResult {
name: name.clone(),
kind: ActivityKind::Refreshing,
outcome,
});
(name, saved)
});
(name_for_handle, h)
})
.collect();
let mut refreshed = Vec::new();
for (name, h) in handles {
match h.join() {
Ok((n, true)) => refreshed.push(n),
Ok(_) => {}
Err(_) => {
clear_activity(activity, &name);
}
}
}
if let Ok(mut q) = refetch.lock() {
for name in &refreshed {
q.insert(name.clone());
}
}
refreshed
}
pub(crate) fn auto_start_windows(
config: &Arc<Mutex<AppConfig>>,
store: &UsageStore,
refetch: &RefetchQueue,
activity: &ActivityStore,
sender: &OpResultSender,
) -> Vec<String> {
let has_active_window: std::collections::HashMap<String, bool> = store
.lock()
.ok()
.map(|s| {
s.iter()
.map(|(name, info)| {
let active = info
.five_hour
.as_ref()
.and_then(|w| w.resets_at.as_ref())
.is_some();
(name.clone(), active)
})
.collect()
})
.unwrap_or_default();
let snapshots: Vec<(String, String)> = {
let mut cfg = config.lock().expect("config mutex poisoned");
match with_state_lock(|| {
let skip_active = active_link_diverged(&cfg);
let now = now_ms();
let mut claimed = Vec::new();
for profile in &cfg.profiles {
if !profile.auto_start {
continue;
}
if skip_active && cfg.is_active(&profile.name) {
continue;
}
if has_live_session(&profile.name) {
continue;
}
if *has_active_window.get(&profile.name).unwrap_or(&false) {
continue;
}
let last = cfg
.state
.last_auto_start_at
.get(&profile.name)
.copied()
.unwrap_or(0);
if now.saturating_sub(last) < STARTUP_DEDUP_MS {
continue;
}
let Some(token) = profile.refresh_token().map(str::to_string) else {
continue;
};
claimed.push((profile.name.clone(), token));
}
for (name, _) in &claimed {
cfg.state.last_auto_start_at.insert(name.clone(), now);
}
if !claimed.is_empty() {
let _ = save_app_state(&cfg.state);
}
Ok::<_, anyhow::Error>(claimed)
}) {
Ok(s) => s,
Err(_) => return Vec::new(),
}
};
for (name, _) in &snapshots {
mark_activity(activity, name, ProfileActivity::AutoStarting);
}
let handles: Vec<(String, _)> = snapshots
.into_iter()
.map(|(name, rt)| {
let config = Arc::clone(config);
let activity = Arc::clone(activity);
let sender = sender.clone();
let name_for_handle = name.clone();
let h = std::thread::spawn(move || {
let (outcome, kicked) = run_auto_start(&config, &name, &rt);
clear_activity(&activity, &name);
let _ = sender.send(OpResult {
name: name.clone(),
kind: ActivityKind::AutoStarting,
outcome,
});
(name, kicked)
});
(name_for_handle, h)
})
.collect();
let mut kicked = Vec::new();
for (name, h) in handles {
match h.join() {
Ok((n, true)) => kicked.push(n),
Ok(_) => {}
Err(_) => {
clear_activity(activity, &name);
}
}
}
if let Ok(mut q) = refetch.lock() {
for name in &kicked {
q.insert(name.clone());
}
}
kicked
}
fn run_auto_start(
config: &Arc<Mutex<AppConfig>>,
name: &str,
refresh_token: &str,
) -> (Result<()>, bool) {
let _rotation_guard = match RotationGuard::acquire(name) {
Ok(g) => g,
Err(e) => return (Err(e), false),
};
if has_live_session(name) {
return (Err(anyhow::anyhow!("live session holds the chain")), false);
}
let tok = match refresh(refresh_token) {
Ok(t) => t,
Err(e) => return (Err(e), false),
};
let access_token = tok.access_token.clone();
if !apply_rotated_tokens_or_rollback_cooldown_locked(config, name, tok) {
return (
Err(anyhow::anyhow!("failed to persist rotated tokens")),
false,
);
}
match kick(&access_token) {
Ok(()) => (Ok(()), true),
Err(e) => (Err(e), false),
}
}
pub(crate) fn auto_start_named(
config: &Arc<Mutex<AppConfig>>,
name: &str,
refetch: &RefetchQueue,
activity: &ActivityStore,
sender: &OpResultSender,
) -> bool {
let now = now_ms();
let token = {
let mut cfg = config.lock().expect("config mutex poisoned");
match with_state_lock(|| {
let Some(profile) = cfg.find(name) else {
return Ok::<_, anyhow::Error>(None);
};
if !profile.auto_start {
return Ok(None);
}
if active_link_diverged(&cfg) && cfg.is_active(name) {
return Ok(None);
}
if has_live_session(name) {
return Ok(None);
}
let last = cfg.state.last_auto_start_at.get(name).copied().unwrap_or(0);
if now.saturating_sub(last) < AUTO_START_COOLDOWN_MS {
return Ok(None);
}
let Some(rt) = profile.refresh_token().map(str::to_string) else {
return Ok(None);
};
cfg.state.last_auto_start_at.insert(name.to_string(), now);
let _ = save_app_state(&cfg.state);
Ok(Some(rt))
}) {
Ok(Some(t)) => t,
_ => return false,
}
};
mark_activity(activity, name, ProfileActivity::AutoStarting);
let (outcome, kicked) = run_auto_start(config, name, &token);
clear_activity(activity, name);
let _ = sender.send(OpResult {
name: name.to_string(),
kind: ActivityKind::AutoStarting,
outcome,
});
if kicked && let Ok(mut q) = refetch.lock() {
q.insert(name.to_string());
}
kicked
}
pub(crate) fn start_window(
config: &Arc<Mutex<AppConfig>>,
name: &str,
refetch: &RefetchQueue,
activity: &ActivityStore,
sender: &OpResultSender,
) -> bool {
let access_token = {
let cfg = config.lock().expect("config mutex poisoned");
cfg.find(name)
.and_then(|p| p.access_token().map(str::to_string))
};
let Some(access_token) = access_token else {
return false;
};
mark_activity(activity, name, ProfileActivity::AutoStarting);
let outcome = kick(&access_token);
let kicked = outcome.is_ok();
clear_activity(activity, name);
let _ = sender.send(OpResult {
name: name.to_string(),
kind: ActivityKind::AutoStarting,
outcome,
});
if kicked && let Ok(mut q) = refetch.lock() {
q.insert(name.to_string());
}
kicked
}
fn write_token_fields(oauth: &mut OAuthToken, tok: TokenResponse) {
oauth.access_token = tok.access_token;
oauth.refresh_token = Some(tok.refresh_token);
oauth.expires_at = Some((now_ms() + tok.expires_in * 1000) as i64);
if let Some(scope) = tok.scope {
oauth.scopes = Some(scope.split_whitespace().map(String::from).collect());
}
}
fn apply_rotated_tokens_or_rollback_cooldown_locked(
config: &Arc<Mutex<AppConfig>>,
name: &str,
tok: TokenResponse,
) -> bool {
let mut cfg = config.lock().expect("config mutex poisoned");
with_state_lock(|| {
let Some(profile) = cfg.find_mut(name) else {
return Ok::<_, anyhow::Error>(false);
};
let Some(creds) = profile.credentials.as_mut() else {
return Ok(false);
};
let Some(oauth) = creds.claude_ai_oauth.as_mut() else {
return Ok(false);
};
write_token_fields(oauth, tok);
if save_profile(profile).is_err() {
cfg.state.last_auto_start_at.remove(name);
let _ = save_app_state(&cfg.state);
return Ok(false);
}
Ok(true)
})
.unwrap_or(false)
}
pub(crate) fn apply_rotated_tokens_locked(
config: &Arc<Mutex<AppConfig>>,
name: &str,
tok: TokenResponse,
window_stamp: Option<(&LastRotatedWindow, i64)>,
) -> bool {
let mut cfg = config.lock().expect("config mutex poisoned");
with_state_lock(|| {
let Some(profile) = cfg.find_mut(name) else {
return Ok::<_, anyhow::Error>(false);
};
let Some(creds) = profile.credentials.as_mut() else {
return Ok(false);
};
let Some(oauth) = creds.claude_ai_oauth.as_mut() else {
return Ok(false);
};
write_token_fields(oauth, tok);
if save_profile(profile).is_err() {
return Ok(false);
}
if let Some((lrw, resets_at)) = window_stamp
&& let Ok(mut guard) = lrw.lock()
{
guard.insert(name.to_string(), resets_at);
}
Ok(true)
})
.unwrap_or(false)
}
fn active_link_diverged(config: &AppConfig) -> bool {
config.state.active_profile.as_deref().is_some_and(|name| {
matches!(
classify_credentials_link(name).ok(),
Some(LinkState::Diverged)
)
})
}
#[cfg(test)]
#[path = "../tests/inline/oauth.rs"]
mod tests;