use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::credential_store::CredentialStore;
use crate::subscription::{SubscriptionProvider, SubscriptionToken};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(90);
const CLAUDE_PROBE: &[&str] = &["-p", "ok", "--model", "claude-haiku-4-5"];
const CODEX_PROBE: &[&str] = &["exec", "--skip-git-repo-check", "ok"];
pub const PROBE_ARGS_ENV: &str = "ROUTER_VENDOR_REFRESH_ARGS";
#[must_use]
pub fn probe_args_env_for(provider: SubscriptionProvider) -> String {
format!("{PROBE_ARGS_ENV}_{}", provider.as_str().to_uppercase())
}
#[must_use]
pub const fn probe_for(provider: SubscriptionProvider) -> Option<&'static [&'static str]> {
match provider {
SubscriptionProvider::Claude => Some(CLAUDE_PROBE),
SubscriptionProvider::Codex => Some(CODEX_PROBE),
SubscriptionProvider::Gemini | SubscriptionProvider::Qwen => None,
}
}
#[derive(Debug, Clone)]
pub struct VendorCli {
provider: SubscriptionProvider,
binary: PathBuf,
home: PathBuf,
probe: &'static [&'static str],
timeout: Duration,
}
impl VendorCli {
#[must_use]
pub fn claude(binary: impl Into<PathBuf>, home: impl Into<PathBuf>) -> Self {
Self::new(SubscriptionProvider::Claude, binary, home)
}
#[must_use]
pub fn codex(binary: impl Into<PathBuf>, home: impl Into<PathBuf>) -> Self {
Self::new(SubscriptionProvider::Codex, binary, home)
}
#[must_use]
pub fn for_provider(
provider: SubscriptionProvider,
binary: impl Into<PathBuf>,
home: impl Into<PathBuf>,
) -> Option<Self> {
probe_for(provider)?;
Some(Self::new(provider, binary, home))
}
fn new(
provider: SubscriptionProvider,
binary: impl Into<PathBuf>,
home: impl Into<PathBuf>,
) -> Self {
Self {
provider,
binary: binary.into(),
home: home.into(),
probe: probe_for(provider).unwrap_or(CLAUDE_PROBE),
timeout: DEFAULT_TIMEOUT,
}
}
#[must_use]
pub const fn provider(&self) -> SubscriptionProvider {
self.provider
}
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
fn probe_args(&self) -> Vec<String> {
std::env::var(probe_args_env_for(self.provider))
.ok()
.or_else(|| std::env::var(PROBE_ARGS_ENV).ok())
.filter(|value| !value.trim().is_empty())
.map_or_else(
|| self.probe.iter().map(|arg| (*arg).to_string()).collect(),
|value| value.split_whitespace().map(str::to_string).collect(),
)
}
pub async fn rotate(
&self,
store: &dyn CredentialStore,
spent: &SubscriptionToken,
) -> Option<SubscriptionToken> {
let debug_log = self
.home
.join(format!("router-refresh-{}.debug.log", std::process::id()));
let args = self.probe_args();
let provider = self.provider;
tracing::info!(
"{provider} credential recovery: asking the vendor client to rotate the chain — {} {}",
self.binary.display(),
args.join(" ")
);
let mut command = tokio::process::Command::new(&self.binary);
if provider == SubscriptionProvider::Claude {
command.env("CLAUDE_CONFIG_DIR", &self.home);
command.arg("--debug-file").arg(&debug_log);
}
command
.args(&args)
.env(provider.home_env(), &self.home)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let started = std::time::Instant::now();
let outcome = match tokio::time::timeout(self.timeout, command.output()).await {
Ok(Ok(output)) => output,
Ok(Err(error)) => {
tracing::warn!(
"the {provider} vendor client at {} could not be run: {error}",
self.binary.display()
);
return None;
}
Err(_) => {
tracing::warn!(
"the {provider} vendor client did not finish within {:?}; the credential was \
left as it was",
self.timeout
);
return None;
}
};
let elapsed = started.elapsed();
journal_debug_log(provider, &debug_log);
if !outcome.status.success() {
tracing::warn!(
"the {provider} vendor client exited with {} after {elapsed:?}",
outcome.status
);
}
let rotated = store.reload()?;
let before = link_digest(spent);
let after = link_digest(&rotated);
if before == after {
tracing::warn!(
"the {provider} vendor client left chain link {before} in {} unchanged after \
{elapsed:?}; nothing was recovered",
store.describe()
);
return None;
}
tracing::info!(
"the {provider} vendor client rotated {} from chain link {before} to {after} in \
{elapsed:?}",
store.describe()
);
Some(rotated)
}
}
fn journal_debug_log(provider: SubscriptionProvider, path: &Path) {
let Ok(contents) = std::fs::read_to_string(path) else {
tracing::debug!(
"the {provider} vendor client wrote no debug log at {}",
path.display()
);
return;
};
let lines: Vec<&str> = contents
.lines()
.filter(|line| line.contains("oauth/token") || line.contains("refresh_token"))
.collect();
if lines.is_empty() {
tracing::debug!(
"the {provider} vendor client's debug log at {} says nothing about its token \
exchange ({} lines); capturing it needs an intercepting proxy",
path.display(),
contents.lines().count()
);
return;
}
tracing::info!(
"the {provider} vendor client's token exchange, as its own debug log reports it:\n{}",
lines.join("\n")
);
}
#[must_use]
pub fn link_digest(token: &SubscriptionToken) -> String {
use sha2::Digest as _;
let Some(refresh) = token.refresh_token.as_deref() else {
return String::from("none");
};
let digest = sha2::Sha256::digest(refresh.as_bytes());
hex::encode(&digest[..4])
}
#[cfg(test)]
#[path = "vendor_cli_refresh_tests.rs"]
mod tests;